ohjongsung's Dev Story

Ohjongsung's Dev Story

도커 이미지 생성 가이드

2019년 10월 19일

도커 이미지를 생성하려면 도커파일을 작성 해야한다. 도커파일을 작성하는 방법은 매우 다양하고 어떻게 하든 도커 이미지가 만들어지면 배포 할 수 있다. 그래서 중요한게 최적화다. 잘 작성된 도커파일은 도커 이미지 사이즈를 줄이고 빌드/배포 시간을 단축시킨다. 회사에서 클라우드 환경에 도커 기반으로 운영 환경을 바꾸려고 한다. 미리미리 공부 좀 하고 조사한 내용을 정리 해놓아서 실제 사용할 때 삽질하는 시간을 줄여야 겠다.

도커 이미지

도커 이미지를 pull 받게 되면 마치 여러개로 분리된 조각을 내려받는 것처럼 보인다.

$ docker pull nginx:latest

Using default tag: latest
latest: Pulling from library/nginx
c499e6d256d6: Already exists
74cda408e262: Pull complete
ffadbd415ab7: Pull complete
Digest: sha256:282530fcb7cd19f3848c7b611043f82ae4be3781cb00105a1d593d7e6286b596
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest

이렇게 분리된 데이터를 **레이어(Layer)** 라고 한다. 레이어는 도커 이미지가 빌드될 때 Dockerfile 에 정의된 명령문(Instructions)을 순서대로 실행하면서 만들어진다. 이 레이어들은 각각 독립적으로 저장되며 읽기 전용이기 때문에 임의로 수정할 수 없다.
docker_image_layers.png
도커 컨테이너가 실행되면 모든 읽기 전용 레이어들을 순서대로 쌓은 다음 마지막에 쓰기 가능한 신규 레이어를 추가하게 된다. 그 다음 컨테이너 안에서 발생하는 결과물들이 쓰기 가능 레이어에 기록되게 되는 것이다.

즉, 아무리 많은 도커 컨테이너를 실행하더라도 기존 읽기 전용 레이어는 변하지 않고, 컨테이너마다 생성된 쓰기 가능 레이어에 데이터가 쌓이기 때문에 서로 겹치지 않으며 컨테이너가 종료되면 모두 사라지게 된다.

도커 이미지 레이어

도커 이미지 레이어가 중요한 이유는 이미지를 빌드할 때마다 이미 생성된 레이어가 캐시 되어 재사용 되기 때문에 빌드 시간을 단축할 수 있다.하지만 Dockerfile 에 정의된 모든 명령문(Instructions)이 레이어가 되는 것은 아니다.

RUNADDCOPY 이 3가지 단계만이 레이어로 저장되고, CMDLABELENVEXPOSE 등과 같이 메타 정보를 다루는 부분은 임시 레이어로 생성되지만 저장되지 않아 도커 이미지 사이즈에 영향을 주지 않는다.

이 글의 가장 첫 부분에서 pull 받은 nginx:latest 도커 이미지의 Dockerfile을 보면 처음에 베이스 이미지를 가져오는 부분을 포함해서 2개 RUN 명령까지 총 3개의 레이어를 받아왔다. 만약 Dockerfile에 RUNADDCOPY 명령문이 수정되면 기존 캐시가 무효가 되어 새로운 레이어를 생성하게 될 것이다.

도커 명령어 베스트 프랙티스

mkdir + cd = WORKDIR

특정 폴더에서 작업할 때 mkdir로 폴더를 생성하고 cd로 이동하는 경우가 많은데, 이 두 개의 커맨드는 아래와 같이 WORKDIR Instruction으로 합쳐질 수 있다.

RUN mkdir -p /some/directory && \
  cd /some/directory && \
  echo "$(pwd)"
  
# or

RUN mkdir -p /some/directory
WORKDIR /some/directory
RUN echo "$(pwd)"

# is equivalent to

WORKDIR /some/directory
RUN echo "$(pwd)"

shell form or exec form

