때는 바야흐로 너에게 닿기를 프로젝트에 합류하기로 결정하고 처음으로 코드를 살펴볼 때였다.
기존 백엔드 개발자 분들이 개인 사정으로 모두 떠난 프로젝트에서 백엔드 개발을 혼자 맡게 되었다.
낯선 코드 속에서 정신이 혼미해졌지만, 우선 기존 코드와 설정을 파악하며 프로젝트의 흐름을 정리해 나갔다.
그러던 중 Dockerfile에서 개선할 수 있는 부분을 발견했다.
기존 Dockerfile
기존 Dockerfile은 다음과 같다.
FROM node:22-slim
WORKDIR /app
RUN npm install -g pnpm
RUN apt-get update && apt-get install -y \
git \
vim \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
EXPOSE 3000
ENTRYPOINT ["pnpm", "run", "start:dev"]
Dockerfile을 살펴보니 pnpm을 사용한 설치 과정이 있었고, 실행 시 pnpm run start:dev
를 사용하고 있었다.
해당 커맨드는 개발 환경에서 파일 변경을 지속적으로 모니터링하여 불필요한 리소스를 소모하는 nest start --watch
로 alias 되어있으므로 운영환경에서는 부적절하다.
개발 환경이 아닌 운영 환경에 적합하도록 entrypoint를 node dist/main
으로 변경하고, multi-stage build를 적용하여 이미지 크기를 최적화하기로 했다.
Multi-stage build 적용
entrypoint를 node dist/main
으로 바꾸고 multi-stage build를 적용하였다.
FROM node:22-slim AS base
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS prod-deps
COPY package.json .
COPY pnpm-lock.yaml .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
RUN apt-get update && apt-get install -y \
git \
vim \
&& rm -rf /var/lib/apt/lists/*
# 애플리케이션 디렉토리의 소유권을 node 사용자로 변경
RUN chown -R node:node /app
# node 사용자로 전환
USER node
EXPOSE 3000
# 애플리케이션 실행
CMD ["node", "dist/main"]
기존 이미지 크기를 줄이고 빌드 단계와 실행 환경을 분리하여 최적화하기 위해 stage를 여러 단계로 나눴다.
- prod-deps에서 production 환경에 필요한 의존성만 설치 후 node_modules 디렉토리를 COPY
- build에서 build에 필요한 의존성을 설치하여 build 후 실제 실행 코드가 있는 dist 디렉토리를 COPY
기존에 있던 불필요한 패키지와 빌드 전 코드를 제외하고 필요한 부분만 production 이미지에 포함시켜 최적화하였다.
불필요한 파일이 많이 제거되었으므로 예상대로라면 이미지 크기가 줄어들어야 한다.
그런데...
오히려 이미지 크기가 커졌다.
기존 이미지 크기 (649MB)
keep-in-touch-be-v2-app latest 6a192eebdc4c 3 hours ago 649MB
바꾼 방식 이미지 크기 (692MB)
REPOSITORY TAG IMAGE ID CREATED SIZE
keep-what latest 8a2db73409ad 3 hours ago 692MB
예상과 다르게 43MB 증가하는 결과가 발생했다.
당황한 나는 .. 여러 가설과 검증을 통해 문제를 파악해 보았다.
원인 분석
빌드 과정 단순화
혹시 prod, dev 의존성을 나누는 것에 문제가 있나 싶어 두 개로 나뉘어 있던 build 레이어를 하나로 통합해 보았다.
FROM node:22-slim AS base
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM base
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
RUN apt-get update && apt-get install -y \
git \
vim \
&& rm -rf /var/lib/apt/lists/*
USER node
EXPOSE 3000
CMD ["node", "dist/main"]
builder
이미지에서 빌드 후 dist와 node_modules만 빼오는 방식이다.
여긴 dev dependency도 포함되어 있으므로 이전 방식보다 node_modules의 크기가 클 것이고 당연히 이미지 크기도 더 커야 한다.
그러나 결과는 612MB로 오히려 줄어들었다.
keep-prod-v3 latest dfe462f4390a 31 minutes ago 612MB
대체 왜,,
이해할 수 없는 결과가 나와서 두 방식의 빌드 결과물을 비교해보았다.
디렉토리 크기 분석
- node_modules, dist 따로 만들어 결과만 추출하는 첫 번째 이미지
1M dist
204M node\_modules
- dev dependency 포함하여 빌드하여 같이 추출하는 두 번째 이미지
988K dist
318M node_modules
예상대로 빌드 과정을 하나로 통합한 두 번째 이미지의 node_modules가 114MB 더 컸다.
근데 최종 이미지 결과는 통합된 첫 번째 이미지가 더 컸다.
이해할 수 없는 결과가 나와서 docker history
로 레이어 크기를 조사했다.
Docker history로 진짜 원인 파악
docker history [image] —no-trunc
IMAGE CREATED CREATED BY SIZE
sha256:8a2db73409ad4febb2c514671f... 3 hours ago CMD ["node" "dist/main"] 0B
<missing> 3 hours ago EXPOSE map[3000/tcp:{}] 0B
<missing> 3 hours ago USER node 0B
<missing> 3 hours ago RUN chown -R node:node /app 152MB
<missing> 3 hours ago RUN apt-get update && apt-get i... 149MB
...
살펴보니 수상한 커맨드가 있었다.
<missing> 3 hours ago RUN /bin/sh -c chown -R node:node /app # buildkit 152MB buildkit.dockerfile.v0
RUN chown -R node:node /app
이 무려 152MB나 차지하고 있다.
두 번째 이미지에서는 이게 없어서 (비교한다더니 대체 왜 없앴을까? 맘대로 작성했는갑다...) 크기가 줄어든 거였다.
https://stackoverflow.com/questions/30085621/why-does-chown-increase-size-of-docker-image
Why does chown increase size of docker image?
I can't understand why the 'chown' command should increase the size of my docker image? The following Dockerfile creates an image of size 5.3MB: FROM alpine:edge RUN adduser example -D -h /exampl...
stackoverflow.com
알고보니 Docker의 레이어 시스템이 chown
과 같은 파일 메타데이터 변경도 새 레이어를 생성하기 때문에 발생하는 문제였다.
Docker의 Copy-on-Write 특성으로 인해 chown
명령으로 변경된 모든 파일이 새 레이어에 다시 복사되어 저장된다.
따라서 파일 내용은 변하지 않았더라도 메타데이터가 변경된 모든 파일의 용량만큼 이미지 크기가 증가하게 된다.
Note that changing the metadata of files, for example, changing file permissions or ownership of a file, can also result in a copy_up operation, therefore duplicating the file to the writable layer.
https://docs.docker.com/engine/storage/drivers/#copying-makes-containers-efficient
Storage drivers
Learn the technologies that support storage drivers.
docs.docker.com
이것에 대한 대안으로 ADD
나 COPY
커맨드에서 --chown
옵션을 사용할 수 있다.
https://docs.docker.com/reference/dockerfile/#copy---chown---chmod]
Dockerfile reference
Find all the available commands you can use in a Dockerfile and learn how to use them, including COPY, ARG, ENTRYPOINT, and more.
docs.docker.com
해결
chown
커맨드를 제거하고 빌드한 결과 540MB의 이미지를 얻을 수 있었다.
keep-what-2 latest 9a00e97a8e2f 3 hours ago 540MB
multi-stage 빌드 적용과 불필요한 chown 명령 제거로 기존 이미지(649MB)에서 109MB를 줄였다.
최종 Dockerfile
FROM node:22-slim AS base
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS prod-deps
COPY package.json .
COPY pnpm-lock.yaml .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
# node 사용자로 전환
USER node
EXPOSE 3000
# 애플리케이션 실행
CMD ["node", "dist/main"]
부록
난 애초에 왜 chown을 썼을까?
많은 공식 이미지가 그렇듯 node
이미지에서 컨테이너 프로세스는 node
사용자를 이용하여 실행하는 것이 권장된다.
https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#non-root-user)
docker-node/docs/BestPractices.md at main · nodejs/docker-node
Official Docker Image for Node.js :whale: :turtle: :rocket: - nodejs/docker-node
github.com
난 "node 사용자에게 소유권을 옮겨야 실행이 되겠지" 하고 단순하게 생각해서 chown
을 사용했다.
하지만 node 이미지에서 node 사용자는 chown
없이도 USER node
로 사용자 전환만 하면 해당 디렉토리를 실행할 수 있었다..
'개발' 카테고리의 다른 글
[PostgreSQL] Sequence 값이 이상하다?! - Caching과 Gapless Assignment (0) | 2025.04.06 |
---|---|
[TypeORM] Entity 관계 설정에 eager, lazy 옵션을 명시해야 할까? (0) | 2025.03.16 |
Notion API로 원하는 날짜에 일정 생성하기 (2) | 2024.10.09 |
[Passport.js] passport 에서 로그인과 callback route 를 나눠야 할까? (1) | 2023.11.04 |
[NestJS] 파일 업로드 구현하다 FileInterceptor 를 커스텀 한 사람이 있다? (1) | 2023.10.08 |