스파르타코딩클럽 | 블로그
스파르타 이야기코딩 꿀팁IT 뉴스수강생 인터뷰
1659403162514-000.png
코딩 꿀팁

Dockerfile 최적화하기

조회수 1092·9분 분량
2022. 8. 2.

안녕하세요. 팀스파르타 개발팀입니다.

많은 분들이 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로 잘 노출하면 다른 개발자들에게 도움이 되겠지요.

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




[자원] 프로세스 수 줄이기

컨테이너 내부의 프로세스 수를 줄이는 것에서부터 시작해보겠습니다. 프로세스는 작업 단위이므로 이를 줄이는 것이 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 startnode 를 실행하고, 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/shSIGINT가 전달된다.
  4. /bin/shSIGINT를 가볍게 무시한다. 따라서 후속 프로세스들은 종료되지 않는다.
  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




마무리하며

혹시 유용하셨나요? 이런 꿀팁을 알려주는 사수와 함께 목적, 사명, 성취감을 느껴보고 싶으신가요? 팀스파르타는 언제나 개발자를 모시는데 관심이 많습니다. 관심이 더 생기셨다면, 채용 페이지를 살펴보시는건 어떨까요? 티타임도 언제나 환영합니다!




참고자료

  • https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
  • https://buddy.works/tutorials/optimizing-dockerfile-for-node-js-part-1
- 해당 콘텐츠는 저작권법에 의해 보호받는 저작물로 스파르타코딩클럽에 저작권이 있습니다.
- 해당 콘텐츠는 사전 동의 없이 2차 가공 및 영리적인 이용을 금하고 있습니다.
내용이 유익하셨다면? 공유하기
copyclip-blog-sharekakao-blog-sharefacebook-blog-share
2개의 글
최신순
인기순
다른 분들이 많이 읽은 글
스파르타 이야기
시니어 개발자가 코딩 부트캠프에서 하는 일 [항해99 멘토 인터뷰]
조회612·4분 분량
시니어 개발자가 코딩 부트캠프에서 하는 일 [항해99 멘토 인터뷰]
스파르타 이야기
항해99의 셰르파, 기술매니저 인터뷰
조회286·4분 분량
항해99의 셰르파, 기술매니저 인터뷰
copyclip-blog-share