Docker

multi-stage로 TypeScript 기반 Nodejs docker image 크기 줄이기

iKay 2022. 8. 2. 01:54
반응형

서론

최근 몇 년 전만 해도 소스코드의 runtime을 container 환경으로 빌드해서 배포&운영 한다는 것이 생소하고 어려웠던 것으로 인식되던 것으로 기억한다. 하지만 요즘은 container 환경으로 빌드만 할 수 있으면 쉽게 배포&운영할 수 있게 해주는 제품들이 시장에 많이 나와서 이러 진입장벽이 많이 낮아진 것 같다. 심지어 소규모 스타트업 회사가 kubernates를 운영할 줄 모른다고 해도 AWS의 ECS, EKS 등을 사용한다면 container 환경으로 쉽게 배포&운영하는데 큰 지장이 없을 정도이다. 현재 내가 몸담고 있는 서비스회사에서도 ECS를 사용하는 곳이 있는데 크게 무리없이 서비스를 잘 하고 있는 것 같다.

 

container는 주로 Docker가 사용되는 것 같다. 사실 나도 Docker말고는 다른 container를 사용해본적이 거의 없고 사실상 현재 업계에서는 Docker가 container로써 표준인 것 처럼 인식되는 것 같다.

 

개인적으로 Nodejs 런타임을 Docker container로 배포하고 운영하는게 난이도가 쉽고 작은 서비스에서는 매우 효율적이라고 생각한다. 왜 좋은지는 다음에 한 번 상세히 다뤄보면 좋을 것 같다.

 

그리고 사실상 Nodejs로 백엔드를 운영한다면 JavaScript를 직접 쓰는 곳은 거의 없고 TypeScript를 사용할 것이다. 나도 현재는 매일매일 TypeScript를 사용하고 있다. 하지만 나는 Nodejs 백엔드를 TypeScript를 이용해서 제대로 운영하고 있을까 매일매일 의심하기도 한다. TypeScript, Nodejs 자체가 진입장벽이 낮고 자유도가 높으니 말이다.

 

Docker도 마찬가지이다. 아마 구글에 "typescript dockerfile example" 라고 치고 맨 위에 있는 Dockerfile 스크립트를 복사 붙여넣기 해도 실제 운영환경에 배포가능한 docker image가 빌드될 것이다.

 

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

 

이렇게 생각없이 붙여넣은 스크립트가 당장은 동작하기 때문에 지금 이 순간에는 정답이라 볼 수 있겠지만 프로젝트 덩치가 커지면 배포되는 container image 크기도 커질 것이기 때문에 배포시 효율적이지 못하게 된다.

 

서론이 길었는데 오늘은 간단히 TypeScript 기반  Nodejs Docker container image의 크기를 줄이는 방법에 대해 살펴보려고 한다. 여러 시도가 있겠지만 최종적으로는 multi-stage 방법으로 그 크기를 최소화 해 볼 예정이다.    

 

소스코드는 https://github.com/i-kay/multi-stage-example-1 를 참고하면 되겠다.

 

준비

예제를 보이기 위해 NestJs 웹 애플리케이션 하나를 준비했다. 아래 cli 한 줄이면 NestJS 프로젝트가 생성된다. 

nest new .

 

.dockerignore 파일을 하나 만들어서 내용을 채워준다. Dockerfile에서 Copy시 무시되는 파일, 디렉토리들이다.

vi .dockerignore

# 아래 내용 추가
dist
node_modules

# :wq

이렇게만 하면 웹 애플리케이션 하나를 개발하고 컨네티어로 만들 준비가 벌써 끝나게 된다.

 

1 단계 - 시작

서론에서 보였듯이 Dockerfile을 아래처럼 작성하고,

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

 image를 build 해보자

docker build . -t multi-stage-example-1:1

image size는 다음과 같다

docker images           
---
REPOSITORY             TAG  ...  SIZE
multi-stage-example-1  1    ...  532MB

 

2 단계 - devDependencies를 삭제?

Nodejs를 경험해봤다면 devDependencies가 운영할때 필수가 아님을 누구나 알 것이다. 그렇다면 Dockerfile에서  `npm install` 된 `node_modules`를 모두 삭제하고 다시 `npm install --production`을 해주면 container image size가 줄어들지 않을까? 

 

Dockerfile을 아래와 같이 수정해보자. 방금 설명대로 `npm_modules`를 production 환경에 맞게 재설치해서 크기를 줄이려 하는 의도이다.

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
+ RUN rm -rf ./npm_modules
+ RUN npm install --production
EXPOSE 3000
CMD ["npm", "start"]

image를 build 해보자.

docker build . -t multi-stage-example-1:2

 

