🏗️ 멀티스테이지 빌드로 이미지 다이어트하기 — 빌드와 런타임은 다른 환경이다
모노레포 CI/CD를 손보면서
turbo prune이랑 Docker 레이어 캐싱으로 빌드 시간은 줄였는데, 정작 결과물인 이미지는 여전히 무거웠다. 빌드 도구랑 devDependencies, 소스 코드까지 최종 이미지에 다 들어가 있었던 게 문제였다. 멀티스테이지 빌드로 빌드 환경이랑 실행 환경을 갈라낸 기록.
빌드가 끝나도 이미지엔 빌드 도구가 남아 있다
싱글 스테이지 Dockerfile은 보통 이렇게 생겼다.
FROM node:20-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install # devDependencies까지 전부
COPY . .
RUN pnpm build
CMD ["node", "dist/server.js"]겉보기엔 문제없다. 빌드도 되고 실행도 된다. 그런데 이 이미지 안에 뭐가 들어 있는지 보면 좀 이상하다.
pnpm install로 깔린 devDependencies, 그러니까 TypeScript나 번들러, 린터 같은 것들은 빌드할 때나 쓰지 실행할 땐 안 돌아간다. 소스 코드 원본도 그렇다. 빌드해서 나온 dist/가 있으면 런타임에 src/는 필요가 없다. 빌드 중간에 생긴 캐시까지 합치면, 실행이랑 상관없는 것들이 이미지에 잔뜩 남는다.
런타임에 실제로 필요한 건 production 의존성이랑 dist/뿐인데도 그렇다.
집 지을 때 크레인이랑 비계가 필요하지만, 입주할 때 그걸 거실에 두고 살지는 않는다. 공사 끝나면 장비는 빼고 집만 남기는 게 당연한데, 싱글 스테이지 빌드는 그 장비를 안 빼고 그대로 들고 입주하는 셈이다. 🏠
이게 단순히 용량 문제만은 아니다. 이미지가 무거우면 레지스트리에 올리고 내려받는 시간이 늘고, 컨테이너 새로 띄울 때 콜드 스타트도 길어진다. 오토스케일링 환경이면 더 그렇다. 보안도 걸린다. 실행에 안 쓰는 빌드 도구가 이미지에 남아 있으면 그만큼 공격받을 수 있는 표면이 넓어지니까, 런타임 이미지는 작게 가져가는 게 낫다.
빌드하는 환경이랑 실행하는 환경을 나눈다
해결은 간단하다. 빌드는 빌드용 환경에서 하고, 거기서 나온 결과물만 깨끗한 실행용 환경으로 옮기면 된다. Docker는 이걸 스테이지로 지원한다.