8 Building images automatically with Dockerfiles

2024. 4. 4. 14:08Docker

This chapter covers

  • Automated image packaging with Dockerfiles
  • Metadata and filesystem instructions
  • Creating maintainable image builds with arguments and multiple stages
  • Packaging for multiprocess and durable containers
  • Reducing the image attack surface and building trust

Dockerfile은 이미지를 빌드하기 위한 커맨드를 포함한 텍스트 파일입니다.
Docker 이미지 빌더는 Dockerfile에 작성된 커맨드들을 위에서 아래로 실행하며, 커맨드는 이미지에 대한 모든 구성이나 변경 작업을 수행할 수 있습니다. Dockerfile을 사용해 이미지를 빌드하면, 호스트 컴퓨터의 파일을 컨테이너로 추가하는 작업과 같은 일이 간단한 싱글 라인의 커맨드로 가능합니다. Dockerfile은 Docker 이미지를 빌드하는 방법을 설명하는 가장 일반적인 방식입니다.

이 장에서는 Dockerfile 빌드 작업의 기본 사항과 Dockerfile을 사용하는 주요 이유, 커맨드에 대한 간략한 개요, 그리고 향후 빌드 동작을 추가하는 방법을 다룹니다. 우리는 코드로 이미지를 수동으로 만드는 대신, 이미지를 빌드하는 프로세스를 자동화하는 익숙한 예제부터 시작할 것입니다. 코드로 이미지의 빌드 과정을 정의하면 버전 관리를 통해 변경 사항을 추적하고, 팀원들과 공유하며, 최적화하고 보안을 강화하는 작업이 간단해집니다.

 

8.1 Packaging Git with a Dockerfile

다음은 7장에서 수작업으로 만든 Git 예제 이미지를 다시 살펴보는 내용입니다. 이미지를 작성하는 과정을 수동 작업에서 코드로 변환하면서 Dockerfile을 사용하는 세부 사항과 장점을 인식할 수 있어야 합니다.

먼저, 새로운 디렉터리를 생성하고, 해당 디렉터리에서 좋아하는 텍스트 편집기로 새 파일을 만드세요. 새 파일의 이름을 Dockerfile로 지정합니다. 아래의 다섯 라인을 작성한 후 파일을 저장하세요:

# Ubuntu에 Git을 설치하기 위한 예제 Dockerfile
FROM ubuntu:latest
LABEL maintainer="dia@allingeek.com"
RUN apt-get update && apt-get install -y git
ENTRYPOINT ["git"]

이 예제를 해석하기 전에, 동일한 디렉터리에서 Dockerfile을 사용하여 새로운 이미지를 빌드하고, 이미지를 auto라는 태그로 빌드합니다:

docker image build --tag ubuntu-git:auto .

이 커맨드는 apt-get의 단계와 출력에 대한 여러 라인의 메시지를 출력하며, 마지막으로 아래와 같은 메시지를 표시합니다:

Successfully built cc63aeb7a5a2
Successfully tagged ubuntu-git:auto

위 커맨드를 실행하면 빌드 프로세스가 시작됩니다. 완료되면 새 이미지를 테스트할 수 있습니다. ubuntu-git 이미지 목록을 확인하고 최신 이미지를 테스트하려면 아래 명령을 실행하세요:

docker image ls

새롭게 빌드된 auto 태그가 다음과 같이 콘솔에 나타날 것입니다:

REPOSITORY   TAG        IMAGE ID        CREATED              VIRTUAL SIZE
ubuntu-git   auto       cc63aeb7a5a2    2 minutes ago        219MB
ubuntu-git   latest     826c66145a59    10 minutes ago       249MB
ubuntu-git   removed    826c66145a59    10 minutes ago       249MB
ubuntu-git   1.9        3e356394c14e    41 hours ago         249MB
...

이제 새로운 이미지를 사용하여 Git 명령을 실행할 수 있습니다:

docker container run --rm ubuntu-git:auto

이 커맨드들은 Dockerfile로 빌드한 이미지가 수작업으로 만든 이미지와 기능적으로 동등함을 보여줍니다. 이를 달성하기 위해 수행한 작업을 살펴봅시다:

먼저, 네 가지 커맨드로 Dockerfile을 작성했습니다:

  • FROM ubuntu:latest: Docker에게 최신 Ubuntu 이미지를 기반으로 시작하라고 지시합니다. 이는 이미지를 수동으로 생성할 때도 동일하게 수행했던 작업입니다.
  • LABEL maintainer: 이미지의 유지보수 담당자 이름과 이메일을 설정합니다. 이러한 정보는 이미지에 문제가 있을 경우 연락할 대상을 알 수 있게 도와줍니다. 이는 이전에 commit 커맨드를 실행할 때 수행했던 작업과 동일합니다.
  • RUN apt-get update && apt-get install -y git: Git을 설치하기 위해 제공된 커맨드를 실행하도록 빌더에 지시합니다.
  • ENTRYPOINT ["git"]: 이미지의 진입점을 git으로 설정합니다.

Dockerfile은 대부분의 스크립트처럼 주석을 포함할 수 있습니다. #로 시작하는 모든 라인은 빌더에서 무시됩니다. 복잡한 Dockerfile은 잘 문서화되는 것이 중요합니다. 주석은 Dockerfile 유지 관리성을 높일 뿐만 아니라, 이미지 채택을 고려하는 사람들이 이미지를 감사하고 모범 사례를 확산시키는 데 도움을 줍니다.

Dockerfile에 대한 유일한 특별 규칙은 첫 번째 커맨드가 FROM이어야 한다는 것입니다. 비어 있는 이미지에서 시작하고 소프트웨어에 종속성이 없거나 모든 종속성을 제공할 경우, scratch라는 특별한 빈 리포지토리에서 시작할 수 있습니다.

Dockerfile을 저장한 후, docker image build 명령을 호출하여 빌드 프로세스를 시작했습니다. 이 커맨드에는 하나의 플래그와 하나의 아규먼트가 포함되었습니다.

  • --tag 플래그(-t로 줄여서 사용 가능)는 결과 이미지에 사용할 전체 리포지토리 지정 이름을 지정합니다. 이 경우, ubuntu-git:auto를 사용했습니다.
  • 끝에 포함된 아규먼트는 단일 마침표(.)였습니다. 이 아규먼트는 빌더에게 Dockerfile의 위치를 알려줍니다. 마침표는 현재 디렉터리에서 파일을 찾도록 지시합니다.

docker image build 커맨드에는 또 다른 플래그인 --file (-f로 줄여서 사용 가능)이 있습니다. 이 플래그는 Dockerfile의 이름을 설정할 수 있게 합니다. 디폴트 이름은 Dockerfile이지만, 이 플래그를 사용하면 BuildScriptrelease-image.df라는 이름의 파일을 찾도록 빌더에게 지시할 수 있습니다. 이 플래그는 파일 이름만 설정하며, 위치는 항상 위치 아규먼트를 통해 지정해야 합니다.

빌더는 이미지를 수작업으로 생성할 때 사용하는 동일한 작업을 자동화하여 작동합니다. 각 커맨드는 지정된 수정사항이 적용된 새 컨테이너의 생성을 트리거합니다. 수정이 완료된 후, 빌더는 해당 레이어를 커밋하고 새 레이어에서 생성된 다음 커맨드와 컨테이너로 넘어갑니다.

빌더는 빌드의 첫 번째 단계로 FROM 커맨드에서 지정된 이미지가 설치되었는지 확인했습니다. 만약 설치되지 않았다면, Docker는 자동으로 이미지를 가져오려고 시도했을 것입니다. 실행한 빌드 커맨드의 출력을 확인해 봅시다:

Sending build context to Docker daemon  2.048kB
Step 1/4 : FROM ubuntu:latest
 ---> 452a96d81c30

이 경우, FROM 커맨드에서 지정된 base 이미지가 ubuntu:latest임을 확인할 수 있습니다. 이 이미지는 이미 로컬 머신에 설치되어 있어야 합니다. 출력에는 base 이미지의 축약된 이미지 ID(12자리만 표기)가 포함되어 있습니다.

다음 커맨드는 이미지에 유지보수 정보를 설정합니다. 이는 새로운 컨테이너를 생성한 다음, 결과 레이어를 커밋합니다. Step 1의 출력에서 이 작업의 결과를 확인할 수 있습니다:

Step 2/4 : LABEL maintainer="dia@allingeek.com"
 ---> Running in 11140b391074
 Removing intermediate container 11140b391074

출력에는 생성된 컨테이너의 ID와 커밋된 레이어의 ID가 포함되어 있습니다. 이 레이어는 다음 명령어(RUN)에서 이미지의 상단으로 사용됩니다. RUN 커맨드는 지정된 아규먼트와 함께 프로그램을 새 이미지 레이어 위에서 실행합니다. 그런 다음 Docker는 파일 시스템 변경 사항을 레이어에 커밋하여 다음 Dockerfile 커맨드에서 사용할 수 있도록 합니다. 이 경우, RUN 커맨드의 출력은 apt-get update && apt-get install -y git 커맨드의 출력으로 가려져 있습니다. 소프트웨어 패키지를 설치하는 것은 RUN 커맨드의 가장 일반적인 사용 사례 중 하나입니다. 컨테이너에서 필요한 모든 소프트웨어 패키지를 명시적으로 설치하여, 필요한 경우 사용할 수 있도록 해야 합니다.

빌드 과정에서 나오는 긴 출력이 필요하지 않은 경우, docker image build 명령을 --quiet 또는 -q 플래그와 함께 호출할 수 있습니다. 조용한 모드로 실행하면 빌드 과정과 중간 컨테이너 관리의 모든 출력을 억제합니다. 조용한 모드에서 빌드 과정의 유일한 출력은 생성된 이미지의 ID입니다. 다음과 같이 보일 수 있습니다:

sha256:e397ecfd576c83a1e49875477dcac50071e1c71f76f1d0c8d371ac74d97bbc90

Git을 설치하는 이 세 번째 단계는 일반적으로 완료하는 데 훨씬 더 오래 걸리지만, 커맨드와 입력뿐만 아니라 커맨드가 실행된 컨테이너의 ID와 결과 레이어의 ID를 볼 수 있습니다. 마지막으로, ENTRYPOINT 커맨드는 동일한 단계를 수행하며 출력도 예측 가능한 결과를 보여줍니다:

Step 4/4 : ENTRYPOINT ["git"]
 ---> Running in 6151803c388a
 Removing intermediate container 6151803c388a
 ---> e397ecfd576c
 Successfully built e397ecfd576c
 Successfully tagged ubuntu-git:auto

빌드의 각 단계 이후, 결과 이미지에 새로운 레이어가 추가됩니다. 이는 특정 단계에서 분기할 수 있다는 것을 의미하지만, 더 중요한 점은 빌더가 각 단계의 결과를 강력하게 캐시할 수 있다는 것입니다. 빌드 스크립트에 문제가 발생하면 문제를 수정한 후 동일한 위치에서 다시 시작할 수 있습니다. 이를 확인하려면 Dockerfile을 일부러 깨뜨려 보십시오.

Dockerfile의 끝에 다음 라인을 추가합니다:

