Dockerfile 최적화하기

가볍고 빠른 이미지 빌드와 운영을 알려드립니다.


주니어 개발자를 위한 Dockerfile최적화 TIP

많은 분들이 Docker를 유용하고 사용하고 계실텐데요. Dockerfile 을 최적화하는 기초적인 방법들은 사수가 알려주지 않으면 잘 알기 어려운 것 같아요.

취준생, 대학생, 그리고 사수 없이 일하는 주니어 개발자 분들을 위해, Dockerfile을 최적화하는 기법을 알려드리고자 글을 작성하게 되었습니다.

어렵지 않은 내용들을 담고 있는데요. 이 글을 읽고 나신 뒤에는 이미지 사이즈를 줄이고, 빌드 시간을 단축하며, 컨테이너가 올바르게 운영될 수 있는 방법들에 대해 알게 되실 거에요.

그럼, 긴 말 없이 바로 시작해볼까요?

Dockerfile, 이렇게 쓰는 게 과연 맞는 걸까?

FROM node:17-alpine # node.js 버전 17이면서 가장 슬림한 녀석을 재료로 하자.
WORKDIR /opt/app # 해당 이미지 내에서 현재 경로를 /opt/app 으로 설정한다.
COPY . . # 호스트에서 이미지로 파일을 전부 복사한다.
RUN npm install # 패키지를 설치한다.
RUN npm run build # 소스를 빌드한다.
RUN npm run start # 실행한다.

Dockerfile의 흔한 예시를 가져와보았습니다. 보통 이렇게들 많이 작성하시죠.

알파인 이미지를 사용함으로써 이미지도 가볍게 만들었고, 필요한 패키지를 설치한 뒤 빌드하고 실행하는 정석적인 스크립트입니다. 하지만 운영 환경에서 사용하기에는 조금 부족하죠.

Dockerfile을 최적화하는 이유, 그리고 달성하고자 하는 목표는 결국 운영 환경에서 잘 사용하기 위함입니다. 그럼 운영 환경에서 잘 사용하려면 어떤 특징을 가져야 할까요? 그것부터 알아보겠습니다.

  1. 시공간 자원(CPU, 메모리, 디스크)을 효과적으로 활용한다. ⇒ 레이어 캐싱으로 빌드 시간을 줄이고, 이미지 용량을 최소화하고, 컨테이너 내 프로세스 수를 줄인다.
  2. 요청과 응답의 원자성을 보장한다. ⇒ POSIX signal을 통해 gracefully shutdown할 수 있도록 한다.
  3. 필요한 요소는 강제한다. ⇒ ENTRYPOINT 로 실행 명령어를 강제한다.
  4. 어플리케이션 명세를 효과적으로 노출한다. ⇒ EXPOSE 로 사용하는 포트를 노출한다.

운영의 기본 요소들, Dockerfile을 통한 해결 방법을 나열해보았습니다. 혹시 조금 어려우실 수 있으니 하나하나 차근차근 이야기해볼게요.

우선 시공간 자원은 크게 어렵지 않으실 겁니다. 컨테이너가 사용하는 프로세스(ps) 수를 줄여준다면 CPU가 덜 힘들겠죠. 이미지 용량을 줄이고 빌드 과정의 캐싱을 잘 활용한다면 디스크와 메모리 자원도 효율적으로 사용하게 될 겁니다.

요청과 응답의 원자성(atomicity)은 말이 좀 어려워보일 수 있으나 사실 매우 쉽고도 당연한 이야기입니다. 클라이언트가 서버에 하나의 요청을 보냈다고 가정해볼게요. 어라, 서버가 이 요청을 미처 처리하지도 못했는데 개발자가 배포를 진행하네요. 결국 응답을 보내지도 못한채 서버는 종료될 것이고, 결국 고객은 당황스러운 상황에 놓일 겁니다. 요청과 응답의 원자성을 보장해준다는 건 이러한 일이 일어나지 않도록 하겠다는 뜻입니다. 이를 위해 리눅스에는 POSIX signal이라는 것이 존재하고, 우리가 할 일은 컨테이너가 올바른 종료 신호를 잘 받을 수 있도록 설정해주면 되겠습니다.

