본문 바로가기

DevOps & Infra/Docker

[Infrastructure/Docker] 튜토리얼 살펴보기#008. 이미지와 관련된 더 많은 기능들

이미지 취약점 스캔 도구

도커는 Synk와 파트너쉽을 맺고 있어 취약성 검색 기능을 제공합니다. 도커의 scan 명령문을 사용하면 이미지의 보안 취약성을 검사하는 것이 가능합니다.

$ docker scan [이미지 이름]

스캔에 사용되는 데이터베이스는 지속적으로 업데이트됩니다. 따라서 기존 이미지에서도 새로운 취약성을 발견 할 수 있습니다. 만약 이미지에서 취약성이 발견되면 예시와 같이 출력됩니다.

✗ Low severity vulnerability found in freetype/freetype
  Description: CVE-2020-15999
  Info: https://snyk.io/vuln/SNYK-ALPINE310-FREETYPE-1019641
  Introduced through: freetype/freetype@2.10.0-r0, gd/libgd@2.2.5-r2
  From: freetype/freetype@2.10.0-r0
  From: gd/libgd@2.2.5-r2 > freetype/freetype@2.10.0-r0
  Fixed in: 2.10.0-r1

✗ Medium severity vulnerability found in libxml2/libxml2
  Description: Out-of-bounds Read
  Info: https://snyk.io/vuln/SNYK-ALPINE310-LIBXML2-674791
  Introduced through: libxml2/libxml2@2.9.9-r3, libxslt/libxslt@1.1.33-r3, nginx-module-xslt/nginx-module-xslt@1.17.9-r1
  From: libxml2/libxml2@2.9.9-r3
  From: libxslt/libxslt@1.1.33-r3 > libxml2/libxml2@2.9.9-r3
  From: nginx-module-xslt/nginx-module-xslt@1.17.9-r1 > libxml2/libxml2@2.9.9-r3
  Fixed in: 2.9.9-r4

출력문에는 취약성의 유형(type of vulnerability), 자세히 알아보기 위한 URL, 그리고 취약성을 수정할 수 있는 관련 라이브러리 버전 등이 나열됩니다. 도커 스캔 도큐먼트를 확인하면 더 자세한 내용과 스캔 명령문의 사용 옵션 등을 확인 할 수 있습니다.

명령줄에서 새로 빌드한 이미지를 스캔하는 명령문 뿐만 아니라, 새로 푸시하는 모든 이미지에 대해서도 자동으로 스캔하게끔 도커 허브의 설정을 수정 할 수 있습니다. 자세한 내용은 도큐먼트를 참고하시길 바랍니다.

이미지 계층화(Layering)

이미지는 어떤 구조로 작성되어 있을까요? 도커의 image history 명령문을 사용하면 이미지 내에서 각 레이어를 생성하는데 사용된 실제 명령문을 확인 할 수 있습니다.

$ docker image history [이미지 이름]

예를 들어, 우리가 작성한 이미지의 레이어는 다음 명령문으로 생성되었습니다.

$ docker image history namepgb/getting-started
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
974bba88037c   6 days ago     CMD ["node" "src/index.js"]                     0B        buildkit.dockerfile.v0
<missing>      6 days ago     RUN /bin/sh -c yarn install --production # b…   86.1MB    buildkit.dockerfile.v0
<missing>      6 days ago     COPY . . # buildkit                             4.61MB    buildkit.dockerfile.v0
<missing>      6 days ago     WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      6 days ago     RUN /bin/sh -c apk add --no-cache python2 g+…   223MB     buildkit.dockerfile.v0
<missing>      3 weeks ago    /bin/sh -c #(nop)  CMD ["node"]                 0B        
<missing>      3 weeks ago    /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B        
<missing>      3 weeks ago    /bin/sh -c #(nop) COPY file:4d192565a7220e13…   388B      
<missing>      3 weeks ago    /bin/sh -c apk add --no-cache --virtual .bui…   7.84MB    
<missing>      3 weeks ago    /bin/sh -c #(nop)  ENV YARN_VERSION=1.22.17     0B        
<missing>      3 weeks ago    /bin/sh -c addgroup -g 1000 node     && addu…   77.6MB    
<missing>      3 weeks ago    /bin/sh -c #(nop)  ENV NODE_VERSION=12.22.10    0B        
<missing>      3 months ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B        
<missing>      3 months ago   /bin/sh -c #(nop) ADD file:9233f6f2237d79659…   5.59MB