CMD Instruction의 경우 아래와 같이 shell form과 exec form의 형태로 작성할 수 있다.

# shell form
ENTRYPOINT java XX:+ExitOnOutOfMemoryError Djava.security.egd=file:/dev/./urandom -jar /app.jar

# exec form
ENTRYPOINT ["java", "-XX:+ExitOnOutOfMemoryError", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app.jar"]

위의 두 방식으로 Spring 애플리케이션을 실행한 뒤, 컨테이너 프로세스 목록을 보면 다음과 같다.

# shell
PID   USER     TIME   COMMAND
    1 root       0:00 /bin/sh -c java -XX:+ExitOnOutOfMemoryError -Djava.securi
    5 root       0:08 java -XX:+ExitOnOutOfMemoryError -Djava.security.egd=file
   33 root       0:00 sh
   37 root       0:00 ps uxa

# exec
PID   USER     TIME   COMMAND
    1 root       0:08 java -XX:+ExitOnOutOfMemoryError -Djava.security.egd=file
   27 root       0:00 sh
   32 root       0:00 ps uxa

shell form의 경우 ENTRYPOINT가 / bin / sh -c의 부속 명령으로 시작되어 신호를 전달하지 못하는 단점이 생긴다.

그래서 Spring 애플리케이션에서 시스템이 SIGTERM 신호를 보냈다는 정보를 찾을 수 없다. 이는 신호가 실제로 쉘로 전송 되고 시작된 프로세스로 신호가 전달되지 않기 때문이다. 즉, 실행 파일은 컨테이너의 PID 1이 아니며 Unix 신호를 수신하지 않으므로 docker stop 명령을 날려도 SIGTERM을 수신하지 않는다. 그래서 Graceful Shutdown을 코드 레벨에서 처리했다 하더라도 제대로 동작하지 않고 stop_grace_period 을 지나서 docker 데몬이 컨테이너를 강제로 중지시키는 SIGKILL 신호를 보내 중지시킨다.

exec form 의 경우, 로그에서 Sring 애플리케이션이 SIGTERM 명령을 처리하면서 모든 리소스를 닫는 것을 확인 할 수 있다.

ationConfigEmbeddedWebApplicationContext : Closing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2401f4c3: startup date [Mon Jan 21 17:44:33 GMT 2019]; root of context hierarchy
o.s.j.e.a.AnnotationMBeanExporter        : Unregistering JMX-exposed beans on shutdown
o.e.jetty.server.AbstractConnector       : Stopped ServerConnector@7a3d45bd{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
org.eclipse.jetty.server.session         : Stopped scavenging
o.e.j.s.h.ContextHandler.application     : Destroying Spring FrameworkServlet 'dispatcherServlet'
o.e.jetty.server.handler.ContextHandler  : Stopped o.s.b.c.e.j.JettyEmbeddedWebAppContext@50d0686{/,[file:///tmp/jetty-docbase.3963833647300409511.8080/],UNAVAILABLE}

shell form 을 사용하면, 종료 시간이 길어질 뿐만아니라 심한 경우에는 사용중 이던 리소스의 해제가 제대로 되지 않고 작업중인던 내용이 무시된채 종료될 수도 있다. 따라서 exec form 을 사용해야 한다

chaining - 적절한 캐시 단위 구분

각각의 RUN 명령어들은 캐시할 수 있는 단위로 볼 수 있다. 너무 많은 캐시 단위들은 불필요하지만, 하나의 RUN 명령어에 모든 명령어를 다 연결시키는 것 역시 캐시가 쉽게 무효화 되게 하여 개발 사이클에 좋지 못하다. 패키지 매니저로부터 패키지들을 다운받을 때에 항상 업데이트와 인스톨을 하나의 RUN 안에 체이닝(chaining)해서 하나의 캐시 단위로 만들자. 그렇지 않다면 예전 버전의 패키지를 다운받을 위험이 있다.

# before
RUN apt-get update
RUN apt-get -y install git
RUN apt-get -y install locales
RUN apt-get -y install gcc

# after
RUN apt-get update && apt-get install -y \
    gcc \
    git \
    locales

ADD or COPY

ADD 와 COPY 는 기능적으로 비슷하지만 일반적으로 COPY 가 바람직하다. ADD 보다 의미가 명확하기 때문이다. COPY 는 로컬 파일을 컨테이너로 복사하는 기본 기능만 지원하지만 ADD 에는 로컬 전용 tar 추출 및 원격 URL 지원과 같은 일부 기능이있어 어떤 목적인지 즉시 확인할 수 없다. 결과적으로 ADD를 가장 잘 사용하는 것은 ADD rootfs.tar.xz /에서와 같이 이미지에 로컬 tar 파일 자동 추출이다.

ADD http://example.com/big.tar.xz /usr/src/things/
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
RUN make -C /usr/src/things all

예를 들면, 위의 샘플과 같은 사용은 피해야 한다. 이미지 크기가 중요하기 때문에 ADD를 사용하여 원격 URL에서 패키지를 가져 오는 것은 권장하지 않는다. 대신 curl 또는 wget 을 사용해야 한다. 그렇게하면 더 이상 필요없는 파일을 추출한 후 삭제할 수 있으며 이미지에 다른 레이어를 추가 할 필요가 없다.

RUN mkdir -p /usr/src/things \
    && curl -SL http://example.com/big.tar.xz \
    | tar -xJC /usr/src/things \
    && make -C /usr/src/things all

ADD의 tar 자동 추출 기능이 필요하지 않은 다른 항목 (파일, 디렉토리)의 경우 항상 COPY를 사용해야 한다.

도커 이미지 베스트 프랙티스

경량 베이스 이미지 사용

알파인 리눅스는 가볍고 간단하고 보안성을 목적으로 개발한 리눅스 배포판이다. 용량을 줄이기 위해 시스템의 기본 C runtime을 glibc 대신 musl libc 를 사용하며 다양한 쉘 명령어는 GNU util 대신 busybox 를 탑재하였다. 용량이 80M인 경량화된 배포판이므로 Embbeded 나 네트웍 서버등 특정 용도에 적합하며 특히 도커(docker)에 채택되어 5M 크기의 리눅스 이미지로 유명하다.

자주 변경되는 레이어를 아래로

아래의 Dockerfile 을 보면 COPY 명령으로 도커 레이어를 생성하고 있는데, 첫 번째는 애플리케이션 소스 코드이고 두 번째는 라이브러리다.

FROM openjdk:11-jre-slim

WORKDIR /root
ARG buildDir=build/unpack

COPY ${buildDir}/app .
COPY ${buildDir}/lib BOOT-INF/lib

CMD java org.springframework.boot.loader.JarLauncher

도커 이미지를 생성할 때, 변경되지 않는 레이어는 캐시를 활용해서 push/pull 시간을 많이 단축시킬 수 있다. 그래서 도커 이미지를 만들 때, 레이어별 캐시 기능을 잘 활용해야 하는데, 샘플 순서상 애플리케이션 코드가 변경되면 후순위 라이브러리는 변경 사항이 없어도 캐시를 활용하지 못하고 재생성되는 비효율이 발생한다.

FROM openjdk:11-jre-slim

WORKDIR /root
ARG buildDir=build/unpack

COPY ${buildDir}/lib BOOT-INF/lib
COPY ${buildDir}/app .

CMD java org.springframework.boot.loader.JarLauncher

애플리케이션 소스 코드와 같이 자주 변경되는 레이어를 아래로 내려 레이어별 캐시 기능을 활용하게 수정해야 한다.

멀티-스테이지 빌드를 활용

멀티-스테이지 빌드는 Dockerfile 1개에 FROM 구문을 여러 개 두는 방식이다. 각 FROM 명령문을 기준으로 스테이지를 구분한다고 했을 때 특정 스테이지 빌드 과정에서 생성된 것 중 사용되지 않거나 불필요한 모든 것들을 무시하고, 필요한 부분만 가져와서 새로운 베이스 이미지에서 다시 새 이미지를 생성할 수 있다.

FROM adoptopenjdk:11-jre-hotspot as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM adoptopenjdk:11-jre-hotspot
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/resources/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

위의 샘플을 보면, JAR 를 나누는 작업과 실제 사용할 도커 이미지를 생성하는 부분으로 분리되어 있다. 실제 도커 이미지를 생성할 때는 나눠진 JAR 에서 필요한 것만 COPY 하고 나머지는 버리는 것이다. 멀티-스테이지를 사용하는 것이 도커 이미지 사이즈 경량화에 도움이 된다.

Transparency Matters

latest 태그 사용을 지양

Base Docker Image의 latest 태그의 의미 자체가 빌드되는 시점의 최신 버전이기 때문에 빌드 시점마다 다른 내용을 가리킬 수 있습니다. 예를 들면 이 글을 작성하는 시점의 python:latest는 3.7.3버전을 가리키지만, 새로운 버전이 릴리즈되면 latest 태그는 새롭게 릴리즈된 버전을 가리키게 됩니다.

게다가 운영의 관점에서 보면 정확하게 어떤 버전의 이미지가 실행되고 있는지를 바로 확인하기 어렵고, Kubernetes를 사용하는 경우 latest 태그를 사용했을 때 imagePullPolicy 설정값에 따라 새로운 이미지를 못 불러오거나, 이미지를 노드에 Cache 하지 않아 매번 Registry에서 불러오는 비효율을 야기할 수 있습니다.

dependency 버전을 명시

애플리케이션에서 사용하는 디펜던시 라이브러리의 버전을 아래의 샘플처럼 항상 최신 라이브러리를 사용하게 설정하면 빌드나 실행단계에서 오류가 발생할 수 있다. 프로그래밍 언어마다 패키지 매니저가 제공하는 Lock 파일 시스템을 활용해서 디펜던시를 고정해야 한다.

mysqlclient>=1.4.2
numpy>=1.16.4

# or

<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>(2.0.0-SNAPSHOT,)</version> # x >= 2.0.0
</dependency>

.dockerignore 사용

폴더를 통째로 복사하는 경우 의도하지 않았던 파일들이 이미지로 복사될 수 있다.

.
├── .git
│   └── (...)
├── Dockerfile
├── README.md
├── node_modules
├── package.json
└── src
    └── (...)

위와 같은 구조를 가진 프로젝트에서 만약 .dockerignore없이 COPY . /some/dir Instruction을 통해 /some/dir 로 파일을 복사하게 되면 실행 환경에서 불필요한 파일과 폴더(e.g. .git, Dockerfile)들, 혹은 의도하지 않았던 Dependency가 node_modules안에 포함된 상태로 이미지에 포함될 수 있습니다. 특히 민감한 Credential이 이미지에 담겨있다는 걸 인지하지 못한 채로 배포되는 경우 더 큰 문제가 발생할 수 있기 때문에 Layer로 복사할 파일들을 명시하거나 .dockerignore를 활용하는 것이 좋다.

COPY 는 명확하게

# before
FROM openjdk:8-jre-alpine
COPY . /app

ENTRYPOINT ["java","-jar","/app.jar"]

# after
FROM openjdk:8-jre-alpine
COPY target/hellospring-0.0.1-SNAPSHOT.jar app.jar

ENTRYPOINT ["java","-jar","/app.jar"]

필요한 부분만 카피하세요. 가능하면 “COPY . ”과 같은 명령어는 피하세요 . 파일들을 도커 이미지로 복사할 때, 당신이 무엇을 카피하고자 하는지를 최대한 명확하게 하세요. 이미지에 복사된 파일들 가운데 하나라도 변경될 경우 캐시가 무효화 됩니다. 위의 예시에서는 이미 빌드를 마친 jar 파일만 복사하는 것을 볼 수 있습니다. 이 경우 관련이 없는 파일들의 변화는 캐시에 영향을 주지 않습니다.

Reference