도커 멀티스테이지 빌드는 이미지 크기를 어떻게 줄이나
빌드 의존성이 런타임 이미지까지 그대로 남는 싱글 스테이지 빌드의 문제를 정리하고, 멀티스테이지로 빌드와 런타임을 분리하는 방법과 레이어 순서로 캐시 적중률을 높이는 원리를 정리합니다.
Dockerfile을 한 스테이지로만 작성하면, 컴파일러나 devDependencies처럼 빌드할 때만 필요한 것들이 최종 이미지에도 그대로 남는다. 멀티스테이지 빌드는 빌드 단계와 런타임 단계를 아예 다른 이미지로 분리해서 이 문제를 없앤다.
싱글 스테이지의 문제
Node.js 프로젝트를 예로 들면, 하나의 Dockerfile 안에서 npm install(devDependencies 포함)로 타입스크립트 컴파일러 등을 설치하고, 소스를 빌드하고, 그 이미지 그대로 애플리케이션을 실행하는 경우가 많다. 문제는 이렇게 하면 런타임에는 전혀 필요 없는 빌드 도구, 테스트 프레임워크, 소스맵, 캐시된 npm 패키지까지 최종 이미지 레이어에 그대로 남는다는 점이다. 이미지가 불필요하게 커져서 pull·배포 시간이 늘어나고, 런타임에 쓰지도 않는 패키지들이 그만큼 공격 표면(취약점이 발견될 수 있는 대상)을 넓힌다.
멀티스테이지 빌드 — 빌드와 런타임을 분리
멀티스테이지 빌드는 FROM ... AS <이름>으로 빌드 전용 스테이지를 하나 만들고, 거기서 나온 결과물만 COPY --from=<이름>으로 최종 런타임 스테이지에 옮겨 담는다. 빌드 도구와 중간 산출물은 빌드 스테이지에만 존재하고, 최종 이미지의 레이어 히스토리에는 아예 포함되지 않는다.
1# 1단계: 빌드 전용2FROM node:20 AS builder3WORKDIR /app4COPY package*.json ./5RUN npm ci6COPY . .7RUN npm run build8 9# 2단계: 런타임 전용10FROM node:20-alpine11WORKDIR /app12COPY --from=builder /app/dist ./dist13COPY --from=builder /app/node_modules ./node_modules14COPY package*.json ./15CMD ["node", "dist/main.js"]builder 스테이지는 전체 소스와 devDependencies를 다 갖고 있지만, 두 번째 스테이지는 dist(빌드 산출물)와 실행에 필요한 node_modules만 가져온다. 베이스 이미지도 node:20(풀 버전) 대신 node:20-alpine(경량 버전)으로 바꿀 수 있는 건, 이 스테이지에서는 컴파일 도구가 전혀 필요 없기 때문이다.
레이어 순서와 캐시 적중률
Docker는 Dockerfile의 각 명령을 레이어로 캐싱한다. 어떤 레이어의 입력(그 명령이 참조하는 파일)이 바뀌면, Docker는 그 레이어부터 이후 모든 레이어를 캐시 없이 다시 실행한다.
그래서 자주 바뀌지 않는 것부터 앞에 배치해야 한다. 위 Dockerfile에서 COPY package*.json ./와 RUN npm ci를 COPY . .(전체 소스 복사)보다 먼저 둔 이유가 이것이다. package.json은 의존성을 추가하거나 뺄 때만 바뀌지만, 소스 코드는 거의 매 커밋마다 바뀐다. 만약 순서를 반대로 해서 COPY . .를 먼저 하고 그다음 npm ci를 실행하면, 소스 코드 한 줄만 바뀌어도 그 이전 레이어인 "전체 소스 복사"의 캐시가 무효화되고, 뒤따르는 npm ci도 캐시를 못 쓰고 매번 처음부터 의존성을 다시 설치하게 된다.
정리
- 멀티스테이지 빌드는 빌드 의존성과 런타임 이미지를 분리해서, 컴파일러나 devDependencies 같은 빌드 전용 산출물이 최종 이미지에 남지 않게 한다. 이미지 크기와 공격 표면이 함께 줄어든다.
- Docker 레이어는 위에서부터 순서대로 캐싱되므로, 자주 바뀌지 않는 레이어(의존성 정의 파일 복사·설치)를 자주 바뀌는 레이어(소스 코드 복사)보다 먼저 배치해야 캐시 적중률이 올라간다.
- 스테이지 분리는 이미지 크기 문제를, 레이어 순서는 빌드 속도 문제를 해결하는 것으로 서로 다른 축이지만, 실무에서는 둘을 항상 같이 적용한다.