필요한 요소를 강제한다는 건, 예상치 못한 실행 환경에 도커 이미지를 노출시키지 않는다는 뜻입니다. 도커 이미지의 실행 명령어는 때에 따라 개발자의 의도와 다르게 덮어씌워질 수 있는데요. 도커는 ENTRYPOINT를 바탕으로 실행 명령어를 강제할 수 있고, 우리는 이를 통해 의도한 대로만 실행할 수 있습니다.

그리고 마지막으로, 운영 환경에서는 명세를 효과적으로 노출하는게 중요합니다. 쉽게 말해 매번 동료 개발자들에게 물어물어 진행할 수는 없는 노릇이니, 미리 잘 적어둠으로써 효과적으로 의사소통하면 좋다는 것인데요. Dockerfile에서 중요한 명세로 컨테이너 포트가 있습니다. 이를 EXPOSE로 잘 노출하면 다른 개발자들에게 도움이 되겠지요.

그럼 이제부터 최적화를 단계별로 실행해보겠습니다.

Dockerfile 최적화하기

자원 : 프로세스 수 줄이기

컨테이너 내부의 프로세스 수를 줄이는 것에서부터 시작해보겠습니다. 프로세스는 작업 단위이므로 이를 줄이는 것이 CPU 및 메모리를 효율적으로 사용하는 첫걸음이겠지요. 이미지 실행 후 컨테이너에서 실행 중인 프로세스를 살펴보면 다음과 같습니다.

❯ docker exec backend-app ps -eo pid,ppid,user,args

PID PPID USER COMMAND
1 0 root npm run start18 1 root node /opt/app/node_modules/.bin/cross-env ENV=local node dist/src/main
25 18 root node dist/src/main
32 0 root ps -eo pid,ppid,user,args

각 프로세스별로 사용 중인 메모리를 유추해보면 다음과 같습니다.

❯ docker exec backend-app pmap -x PID

Address Kbytes PSS Dirty Swap
...
total 312900 37978 16564 0 # PID 1
total 255512 19257 8336 0 # PID 18
total 284432 46371 21368 0 # PID 25

# Memory 101MB 사용 중.

약 101MB를 사용하고 있네요.

프로세스 command를 살펴보겠습니다.

npm run start
-> node /opt/app/node_modules/.bin/cross-env ENV=local node dist/src/main
-> node dist/src/main

npm run start 가 node 를 실행하고, node 가 다시 빌드 결과물을 실행하고 있습니다. 그렇다면 우리는 node dist/src/main 만 실행하면 되겠네요.

아래와 같이 Dockerfile을 변경합니다.

FROM node:17-alpine # node.js 버전 17이면서 가장 슬림한 녀석을 재료로 하자.
WORKDIR /opt/app # 해당 이미지 내에서 현재 경로를 /opt/app 으로 설정한다.
COPY . . # 호스트에서 이미지로 파일을 전부 복사한다.
RUN npm install # 패키지를 설치한다.
RUN npm run build # 소스를 빌드한다.
CMD ["node", "dist/src/main"] # 빌드를 실행한다.

결과는 아래와 같습니다. 3개의 프로세스를 1개로 줄였습니다

.❯ docker exec backend-app ps -eo pid,ppid,user,args
PID PPID USER COMMAND
1 0 root node dist/src/main
36 0 root ps -eo pid,ppid,user,args

메모리는 약 34% (~35MB) 감소하였네요. 확실히 효과가 있습니다.

❯ docker exec backend-app pmap -x 1
Address Kbytes PSS Dirty Swap Mode Mapping
...
total 284188 67632 21232 0

# Memory 66MB 사용 중.

원자성 : Command Form

Dockerfile 에서 실행 명령어를 전달하는 form은 shell 과 exec 타입이 있는데요. shell 은 일반 명령어를 주욱 작성하는 것이고 exec는 JSON array 형태로 제공하는 것입니다.