하지만 용량이 줄어 들지 않는다. 오히려 2MB 늘었다. 

docker images           
---
REPOSITORY             TAG  ...  SIZE
multi-stage-example-1  2    ...  534MB
multi-stage-example-1  1    ...  532MB

그 이유는 

+ RUN rm -rf ./npm_modules
+ RUN npm install --production

을 추가했을지라도 그 전 명령어들이 image layer에 남아있기 때문이다. 쉽게 말해서 `>` 표시된 부분이 image가 build될 때 그대로 남게 된다. 

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
> RUN npm install
> COPY . .
> RUN npm run build
+ RUN rm -rf ./npm_modules
+ RUN npm install --production
EXPOSE 3000
CMD ["npm", "start"]

그 내용은 `docker image history`를 통해 자세히 볼 수 있다. 결과를 보면 알 수 있겠듯이 상당한 용량을 차지하는 `npm install` 부분이 제거되지 않을 것 같다. 

docker history multi-stage-example-1:2
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
c1674234860f   2 hours ago   CMD ["npm" "start"]                             0B        buildkit.dockerfile.v0
<missing>      2 hours ago   EXPOSE map[3000/tcp:{}]                         0B        buildkit.dockerfile.v0
<missing>      2 hours ago   RUN /bin/sh -c npm install --production # bu…   1.98MB    buildkit.dockerfile.v0
<missing>      2 hours ago   RUN /bin/sh -c rm -rf ./npm_modules # buildk…   0B        buildkit.dockerfile.v0
<missing>      2 hours ago   RUN /bin/sh -c npm run build # buildkit         62.5kB    buildkit.dockerfile.v0
<missing>      2 hours ago   COPY . . # buildkit                             470kB     buildkit.dockerfile.v0
<missing>      2 hours ago   RUN /bin/sh -c npm install # buildkit           365MB     buildkit.dockerfile.v0
<missing>      2 hours ago   COPY package*.json ./ # buildkit                325kB     buildkit.dockerfile.v0
<missing>      2 hours ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      4 days ago    /bin/sh -c #(nop)  CMD ["node"]                 0B        
<missing>      4 days ago    /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B        
<missing>      4 days ago    /bin/sh -c #(nop) COPY file:4d192565a7220e13…   388B      
<missing>      4 days ago    /bin/sh -c apk add --no-cache --virtual .bui…   7.77MB    
<missing>      4 days ago    /bin/sh -c #(nop)  ENV YARN_VERSION=1.22.19     0B        
<missing>      4 days ago    /bin/sh -c addgroup -g 1000 node     && addu…   153MB     
<missing>      4 days ago    /bin/sh -c #(nop)  ENV NODE_VERSION=18.7.0      0B        
<missing>      13 days ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B        
<missing>      13 days ago   /bin/sh -c #(nop) ADD file:a2648378045615c37…   5.53MB

 

3 단계 - devDependencies를 정말 삭제할 수 없을까? 

결론부터 말하자면 docker가 build될 때 devDependencies를 삭제할 수 있다. `RUN` 명령어 마다 layer를 만들기 때문에 하나의 명령어로 실행되게끔 만들면 되긴 한다. 

 

이건 change 이고

FROM node:18-alpine
WORKDIR /usr/src/app
- COPY package*.json ./
- RUN npm install
COPY . .
- RUN npm run build
- RUN rm -rf ./npm_modules
- RUN npm install --production
+ RUN npm install \
	&& npm run build \
	&& rm -rf ./npm_modules \
	&& npm install --production
EXPOSE 3000
CMD ["npm", "start"]

 이건 결과 script 이다. 이것을 보면 되겠다.

FROM node:18-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm install \
	&& npm run build \
	&& rm -rf ./npm_modules \
	&& npm install --production
EXPOSE 3000
CMD ["npm", "start"]

보다시피 심플하다. 

 

마찬가지로 image를 build 해서

docker build . -t multi-stage-example-1:3

 image 사이즈를 보면 줄긴했다. 

docker images           
---
REPOSITORY             TAG  ...  SIZE
multi-stage-example-1  3    ...  301MB
multi-stage-example-1  2    ...  534MB
multi-stage-example-1  1    ...  532MB

결과로만 보면 이 방법도 나쁘진 않은 것 같다. 하지만 이 방법의 문제점은 한 줄로 명령어를 작성해서 보기가 좋지 않고 layer가 캐싱될 수 없다는 것이다. 대부분의 경우 소스코드의 수정이 일어나지만 node module을 새로 설치하는 경우는 드물 것이다. 그래서 

COPY package*.json ./
RUN npm install

 부분을 따로 1 단계, 2 단계 에서 분리한 것인데 3 단계에서는 그것을 이용할 수가 없다. 이 캐싱을 잘 이용하면 빌드를 매우 빠르게 할 수 있다. 

 

