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을 최적화하는 이유, 그리고 달성하고자 하는 목표는 결국 운영 환경에서 잘 사용하기 위함입니다. 그럼 운영 환경에서 잘 사용하려면 어떤 특징을 가져야 할까요? 그것부터 알아보겠습니다.
- 시공간 자원(CPU, 메모리, 디스크)을 효과적으로 활용한다. ⇒ 레이어 캐싱으로 빌드 시간을 줄이고, 이미지 용량을 최소화하고, 컨테이너 내 프로세스 수를 줄인다.
- 요청과 응답의 원자성을 보장한다. ⇒ POSIX signal을 통해 gracefully shutdown할 수 있도록 한다.
- 필요한 요소는 강제한다. ⇒ ENTRYPOINT 로 실행 명령어를 강제한다.
- 어플리케이션 명세를 효과적으로 노출한다. ⇒ 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 을 사용하면 이런 일이 일어납니다.
- 일단 bash, sh 등 쉘을 실행함.
- 해당 쉘이 다른 프로세스를 실행함.
- 따라서 컨테이너에 종료 요청(docker stop)이 왔을 때, init process (=PID 1인 녀석)인 /bin/sh에 SIGINT가 전달된다.
- /bin/sh 는 SIGINT를 가볍게 무시한다. 따라서 후속 프로세스들은 종료되지 않는다.
- 도커는 10초 정도 기다렸다가, SIGTERM 신호를 못 받은 것을 알아차리고, SIGKILL 신호를 보낸다.
- 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위, 스파르타 내일배움캠프에 지원해 보세요.
사전 지식이 없어도 맞춤형 커리큘럼을 통해 커리어를 주도적으로 설계할 수 있는 역량을 기를 수 있어요.
수료 후엔 인턴십 프로그램, 현직자 멘토의 1:1 이력서 코칭 등 취업 지원 패키지가 평생 지원됩니다.
- 해당 콘텐츠는 사전 동의 없이 2차 가공 및 영리적인 이용을 금하고 있습니다.