RUN echo $VERSION Shell <- Success
RUN ["echo", "$VERSION"] Exec <- Failure

shell form은 반드시 /bin/sh 과 같이 다양한 명령어를 연결할 수 있다는 장점이 있으나, 쉘을 통해 실행되므로 불필요한 프로세스를 실행시키고, 이후 언급할 signal trapping을 제대로 수행하지 못합니다.

  • RUN: shell form
  • 그 외(ENTRYPOINT, CMD 등): exec form

위와 같은 일반론 정도를 머리에 넣어두되, 기계적으로 적용할 필요는 없겠습니다. ‘쉘 기능이 반드시 필요한게 아니라면 exec form을 사용한다’라고 생각해주세요.

원자성 : Signal Handling

shell form 대신 exec form을 선호하는 것이 꼭 프로세스 수를 줄이기 위한 것만은 아닙니다. 위에 언급하였다시피 진짜 이유는 signal trapping과 관련이 있는데요.

리눅스에서 프로세스들은 IPC(inter-process communication)으로 통신할 수 있습니다. IPC 중 하나가 signal 인데요. CLI를 사용하고 있다면 자신도 모르는 새 사용하고 있는 것입니다. 예를 들어 Ctrl + C 를 눌러 프로세스를 종료시칸다면, 사실은 커널을 통해 해당 프로세스에게 SIGINT 를 보내는 것입니다. “지금 하고 있는 작업을 잘 마무리하고 그 다음에 프로세스를 종료시켜줘”라는 뜻이지요.

컨테이너 실행 명령어에 shell form 을 사용하면 이런 일이 일어납니다.

  1. 일단 bash, sh 등 쉘을 실행함.
  2. 해당 쉘이 다른 프로세스를 실행함.
  3. 따라서 컨테이너에 종료 요청(docker stop)이 왔을 때, init process (=PID 1인 녀석)인 /bin/sh에 SIGINT가 전달된다.
  4. /bin/sh 는 SIGINT를 가볍게 무시한다. 따라서 후속 프로세스들은 종료되지 않는다.
  5. 도커는 10초 정도 기다렸다가, SIGTERM 신호를 못 받은 것을 알아차리고, SIGKILL 신호를 보낸다.
  6. SIGKILL은 강제종료이므로, 컨테이너 안에서 실행 중이던 프로세스들은 gracefully shut down (=하던 일 마무리짓고 종료) 하지 못하고 종료된다. ⇒ 요청과 응답의 원자성이 깨지는 순간입니다. 이를 피하기 위해 exec corm을 사용하는 것이지요. init process 를 실행 명령어로 지정해주어야 한다.

여기까지 잘 따라오셨다면, 아래와 같은 Dockerfile이 이해되실 거에요.

FROM node:17-alpine # node.js 버전 17이면서 가장 슬림한 녀석을 재료로 하자.
WORKDIR /opt/app # 해당 이미지 내에서 현재 경로를 /opt/app 으로 설정한다.
COPY . . # 호스트에서 이미지로 파일을 전부 복사한다.
RUN npm install # 패키지를 설치한다.
RUN npm run build # 소스를 빌드한다.
CMD ["node", "dist/src/main"] # 빌드를 실행한다. SIGINT 를 통해 gracefully shutdown 할 수 있다.

시간복잡도 : 레이어 캐싱 활용

위 이미지에서 CACHED 가 보이시나요? 도커는 이미지를 만들 때 각 명령어마다 Layer를 만들어두고 이전과 변한 것이 없을 경우 재사용합니다. 이를 잘 활용하면 빌드 시간을 대폭 단축할 수 있습니다.

FROM node:17-alpine # [1] node.js 버전 17이면서 가장 슬림한 녀석을 재료로 하자.
WORKDIR /opt/app # [2] 해당 이미지 내에서 현재 경로를 /opt/app 으로 설정한다.
COPY . . # [3] 호스트에서 이미지로 파일을 전부 복사한다.
RUN npm install # [4] 패키지를 설치한다.
RUN npm run build # [5] 소스를 빌드한다.
CMD ["node", "dist/src/main"] # [6] 빌드를 실행한다.

