10/20/2024

Distroless 이미지로 NodeJS 기반 애플리케이션 Docker 이미지 크기 줄이기

NestJS 프로젝트를 진행할 때 사용한 다단계 빌드로 Docker 이미지를 만들었는데 이상하게 이미지의 크기가 너무 컸다. 이번에도 같은 방법으로 ExresspJS 애플리케이션의 Docker 이미지를 만들었는데 크기가 무려 600MB... NodeJS 기반 프레임워크의 애플리케이션이 이렇게 클 수가 없다고 생각하여 검색과 여러 자료를 찾아보면서 Docker 이미지 크기를 줄이는 방법을 발견했다. Docker 이미지 크기를 줄이는 방법을 알아본다.


의존성

이전 프로젝트에서도 겪은 문제인데 NestJS나 ExpressJS를 TypeScript와 함께 사용하면 항상 문제가 되는 것이 바로 개발 환경 의존성이다. 다시 말해 @types로 시작하는 타입과 관련한 여러 패키지! 로컬에서 개발하고 실행할 때는 별 문제가 없지만 Dockerfile을 통해서 Docker 이미지를 만들 경우 타입 오류가 항상 발생한다. 처음에는 저번처럼 TypeScript 파일을 생성해서 문제를 해결하려고 생각했으나 다른 방법이 생각났다. 바로 개발 환경 의존성을 포함한 모든 의존성을 다 설치하고 빌드 후 개발 환경 의존성만 제거하는 것이다. 검색을 통해서 npm prune 명령어가 이 작업을 처리한다는 것을 발견했다. 즉, npm prune --omit=dev


다단계

검색을 통해서 여러 Dockerfile을 보면서 NestJS 프로젝트에 적용한 다단계 빌드가 Docker 이미지의 크기를 줄여주지 않은 이유를 발견했다. 알고보니 이전 단계를 그대로 가져왔다! 즉, 기본 이미지를 선택하고 COPY --from에서 이전 단계를 선택해야 하는데 FROM 단계1 AS 단계2를 사용한 것이 문제였다. 여기서 Docker 이미지의 크기를 더 줄이고 싶으면 COPY --from을 통해 이전 단계에서 모든 파일과 디렉터리가 아니라 애플리케이션에 필요한 파일과 디렉터리만 가져오면 된다!


Distroless 이미지

개발 환경 의존성을 제거했지만 여전히 Docker 이미지의 크기는 상당했다. 기본 이미지는 Slim을 사용해서 더 줄일 수 없다고 생각했는데 알고보니 애플리케이션이 필요한 의존성만 가지는 Distroless 이미지가 있다. 쉘이나 패키지 관리자와 같은 유틸리티를 포함하지 않는다. Distroless 이미지는 마지막 Dockerfile 단계에서 애플리케이션 디렉터리 트리를 복사하는 용도로 사용된다. 이미지를 절대 최소한으로 유지하기 위해 설계되었으며 낮은 CVE(공통 취약점 및 노출) 수를 가지는 장점이 있다. 

하지만 당연히 단점도 있는데 개발 또는 테스트 단계에서 쉘과 패키지 관리자가 필요하기 때문에 이러한 단계에서는 사용할 수 없다. 또한, Distroless 이미지는 특정 버전으로 고정하기가 어렵다. 주(major) 버전만 고정할 수 있으며 gcr.io/distroless/nodejs20-debian12와 같은 형식으로 사용할 수 있어서 결정론적 빌드를 원하는 사람들에게는 버전 태그로는 충분하지 않다. 참고로, 결정론적 빌드란 모든 구성 요소가 정확한 버전으로 고정되고 한 달 간격으로 이미지를 두 번 빌드하더라도 동일한 결과가 나와야 한다는 의미이다. Distroless는 이미지의 SHA256 해시를 고정하면 결정론적일 수 있지만 이미지를 수정할 때마다 태그가 재사용되고 어떤 해시가 이전 버전에 해당하는지 확인할 방법이 없기 때문에(수동으로 기록하지 않는 이상) 이는 이상적인 방법이 아니다.

장단점을 고려했을 때 Distroless 이미지를 사용하는 방법은 최종 운영 단계 외에는 Slim과 같은 기본 이미지를 설정하고 운영 단계에서 애플리케이션 관련 디렉터리와 파일을 COPY --chown=1000:1000한 후, 특정 Distroless 이미지의 SHA256 해시로 고정하는 것이다.


Dockerfile

3가지의 방법을 적용한 Dockerfile은 다음과 같다. Distroless 이미지를 SHA256 해시로 고정은 하지 않았다. 적용 전 Docker 이미지의 크기는 서비스마다 조금씩 차이는 있지만 대략 600MB 였다. 하지만 적용 후 280MB로 대략 53% 정도 크기가 줄었다. 하지만 여기서도 분명히 개선의 여지가 있다고 본다. 200MB 밑으로 내려가야 하지 않을까? 이에 대해서도 조금 더 알아보자! 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
FROM gcr.io/distroless/nodejs18-debian12 AS distroless
FROM node:18.13.0-slim AS base
 
RUN npm install npm@8.19.3 -g
RUN npm install typescript@5.4.5 -g
 
USER node
 
WORKDIR /app
RUN chown -R node:node /app
COPY --chown=node:node package*.json ./
RUN npm ci --include=dev && npm cache clean --force
 
FROM base AS source
 
USER node
 
COPY --chown=node:node . .
RUN npm run build
RUN npm prune --omit=dev
 
WORKDIR /app/public/image/tours
RUN chown -R node:node /app/public/image/tours
 
 
FROM distroless AS prod
 
USER 1000
 
COPY --from=source --chown=1000:1000 /app/node_modules /app/node_modules
COPY --from=source --chown=1000:1000 /app/dist /app/dist
 
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
 
ARG PORT=3000
ENV PORT $PORT
EXPOSE $PORT
 
WORKDIR /app
 
CMD ["dist/src/server.js"]
cs

update: 2024.10.20

댓글 없음:

댓글 쓰기