1.2GB를 68MB로 - Nest.js 도커 image 경량화 이야기 (+ Dockerfile 순서가 중요한 이유)

1.2GB를 68MB로 - Nest.js 도커 image 경량화 이야기 (+ Dockerfile 순서가 중요한 이유)
Photo by Philippe Oursel / Unsplash

좋은 Dockerfile이란 무엇일까. 다음과 같이 정의할 수 있다고 생각한다:

  1. 빠른 배포를 위해 최종 이미지의 크기가 작아야 한다.
  2. 빠른 빌드를 위해 이미지 빌드시 걸리는 시간을 줄인다.
  3. 최종 이미지의 보안 취약점이 적거나 없어야 한다.

다른 언어도 그렇지만 Node.js 특히 TypeScript로 제작한 어플리케이션은 Containerize할때 조금의 노력을 더 가해야한다.

이유는 크게 3개를 들을 수 있다:

  1. 인터프리터 언어이지만 소스 그대로 실행시키지 못하고 TypeScript 트랜스파일을 해야한다.
  2. 1번으로 인해 인터프리터 언어임에도 불구하고 빌드시 필요한 라이브러리와 런타임시 필요한 라이브러리가 다르다.
  3. 1번, 2번으로 인해 필요한 라이브러리/프레임워크 수가 늘고 결론적으로 node_modules가 기하 급수적으로 커진다.

어찌보면 전부 한 문맥으로 보인다. 물론 Deno나 Bun같은 창의적인 시도들이 많아지고 매우 긍정적으로 보고 있지만 몇 년내 생태계를 변화시키기에는 어려워 보인다.

암튼 이 3개의 이유로 우리가 얻을 수 있는 것은

Dockerfile을 잘못 작성했을때는 n배로 무거워진다지만,
Dockerfile을 잘 작성한다면 n배로 더 가벼워질 수 있다는 것이다!

예제를 통해 적용해보자.


참고로 다른 포스트 Container Internals에서 도커와 컨테이너 기술의 동작 원리와 Dockerfile로 빌드한 이미지가 어떻게 저장되는지에 대해 알아볼 수 있다:

Container Internals - 리눅스 커널부터 살펴보는 컨테이너 기술과 도커의 구조
💁‍♂️이 내용은 경북소프트웨어고에서 제가 강의한 “Container & Docker basics” 수업 내용을 일부분 발췌하고 더 전문적인 내용을 추가한 것입니다. 이 포스트에 있는 스케치들은 draw.io 라는 툴을 사용하여 직접 제작하였습니다. Container 기술 전에는 Virtual Machine(가상 머신) 기술이 있었다. 가상 머신을 통해 우리는 가상화 소프트웨어 시장을 열었고 대표적인 가상화 소프트웨어, 즉 Hypervisor에는

    로컬에서 했던대로 해보기

    자 먼저 최악의 경우를 생각해보자. 개발자들이 이렇게 작성하는 경우가 상당히 많다. 자신이 로컬 머신에서 했던 행동을 그대로 했을 뿐이기 때문이다.

    FROM node
    
    COPY . /app
    
    WORKDIR /app
    
    RUN npm i
    
    RUN npm run build
    
    CMD ["npm", "run", "start:prod"]

    물론 작동에는 아무런 문제가 없다.

    용량을 보면 무려 1.28GB를 사용하는 것을 알 수 있다.
    Scout를 통해 확인해 보았을 때 Critical이 1개 존재한다.

    하지만 이미지 용량이 크고 취약점이 존재한다. 왜 그럴까?

    https://hub.docker.com/_/node/

    Docker Hub의 node 이미지 설명에 따르면 태그를 붙히지 않은 node 이미지 (latest 이미지)는 Debian의 최신 버전을 사용한다고 한다.

    하지만 우리의 어플리케이션은 Debian만큼 full-packaged OS가 필요하지 않다. 그저 Node.js로 실행만 잘 시키면 되기 때문이다.

    Base 이미지를 바꿔보기

    Docker

    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. 도커 레이어 수가 적고 미리 설치된 바이너리 수가 적어 매우 가볍다.
    2. 1번으로 인해 Attack Surface가 확 줄어들어 보안에 강하다.
    3. 1번으로 인해 이미지 pull/push 가 빨라 배포가 빠르게 진행된다.

    그럼 어떻게 Alpine Linux는 다른 OS에 비해 가벼워 질 수 있었을까?

    libc... glibc와 musl

    Alpine Linux는 다른 Linux들과 달리 glibc가 아니라 musl을 사용한다.

    C standard library - Wikipedia

    libc 는 C언어에서 기본적으로 제공하는 라이브러리/함수들을 모아둔 명세로 적어도 이런 이름과 이런 작동을 하는 라이브러리/함수들이 있어야 현대 소프트웨어들이 작동한다는 것을 정의한 것이다.

    대충 stdio.hprintf() 같은 것을 만든 사람들이라는 것이다!

    하지만 그저 문서일 뿐인 libc 를 실제로 구현한 것이 glibcmusl이 된다.

    glibc - Wikipedia

    glibc는 이름에서 유추할 수 있다 싶이 GNU 재단에서 제작하였고 1987년에 제작해 매우 오래되었다. 그만큼 stable 하지만 사실상 쓸모없는 코드들이 너무 많이 존재하고 그만큼 무겁다.

    musl - Wikipedia

    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 로 상당히 줄어든 것을 볼 수 있다.
    취약점도 Critical이 없어지고 비교적 Impact가 적은 2H로 표시된다. 또한 M과 L이 매우 많이 줄었다.

    OS를 변경한 것은 상당한 효과가 있었다. 하지만 쫌 더 줄일 순 없을까?

    중간 점검

    우리는 사실 한 도커 이미지에서 빌드와 런타임을 동시에 진행했다. 과연 이것이 옳은 일일까?

    빌드와 런타임을 한 이미지에서 한다면 다음과 같은 의문점들이 생긴다.

    1. 최종 이미지 전에 Nest.js 빌드는 이미 끝났는데... 빌드에 썼던 원본 코드나 TypeScript 모듈들이 실제 실행할때도 필요할까?
    2. 이미 모듈들은 다 받았는데.. npm 바이너리가 굳이 실행할때 필요할까?
    3. 라이브러리를 추가하지도 않았는데 도커 이미지 빌드때 마다 npm i가 실행된다..

    하나하나씩 해결해보자...

    유료 구독하고 더 많은 내용을 확인해 보세요!

    이미 가입하셨나요? 여기를 눌러 로그인

    구독하고 더 많은 포스트들을 즐겨보세요!

    무료 가입 후 이메일로도 포스트를 보내드려요!
    your_name@example.com
    구독하기