docker의 layer caching에 대해서는 다음에 한 번 다뤄보면 좋을 것 같다. 

4 단계 - multi-stage 도입

실제로 3 단계까지 고민을 했었고 더 나은 방법이 없을까 찾아봤을 때 쉽게 답을 찾을 수 있어서 조금 허무했다. 그 방벙이 지금 소개할 multi-stage 방식이다. multi-stage 방식에서는 흔히 builder라는 stage와 실제 배포될 stage를 구분해서 build 될 때 불필요한 것들을 가지지 않게 하는 것 같다.

 

우선 Dockerfile을 보자. 

FROM node:18-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --production
COPY --from=builder /usr/src/app/dist ./dist
EXPOSE 3000
CMD ["npm", "run", "start:prod"]

script를 살펴보자.

 

FROM으로 stage는 구분되고 첫 번째 stage는 `AS builder`라고 붙여서 `builder` 라고 명시를 하였다. 이름을 붙이지 않으면 `STAGE-0` 이런식으로 숫자가 붙게된다.

 

`package.json`과 `package-lock.json`만 복사해오고 `npm install --production` 함으로써 `devDependencies`는 제외하고 `dependencies`만 설치돼 매우 경량화 된다.

 

첫 번째 stage에서 `/usr/src/app`이란 곳에 install & build를 하게 돼서 1 단계와 같은 결과를 가지고 있을 것이다 하지만 두 번째 stage로 넘어오면서 `COPY --from=builder {src} {dest}` 해준 것만 실제로 전달되며 최종 stage로만 image는 build 된다. script 내용대로 `COPY --from=builder` 할 때 실행가능한 .js 파일들, 즉 dist 디렉토리만 복사해오게 돼 매우 경량화된다.

 

그리고 `devDependencies`애 있던 `nestcli`를 이젠 더 이상 사용할 수 없기 때문에 `npm run start:prod` 로 실행되도록 해야 한다. 

 

 

image를 build하고

docker build . -t multi-stage-example-1:4

 

image size를 보자. 

docker images           
---
REPOSITORY             TAG  ...  SIZE
multi-stage-example-1  4    ...  258MB
multi-stage-example-1  3    ...  301MB
multi-stage-example-1  2    ...  534MB
multi-stage-example-1  1    ...  532MB

1 단계로부터 약 280MB나 줄일 수 있게 됐고 캐싱 레이어도 유지할 수 있게 되었다..!

 

docker history를 보자. 첫 번째 stage에서 install, build 등은 포함되지 않는 것을 한 번 더 확인할 수 있다. 

docker history multi-stage-example-1:4
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
4d6a2809a48f   2 hours ago   CMD ["npm" "run" "start:prod"]                  0B        buildkit.dockerfile.v0
<missing>      2 hours ago   EXPOSE map[3000/tcp:{}]                         0B        buildkit.dockerfile.v0
<missing>      2 hours ago   COPY /usr/src/app/dist ./dist # buildkit        60.8kB    buildkit.dockerfile.v0
<missing>      2 hours ago   RUN /bin/sh -c npm install --production # bu…   91.3MB    buildkit.dockerfile.v0
<missing>      2 hours ago   COPY package*.json ./ # buildkit                325kB     buildkit.dockerfile.v0
<missing>      2 hours ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      4 days ago    /bin/sh -c #(nop)  CMD ["node"]                 0B        
<missing>      4 days ago    /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B        
<missing>      4 days ago    /bin/sh -c #(nop) COPY file:4d192565a7220e13…   388B      
<missing>      4 days ago    /bin/sh -c apk add --no-cache --virtual .bui…   7.77MB    
<missing>      4 days ago    /bin/sh -c #(nop)  ENV YARN_VERSION=1.22.19     0B        
<missing>      4 days ago    /bin/sh -c addgroup -g 1000 node     && addu…   153MB     
<missing>      4 days ago    /bin/sh -c #(nop)  ENV NODE_VERSION=18.7.0      0B        
<missing>      13 days ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B        
<missing>      13 days ago   /bin/sh -c #(nop) ADD file:a2648378045615c37…   5.53MB

 

결론

이번에는 TypeScript기반 Nodejs 웹 애플리케이션을 Docker container로 build 하는 과정에서 multi-stage 방식을 사용함으써 container image를 줄여봤다. container image size가 계속 커진다면 이렇게 multi-stage 방식으로 builder를 분리시켜 운영하는 것도 고려해볼 만한 선택지 중 하나가 될 것 같다.  

 

참고

- https://docs.docker.com/develop/dev-best-practices/#how-to-keep-your-images-small

 

반응형