이 Dockerfile은 “코드를 단 한 줄도 바꾸지 않아도" 매번 패키지를 설치하고 빌드를 실행합니다. working directory에 있는 것을 전부 복사하기 때문이죠. 달리 말해 COPY . . 이 매번 실행되므로 그 뒤 과정도 매번 새롭게 실행되는 것입니다.

[4]번 단계와 같은 패키지 설치는 시간이 지날수록 시간이 많이 소요되는 단계이기 때문에, 이를 줄여보면 좋겠습니다.

FROM node:17-alpine
WORKDIR /opt/ap

# 패키지 설치 단계
COPY ["package.json", "package-lock.json", "./"]
RUN ["npm", "install"]

# 빌드 단계
COPY ["tsconfig.build.json", "tsconfig.json", "./"]
COPY ["nest-cli.json", "./"]
COPY ["src/", "./src/"]
COPY ["config/", "./config/"]
RUN ["npm", "run", "build"]

CMD ["node", "dist/src/main"]

패키지 설치와 빌드 단계를 분리함으로써, 분명하게 패키지가 바뀌었을 때만 설치하도록 하였습니다. 약 150초의 소요시간을 6초로 줄일 수 있습니다.

명세 : ENTRYPOINT 활용하기

docker run 을 통해 이미지를 컨테이너로 실행할 때, CMD 대신 ENTRYPOINT 를 사용하길 권해드리는데요. 그 이유는 실행 명령어를 CMD로 쓰면 덮어쓸 수 있으나 ENTRYPOINT는 덮어쓸 수 없기 때문입니다.

실행 명령어가 명확한 DB, 서버 등은 CMD 대신 ENTRYPOINT를 쓰는 것이 오작동을 막아줍니다.

ENTRYPOINT ["node", "dist/src/main"]
-> docker run --name app app:base ps -aef: 이어붙여져서 잘못된 명령어로 인식

CMD ["node", "dist/src/main"]
-> docker run --name app app:base ps -aef: 실행 안하고, 프로세스 목록을 반환.
"Cmd": null,
"Image": "",
"Volumes": null,
"WorkingDir": "/opt/app",
"Entrypoint": [
"node",
"/opt/app/node_modules/.bin/cross-env",
"ENV=${ENV}",
"node",
"dist/src/main"
],

# 이미지를 inspect 했을 때 결과물의 일부. Cmd와 Entrypoint가 분리되어있습니다.

명세 : EXPOSE

도커 이미지 내부 소스코드를 뜯어보아야만 어떤 포트가 열려있는지 안다는건 불편하니, 명세에 넣어줍니다.

EXPOSE 8080/tcp

실제로 포트가 열려있는지 확인합니다.

> docker inspect --format '{{ range $key, $value := .Config.ExposedPorts }} {{ $key }} {{ end }}' app:base
8080/tcp

공간복잡도 : 이미지 사이즈 줄이기

마지막으로, 이미지 크기를 줄여보겠습니다. 그러기 위해서는 이미지 구성 요소를 살펴보는게 좋겠네요. 이미지 루트 구성을 살펴보면 다음과 같습니다.

❯ docker exec -it backend-app du -ahd1
168.0K ./dist
302.3M ./docker
8.0K ./proxy
20.0K ./config
24.0K ./src
4.0K ./nest-cli.json
4.0K ./tsconfig.build.json
4.0K ./tsconfig.json
329.3M ./node_modules
704.0K ./package-lock.json
4.0K ./package.json
632.5M .

여러 녀석들이 있지만 모든 것이 배포에 필요한 것은 아닙니다.

./dist, ./node_modules 가 있다면 어플리케이션을 배포하는데는 아무런 무리가 없어보이네요. 다른 파일은 지워도 된다는 뜻이지요. 따라서 다음과 같이 삭제하는 명령어를 Dockerfile에 추가해봅니다.