RUN This will not work

그런 다음 빌드를 다시 실행합니다:

docker image build --tag ubuntu-git:auto .

출력은 빌더가 캐시된 결과를 사용하는 대신 건너뛸 수 있었던 단계를 보여줍니다:

Sending build context to Docker daemon  2.048kB
Step 1/5 : FROM ubuntu:latest
 ---> 452a96d81c30
Step 2/5 : LABEL maintainer="dia@allingeek.com"
 ---> Using cache
 ---> 83da14c85b5a
Step 3/5 : RUN apt-get update && apt-get install -y git
 ---> Using cache
 ---> 795a6e5d560d
Step 4/5 : ENTRYPOINT ["git"]
 ---> Using cache
 ---> 89da8ffa57c7
Step 5/5 : RUN This will not work
 ---> Running in 2104ec7bc170
/bin/sh: 1: This: not found
The command '/bin/sh -c This will not work' returned a non-zero code: 127

Step 1부터 4까지는 이전 빌드 중에 이미 빌드되었기 때문에 건너뛰었습니다. Step 5는 컨테이너에 This라는 이름의 프로그램이 없기 때문에 실패했습니다. 이 경우, 컨테이너 출력은 Dockerfile의 특정 문제를 알려주는 유용한 정보를 제공합니다. 문제를 수정하면 동일한 단계가 다시 건너뛰어 빌드가 성공하며, 다음과 같은 출력이 표시됩니다:

Successfully built d7a8ee0cebd4

빌드 중 캐싱을 사용하면 자료 다운로드, 프로그램 컴파일 또는 기타 시간 집약적인 작업이 포함된 빌드에서 시간을 절약할 수 있습니다. 전체 빌드가 필요한 경우, --no-cache 플래그를 docker image build에 사용하여 캐시 사용을 비활성화할 수 있습니다. 이 옵션은 꼭 필요할 때만 사용해야 합니다. 그렇지 않으면 업스트림 소스 시스템과 이미지 빌드 시스템에 더 큰 부하를 줄 수 있습니다.

이 짧은 예제는 Dockerfile 커맨드 중 4개를 사용합니다. 네트워크에서 이미지를 다운로드하는 방식으로만 파일을 추가했고, 환경을 제한적으로 수정했으며, 일반적인 도구를 제공합니다. 다음 예제는 보다 구체적인 목적과 로컬 코드를 사용하여 보다 완전한 Dockerfile 입문서를 제공합니다.

 

8.2 Docker Primer: 기본 개념과 사용법

Dockerfile은 간결한 구문으로 주석을 허용하여 표현력이 뛰어나고 이해하기 쉽습니다. Dockerfile은 모든 버전 관리 시스템으로 변경 사항을 추적할 수 있습니다. 이미지를 여러 버전으로 유지 관리하는 것은 여러 Dockerfile을 관리하는 것만큼 간단합니다. Dockerfile 빌드 프로세스 자체는 광범위한 캐싱을 사용하여 빠른 개발과 반복 작업을 지원합니다. 빌드는 추적 가능하고 재현 가능합니다. 또한 기존 빌드 시스템 및 다양한 지속적 통합 도구와 쉽게 통합됩니다. 이러한 이유로 수작업으로 이미지를 만드는 것보다 Dockerfile을 사용하는 것이 더 선호되며, Dockerfile 작성 방법을 배우는 것이 중요합니다.

이 섹션의 예제는 대부분의 이미지에서 사용되는 핵심 Dockerfile 커맨드를 다룹니다. 이후 섹션에서는 다운스트림 동작을 생성하고 더 유지 관리하기 쉬운 Dockerfile을 만드는 방법을 보여줍니다. 여기에서는 각 커맨드를 입문 수준에서 다루며, Dockerfile 커맨드에 대한 깊이 있는 설명을 위해서는 Docker 공식 문서가 항상 최고의 참고 자료가 될 것입니다. 해당 문서는 Docker 공식 문서에서 확인할 수 있습니다. Docker 빌더 참조는 훌륭한 Dockerfile 예제와 모범 사례 가이드도 제공합니다.

 

8.2.1 Metadata instructions

첫 번째 예제는 base 이미지와, chapter 2에서 사용했던 mailer 프로그램의 서로 다른 버전을 포함하는 두 개의 이미지를 빌드합니다. 프로그램의 목적은 TCP 포트에서 메시지를 수신하고, 해당 메시지를 대상 수신자에게 전송하는 것입니다. mailer의 첫 번째 버전은 메시지를 수신하지만 해당 메시지를 로그로 기록만 합니다. 두 번째 버전은 메시지를 정의된 URL로 HTTP POST를 통해 전송합니다.

Dockerfile 빌드를 사용하는 가장 좋은 이유 중 하나는 컴퓨터에서 이미지를 생성할 때 파일을 복사하는 작업을 단순화하기 때문입니다. 하지만 모든 파일이 이미지에 복사되는 것이 적절하지는 않습니다. 새로운 프로젝트를 시작할 때 가장 먼저 해야 할 일은 어떤 파일이 이미지에 복사되지 않아야 하는지를 정의하는 것입니다. 이를 .dockerignore라는 파일에 정의할 수 있습니다. 이 예제에서는 세 개의 Dockerfile을 생성하며, 이 Dockerfile들 모두 결과 이미지에 복사될 필요가 없습니다.

선호하는 텍스트 편집기를 사용하여 .dockerignore라는 새 파일을 생성하고 아래 내용을 복사하세요:

.dockerignore
mailer-base.df
mailer-logging.df
mailer-live.df

작업을 완료한 후 파일을 저장하고 닫으세요. 이렇게 하면 .dockerignore 파일이나 mailer-base.df, mailer-logging.df, mailer-live.df라는 이름의 파일이 빌드 중 이미지에 복사되지 않도록 방지할 수 있습니다. 이러한 사전 작업을 마친 후 base 이미지 작업을 시작할 수 있습니다.

base 이미지를 빌드하면 공통 레이어를 생성하는 데 도움이 됩니다. mailer의 각 버전은 mailer-base라는 이미지를 기반으로 빌드됩니다. Dockerfile을 작성할 때는 각 Dockerfile 커맨드가 새로운 레이어를 생성한다는 점을 염두에 두어야 합니다. 가능한 경우 커맨드를 결합해야 합니다. 빌더는 최적화를 수행하지 않기 때문입니다. 이를 실천하기 위해 mailer-base.df라는 새 파일을 생성하고 다음 라인을 추가하세요:

FROM debian:buster-20190910
LABEL maintainer="dia@allingeek.com"
RUN groupadd -r -g 2200 example && \
useradd -rM -g example -u 2200 example
ENV APPROOT="/app" \
APP="mailer.sh" \
VERSION="0.6"
LABEL base.name="Mailer Archetype" \
base.version="${VERSION}"
WORKDIR $APPROOT
ADD . $APPROOT
ENTRYPOINT ["/app/mailer.sh"]  <--- This file does not exist yet.
EXPOSE 33333
# Do not set the default user in the base otherwise
# implementations will not be able to update the image
# USER example:example

모든 작업을 결합하여 mailer-base 파일이 위치한 디렉터리에서 docker image build 명령을 실행하세요. -f 플래그는 빌더에게 입력으로 사용할 파일 이름을 지정합니다:

docker image build -t dockerinaction/mailer-base:0.6 -f mailer-base.df .
Dockerfile의 이름 지정
Dockerfile의 디폴트 이름이자 가장 일반적인 이름은 Dockerfile입니다. 하지만 Dockerfile은 단순한 텍스트 파일이기 때문에 아무 이름이나 사용할 수 있으며, 빌드 커맨드에서 지정한 파일 이름을 사용할 수 있습니다. 일부 사람들은 .df와 같은 확장자를 사용하여 Dockerfile을 이름 짓습니다. 이렇게 하면 단일 프로젝트 디렉터리 내에서 여러 이미지에 대한 빌드를 쉽게 정의할 수 있습니다(예: app-build.df, app-runtime.df, app-debugtools.df). 또한, 파일 확장자를 사용하면 편집기에서 Dockerfile 지원을 활성화하기도 쉽습니다.

이 Dockerfile에서는 다섯 가지 새로운 커맨드가 도입되었습니다. 첫 번째 새로운 커맨드는 ENV입니다. ENV는 이미지에 대한 환경 변수를 설정하며, 이는 docker container run 또는 docker container create 명령의 --env 플래그와 유사합니다.

이 경우, 하나의 ENV 커맨드를 사용하여 세 가지 개별 환경 변수들을 설정합니다. 이를 세 개의 연속된 ENV 커맨드로도 수행할 수 있지만, 그렇게 하면 세 개의 레이어가 생성됩니다. ENV 커맨드는 쉘 스크립팅에서처럼 줄바꿈 문자를 이스케이프하기 위해 백슬래시(\)를 사용하여 읽기 쉽게 유지할 수 있습니다.

Step 4/9 : ENV APPROOT="/app" APP="mailer.sh" VERSION="0.6"
---> Running in c525f774240f
Removing intermediate container c525f774240f

Dockerfile에서 선언된 환경 변수는 도커 이미지에서 사용할 수 있을 뿐만 아니라, 다른 Dockerfile 커맨드에서 대체값(substitution)으로도 사용될 수 있습니다. 이 Dockerfile에서는 환경 변수 VERSION이 다음 새로운 커맨드인 LABEL에서 대체값으로 사용되었습니다.

Step 5/9 : LABEL base.name="Mailer Archetype" base.version="${VERSION}"
---> Running in 33d8f4d45042
Removing intermediate container 33d8f4d45042
---> 20441d0f588e

LABEL 커맨드는 이미지나 컨테이너에 추가 메타데이터로 기록되는 키/값 쌍을 정의하는 데 사용됩니다. 이는 docker rundocker create 커맨드에서 사용되는 --label 플래그와 유사합니다. 이전의 ENV 커맨드와 마찬가지로, 여러 레이블은 하나의 커맨드로 설정할 수 있으며, 그렇게 하는 것이 바람직합니다.

이 경우, base.version 레이블의 값으로 VERSION 환경 변수의 값이 대체되었습니다. 이와 같은 방식으로 환경 변수를 사용하면 VERSION 값이 컨테이너 내부에서 실행되는 프로세스에서 사용 가능하며 적절한 레이블에도 기록됩니다. 이를 통해 Dockerfile의 유지 관리성이 향상됩니다. 값이 한 곳에서만 설정되므로 불일치한 변경을 하는 것이 더 어려워지기 때문입니다.

