많은 분들이 Docker를 유용하고 사용하고 계실텐데요. 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을 최적화하는 이유, 그리고 달성하고자 하는 목표는 결국 운영 환경에서 잘 사용하기 위함입니다. 그럼 운영 환경에서 잘 사용하려면 어떤 특징을 가져야 할까요? 그것부터 알아보겠습니다.
운영의 기본 요소들, Dockerfile을 통한 해결 방법을 나열해보았습니다. 혹시 조금 어려우실 수 있으니 하나하나 차근차근 이야기해볼게요.
우선 시공간 자원은 크게 어렵지 않으실 겁니다. 컨테이너가 사용하는 프로세스(ps) 수를 줄여준다면 CPU가 덜 힘들겠죠. 이미지 용량을 줄이고 빌드 과정의 캐싱을 잘 활용한다면 디스크와 메모리 자원도 효율적으로 사용하게 될 겁니다.
요청과 응답의 원자성(atomicity)은 말이 좀 어려워보일 수 있으나 사실 매우 쉽고도 당연한 이야기입니다. 클라이언트가 서버에 하나의 요청을 보냈다고 가정해볼게요. 어라, 서버가 이 요청을 미처 처리하지도 못했는데 개발자가 배포를 진행하네요. 결국 응답을 보내지도 못한채 서버는 종료될 것이고, 결국 고객은 당황스러운 상황에 놓일 겁니다. 요청과 응답의 원자성을 보장해준다는 건 이러한 일이 일어나지 않도록 하겠다는 뜻입니다. 이를 위해 리눅스에는 POSIX signal이라는 것이 존재하고, 우리가 할 일은 컨테이너가 올바른 종료 신호를 잘 받을 수 있도록 설정해주면 되겠습니다.
필요한 요소를 강제한다는 건, 예상치 못한 실행 환경에 도커 이미지를 노출시키지 않는다는 뜻입니다. 도커 이미지의 실행 명령어는 때에 따라 개발자의 의도와 다르게 덮어씌워질 수 있는데요. 도커는 ENTRYPOINT를 바탕으로 실행 명령어를 강제할 수 있고, 우리는 이를 통해 의도한 대로만 실행할 수 있습니다.
그리고 마지막으로, 운영 환경에서는 명세를 효과적으로 노출하는게 중요합니다. 쉽게 말해 매번 동료 개발자들에게 물어물어 진행할 수는 없는 노릇이니, 미리 잘 적어둠으로써 효과적으로 의사소통하면 좋다는 것인데요. Dockerfile에서 중요한 명세로 컨테이너 포트가 있습니다. 이를 EXPOSE로 잘 노출하면 다른 개발자들에게 도움이 되겠지요.
그럼 이제부터 최적화를 단계별로 실행해보겠습니다.
컨테이너 내부의 프로세스 수를 줄이는 것에서부터 시작해보겠습니다. 프로세스는 작업 단위이므로 이를 줄이는 것이 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 사용 중.
Dockerfile 에서 실행 명령어를 전달하는 form은 shell 과 exec 타입이 있는데요. shell 은 일반 명령어를 주욱 작성하는 것이고 exec는 JSON array 형태로 제공하는 것입니다.
RUN echo $VERSION Shell <- Success RUN ["echo", "$VERSION"] Exec <- Failure
shell form은 반드시 /bin/sh 과 같이 다양한 명령어를 연결할 수 있다는 장점이 있으나, 쉘을 통해 실행되므로 불필요한 프로세스를 실행시키고, 이후 언급할 signal trapping을 제대로 수행하지 못합니다.
위와 같은 일반론 정도를 머리에 넣어두되, 기계적으로 적용할 필요는 없겠습니다. ‘쉘 기능이 반드시 필요한게 아니라면 exec form을 사용한다’라고 생각해주세요.
shell form 대신 exec form을 선호하는 것이 꼭 프로세스 수를 줄이기 위한 것만은 아닙니다. 위에 언급하였다시피 진짜 이유는 signal trapping과 관련이 있는데요.
리눅스에서 프로세스들은 IPC(inter-process communication)으로 통신할 수 있습니다. IPC 중 하나가 signal 인데요. CLI를 사용하고 있다면 자신도 모르는 새 사용하고 있는 것입니다. 예를 들어 Ctrl + C 를 눌러 프로세스를 종료시칸다면, 사실은 커널을 통해 해당 프로세스에게 SIGINT 를 보내는 것입니다. “지금 하고 있는 작업을 잘 마무리하고 그 다음에 프로세스를 종료시켜줘”라는 뜻이지요.
컨테이너 실행 명령어에 shell form 을 사용하면 이런 일이 일어납니다.
여기까지 잘 따라오셨다면, 아래와 같은 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초로 줄일 수 있습니다.
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 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
이렇게 이미지 사이즈를 줄이고, 빌드 시간을 단축하며, 컨테이너가 올바르게 운영될 수 있는 방법들에 대해 공유해 보았습니다. 이 글의 내용이 각자의 자리에서 고군분투하는 개발자분들에게 도움이 되었길 바랍니다.
_____
누구나 큰일 낼 수 있어
스파르타코딩클럽
CREDIT
글 | 남병관
편집 | 이상우
참고자료
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
https://buddy.works/tutorials/optimizing-dockerfile-for-node-js-part-1