1.2GB를 68MB로 - Nest.js 도커 image 경량화 이야기 (+ Dockerfile 순서가 중요한 이유)
좋은 Dockerfile이란 무엇일까. 다음과 같이 정의할 수 있다고 생각한다:
- 빠른 배포를 위해 최종 이미지의 크기가 작아야 한다.
- 빠른 빌드를 위해 이미지 빌드시 걸리는 시간을 줄인다.
- 최종 이미지의 보안 취약점이 적거나 없어야 한다.
다른 언어도 그렇지만 Node.js 특히 TypeScript로 제작한 어플리케이션은 Containerize할때 조금의 노력을 더 가해야한다.
이유는 크게 3개를 들을 수 있다:
- 인터프리터 언어이지만 소스 그대로 실행시키지 못하고 TypeScript 트랜스파일을 해야한다.
- 1번으로 인해 인터프리터 언어임에도 불구하고 빌드시 필요한 라이브러리와 런타임시 필요한 라이브러리가 다르다.
- 1번, 2번으로 인해 필요한 라이브러리/프레임워크 수가 늘고 결론적으로
node_modules
가 기하 급수적으로 커진다.
어찌보면 전부 한 문맥으로 보인다. 물론 Deno나 Bun같은 창의적인 시도들이 많아지고 매우 긍정적으로 보고 있지만 몇 년내 생태계를 변화시키기에는 어려워 보인다.
암튼 이 3개의 이유로 우리가 얻을 수 있는 것은
Dockerfile을 잘못 작성했을때는 n배로 무거워진다지만,
Dockerfile을 잘 작성한다면 n배로 더 가벼워질 수 있다는 것이다!
예제를 통해 적용해보자.
참고로 다른 포스트 Container Internals에서 도커와 컨테이너 기술의 동작 원리와 Dockerfile로 빌드한 이미지가 어떻게 저장되는지에 대해 알아볼 수 있다:
로컬에서 했던대로 해보기
자 먼저 최악의 경우를 생각해보자. 개발자들이 이렇게 작성하는 경우가 상당히 많다. 자신이 로컬 머신에서 했던 행동을 그대로 했을 뿐이기 때문이다.
FROM node
COPY . /app
WORKDIR /app
RUN npm i
RUN npm run build
CMD ["npm", "run", "start:prod"]
물론 작동에는 아무런 문제가 없다.
1.28GB
를 사용하는 것을 알 수 있다.하지만 이미지 용량이 크고 취약점이 존재한다. 왜 그럴까?
Docker Hub의 node 이미지 설명에 따르면 태그를 붙히지 않은 node 이미지 (latest 이미지)는 Debian의 최신 버전을 사용한다고 한다.
하지만 우리의 어플리케이션은 Debian만큼 full-packaged OS가 필요하지 않다. 그저 Node.js로 실행만 잘 시키면 되기 때문이다.
Base 이미지를 바꿔보기
Docker Hub에 나와있는 것 처럼 node 이미지는 3가지 Variants가 있다.
node:<version>
: Debian OS를 기반으로 node와 기타 필요한 것을 모두 깔아둔 이미지node:<version>-slim
: Debian OS를 기반으로 node+npm 정도만 깔아둔 이미지node:<version>-alpine
: Alpine OS를 기반으로 node+npm 정도만 깔아둔 이미지
이 중 Alpine Linux를 기반으로 하는 이미지는 Alpine 특성상 매우 용량이 가볍고 빠르다.
[deepdive] Alpine Linux란?
Alpine Linux가 왜 Containerlize 하기 편한 OS지는 다음과 같이 정의할 수 있다:
- 도커 레이어 수가 적고 미리 설치된 바이너리 수가 적어 매우 가볍다.
- 1번으로 인해 Attack Surface가 확 줄어들어 보안에 강하다.
- 1번으로 인해 이미지 pull/push 가 빨라 배포가 빠르게 진행된다.
그럼 어떻게 Alpine Linux는 다른 OS에 비해 가벼워 질 수 있었을까?
libc... glibc와 musl
Alpine Linux는 다른 Linux들과 달리 glibc
가 아니라 musl
을 사용한다.
libc
는 C언어에서 기본적으로 제공하는 라이브러리/함수들을 모아둔 명세로 적어도 이런 이름과 이런 작동을 하는 라이브러리/함수들이 있어야 현대 소프트웨어들이 작동한다는 것을 정의한 것이다.
대충 stdio.h
나 printf()
같은 것을 만든 사람들이라는 것이다!
하지만 그저 문서일 뿐인 libc
를 실제로 구현한 것이 glibc
와 musl
이 된다.
glibc
는 이름에서 유추할 수 있다 싶이 GNU 재단에서 제작하였고 1987년에 제작해 매우 오래되었다. 그만큼 stable 하지만 사실상 쓸모없는 코드들이 너무 많이 존재하고 그만큼 무겁다.
musl
은 2011년에 개발되기 시작했고 가벼운 libc
를 구현하기 위해 처음부터 다시 제작했다. 매우 가볍지만 개발이 시작된지 별로 안되었기 때문에 잔 버그와 약간의 취약점들이 있다.
Well musl is focus on be a slim tool with all you need, so it's simple and lightweight. ... But actually a lot of development has to be done to make it stable.
Glibc has collected through the years a lot of development, and most of it now is deprecated or used only on legacy infrastructures. So is a pretty huge and have with a lot of "useless" code. But well, it simply works.
- u/luzkero from r/voidlinux
자 그럼 Alpine Linux를 이미지에 적용해 보자.
FROM node:alpine
COPY . /app
WORKDIR /app
RUN npm i
RUN npm run build
CMD ["npm", "run", "start:prod"]
297MB
로 상당히 줄어든 것을 볼 수 있다.OS를 변경한 것은 상당한 효과가 있었다. 하지만 쫌 더 줄일 순 없을까?
중간 점검
우리는 사실 한 도커 이미지에서 빌드와 런타임을 동시에 진행했다. 과연 이것이 옳은 일일까?
빌드와 런타임을 한 이미지에서 한다면 다음과 같은 의문점들이 생긴다.
- 최종 이미지 전에 Nest.js 빌드는 이미 끝났는데... 빌드에 썼던 원본 코드나 TypeScript 모듈들이 실제 실행할때도 필요할까?
- 이미 모듈들은 다 받았는데.. npm 바이너리가 굳이 실행할때 필요할까?
- 라이브러리를 추가하지도 않았는데 도커 이미지 빌드때 마다
npm i
가 실행된다..
하나하나씩 해결해보자...