레이블로 메타데이터 구성하기
Docker Inc.은 이미지, 네트워크, 컨테이너 및 기타 객체를 구성하는 데 도움을 주기 위해 레이블을 사용하여 메타데이터를 기록할 것을 권장합니다. 각 레이블 키는 작성자가 소유하거나 협업 중인 도메인의 역순 DNS 표기법을 접두사로 사용하는 것이 좋습니다. 예를 들어, com..some-label과 같은 형식입니다. 레이블은 유연하고 확장 가능하며 경량이지만, 구조가 부족하기 때문에 정보를 활용하기 어려울 수 있습니다.
Label Schema 프로젝트(http://label-schema.org/)는 레이블 이름을 표준화하고 호환 가능한 도구를 촉진하기 위한 커뮤니티 노력입니다. 이 스키마는 빌드 날짜, 이름, 설명과 같은 이미지의 중요한 속성을 다룹니다. 예를 들어, Label Schema 네임스페이스를 사용할 때 빌드 날짜의 키는 org.label-schema.build-date로 명명되며, 값은 RFC 3339 형식(예: 2018-07-12T16:20:50.52Z)이어야 합니다.

 

다음 두 커맨드는 WORKDIREXPOSE입니다. 이 커맨드들은 docker rundocker create 커맨드에서의 해당 플래그와 유사하게 동작합니다. WORKDIR 커맨드의 아규먼트에는 환경 변수가 대체값으로 사용되었습니다.

Step 6/9 : WORKDIR $APPROOT
Removing intermediate container c2cb1fc7bf4f
---> cb7953a10e42

WORKDIR 커맨드의 결과로 디폴트 작업 디렉토리가 /app으로 설정된 이미지가 생성됩니다. WORKDIR를 존재하지 않는 위치로 설정하면, 명령어 옵션을 사용할 때와 마찬가지로 해당 위치가 새로 생성됩니다.

마지막으로, EXPOSE 명령어는 TCP 포트 33333을 열어주는 레이어를 생성합니다.

Step 9/9 : EXPOSE 33333
---> Running in cfb2afea5ada
Removing intermediate container cfb2afea5ada
---> 38a4767b8df4

이 Dockerfile에서 알아볼 수 있는 부분은 FROM, LABEL, ENTRYPOINT 커맨드입니다. 간략히 설명하자면, FROM 커맨드는 레이어 스택의 시작점을 debian:buster-20190910 이미지로 설정합니다. 생성되는 모든 새 레이어는 이 이미지 위에 추가됩니다. LABEL 커맨드는 이미지의 메타데이터에 키/값 쌍을 추가합니다. ENTRYPOINT 커맨드는 컨테이너 시작 시 실행할 실행 파일을 설정합니다. 여기서는 exec ./mailer.sh 커맨드를 설정하며, 커맨드의 쉘 형식을 사용합니다.

ENTRYPOINT 커맨드는 두 가지 형식이 있습니다: shell 형식과 exec 형식입니다.
쉘 형식은 공백으로 구분된 아규먼트가 있는 쉘 명령처럼 보입니다. exec 형식은 첫 번째 값이 실행할 커맨드이고, 나머지 값은 아규먼트인 문자열 배열입니다. 쉘 형식으로 지정된 커맨드는 디폴트 쉘의 아규먼트로 실행됩니다. 구체적으로, 이 Dockerfile에서 사용된 커맨드는 런타임 시 /bin/sh -c 'exec ./mailer.sh'로 실행됩니다.

가장 중요한 점은, ENTRYPOINT에 shell 형식을 사용하면, CMD 커맨드 또는 docker container run 실행 시 추가된 모든 아규먼트가 무시된다는 것입니다. 이로 인해 ENTRYPOINT의 shell 형식은 유연성이 떨어집니다.

빌드 출력에서 ENVLABEL 커맨드가 각각 단일 단계와 레이어를 생성했음을 알 수 있습니다. 그러나 출력만으로는 환경 변수 값이 올바르게 대체되었는지 확인할 수 없습니다. 이를 확인하려면 이미지를 검사해야 합니다.

docker inspect dockerinaction/mailer-base:0.6
Tip: docker inspect 커맨드는 컨테이너나 이미지의 메타데이터를 확인하는 데 사용할 수 있습니다. 이 경우, 이미지를 검사하기 위해 사용되었습니다.

 

관련된 출력 라인들은 다음과 같습니다:

"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"APPROOT=/app",
"APP=mailer.sh",
"VERSION=0.6"
],
...
"Labels": {
"base.name": "Mailer Archetype",
"base.version": "0.6",
"maintainer": "dia@allingeek.com"
},
...
"WorkingDir": "/app"

메타데이터는 환경 변수 대체가 제대로 작동하고 있음을 명확히 보여줍니다. 이 형태의 대체는 ENV, ADD, COPY, LABEL, WORKDIR, VOLUME, EXPOSE, USER 커맨드에서 사용할 수 있습니다.

마지막으로 주석 처리된 라인은 메타데이터 커맨드인 USER입니다. 이는 이후의 모든 빌드 단계와 이 이미지에서 생성된 컨테이너에 대해 사용자와 그룹을 설정합니다. 이 경우, base 이미지에서 이를 설정하면 하위 Dockerfile이 소프트웨어를 설치하지 못하도록 방지합니다. 이는 이러한 Dockerfile이 권한을 위해 디폴트값을 계속 전환해야 함을 의미하며, 적어도 두 개의 추가 레이어를 생성하게 됩니다. 더 나은 접근 방식은 베이스 이미지에서 사용자와 그룹 계정을 설정하고, 빌드가 완료된 후 구현 단계에서 디폴트 사용자를 설정하는 것입니다.

이 Dockerfile에서 가장 흥미로운 점은 ENTRYPOINT가 존재하지 않는 파일로 설정되어 있다는 것입니다. 베이스 이미지에서 컨테이너를 실행하려고 하면 엔트리포인트가 실패하게 됩니다. 하지만 베이스 이미지에서 엔트리포인트를 설정하면, mailer의 특정 구현을 위해 이 레이어를 중복할 필요가 없어집니다. 다음 두 Dockerfile은 서로 다른 mailer.sh 구현을 빌드합니다.

 

8.2.2 Filesystem instructions

사용자 정의 기능을 포함하는 이미지는 파일 시스템을 수정해야 합니다. Dockerfile에는 파일 시스템을 수정하는 세 가지 커맨드가 있습니다: COPY, VOLUME, ADD. 첫 번째 구현을 위한 Dockerfile은 mailer-logging.df라는 파일에 작성해야 합니다:

FROM dockerinaction/mailer-base:0.6
RUN apt-get update && \
    apt-get install -y netcat
COPY ["./log-impl", "${APPROOT}"]
RUN chmod a+x ${APPROOT}/${APP} && \
    chown example:example /var/log
USER example:example
VOLUME ["/var/log"]
CMD ["/var/log/mailer.log"]

이 Dockerfile에서는 mailer-base에서 생성된 이미지를 시작점으로 사용합니다. 여기에서 새로 추가된 세 가지 커맨드는 COPY, VOLUME, CMD입니다.

  • COPY 커맨드는 이미지가 빌드되는 호스트 파일 시스템에서 빌드 컨테이너로 파일을 복사합니다. COPY 커맨드는 최소 두 개의 아규먼트를 필요로 합니다. 마지막 아규먼트는 목적지이며, 나머지 모든 아규먼트(첫번째 아규먼트부터)는 소스 파일입니다. 이 커맨드는 한 가지 예상치 못한 특징이 있습니다: 복사된 모든 파일의 소유권이 디폴트 사용자 설정에 관계없이 root로 설정됩니다. 따라서 파일 소유권을 변경하는 RUN 커맨드는 수정할 모든 파일이 이미지로 복사된 후 실행하는 것이 좋습니다.
  • COPY 커맨드는 ENTRYPOINT 및 다른 커맨드와 마찬가지로 shell 스타일 및 exec 스타일 아규먼트를 모두 지원합니다. 그러나 아규먼트 중 공백이 포함된 경우 exec 형식을 사용해야 합니다.
팁: 가능하면 exec(또는 문자열 배열) 형식을 사용하는 것이 모범 사례입니다. 최소한, Dockerfile이 일관되게 작성되어야 하며 스타일을 혼합하지 않아야 합니다. 이는 Dockerfile을 더 읽기 쉽게 만들고, 명령어가 세부적인 차이를 이해하지 않아도 예상대로 동작하도록 보장합니다.

 

새로 추가된 두 번째 커맨드는 VOLUME입니다. 이는 docker run 또는 docker create 호출 시 --volume 플래그가 수행하는 작업을 이해한다면 이 커맨드는 여러분들이 예상한 대로 작동합니다. 문자열 배열 아규먼트의 각 값은 결과 레이어에 새 볼륨 정의로 생성됩니다. 이미지 빌드 시점에 볼륨을 정의하는 것은 런타임보다 제한적입니다. 이미지 빌드 시점에는 바인드 마운트 볼륨 또는 읽기 전용 볼륨을 지정할 방법이 없습니다. VOLUME 커맨드는 두 가지 작업만 수행합니다: 이미지 파일 시스템에 정의된 위치를 생성하고, 이미지 메타데이터에 볼륨 정의를 추가합니다.

이 Dockerfile의 마지막 커맨드는 CMD입니다. CMDENTRYPOINT 커맨드와 밀접한 관련이 있으며, 그림 8.1에 나와 있습니다. 두 커맨드 모두 쉘 또는 exec 형식을 취하며, 컨테이너 내에서 프로세스를 시작하는 데 사용됩니다. 하지만 중요한 몇 가지 차이점이 있습니다.

Figure 8.1 Relationship between ENTRYPOINT and CMD

 

1. Default EntryPoint는 /bin/sh

  • 디폴트로 Docker 컨테이너는  커맨드를 실행할 때 /bin/sh를 엔트리포인트로 사용합니다.
  • 만약 ENTRYPOINT가 명시적으로 설정되지 않았다면, 컨테이너는 /bin/sh를 통해 커맨드를 실행합니다.

예시:

  • Dockerfile에서 CMD ["echo", "Hello, World!"]로 설정하면 컨테이너 시작시, 다음과 같이 실행됩니다.
/bin/sh -c "echo Hello, World!"

 

2. CMD는 디폴트 커맨드나 아규먼트를 제공

  • CMD는 컨테이너 실행 시 전달할 디폴트 명령어 또는 디폴트 아규먼트를 정의합니다.
  • ENTRYPOINT가 설정되어 있지 않다면, CMD 자체가 실행될 커맨드 역할을 합니다.
  • 그러나, ENTRYPOINT가 설정되어 있다면 CMD는 ENTRYPOINT에 전달될 디폴트 아규먼트를 정의합니다.

ENTRYPOINT와 CMD의 조합:

  • Dockerfile에 아래와 같이 설정되어 있다고 가정:
ENTRYPOINT ["mailer"]
CMD ["/var/log/mailer.log"]
  • 실행 시 컨테이너가 실행하는 실제 커맨드는:
mailer /var/log/mailer.log

 

이미지를 빌드하기 전에, mailer 프로그램의 로깅 버전을 생성해야 합니다. ./log-impl 디렉터리를 생성합니다. 그 디렉터리 안에 mailer.sh라는 이름의 파일을 생성한 뒤, 아래 스크립트를 해당 파일에 복사합니다.

#!/bin/sh
printf "Logging Mailer has started.\n"
while true
do
MESSAGE=$(nc -l -p 33333)
printf "[Message]: %s\n" "$MESSAGE" > $1
sleep 1
done

 