RUN ["/bin/sh", "-c", "find . ! -name dist ! -name node_modules -maxdepth 1 -mindepth 1 -exec rm -rf {} \\\\;"]

사실 이것은 오히려 용량을 증가시킵니다. 왜냐하면 도커는 레이어를 축적하여 이미지를 생성하고, 한 번 생성된 레이어는 제거할 수 없기 때문입니다. 이를 해결하기 위해, 다음과 같이 명령어를 변경합니다.

FROM node:17-alpine as staged

WORKDIR /opt/app

COPY ["package.json", "package-lock.json", "./"]
RUN ["npm", "install"]

COPY ["tsconfig.build.json", "tsconfig.json", "./"]
COPY ["src/", "./src/"]
RUN ["npm", "run", "build"]

RUN ["/bin/sh", "-c", "find . ! -name dist ! -name node_modules -maxdepth 1 -mindepth 1 -exec rm -rf {} \\\\;"]

FROM node:17-alpine as completed
WORKDIR /opt/app
COPY --from=staged /opt/app ./
ENTRYPOINT ["node", "dist/src/main"]
EXPOSE 8080/tcp

위 Dockerfile 에서 눈여겨보실 부분은 FROM 입니다. 두 개가 존재하는데요. staged로 이름붙인 녀석은 “빌드 파일을 생성하기 위한 도구"로 활용하고, completed로 이름붙인 녀석이 실제 배포에 활용되는 이미지인 것이지요. 이러한 기법을 multi-stage build 라고 하는데요. 보다 자세한 내용이 궁금하시다면, 공식 문서의 multi-stage builds 를 참고하셔도 좋겠습니다. 필요한 것들만 알파인 이미지에 얹었을 때 47% 개선이 이뤄진 것을 볼 수 있습니다.

❯ docker exec -it app du -ahd1
329.3M ./node_modules
164.0K ./dist
329.5M .

최적화 이전: 774MB
최적화 이후: 409MB

Dockerfile 최적화 꿀팁, 유용하셨나요?

이렇게 이미지 사이즈를 줄이고, 빌드 시간을 단축하며, 컨테이너가 올바르게 운영될 수 있는 방법들에 대해 공유해 보았습니다. 이 글의 내용이 각자의 자리에서 고군분투하는 개발자분들에게 도움이 되었길 바랍니다.

_____

누구나 큰일 낼 수 있어

스파르타코딩클럽

CREDIT

글 | 남병관

편집 | 이상우

참고자료

https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

https://buddy.works/tutorials/optimizing-dockerfile-for-node-js-part-1


국비 지원 받고 IT업계에서 커리어 쌓는 방법

개발자, 디자이너, PM, 데이터 분석가 등 IT업계 직군 취업에 관심이 있으신가요?

온라인 부트캠프 중 취업률 1위, 스파르타 내일배움캠프에 지원해 보세요.

사전 지식이 없어도 맞춤형 커리큘럼을 통해 커리어를 주도적으로 설계할 수 있는 역량을 기를 수 있어요.

Una comparación útil de camisetas de clubes comienza por los colores, el escudo y los detalles del diseño. El pedido puede cerrarse después de confirmar que la descripción no mezcle versiones de temporadas distintas.

수료 후엔 인턴십 프로그램, 현직자 멘토의 1:1 이력서 코칭 등 취업 지원 패키지가 평생 지원됩니다.

경기일 복장은 팀을 응원하는 의미와 실제 착용 편의성을 함께 고려해야 합니다. 아동용 사이즈가 중요하다면 리버풀 축구 유니폼(camisetas del Liverpool)을 사이즈 정보와 함께 살펴보는 것이 좋습니다. 통기성이 좋은 소재는 따뜻한 환경에서 착용할 때 편안함을 높여 줍니다.


- 해당 콘텐츠는 저작권법에 의해 보호받는 저작물로 스파르타코딩클럽에 저작권이 있습니다.
- 해당 콘텐츠는 사전 동의 없이 2차 가공 및 영리적인 이용을 금하고 있습니다.