각 행은 이미지의 레이어를 나타냅니다. 히스토리는 첫 행에서 가장 최신에 생성된 레이어부터 시작하며, 마지막 행은 가장 기초가 되는 레이어로 끝납니다. 또한 각 레이어의 크기를 확인할 수도 있어 사이즈가 큰 이미지를 진단하는데 도움이 됩니다.

레이어 캐싱

레이어의 계층화 방식은 이미지 빌드 시간을 단축하는 방법에 대해서 중요한 힌트가 됩니다. 만약 레이어가 변경되면, 변경된 레이어의 모든 다운스트림(Downstream)에 대해서 새로 빌드하게 됩니다.

우리가 처음 작성했던 도커 파일(Dockerfile)을 확인해 볼까요?

FROM node:12-alpine
# Adding build tools to make yarn install work on Apple silicon / arm64 machines
RUN apk add --no-cache python2 g++ make
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]

도커 파일의 내용과 이미지 히스토리를 비교해보면, 도커 파일의 각 명령문이 이미지의 새로운 레이어로 구성되었음을 확인 할 수 있습니다. 우리가 튜토리얼에서 사용한 이미지를 변경할 때마다, yarn 종속성에 대해서 매번 다시 설치했던 것을 기억하시나요? 사실 빌드를 새로 할 때마다 동일한 종속성을 다시 설치하는 것은 굉장히 비효율적입니다.

이 문제를 해결하기 위해서는, 종속성에 대해서 캐싱을 제공할 수 있도록 도커 파일을 다시 작성해야 합니다. Node.js 기반의 애플리케이션은 종속성 관리를 위해 package.json 파일을 사용합니다. 

우리가 이전에 작성한 도커 파일에서는 워크 디렉토리 내 모든 파일을 복사했습니다. 그렇다면 package.json 파일을 먼저 복사하고, yarn 종속성을 설치하고 나머지 파일들을 복사하는것은 어떨까요?

FROM node:12-alpine
# Adding build tools to make yarn install work on Apple silicon / arm64 machines
RUN apk add --no-cache python2 g++ make
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]

이렇게 되면 package.json이 변경된 경우에 다운스트림의 일부인 RUN yarn install --production 명령문이 실행되게 됩니다. 소스 코드는 yarn 종속성 설치보다 다운스트림에 속하므로, 소스 코드만 수정할 때에는 yarn 종속성을 새로 설치하지 않게 됩니다.

하지만 이어지는 명령문에서 모든 파일을 복사(COPY . .)하고 있으므로, 종속성에 의해 생성/수정되는 node_modules 디렉토리가 다시 덮어질 수 있습니다.

도커 파일과 같은 경로에 .dockerignore 파일을 생성합니다. 별도의 확장자는 추가하지 않습니다.

app
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
├── node_modules
├── package.json
├── spec
├── src
└── yarn.lock

그리고  .dockerignore 파일 내부를 다음과 같이 작성합니다.

node_modules

.dockerignore는 파일 복사(COPY)에 대한 예외 규칙을 정의합니다. 깃에서 .gitignore 파일과 비슷합니다. 앞으로는 이미지를 빌드할 때 node_modules 디렉토리가 더 이상 복사되지 않게 됩니다. 이와 관련해 자세한 내용은 도커 파일 도큐먼트에서 확인 할 수 있습니다.

그럼 이미지의 레이어가 제대로 캐싱되는지 확인해볼까요?

$ docker build -t [이미지 이름] .

출력 내용은 다음과 같습니다.

$ docker build -t namepgb/getting-started .
[+] Building 20.1s (11/11) FINISHED                                                                                                                                                                       
 => [internal] load build definition from Dockerfile                                                                                                                                                 0.0s
 => => transferring dockerfile: 388B                                                                                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                                                                                    0.0s
 => => transferring context: 52B                                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/node:12-alpine                                                                                                                                    0.0s
 => [internal] load build context                                                                                                                                                                    0.0s
 => => transferring context: 191.04kB                                                                                                                                                                0.0s
 => [1/6] FROM docker.io/library/node:12-alpine                                                                                                                                                      0.0s
 => CACHED [2/6] RUN apk add --no-cache python2 g++ make                                                                                                                                             0.0s
 => CACHED [3/6] WORKDIR /app                                                                                                                                                                        0.0s
 => [4/6] COPY package.json yarn.lock ./                                                                                                                                                             0.0s
 => [5/6] RUN yarn install --production                                                                                                                                                             17.9s
 => [6/6] COPY . .                                                                                                                                                                                   0.1s
 => exporting to image                                                                                                                                                                               2.0s 
 => => exporting layers                                                                                                                                                                              1.9s 
 => => writing image sha256:b5bb12022989a8d0463381c89a63ef5efa3cb2ba62d8483f08937095f1f52b82                                                                                                         0.0s 
 => => naming to docker.io/namepgb/getting-started                                                                                                                                                   0.0s