이 스크립트의 구조적인 세부 사항은 중요하지 않습니다. 알아야 할 것은 이 스크립트가 포트 33333에서 mailer 데몬을 시작하고, 수신한 각 메시지를 프로그램의 첫 번째 아규먼트로 지정된 파일에 기록한다는 것입니다. 다음 명령어를 사용하여 mailer-logging.df 파일이 있는 디렉터리에서 mailer-logging 이미지를 빌드하세요:

docker image build -t dockerinaction/mailer-logging -f mailer-logging.df .

 

이 이미지 빌드의 결과는 특별할 것이 없습니다. 이제 이 새 이미지를 사용하여 이름이 지정된 컨테이너를 시작하세요.

docker run -d --name logging-mailer dockerinaction/mailer-logging

 

로깅 메일러가 이제 빌드되고 실행 중일 것입니다. 이 구현에 연결된 컨테이너는 그들의 메시지가 /var/log/mailer.log에 기록됩니다. 실제 상황에서는 이것이 매우 흥미롭거나 유용하지 않을 수 있지만, 테스트에는 유용할 수 있습니다. 운영 모니터링을 위해서는 이메일을 보내는 구현이 더 나을 것입니다.

 

다음 구현 예제는 Amazon Web Services에서 제공하는 Simple Email Service(SES)를 사용하여 이메일을 보냅니다. 새 Dockerfile로 시작하세요. 이 파일의 이름을 mailer-live.df로 지정합니다.

FROM dockerinaction/mailer-base:0.6
ADD ["./live-impl", "${APPROOT}"]
RUN apt-get update && \
apt-get install -y curl netcat python && \
curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py" && \
python get-pip.py && \
pip install awscli && \
rm get-pip.py && \
chmod a+x "${APPROOT}/${APP}"
USER example:example
CMD ["mailer@dockerinaction.com", "pager@dockerinaction.com"]

 

이 Dockerfile에는 새로운 커맨드인 ADD가 포함되어 있습니다. ADD 커맨드는 COPY 와 유사하게 동작하지만, 두 가지 중요한 차이점이 있습니다:

  1. 원격 소스 파일 가져오기:
    • URL이 지정된 경우 원격 소스 파일을 가져옵니다.
  2. 아카이브 파일 자동 추출:
    • 소스가 아카이브 파일(예: .tar, .gz, .zip)로 판단되면 파일을 자동으로 추출합니다.

이 두 가지 차이점 중에서 아카이브 파일의 자동 추출이 더 유용합니다.
ADD 커맨드의 원격 가져오기 기능은 편리해 보이지만, 비추천됩니다. 이유는 다음과 같습니다:

  • 사용되지 않는 파일을 정리할 메커니즘이 없습니다.
  • 추가 레이어가 생성되어 이미지가 비효율적으로 커질 수 있습니다.

대신, ADD 커맨드 대신 RUN 커맨드를 체인(chained RUN instruction) 형태로 사용하는 것이 좋습니다. 이는 mailer-live.df의 세 번째 커맨드에서도 확인할 수 있습니다.

이 Dockerfile에서 주목할 또 다른 커맨드는 CMD입니다. 여기서는 두 개의 아규먼트를 전달합니다:

  • 이메일 발송 시 사용될 FromTo 필드를 지정합니다.
  • 이는 mailer-logging.df에서 단일 아규먼트만 지정했던 것과는 다릅니다.

다음으로, mailer-live.df가 포함된 위치에 live-impl이라는 새 하위 디렉터리를 생성합니다. 그 디렉터리 안에 mailer.sh라는 파일을 생성하고, 아래 스크립트를 해당 파일에 추가합니다.