내용을 살펴보면, 거의 모든 레이어(1,4,5,6)가 다시 빌드된 것을 확인 할 수 있습니다. 반면 캐싱된 레이어는 두 개(2,3) 뿐입니다. 우리가 도커 파일을 꽤 많이 수정했기 때문으로 보입니다. 이제 src/static/index.html 파일을 열어서 다음 내용을 수정합니다(11번째 줄).

<!-- <title>Todo App</title> -->
<title>The Awesome Todo App</title>

웹 브라우저에 표시되는 타이틀의 문구를 수정하였습니다. 다시 이미지를 빌드해볼까요?

$ docker build -t namepgb/getting-started .
[+] Building 0.2s (11/11) FINISHED                                                                                                                                                                        
 => [internal] load build definition from Dockerfile                                                                                                                                                 0.0s
 => => transferring dockerfile: 129B                                                                                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                                                                                    0.0s
 => => transferring context: 34B                                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/node:12-alpine                                                                                                                                    0.0s
 => [internal] load build context                                                                                                                                                                    0.0s
 => => transferring context: 11.09kB                                                                                                                                                                 0.0s
 => [1/6] FROM docker.io/library/node:12-alpine                                                                                                                                                      0.0s
 => CACHED [2/6] RUN apk add --no-cache python2 g++ make                                                                                                                                             0.0s
 => CACHED [3/6] WORKDIR /app                                                                                                                                                                        0.0s
 => CACHED [4/6] COPY package.json yarn.lock ./                                                                                                                                                      0.0s
 => CACHED [5/6] RUN yarn install --production                                                                                                                                                       0.0s
 => [6/6] COPY . .                                                                                                                                                                                   0.0s
 => exporting to image                                                                                                                                                                               0.0s
 => => exporting layers                                                                                                                                                                              0.0s
 => => writing image sha256:37872385d0481e0208ff4c99e88bf6d6981715883d4d609ea1122500bf235571                                                                                                         0.0s
 => => naming to docker.io/namepgb/getting-started

이번에는 총 4개의 레이어(2,3,4,5)가 캐싱된 것을 확인 할 수 있습니다. 반면 소스 코드가 수정되었기 때문에 COPY . . 명령문에 의한 레이어는 캐싱되지 않고 새로 빌드됩니다.

멀티 스테이지(Multi-Stage) 빌드

멀티 스테이지 빌드는 이미지를 빌드하기 위해서 여러 단계를 사용하는 방법을 뜻합니다. 멀티 스테이지 빌드를 사용하면 몇 가지 이점을 얻을 수 있습니다.

  • 빌드 시점의 종속성(Build-time dependencies)와 런타임 종속성(Runtime) 종속성의 분리
  • 앱 실행에 필요한 내용만 전송함으로써 이미지 크기 축소 등

우리가 자바 애플리케이션을 생산하여 배포해야 한다면, JDK가 필요합니다. JDK는 자바의 소스 코드를 바이트 코드로 컴파일하기 위해서 사용됩니다. 하지만 애플리케이션이 배포되는 프로덕션 환경에서는 JDK가 더 이상 사용되지 않습니다. 물론 Maven 또는 Gradle과 같은 툴 역시 프로덕션 환경에서는 쓰이지 않습니다.

즉, 애플리케이션의 생산 단계에서 필요한 모든 것을 프로덕션 환경에서 사용하기 위한 이미지에 빌드해야 할 필요가 없습니다. 이때 멀티 스테이지 빌드가 해결책이 됩니다.

FROM maven AS build
WORKDIR /app
COPY . .
RUN mvn package

FROM tomcat
COPY --from=build /app/target/file.war /usr/local/tomcat/webapps

예시에서는 Maven을 사용하여 자바를 빌드하기 위한 첫 단계(Stage 1)-FROM maven AS build가 존재합니다. 두 번째 단계(Stage 2)-FROM tomcat에서는 빌드 단계에서 파일을 복사합니다. 최종적인 이미지는 마지막 단계에서 생성된 이미지입니다.

튜토리얼에서는 멀티 스테이지 빌드에 대해서 자세한 내용을 다루지는 않습니다. 자신의 개발 환경에 맞는 멀티 스테이지 빌드 방식을 추가로 알아보는 것을 권장드립니다.