#!/bin/sh
printf "Live Mailer has started.\n"
while true
do
  MESSAGE=$(nc -l -p 33333)
  aws ses send-email --from $1 \
    --destination {\"ToAddresses\":[\"$2\"]} \
    --message "{\"Subject\":{\"Data\":\"Mailer Alert\"},\
              \"Body\":{\"Text\":{\"Data\":\"${MESSAGE}\"}}}"
  sleep 1
done

 

이 스크립트에서 중요한 점은 다른 mailer 구현과 마찬가지로 포트 33333에서 연결을 대기하고, 메시지를 수신하면 작업을 수행한 뒤 잠시 대기하고 다음 메시지를 기다린다는 것입니다. 하지만 이번에는 Simple Email Service 커맨드라인 도구를 사용하여 이메일을 보냅니다.

컨테이너를 빌드하고 시작하려면 다음 두 커맨드를 실행하세요:

docker image build -t dockerinaction/mailer-live -f mailer-live.df . 
docker run -d --name live-mailer dockerinaction/mailer-live
 

컨테이너를 실행한 뒤, 이를 감시하는 프로그램(watcher)을 연결해 보면 로깅 mailer는 예상대로 작동하지만, 라이브 mailer가 Simple Email Service와의 연결에서 문제가 발생하는 것을 알 수 있습니다. 약간의 조사를 거치면 컨테이너가 잘못 구성되었다는 것을 깨닫게 됩니다.

aws 프로그램은 특정 환경 변수를 설정해야 합니다. 이 예제를 작동시키려면 다음 환경 변수를 설정해야 합니다:

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_DEFAULT_REGION

이 환경 변수는 AWS 클라우드 자격 증명과 이 예제에서 사용할 위치를 정의합니다.

프로그램이 필요로 하는 실행 조건을 하나씩 발견하며 해결하는 것은 사용자에게 좌절감을 줄 수 있습니다. 8.5.1절에서는 이러한 문제를 줄이고 사용자가 쉽게 사용할 수 있도록 돕는 이미지 설계 패턴에 대해 다룹니다.

설계 패턴에 대해 알아보기 전에, Dockerfile 커맨드의 마지막 부분을 배워야 합니다. 모든 이미지가 애플리케이션을 포함하지는 않습니다. 일부 이미지는 하위 이미지의 플랫폼으로 구축됩니다. 이러한 경우 하위 이미지의 빌드 시 동작을 주입할 수 있는 기능이 특히 유용합니다.

 

8.3 Injecting downstream build-time behavior

베이스 이미지를 작성하는 사람들에게 중요한 Dockerfile 커맨드 중 하나는 ONBUILD입니다.
ONBUILD 커맨드는 생성된 이미지가 다른 빌드의 베이스로 사용될 경우 실행될 다른 커맨드를 정의합니다.
예를 들어, ONBUILD 커맨드를 사용하여 하위 레이어에서 제공된 프로그램을 컴파일할 수 있습니다.

상위 Dockerfile은 빌드 디렉터리의 내용을 알려진 위치로 복사한 다음, 해당 위치에서 코드를 컴파일합니다.
상위 Dockerfile은 다음과 같은 커맨드 세트를 사용할 수 있습니다:

ONBUILD COPY [".", "/var/myapp"]
ONBUILD RUN go build /var/myapp

 

ONBUILD 뒤에 나오는 커맨드는 해당 Dockerfile이 빌드될 때 실행되지 않습니다.
대신, 이러한 커맨드는 생성된 이미지의 메타데이터에 ContainerConfig.OnBuild 항목으로 기록됩니다.
이 커맨드들은 다음과 같은 메타데이터에 포함됩니다:

...
"ContainerConfig": {
...
    "OnBuild": [
       "COPY [\".\", \"/var/myapp\"]",
       "RUN go build /var/myapp"
    ],
...

 

이 메타데이터는 다른 Dockerfile 빌드의 베이스로 사용될 때까지 유지됩니다.
하위 Dockerfile이 상위 이미지(ONBUILD 커맨드가 포함된 이미지)를 FROM 커맨드에서 사용할 경우,
ONBUILD 명령어는 FROM 커맨드 이후다음 커맨드 이전에 실행됩니다.

ONBUILD 단계가 빌드에 언제 주입되는지 정확히 확인하려면, 아래 예제를 살펴보세요.
이를 완전히 이해하려면 두 개의 Dockerfile을 작성하고 두 개의 빌드 커맨드를 실행해야 합니다.
먼저, ONBUILD 커맨드를 정의하는 상위 Dockerfile을 만듭니다. 이 파일의 이름을 base.df로 지정하고, 다음 커맨드를 추가합니다:

FROM busybox:latest
WORKDIR /app
RUN touch /app/base-evidence
ONBUILD RUN ls -al /app

 

base.df를 빌드하여 생성된 이미지는 /app 디렉터리에 base-evidence라는 빈 파일을 추가합니다.
ONBUILD 커맨드는 빌드 시점에 /app 디렉터리의 내용을 나열합니다.
따라서 파일 시스템에서 변경이 언제 이루어지는지 정확히 확인하려면 조용한 모드(quiet mode)로 빌드를 실행하지 않는 것이 중요합니다.

다음으로, 하위 Dockerfile을 만드세요. 이 파일을 빌드하면 결과 이미지에 변경 사항이 언제 적용되는지 정확히 확인할 수 있습니다.
파일 이름을 downstream.df로 지정하고, 다음 내용을 포함하세요:

FROM dockerinaction/ch8_onbuild
RUN touch downstream-evidence
RUN ls -al .

 

이 Dockerfile은 dockerinaction/ch8_onbuild라는 이미지를 베이스로 사용합니다.
따라서 베이스 이미지를 빌드할 때 해당 이름을 저장소(repository) 이름으로 사용해야 합니다.

그런 다음, 하위 빌드는 두 번째 파일을 생성하고 /app의 내용을 다시 나열합니다.
이제 이 두 파일을 준비했으니 빌드를 시작할 준비가 되었습니다.
다음 커맨드를 실행하여 상위 이미지를 생성하세요:

docker image build -t dockerinaction/ch8_onbuild -f base.df .

 

위 빌드의 출력은 다음과 같을 것입니다:

Sending build context to Docker daemon 3.072kB
Step 1/4 : FROM busybox:latest
---> 6ad733544a63
Step 2/4 : WORKDIR /app
Removing intermediate container dfc7a2022b01
---> 9bc8aeafdec1
Step 3/4 : RUN touch /app/base-evidence
---> Running in d20474e07e45
Removing intermediate container d20474e07e45
---> 5d4ca3516e28
Step 4/4 : ONBUILD RUN ls -al /app
---> Running in fce3732daa59
Removing intermediate container fce3732daa59
---> 6ff141f94502
Successfully built 6ff141f94502
Successfully tagged dockerinaction/ch8_onbuild:latest

 

다음으로 다음 커맨드로 하위 이미지를 빌드합니다.

docker image build -t dockerinaction/ch8_onbuild_down -f downstream.df .

 

빌드 결과는 ONBUILD 명령어(베이스 이미지에서 정의된)가 언제 실행되는지를 명확히 보여줍니다:

Sending build context to Docker daemon 3.072kB
Step 1/3 : FROM dockerinaction/ch8_onbuild
# Executing 1 build trigger
---> Running in 591f13f7a0e7
total 8
drwxr-xr-x 1 root root 4096 Jun 18 03:12 .
drwxr-xr-x 1 root root 4096 Jun 18 03:13 ..
-rw-r--r-- 1 root root 0 Jun 18 03:12 base-evidence
Removing intermediate container 591f13f7a0e7
---> 5b434b4be9d8
Step 2/3 : RUN touch downstream-evidence
---> Running in a42c0044d14d
Removing intermediate container a42c0044d14d
---> e48a5ea7b66f
Step 3/3 : RUN ls -al .
---> Running in 7fc9c2d3b3a2
total 8
drwxr-xr-x 1 root root 4096 Jun 18 03:13 .
drwxr-xr-x 1 root root 4096 Jun 18 03:13 ..
-rw-r--r-- 1 root root 0 Jun 18 03:12 base-evidence
-rw-r--r-- 1 root root 0 Jun 18 03:13 downstream-evidence
Removing intermediate container 7fc9c2d3b3a2
---> 46955a546cd3
Successfully built 46955a546cd3
Successfully tagged dockerinaction/ch8_onbuild_down:latest

빌더가 베이스 빌드의 4단계에서 ONBUILD 커맨드를 컨테이너 메타데이터에 등록하는 것을 볼 수 있습니다.
그 후, 하위 이미지 빌드의 출력에서는 베이스 이미지로부터 상속받은 트리거(ONBUILD 명령어)를 보여줍니다.

빌더는 0단계(FROM 커맨드) 바로 뒤에 트리거를 발견하고 처리합니다.
출력에는 트리거에서 지정된 RUN 커맨드의 결과가 포함됩니다.

출력 결과는 베이스 빌드의 증거만 존재함을 보여줍니다.
그 후, 빌더가 하위 Dockerfile의 커맨드를 처리하면서 /app 디렉터리의 내용을 다시 나열합니다.
이때 두 번의 변경 사항이 모두 나열된 증거를 확인할 수 있습니다

 

이 예제는 실용적이라기보다는 설명적입니다.
실제 사용 사례를 이해하려면 Docker Hub에서 onbuild 접미사가 붙은 이미지를 찾아보는 것을 추천합니다.
이를 통해 ONBUILD가 실제로 어떻게 사용되는지 알 수 있습니다.
다음은 추천하는 예제들입니다:

 

node - Official Image | Docker Hub

Docker Official Images are a curated set of Docker open source and drop-in solution repositories. Why Official Images? These images have clear documentation, promote best practices, and are designed for the most common use cases.

hub.docker.com

 

8.4 Creating maintainable Dockerfiles

Dockerfile에는 밀접하게 관련된 이미지들을 유지 관리하기 쉽게 만드는 기능이 포함되어 있습니다.
이 기능들은 작성자가 빌드 시간에 이미지 간 메타데이터와 데이터를 공유하도록 도와줍니다.
다음은 Dockerfile 구현을 통해 이러한 기능을 사용하여 더욱 간결하고 유지보수하기 쉬운 Dockerfile을 만드는 방법을 설명합니다.

 

중복 문제

메일러 애플리케이션의 Dockerfile을 작성하면서, 각 업데이트마다 변경해야 하는 몇 가지 반복적인 부분이 있다는 것을 알게 되었을 것입니다.
VERSION 변수가 중복의 대표적인 예입니다.

  • VERSION 메타데이터는 이미지 태그, 환경 변수, 레이블 메타데이터에 사용됩니다.
  • 이로 인해 여러 곳에서 수동으로 값을 변경해야 하며, 이는 실수를 유발할 가능성을 높입니다.

또한 빌드 시스템은 종종 애플리케이션의 버전 관리 시스템에서 버전 메타데이터를 가져옵니다.
Dockerfile이나 스크립트에 이 값을 하드코딩하지 않는 것이 더 바람직합니다.

 

ARG 명령어로 문제 해결

DockerfileARG 커맨드는 이러한 문제를 해결하는 방법을 제공합니다.

  • ARG는 사용자가 이미지 빌드 시 제공할 수 있는 변수를 정의합니다.
  • Docker는 이 변수를 Dockerfile에 삽입하여 파라미터화된 Dockerfile을 생성할 수 있습니다.

빌드 시 변수 전달

빌드 시 --build-arg 옵션을 사용하여 변수를 전달할 수 있습니다:

docker image build --build-arg VERSION=1.0 .

 

ARG 사용 예제

mailer-base.df2번째 라인ARG VERSION 명령어를 추가해 보겠습니다:

 

FROM debian:buster-20190910
ARG VERSION=unknown       (1)
LABEL maintainer="dia@allingeek.com"
RUN groupadd -r -g 2200 example && \
useradd -rM -g example -u 2200 example
ENV APPROOT="/app" \
APP="mailer.sh" \
VERSION="${VERSION}"
LABEL base.name="Mailer Archetype" \
base.version="${VERSION}"
WORKDIR $APPROOT
ADD . $APPROOT
ENTRYPOINT ["/app/mailer.sh"]
EXPOSE 33333

(1) Defines the VERSION build arg with default value “unknown”

 

이제 버전을 셸 변수로 한 번만 정의하고, 커맨드라인에서 이미지 태그와 빌드 아규먼트로 전달하여 이미지 내에서 사용할 수 있습니다:

$ version=0.6; docker image build -t dockerinaction/mailer-base:${version} \
> -f mailer-base.df \
> --build-arg VERSION=${version} \
> .

 

docker image inspect 커맨드를 사용하여 VERSION 값이 base.version 레이블까지 올바르게 대체되었는지 확인해 보겠습니다:

docker image inspect --format '{{ json .Config.Labels }}' \
dockerinaction/mailer-base:0.6

 

inspect 커맨드는 다음과 같은 JSON 형식의 출력을 생성합니다:

{
  "base.name": "Mailer Archetype",
  "base.version": "0.6",
  "maintainer": "dia@allingeek.com"
}

 

VERSION을 빌드 아규먼트로 지정하지 않았다면, 디폴트 값인 unknown이 사용되었을 것이며, 빌드 과정에서 경고 메시지가 출력되었을 것입니다.

이제 이미지 빌드의 단계를 구분함으로써 중요한 문제를 관리할 수 있도록 도와주는 멀티스테이지 빌드(multistage build)에 대해 알아보겠습니다.
멀티스테이지 빌드는 몇 가지 일반적인 문제를 해결하는 데 유용합니다. 주요 사용 사례는 다음과 같습니다:

  • 다른 이미지의 일부를 재사용.
  • 애플리케이션 빌드와 애플리케이션 런타임 이미지 빌드를 분리.
  • 애플리케이션 런타임 이미지를 특화된 테스트 또는 디버그 도구로 강화.

다음 예제는 다른 이미지의 일부를 재사용하고, 애플리케이션 빌드와 런타임의 문제를 분리하는 방법을 보여줍니다.
먼저 Dockerfile의 멀티스테이지 기능에 대해 배워보겠습니다.

 

멀티스테이지 Dockerfile

멀티스테이지 Dockerfile은 여러 개의 FROM 커맨드를 포함하는 Dockerfile입니다.

  • FROM 커맨드는 새 빌드 단계를 표시하며, 해당 단계의 최종 레이어는 이후 단계에서 참조할 수 있습니다.
  • 빌드 단계 이름AS <name>FROM 커맨드에 추가하여 지정할 수 있습니다.
    여기서 name은 사용자가 지정하는 식별자(예: builder)입니다.

이름 사용 방법

  • FROM 또는 COPY --from=<name|index> 커맨드에서 단계 이름을 사용하여 빌드된 파일의 소스 레이어를 참조할 수 있습니다.

멀티스테이지 빌드의 결과

  • 멀티스테이지 Dockerfile을 빌드할 때는 여전히 하나의 Docker 이미지만 생성됩니다.
  • 최종적으로 실행된 단계에서 이미지를 생성합니다.

Figure 8.2 Multistage Docker builds

 

 

멀티스테이지 빌드의 사용을 보여주는 예제를 통해 이를 설명하겠습니다.
이 예제는 두 개의 단계와 약간의 구성을 사용합니다(그림 8.2 참고).
이 예제를 따라가기 쉬운 방법은 git@github.com:dockerinaction/ch8_multi_stage_build.git  깃 리포지토리를 클론하는 것입니다.

이 Dockerfile은 두 개의 단계를 정의합니다: builder 단계와 runtime 단계.

  • builder 단계: 종속성을 수집하고 예제 프로그램을 빌드합니다.
  • runtime 단계: 인증서(CA)와 프로그램 파일을 런타임 이미지에 복사하여 실행에 사용합니다.

http-client.df Dockerfile의 소스는 다음과 같습니다:

#################################################
# Define a Builder stage and build app inside it
FROM golang:1-alpine as builder
# Install CA Certificates
RUN apk update && apk add ca-certificates
# Copy source into Builder
ENV HTTP_CLIENT_SRC=$GOPATH/src/dia/http-client/
COPY . $HTTP_CLIENT_SRC
WORKDIR $HTTP_CLIENT_SRC
# Build HTTP Client
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -v -o /go/bin/http-client
#################################################
# Define a stage to build a runtime image.
FROM scratch as runtime
ENV PATH="/bin"
# Copy CA certificates and application binary from builder stage
COPY --from=builder \
/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /go/bin/http-client /http-client
ENTRYPOINT ["/http-client"]

 

이미지 빌드를 자세히 살펴보겠습니다.

FROM golang:1-alpine as builder 커맨드는 첫 번째 단계를 Golang의 Alpine 이미지 변형을 기반으로 하고, 이후 단계에서 쉽게 참조할 수 있도록 builder라는 별칭(alias)을 지정합니다.

  1. builder 단계에서의 작업:
    • 인증서 기관(Certificate Authority, CA) 파일을 설치합니다.
      이는 HTTPS를 지원하는 전송 계층 보안(TLS) 연결을 설정하는 데 사용됩니다.
      이 CA 파일은 builder 단계에서는 사용되지 않지만, runtime 이미지에서 구성에 사용됩니다.
    • 그다음, http-client 소스 코드를 컨테이너로 복사하고, Golang 프로그램인 http-client를 정적 바이너리로 빌드합니다.
    • http-client 프로그램은 /go/bin/http-client 경로에 저장됩니다.
  2. http-client 프로그램의 역할:
    • 이 프로그램은 간단한 HTTP 요청을 수행하여 자신의 소스 코드를 GitHub에서 가져옵니다.
package main
import (
"net/http"
)
import "io/ioutil"
import "fmt"

func main() {
	url := "https://raw.githubusercontent.com/" +
	"dockerinaction/ch8_multi_stage_build/master/http-client.go"
	resp, err := http.Get(url)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	fmt.Println("response:\n", string(body))
}

runtime 단계scratch*를 기반으로 합니다.
FROM scratch로 이미지를 빌드하면 파일 시스템은 비어 있는 상태에서 시작하며, COPY 커맨드로 추가된 내용만 이미지에 포함됩니다.

프로그램의 http.Get 구문은 파일을 HTTPS 프로토콜을 사용해 가져옵니다.
따라서 프로그램은 유효한 TLS 인증서 기관(CA) 세트를 필요로 합니다.

이 인증서 기관 파일은 builder 단계에서 미리 설치했기 때문에 runtime 단계에서도 사용할 수 있습니다.
runtime 단계builder 단계에서 인증서 파일(ca-certificates.crt)과 프로그램 파일(http-client)을 복사하여 실행 환경을 구성합니다.
다음 커맨드들을 사용해 이 파일들을 복사합니다:

COPY --from=builder \
/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /go/bin/http-client /http-client
 

runtime 단계는 이미지의 ENTRYPOINT를 /http-client로 설정하며, 이는 컨테이너가 시작될 때 실행됩니다.
최종 이미지는 단 두 개의 파일만 포함합니다.
이 이미지는 다음과 같은 커맨드로 빌드할 수 있습니다:

docker build -t dockerinaction/http-client -f http-client.df .

 

이 이미지는 다음과 같이 실행되어질 수 있습니다:

docker container run --rm -it dockerinaction/http-client:latest

 

http-client 이미지가 성공적으로 실행되면, 이전에 나열된 http-client.go 소스 코드가 출력됩니다.

요약하자면, http-client.df Dockerfile은 다음과 같은 과정을 거칩니다:

  1. builder 단계:
    • 런타임 종속성을 가져오고 http-client 프로그램을 빌드합니다.
  2. runtime 단계:
    • http-client와 그 종속성을 builder 단계에서 최소화된 scratch 기반 이미지로 복사하고 실행을 위해 구성합니다.

최종 이미지는 프로그램 실행에 필요한 내용만 포함하며, 크기는 약 6MB에 불과합니다.

다음 섹션에서는 방어적인 시작 스크립트를 사용하여 애플리케이션을 전달하는 다른 스타일을 다룰 것입니다.

 

8.5 Using startup scripts and multiprocess containers

어떤 도구를 사용하든, 항상 몇 가지 이미지 설계 측면을 고려해야 합니다.
컨테이너에서 실행되는 소프트웨어가 startup 지원, 감독, 모니터링, 또는 컨테이너 내 다른 프로세스와의 조정이 필요한지 스스로에게 물어보아야 합니다.
만약 그렇다면, 이미지에 startup  스크립트 또는 초기화 프로그램을 포함하고 이를 ENTRYPOINT로 설정해야 합니다.

 

8.5.1 Environmental preconditions validation

어떤 도구를 사용하든, 항상 몇 가지 이미지 설계 측면을 고려해야 합니다.
컨테이너에서 실행되는 소프트웨어가 시작 지원, 감독, 모니터링, 또는 컨테이너 내 다른 프로세스와의 조정이 필요한지 스스로 물어보아야 합니다.
만약 그렇다면, 시작 스크립트나 초기화 프로그램을 이미지에 포함하고 이를 ENTRYPOINT로 설정해야 합니다.

실패 모드는 전달하기 어렵고, 임의의 시간에 발생하면 사용자를 당황하게 만들 수 있습니다.
그러나 컨테이너 구성 문제가 항상 이미지 시작 시점에 실패를 유발한다면, 사용자는 컨테이너가 시작되었을 때 지속적으로 실행될 것이라는 자신감을 가질 수 있습니다.

소프트웨어 설계에서 빠르게 실패(fail fast)하고 선행 조건 검증(precondition validation)을 수행하는 것은 모범 사례입니다.
이미지 설계에서도 동일하게 적용되는 것이 합리적입니다.
검증해야 할 선행 조건은 실행 환경(context)에 대한 가정입니다.

Docker 컨테이너는 생성되는 환경을 제어할 수는 없지만, 자신의 실행은 제어할 수 있습니다.
이미지 작성자는 실행 전 환경과 종속성을 검증함으로써 이미지 사용 경험을 개선할 수 있습니다.
이러한 방식으로, 해당 이미지에서 생성된 컨테이너가 빠르게 실패하고 설명적인 에러 메시지를 표시하면, 사용자는 이미지의 요구사항에 대해 더 잘 이해할 수 있습니다.

예제: WordPress 컨테이너
WordPress는 특정 환경 변수가 설정되거나 컨테이너 링크가 정의되어야 합니다.
이 조건이 충족되지 않으면, WordPress는 블로그 데이터를 저장하는 데이터베이스에 연결할 수 없습니다.
데이터에 접근할 수 없는 상태에서 WordPress를 시작하는 것은 의미가 없습니다.

WordPress 이미지는 특정 스크립트를 컨테이너의 ENTRYPOINT로 사용합니다.
이 스크립트는 컨테이너의 실행 환경이 WordPress 버전과 호환되는 방식으로 설정되었는지 검증합니다.
필수 조건이 충족되지 않으면(예: 링크가 정의되지 않았거나 환경 변수가 설정되지 않은 경우),
이 스크립트는 WordPress를 시작하기 전에 종료하고 컨테이너는 비정상적으로 멈춥니다.

프로그램 시작을 위한 선행 조건을 검증하는 작업은 일반적으로 사용 사례에 따라 다릅니다.
소프트웨어를 이미지에 패키징할 경우, 보통 다음 작업이 필요합니다:

  1. 스크립트를 작성하거나
  2. 프로그램 시작 도구를 신중하게 구성합니다.

시작 프로세스에서는 가능한 한 가정된 컨텍스트를 검증해야 합니다.
여기에는 다음이 포함됩니다:

  • 링크(및 별칭)
  • 환경 변수
  • Secrets
  • 네트워크 접근
  • 네트워크 포트 사용 가능 여부
  • Root 파일 시스템 마운트 파라미터(read-write 또는 read-only)
  • Volumes
  • 현재 user

이 작업을 수행하기 위해 원하는 스크립팅 또는 프로그래밍 언어를 사용할 수 있습니다.
그러나 최소 이미지를 작성하려면 이미지에 이미 포함된 언어 또는 도구를 사용하는 것이 좋습니다.
대부분의 베이스 이미지는 /bin/sh 또는 /bin/bash와 같은 셸을 포함하고 있습니다.
셸 스크립트는 다음과 같은 이유로 가장 일반적으로 사용됩니다:

  • 셸 프로그램은 흔히 사용 가능합니다.
  • 특정 프로그램 및 환경 요구사항에 쉽게 적응할 수 있습니다.

단일 바이너리 이미지에서의 검증
http-client 예제와 같이 단일 바이너리를 위한 이미지를 scratch에서 빌드하는 경우,
프로그램 자체가 선행 조건을 검증하는 책임을 집니다.
이유는 컨테이너 내부에 다른 프로그램이 없기 때문입니다.

 

예제: 의존성 있는 프로그램을 위한 셸 스크립트
다음은 웹 서버에 의존하는 프로그램을 위한 셸 스크립트의 예입니다.
컨테이너가 시작될 때, 이 스크립트는 다음을 확인합니다:

  1. 다른 컨테이너가 web 별칭으로 링크되었고 포트 80을 노출했거나
  2. WEB_HOST 환경 변수가 정의되었는지 확인합니다.
#!/bin/bash
set -e
if [ -n "$WEB_PORT_80_TCP" ]; then
if [ -z "$WEB_HOST" ]; then
WEB_HOST='web'
else
echo >&2 '[WARN]: Linked container, "web" overridden by $WEB_HOST.'
echo >&2 "===> Connecting to WEB_HOST ($WEB_HOST)"
fi
fi
if [ -z "$WEB_HOST" ]; then
echo >&2 '[ERROR]: specify container to link; "web" or WEB_HOST env var'
exit 1
fi
exec "$@" # run the default command

 

셸 스크립트에 익숙하지 않다면, 지금이 배울 적절한 시점입니다.
이 주제는 접근하기 쉽고, 스스로 학습할 수 있는 훌륭한 자료들이 많이 있습니다.

이 특정 스크립트는 환경 변수와 컨테이너 링크를 모두 테스트하는 패턴을 사용합니다.
환경 변수가 설정되어 있다면, 컨테이너 링크는 무시됩니다.
마지막으로, 디폴트 커맨드가 실행됩니다.

구성 검증을 위해 startup 스크립트를 사용하는 이미지는 사용자가 잘못 사용할 경우 빠르게 실패해야 합니다.
하지만 동일한 컨테이너는 다른 이유로 나중에 실패할 수도 있습니다.

startup 스크립트를 컨테이너 재시작 정책과 결합하면 신뢰할 수 있는 컨테이너를 만들 수 있습니다.
그러나 컨테이너 재시작 정책은 완벽한 솔루션이 아닙니다.
실패한 뒤 재시작을 대기 중인 컨테이너는 실행되지 않습니다.
이 말은, 운영자가 백오프(backoff) 대기 시간 동안 컨테이너 내에서 다른 프로세스를 실행할 수 없음을 의미합니다.

이 문제를 해결하려면 컨테이너가 절대 멈추지 않도록 보장해야 합니다.

 

8.5.2 Initialization processes

유닉스 기반 컴퓨터는 일반적으로 init 프로세스를 먼저 시작합니다.
이 init 프로세스는 다른 모든 시스템 서비스를 시작, 실행 유지, 종료하는 역할을 담당합니다.
init 스타일의 시스템을 사용하여 컨테이너 프로세스를 시작, 관리, 재시작, 종료하는 것은 적절한 방식일 수 있습니다.

init 프로세스는 일반적으로 초기화된 시스템의 이상적인 상태를 설명하는 파일이나 파일 집합을 사용합니다.
이 파일들은 어떤 프로그램을 시작할지, 언제 시작할지, 종료되었을 때 어떤 작업을 수행할지를 설명합니다.
init 프로세스를 사용하는 것은 다수의 프로그램을 시작하고, 고아 프로세스를 정리하며, 프로세스를 모니터링하고, 실패한 프로세스를 자동으로 재시작하는 최선의 방법입니다.

이 패턴을 채택하기로 결정했다면, 애플리케이션 중심의 Docker 컨테이너에서 init 프로세스를 ENTRYPOINT로 사용해야 합니다.
사용하는 init 프로그램에 따라, 환경을 미리 준비하기 위해 시작 스크립트가 필요할 수 있습니다.
예를 들어, runit 프로그램은 실행하는 프로그램에 환경 변수를 전달하지 않습니다.
만약 서비스가 환경을 검증하는 startup 스크립트를 사용한다면, 필요한 환경 변수에 접근할 수 없게 됩니다.
이 문제를 해결하는 가장 좋은 방법은 runit 프로그램용 startup 스크립트를 사용하는 것입니다.
이 스크립트는 환경 변수를 파일에 기록하여 애플리케이션의 startup 스크립트가 이를 액세스할 수 있도록 합니다.

여러 오픈 소스 init 프로그램이 존재합니다.
기능이 풍부한 리눅스 배포판에는 SysV, Upstart, systemd와 같은 대규모의 init 시스템이 디폴트로 포함되어 있습니다.
Ubuntu, Debian, CentOS와 같은 리눅스 Docker 이미지에는 init 프로그램이 설치되어 있지만, 디폴트로 작동하지 않습니다.
이 프로그램들은 설정이 복잡할 수 있으며, root 액세스가 필요한 리소스에 대해 강한 종속성을 가질 수 있습니다.
이런 이유로, 커뮤니티에서는 경량 init 프로그램을 사용하는 경향이 있습니다.
runit, tini, BusyBox init, Supervisord, DAEMON Tools는 인기 있는 선택지입니다.
이 프로그램들은 비슷한 문제를 해결하려고 하지만, 각각의 장단점이 있습니다.

애플리케이션 컨테이너에 init 프로세스를 사용하는 것은 모범 사례이지만, 모든 사용 사례에 적합한 완벽한 init 프로그램은 없습니다.
컨테이너에서 사용할 init 프로그램을 평가할 때 다음 요소를 고려하세요:

  • 이미지에 추가되는 종속성
  • 파일 크기
  • 프로그램이 자식 프로세스에 signal을 전달하는 방식(혹은 전달 여부)
  • 필요한 사용자 액세스 권한
  • 모니터링 및 재시작 기능(재시작 시 백오프 기능은 보너스)
  • 좀비 프로세스 정리 기능

init 프로세스는 매우 중요하기 때문에, Docker는 컨테이너에서 실행되는 프로그램을 관리하기 위해 init 프로세스를 추가할 수 있는 --init 옵션을 제공합니다.
--init 옵션을 사용하면 기존 이미지에 init 프로세스를 추가할 수 있습니다.
예를 들어, Alpine:3.6 이미지를 사용하여 Netcat을 실행하고 init 프로세스로 관리할 수 있습니다:

docker container run -it --init alpine:3.6 nc -l -p 3000
 

호스트의 프로세스를 ps -ef 커맨드로 확인하면, Docker가 컨테이너 내부에서 /dev/init -- nc -l -p 3000을 실행했음을 알 수 있습니다.
디폴트로 Docker는 tini 프로그램을 init 프로세스로 사용하지만, 다른 init 프로그램을 지정할 수도 있습니다.

어떤 init 프로그램을 선택하든, 이미지를 사용하는 사람이 신뢰할 수 있도록 컨테이너에서 이를 활용하세요.
컨테이너가 구성 문제를 전달하기 위해 빠르게 실패해야 하는 경우, init 프로그램이 그 실패를 숨기지 않도록 해야 합니다.

이제 컨테이너 내부에서 프로세스를 실행하고 signal를 전달하는 기초를 다졌으니, 컨테이너화된 프로세스의 상태를 협력자에게 전달하는 방법을 살펴보겠습니다.

 

8.5.3 The purpose and use of health checks

헬스 체크(Health Check)는 컨테이너 내부에서 실행 중인 애플리케이션이 준비 상태인지, 기능을 수행할 수 있는지를 판단하기 위해 사용됩니다.
엔지니어는 컨테이너에서 애플리케이션이 실행 중이지만 멈추거나 종속성이 깨졌을 때를 감지하기 위해 애플리케이션별 헬스 체크를 정의합니다.

Docker는 애플리케이션이 정상인지 확인하기 위해 컨테이너 내부에서 단일 커맨드를 실행합니다.
헬스 체크 실행을 지정하는 방법은 두 가지입니다:

  • 이미지 정의 시: HEALTHCHECK 커맨드를 사용.
  • 컨테이너 실행 시: 커맨드라인에서 커맨드 지정.

NGINX 웹 서버의 HEALTHCHECK Dockerfile

FROM nginx:1.13-alpine HEALTHCHECK --interval=5s --retries=2 \ CMD nc -vz -w 2 localhost 80 || exit 1
 
  • HEALTHCHECK 커맨드는 신뢰할 수 있고, 가볍고, 애플리케이션의 주요 작업에 방해되지 않아야 합니다.
  • 이 커맨드는 자주 실행되기 때문입니다.
  • 커맨드의 종료 상태(exit status)는 컨테이너의 상태를 판단하는 데 사용됩니다.

Docker에서 정의한 종료 상태

  • 0: 성공 — 컨테이너가 정상적이고 사용할 준비가 되어 있음.
  • 1: 비정상 — 컨테이너가 올바르게 작동하지 않음.
  • 2: 예약됨 — 이 종료 코드는 사용하지 말 것.

리눅스/유닉스 프로그램은 일반적으로 작업이 예상대로 수행되었을 때 0을 반환하고, 그렇지 않을 경우 nonzero을 반환합니다.
|| exit 1셸 트릭으로, or exit 1을 의미합니다.
이는 nc가 nonzero 상태로 종료되면 해당 상태를 1로 변환하여 Docker가 컨테이너가 비정상적임을 알 수 있도록 합니다.
nonzero 종료 상태를 1로 변환하는 것은 일반적인 패턴입니다.
Docker는 헬스 체크 상태에 대해 12만 정의하기 때문에, 다른 nonzero 상태는 자동으로 비정상으로 간주됩니다.

 

NGINX 이미지 빌드 및 실행

docker image build -t dockerinaction/healthcheck . docker container run --name healthcheck_ex -d dockerinaction/healthcheck
  • HEATHCHECK가 정의된 컨테이너가 실행 중이라면, docker ps 커맨드로 컨테이너의 상태를 확인할 수 있습니다.
  • STATUS 컬럼에 컨테이너의 현재 상태가 표시됩니다.
  • 출력이 보기 불편할 수 있으므로, 다음 커맨드로 이름, 이미지, 상태를 테이블 형식으로 출력할 수 있습니다:
    docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
    출력 예시:
    NAMES                       IMAGE                                               STATUS
    healthcheck_ex          dockerinaction/healthcheck                Up 3 minutes (healthy)

헬스 체크 디폴트 설정

  • 디폴트로 HEALTHCHECK 커맨드는 30초마다 실행됩니다.
  • 연속 3번 실패하면 컨테이너의 상태가 unhealthy로 전환됩니다.
  • 헬스 체크의 간격(interval)실패 임계값(retries)은 HEALTHCHECK 커맨드 또는 컨테이너 실행 시 조정할 수 있습니다.

추가 옵션

  • Timeout: HEALTHCHECK 커맨드 실행 및 종료의 시간 제한.
  • 시작 유예 기간(Start Period):
    • 컨테이너 시작 후 초기 유예 기간 동안 헬스 체크 실패를 상태에 반영하지 않음.
    • HEALTHCHECK 커맨드가 정상 상태를 반환하면 컨테이너가 시작된 것으로 간주되고, 이후의 실패는 상태에 반영.

이미지 작성 시 헬스 체크 권장

이미지 작성자는 가능한 경우 유용한 헬스 체크를 정의해야 합니다.

  • 일반적으로 애플리케이션의 일부를 테스트하거나 웹 서버의 /health 엔드포인트와 같은 내부 상태 지표를 확인합니다.
  • 그러나 실행 방식에 대해 충분히 알지 못하는 경우 HEALTHCHECK 커맨드를 정의하기 어렵습니다.

헬스 체크를 실행 시 정의

Docker는 컨테이너 실행 시 --health-cmd 옵션을 제공하여 헬스 체크를 정의할 수 있습니다:

docker container run --name=healthcheck_ex -d \ --health-cmd='nc -vz -w 2 localhost 80 || exit 1' \ nginx:1.13-alpine
  • 실행 시 헬스 체크를 정의하면, 이미지에 정의된 헬스 체크를 덮어씁니다.
  • 이는 특정 환경 요구 사항을 고려하여 서드파티 이미지를 통합하는 데 유용합니다.

이 도구를 활용해 내구성 있는 컨테이너를 생성할 수 있습니다.
하지만 내구성은 보안이 아니며, 사용자가 내구성 있는 이미지를 신뢰할 수 있다고 하더라도, 이미지가 보안적으로 강화되지 않았다면 신뢰해서는 안 됩니다.

 

8.6 Building hardened application images

이미지 작성자로서, 자신의 작업물이 사용될 모든 시나리오를 예상하기는 어렵습니다.
이러한 이유로, 가능한 한 생성하는 이미지를 강화(hardening)해야 합니다.
이미지 강화는 이를 기반으로 생성된 Docker 컨테이너 내부의 공격 표면을 줄이는 방식으로 이미지를 설계하는 과정을 의미합니다.

애플리케이션 이미지를 강화하는 일반적인 전략은 포함된 소프트웨어를 최소화하는 것입니다.
자연스럽게, 포함된 구성 요소를 줄이면 잠재적인 취약점도 줄어듭니다.
더 나아가, 최소화된 이미지를 빌드하면 이미지 다운로드 시간이 짧아지고, 사용자가 컨테이너를 더 빠르게 배포하고 빌드할 수 있도록 돕습니다.

일반적인 전략 외에도 이미지를 강화하기 위해 다음 세 가지를 수행할 수 있습니다:

  1. 특정 이미지를 기반으로 빌드를 강제하기: 작성한 이미지가 특정 이미지에서만 빌드되도록 보장합니다.
  2. 합리적인 디폴트 사용자 설정: 이미지에서 빌드된 컨테이너가 어떤 방식으로 만들어지든 관계없이 합리적인 디폴트 사용자를 가지도록 설정합니다.
  3. 루트 사용자 권한 상승 경로 제거: 프로그램에 설정된 setuid 또는 setgid 속성을 통해 발생할 수 있는 루트 사용자 권한 상승 경로를 제거합니다.

8.6.1 Content-addressable image identifiers

지금까지 이 책에서 논의된 이미지 식별자는 작성자가 이미지를 사용자에게 투명하게 업데이트할 수 있도록 설계되었습니다.
이미지 작성자는 자신의 작업이 어떤 이미지를 기반으로 빌드될지 선택하지만, 이 투명성 계층은 보안 문제에 대해 확인된 후에도
베이스 이미지가 변경되지 않았는지 신뢰하기 어렵게 만듭니다.

Docker 1.6 이후부터, 이미지 식별자는 선택적으로 다이제스트(digest) 구성 요소를 포함할 수 있습니다.
다이제스트 구성 요소를 포함하는 이미지 ID는 Content-Addressable Image Identifier, CAIID라고 불립니다.
이는 특정 내용이 포함된 특정 레이어를 참조하며, 단순히 특정 레이어(변경될 가능성이 있는)를 참조하는 것과는 다릅니다.

이제 이미지 작성자는 Version 2 레포지토리에 있는 특정하고 변하지 않는 시작 지점에서 빌드를 강제할 수 있습니다.
표준 태그 위치 대신 @ 기호 뒤에 다이제스트를 추가합니다.

docker image pull 커맨를 사용하여 원격 레포지토리에서 이미지를 가져올 때, 출력에서 Digest로 표시된 라인을 확인하여 다이제스트를 찾을 수 있습니다.
다이제스트를 얻은 후에는 이를 Dockerfile의 FROM 커맨드에서 식별자로 사용할 수 있습니다.
예를 들어, 다음은 특정 스냅샷인 debian:stable을 베이스로 사용하는 예제입니다:

docker pull debian:stable
stable: Pulling from library/debian
31c6765cabf1: Pull complete
Digest: sha256:6aedee3ef827...
# Dockerfile:
FROM debian@sha256:6aedee3ef827...
...

 

Dockerfile이 이미지를 빌드하는 시점이나 횟수와 관계없이, 각 빌드는 CAIID로 식별된 내용을 베이스 이미지로 사용합니다.
이것은 베이스 이미지의 알려진 업데이트를 이미지에 통합하거나, 컴퓨터에서 실행 중인 소프트웨어의 정확한 빌드 버전을 식별하는 데 특히 유용합니다.

비록 이것이 이미지의 공격 표면을 직접적으로 제한하지는 않지만, CAIID를 사용하면 이미지 내용이 본인의 모르게 변경되는 것을 방지할 수 있습니다. 다음에 다룰 두 가지 실천 사항은 이미지의 공격 표면을 직접적으로 다룹니다.

 

8.6.2 User permissions

알려진 컨테이너 탈출 전략은 모두 컨테이너 내부에서 시스템 관리자 권한을 갖는 것에 의존합니다.
6장에서는 컨테이너를 강화하는 데 사용되는 도구를 다루며, 사용자 관리와 리눅스의 USR 네임스페이스에 대한 깊이 있는 논의를 포함하고 있습니다.
이 섹션에서는 이미지에서 합리적인 디폴 사용자를 설정하기 위한 표준 관행을 설명합니다.

 

이미지 디폴트 설정을 재정의할 수 있는 사용자

Docker 사용자는 컨테이너를 생성할 때 이미지의 기본값을 언제든지 재정의할 수 있습니다.
따라서 이미지만으로 컨테이너가 root 사용자로 실행되지 않도록 완전히 방지할 수는 없습니다.
이미지 작성자가 할 수 있는 최선의 방법은 다음과 같습니다:

  1. root가 아닌 다른 사용자를 생성합니다.
  2. root가 아닌 디폴트 사용자와 그룹을 설정합니다.

Dockerfile에는 USER 커맨드가 포함되어 있으며, 이는 docker container run 또는 docker container create 커맨드와 동일한 방식으로 사용자와 그룹을 설정합니다.
이 커맨드는 Dockerfile의 기본 사항에서 다뤘으며, 이번 섹션에서는 권장 사항과 모범 사례에 대해 설명합니다.

 

권장 사항: 가능한 빨리 권한을 낮추기

  • 모범 사례는 가능한 빨리 권한을 낮추는 것(drop privileges)입니다.
  • 이는 USER 커맨드를 Dockerfile에 추가하거나, 컨테이너 부팅 시 실행되는 startup 스크립트를 통해 수행할 수 있습니다.
  • 하지만 이미지 작성자는 적절한 타이밍을 신중히 결정해야 합니다.

1. 너무 일찍 권한을 낮추는 경우

  • USER 커맨드를 너무 일찍 사용하면 Dockerfile의 커맨드를 완료할 권한이 부족할 수 있습니다.
  • 예를 들어, 다음 Dockerfile은 제대로 빌드되지 않습니다:
     
    FROM busybox:latest USER 1000:1000 RUN touch /bin/busybox
  • 빌드 시 Permission denied 오류가 발생합니다.
    이는 UID 1000 사용자가 /bin/busybox 파일의 소유권을 변경할 권한이 없기 때문입니다.
  • 문제를 해결하려면 USER 커맨드를 더 뒤로 이동해야 합니다.

2. 런타임 중 필요한 권한

  • 실행 시 관리자 권한이 필요한 프로세스가 있다면, 권한을 너무 일찍 낮추는 것은 적절하지 않습니다.
  • 예: 시스템 포트(1~1024)에 접근하려면 관리자 권한(또는 최소 CAP_NET_ADMIN)이 필요합니다.
  • Netcat을 사용해 port 80을 열려고 하면 권한 부족으로 실패합니다:
    FROM busybox:1.29 USER 1000:1000 ENTRYPOINT ["nc"] CMD ["-l", "-p", "80", "0.0.0.0"]
  • 실행 결과: 
    nc: bind: Permission denied
  • 이러한 경우, 디폴트 사용자 변경 대신 startup 스크립트에서 권한을 낮추는 것이 더 나은 접근 방식입니다.

어떤 사용자로 권한을 낮출 것인가?

Docker의 디폴트 설정에서는 컨테이너와 호스트가 동일한 USR 네임스페이스를 사용합니다.

  • 예: 컨테이너의 UID 1000은 호스트의 UID 1000과 동일합니다.
  • 그러나 Docker userns-remap 기능을 활성화하면 컨테이너의 UID가 호스트의 비권한 UID로 매핑됩니다.
  • 이 기능은 UID/GID를 완전히 분리하지만, 사용 여부는 알 수 없습니다.

적절한 UID/GID 선택

  • 공통 또는 시스템 수준 UID/GID는 사용하지 않는 것이 좋습니다.
  • 스크립트와 Dockerfile의 가독성을 위해 직접 UID/GID를 사용하지 않는 것이 좋습니다.

사용자 및 그룹 생성

  • 이미지를 위한 사용자를 생성하려면 RUN 커맨드를 사용합니다.
  • 예: PostgreSQL Dockerfile에서 사용자와 그룹을 생성하는 커맨드: 
    RUN groupadd -r postgres && useradd -r -g postgres postgres
  • 이 커맨드는 UID와 GID를 자동으로 할당하며, Dockerfile 초기에 배치하여 빌드 캐시를 활용하고 ID를 일관되게 유지합니다.
  • PostgreSQL 프로세스는 gosu 프로그램을 사용해 postgres 사용자로 실행되며, 컨테이너 내부에서 관리자 권한 없이 실행됩니다.

일반 규칙

  • 특정 애플리케이션 코드를 실행하도록 설계된 이미지는 가능한 한 빨리 권한을 낮춰야 합니다.
  • 시스템은 합리적인 디폴트값을 설정하여 적절히 동작해야 하며, 추가로 공격 표면을 줄이는 단계가 필요합니다.

 

8.6.3 SUID and SGID permissions

마지막 이미지 강화 작업은 setuid(SUID) 또는 setgid(SGID) 권한을 완화하는 것입니다.
잘 알려진 파일 시스템 권한(읽기, 쓰기, 실행)은 리눅스에서 정의된 권한의 일부일 뿐입니다.
이 외에도 SUIDSGID는 특히 주목할 필요가 있습니다.

 

SUID와 SGID의 동작 원리

  • SUID: 실행 파일에 SUID 비트가 설정되면, 해당 파일은 항상 소유자의 권한으로 실행됩니다.
    • 예: /usr/bin/passwd 프로그램은 root 사용자 소유이며 SUID 권한이 설정되어 있습니다.
    • 만약 일반 사용자(bob)가 passwd를 실행하면, 이 프로그램은 root 권한으로 실행됩니다.
  • SGID: SGID 비트가 설정되면, 실행 파일은 소유 그룹의 권한으로 실행됩니다.

 

SUID 권한 예제

다음 Dockerfile을 사용하여 이미지를 빌드하고, SUID의 동작을 확인할 수 있습니다:

FROM ubuntu:latest
# whoami에 SUID 비트 설정
RUN chmod u+s /usr/bin/whoami
# 예제 사용자 생성 및 기본 사용자 설정
RUN adduser --system --no-create-home --disabled-password --disabled-login \
    --shell /bin/sh example
USER example
# 컨테이너 사용자와 whoami 실행 권한 비교
CMD printf "Container running as: %s\n" $(id -u -n) && \
    printf "Effectively running whoami as: %s\n" $(whoami)
 
 
  1. 이미지를 빌드:
    docker image build -t dockerinaction/ch8_whoami .
  2. 컨테이너 실행:
    docker run dockerinaction/ch8_whoami

출력 결과:

Container running as: example
Effectively running whoami as: root
  • 컨테이너는 example 사용자로 실행되었지만, whoami는 root 권한으로 실행됩니다.

Base 이미지에서 SUID 및 SGID 파일 검색

  1. SUID 파일 검색:출력 예시:
    docker run --rm debian:stretch find / -perm /u=s -type f
    출력 예시:
    /bin/umount
    /bin/ping
    /bin/su
    /bin/mount
    /usr/bin/chfn
    /usr/bin/passwd
    /usr/bin/newgrp
    /usr/bin/gpasswd
    /usr/bin/chsh
     
  2. SGID 파일 검색:
    docker container run --rm debian:stretch find / -perm /g=s -type f
    출력 예시:
    /sbin/unix_chkpwd
    /usr/bin/chage
    /usr/bin/expiry
    /usr/bin/wall

SUID와 SGID 완화

컨테이너 내부에서 root 계정을 탈취할 수 있는 잠재적 버그는 이러한 권한을 가진 파일에서 발생할 수 있습니다.
대부분의 경우, 이미지 빌드 중에는 필요하지만 애플리케이션 사용에는 드물게 필요합니다.

해결 방법

  • SUID/SGID 권한 제거: 이미지의 공격 표면을 줄이기 위해 이 권한을 제거하는 것이 좋습니다.
     
    RUN for i in $(find / -type f \( -perm /u=s -o -perm /g=s \)); \
            do chmod ug-s $i; done

이 커맨드는 현재 이미지에 있는 모든 파일에서 SUID와 SGID 권한을 제거합니다.

 

이미지를 강화하면 사용자가 보안이 강화된 컨테이너를 빌드할 수 있습니다.
의도적으로 약한 컨테이너를 빌드하는 사용자를 완전히 보호할 수는 없지만, 대부분의 사용자에게는 이러한 강화 작업이 큰 도움이 됩니다.

 

Summary

대부분의 Docker 이미지는 Dockerfile을 기반으로 자동으로 빌드됩니다.
이 장에서는 Docker가 제공하는 빌드 자동화와 Dockerfile의 모범 사례를 다룹니다.
다음 주요 내용을 이해했는지 확인하세요:

  • Docker는 Dockerfile에서 지침을 읽어 이미지를 빌드하는 자동화된 이미지 빌더를 제공합니다.
  • 각 Dockerfile 지침은 하나의 이미지 레이어를 생성합니다.
  • 가능한 경우 지침을 병합하여 이미지 크기와 레이어 수를 최소화해야 합니다.
  • Dockerfile에는 디폴트 사용자, 노출된 포트, 디폴트 커맨드, 엔트리포인트 등 이미지 메타데이터를 설정하는 지침이 포함됩니다.
  • 기타 Dockerfile 지침은 로컬 파일 시스템이나 원격 위치에서 생성된 이미지로 파일을 복사합니다.
  • 하위 빌드는 상위 Dockerfile의 ONBUILD 지침으로 설정된 빌드 트리거를 상속받습니다.
  • Dockerfile 유지관리는 멀티스테이지 빌드와 ARG 지침으로 개선할 수 있습니다.
  • Startup 스크립트를 사용하여 주요 애플리케이션을 실행하기 전에 컨테이너의 실행 컨텍스트를 검증해야 합니다.
  • 유효한 실행 컨텍스트에는 적절한 환경 변수 설정, 사용 가능한 네트워크 종속성, 적절한 사용자 구성이 포함되어야 합니다.
  • Init 프로그램은 여러 프로세스를 시작하고, 모니터링하며, 고아 자식 프로세스를 정리하고, signal를 자식 프로세스로 전달하는 데 사용할 수 있습니다.
  • 이미지는 다음과 같은 방법으로 강화해야 합니다:
    • 내용 기반 이미지 식별자(Content-Addressable Image Identifier)로 빌드.
    • non-root 디폴트 사용자 생성.
    • SUID 또는 SGID 권한이 있는 실행 파일 비활성화 또는 제거.

 

'Docker' 카테고리의 다른 글

10 Image pipelines  (0) 2024.04.07
9 Public and privatesoftware distribution  (0) 2024.04.07
7 Packaging software in images  (0) 2024.04.03
6 Limiting risk with resource controls  (0) 2024.04.03
5 Single-host networking  (0) 2024.03.29