Multi-stage Build로 이미지 크기 줄이기

Multi-stage Build로 이미지 크기 줄이기

한눈에 보기

Multi-stage Build의 핵심은 단순히 MB를 줄이는 것이 아니라 build 환경과 실행 환경의 신뢰 경계를 나누는 것이다. compiler, source, test tool, package manager를 builder stage에 두고 검증된 artifact와 필수 runtime dependency만 final stage로 옮긴다. 이때 base image의 libc와 architecture, dynamic library, CA certificate, time zone data, non-root 권한까지 확인해야 한다. 작은 이미지는 전송과 공격 표면을 줄이지만, shell이 없는 환경의 디버깅과 취약점 탐지 결과를 함께 운영할 방법도 필요하다.

목차

하나의 이미지에 빌드와 실행을 모두 넣었을 때

TypeScript 서비스를 한 stage에서 build한다고 하자.

FROM node:22-bookworm
WORKDIR /workspace

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm test
RUN npm run build

CMD ["node", "dist/server.js"]

실행은 되지만 production container에는 실행과 무관한 것들이 남는다.

이것은 단지 image 크기 문제만은 아니다. production filesystem에 source와 build tool이 있으면 노출할 정보와 실행 가능한 도구가 늘고, vulnerability scanner가 평가할 package도 많아진다. 장애 조사 때 어떤 파일이 runtime에 필요한지 구분하기도 어렵다.

목표

“builder image를 작게 만들기”보다 “final image의 책임을 실행에 한정하기”가 먼저다.

Stage는 독립된 파일시스템이다

Dockerfile에서 새로운 FROM이 나오면 별도 stage가 시작된다.

FROM node:22-bookworm AS build
WORKDIR /workspace
COPY . .
RUN npm ci && npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /workspace
COPY --from=build /workspace/dist ./dist
CMD ["node", "dist/server.js"]

두 번째 stage는 첫 번째 stage의 전체 filesystem을 상속하지 않는다. COPY --from=build로 고른 경로만 가져온다.

flowchart LR
    A[Source and lock file] --> B[Build stage]
    B --> C[Test]
    C --> D[dist artifact]
    D --> E[Runtime stage]
    F[Runtime dependencies] --> E
    E --> G[Production image]

stage 이름을 0, 1 같은 index 대신 지정하면 순서를 바꿔도 참조가 유지된다.

COPY --from=build /workspace/dist ./dist

BuildKit은 선택한 target이 의존하지 않는 stage를 생략할 수 있다. 하지만 stage가 많다는 이유만으로 자동 최적화되는 것은 아니다. stage dependency를 읽을 수 있게 이름과 책임을 명확히 둔다.

최종 이미지에 무엇을 복사할지 먼저 정하기

Multi-stage Dockerfile을 쓰기 전에 runtime manifest를 작성해 보는 편이 좋다.

분류 Final image 포함
실행 artifact dist/server.js, binary
runtime dependency production node_modules, shared library
설정 schema validation에 필요한 JSON 필요할 때
migration 배포 과정에서 같은 image가 실행한다면 정책에 따라
source TypeScript 원본 보통 아니오
test fixture, coverage, test runner 아니오
compiler TypeScript, GCC, JDK 아니오
credential .npmrc, signing key 절대 아니오

COPY --from=build /workspace /workspace처럼 builder 전체를 옮기면 stage를 나눈 의미가 약해진다.

# 경계가 너무 넓다.
COPY --from=build /workspace /workspace

artifact 경로를 고정하고 필요한 것을 명시한다.

COPY --from=build /workspace/dist ./dist
COPY --from=production-deps /workspace/node_modules ./node_modules
COPY package.json ./

이 목록 자체가 runtime dependency 문서가 된다.

Node.js 서비스를 Stage로 분리하기

다음은 가상의 TypeScript API를 위한 구조다.

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS base
WORKDIR /workspace
ENV CI=true

FROM base AS dependencies
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM dependencies AS test
COPY tsconfig.json ./
COPY src ./src
COPY test ./test
RUN npm test

FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM base AS production-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev \
    && npm cache clean --force

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /workspace

COPY --from=production-deps --chown=node:node \
    /workspace/node_modules ./node_modules
COPY --from=build --chown=node:node \
    /workspace/dist ./dist
COPY --chown=node:node package.json ./

USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

이 예제에는 의도적인 선택이 있다.

  1. development dependency가 있는 dependencies는 build/test에만 쓴다.
  2. production dependency는 별도 stage에서 lock file 기준으로 설치한다.
  3. final stage에는 dist, production node_modules, package metadata만 복사한다.
  4. 파일 ownership을 복사 시점에 맞춰 별도 chown layer를 만들지 않는다.
  5. process는 image가 제공하는 non-root user로 실행한다.

npm prune --omit=dev로 하나의 설치 결과를 줄이는 방법도 있다. 속도는 나을 수 있지만 install script와 native addon 결과가 production-only clean install과 같은지 확인해야 한다.

예시 코드는 출발점이다

framework가 runtime에 view template, static file, migration, generated schema를 필요로 한다면 명시적으로 추가해야 한다.

Compiled Binary는 동적 의존성을 확인하기

Go나 Rust binary 하나만 scratch로 복사하면 매우 작은 image를 만들 수 있다.

# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/sample-api ./cmd/api

FROM scratch AS runtime
COPY --from=build /out/sample-api /sample-api
ENTRYPOINT ["/sample-api"]

하지만 모든 binary가 정적으로 연결되는 것은 아니다. CGO, OpenSSL, image processing library처럼 shared library가 필요하면 builder에는 있던 .so가 final stage에 없어 기동에 실패한다.

error while loading shared libraries:
libexample.so.1: cannot open shared object file

확인할 항목은 다음과 같다.

scratch가 맞지 않으면 필요한 runtime component가 포함된 slim 또는 distroless 계열이 더 안전할 수 있다.

Alpine과 Slim을 크기만으로 고르지 않기

Alpine 기반 image는 작지만 musl libc를 사용한다. Debian/Ubuntu 계열 slim은 보통 glibc 환경이다. 이 차이는 native module과 prebuilt binary 호환성에 영향을 준다.

기준 Alpine 계열 Debian slim 계열
기본 크기 대체로 작음 상대적으로 큼
libc musl glibc
native binary 호환 별도 build가 필요할 수 있음 glibc 배포 artifact와 맞는 경우가 많음
package ecosystem apk apt
익숙한 진단 도구 제한적일 수 있음 선택 폭이 비교적 큼

builder는 glibc인데 runtime을 Alpine으로 바꾸면 native addon이 깨질 수 있다.

# 위험할 수 있는 조합
FROM node:22-bookworm AS build
RUN npm ci

FROM node:22-alpine
COPY --from=build /workspace/node_modules ./node_modules

순수 JavaScript dependency만 있다는 근거가 없다면 같은 OS family와 runtime version을 맞추는 편이 예측 가능하다. Alpine을 선택한다면 builder도 같은 target 환경으로 두고 실제 native dependency를 test한다.

크기 차이만 보고 base를 교체하지 않는다

image가 40MB 줄어도 production crash나 DNS·TLS 차이를 만들면 최적화가 아니다.

Non-root 실행을 Final Stage에서 보장하기

build stage는 package 설치 때문에 root로 실행될 수 있다. 그 사실이 final stage의 runtime 권한까지 결정할 필요는 없다.

FROM node:22-bookworm-slim AS runtime
WORKDIR /workspace

COPY --from=build --chown=node:node /workspace/dist ./dist
USER node
CMD ["node", "dist/server.js"]

application이 runtime에 파일을 써야 한다면 디렉터리를 먼저 준비한다.

RUN mkdir -p /workspace/tmp \
    && chown node:node /workspace/tmp
USER node

더 좋은 방향은 writable path를 좁히고 upload나 durable data를 외부 volume/object storage로 보내는 것이다. root filesystem read-only 옵션과 함께 시험할 수 있다.

docker run --rm \
  --read-only \
  --tmpfs /workspace/tmp:rw,noexec,nosuid,size=64m \
  sample-api:test

non-root 전환 후에는 1024 미만의 privileged port, bind mount ownership, certificate path, temporary directory를 확인한다.

Base Image와 Dependency를 재현 가능하게 고정하기

node:latest는 시간이 지나면 다른 runtime과 OS를 가리킨다. 최소한 major와 OS family를 명시하고, 강한 재현성이 필요하면 digest로 고정한다.

FROM node:22-bookworm-slim@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef AS runtime

가상 digest이므로 실제 사용하면 안 된다. 고정 후에도 update 자동화가 필요하다.

builder와 runtime의 runtime version이 다르면 build output이 기대하는 기능이 없을 수 있다.

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-bookworm-slim AS build
# ...
FROM node:${NODE_VERSION}-bookworm-slim AS runtime

ARG로 한 곳에서 맞추는 것은 편리하지만 tag 해석 시점은 여전히 변할 수 있다. CI가 실제 해석된 digest, package lock checksum, artifact checksum을 기록하면 배포 조사에 도움이 된다.

테스트 Stage와 Production Stage 나누기

test가 성공해야 production image를 만들 수 있게 dependency graph를 구성해야 한다. 단순히 test stage를 Dockerfile에 써 두는 것만으로 final target이 그것을 실행한다는 보장은 없다.

FROM dependencies AS test
COPY src ./src
COPY test ./test
RUN npm test

FROM dependencies AS build
COPY src ./src
RUN npm run build

runtimebuild에만 의존하면 BuildKit은 test를 건너뛸 수 있다. CI에서 target을 명시적으로 실행한다.

docker buildx build --target test .
docker buildx build --target runtime --tag sample-api:candidate .

더 강한 연결이 필요하면 test가 검증한 artifact를 다음 stage가 받도록 구조를 바꾼다. 다만 test 실행 결과를 증명하는 빈 파일을 복사하는 기교보다는 CI pipeline의 required job과 artifact digest를 명확히 관리하는 편이 읽기 쉽다.

개발용 stage도 별도로 둘 수 있다.

FROM dependencies AS development
COPY . .
CMD ["npm", "run", "dev"]

production target에 development stage의 source나 port 설정이 섞이지 않게 한다.

Build Secret과 Source가 Final Image에 없는지 확인하기

Multi-stage Build는 builder layer를 final image manifest에 넣지 않지만 이것만으로 secret 사용이 안전해지는 것은 아니다. remote builder cache와 CI log, registry cache export에는 builder 관련 정보가 남을 수 있다.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

secret mount를 사용하고, source repository에도 credential을 두지 않는다. final image는 별도로 검사한다.

docker history --no-trunc sample-api:candidate
docker image save sample-api:candidate --output sample-api.tar

archive를 무작정 production 환경에서 풀기보다 격리된 CI scanner로 다음을 찾는다.

단순 문자열 검색의 한계

scanner가 못 찾았다고 secret이 없다고 단정하지 않는다. 애초에 build context와 명령에 secret이 들어오지 않는 구조가 우선이다.

작은 이미지와 낮은 위험은 같은 말이 아니다

package 수가 줄면 일반적으로 알려진 취약점 후보와 공격 도구가 줄어든다. 그러나 image 크기와 위험이 비례하는 것은 아니다.

Image A: 40 MB, 인터넷에 노출된 치명적 runtime 취약점 1개
Image B: 120 MB, 사용되지 않는 low severity package 여러 개

크기만으로 A가 안전하다고 할 수 없다. 실제 평가에는 다음 맥락이 필요하다.

Multi-stage는 공격 표면을 줄이는 한 수단이다. 취약점 scan, non-root, read-only filesystem, 최소 capability, 배포 정책과 함께 사용한다.

Shell 없는 이미지의 운영과 디버깅

distroless나 scratch에는 shell, curl, ps가 없을 수 있다. 이것은 공격자가 악용할 도구를 줄이고 runtime 변형을 막는 장점이 있지만, 기존의 docker exec -it ... sh 장애 대응은 통하지 않는다.

운영 방식을 바꿔야 한다.

FROM runtime-base AS production
COPY --from=build /out/sample-api /sample-api

FROM debug-base AS debug
COPY --from=build /out/sample-api /sample-api
RUN install-debug-tools

install-debug-tools는 개념을 나타내는 가상 명령이다. debug image를 production에 상시 배포하지 않고, 접근 권한과 보존 기간을 제한한다.

이미지 크기와 내용 측정하기

최적화 전후에는 compressed registry size, local uncompressed size, layer 구성, startup 영향 등을 따로 본다.

docker image ls sample-api
docker history sample-api:candidate
docker image inspect sample-api:candidate

비교 표는 실제 CI 결과로 채우는 것이 좋다.

지표 Single stage Multi-stage
registry 전송 크기 측정 필요 측정 필요
package 수 측정 필요 측정 필요
critical/high finding 측정 필요 측정 필요
cold pull 시간 측정 필요 측정 필요
build 시간 측정 필요 측정 필요

큰 layer가 무엇인지 확인하고, 단순히 stage 개수만 늘렸는데 final image가 같다면 실제로 불필요한 파일을 제외했는지 다시 본다.

실패 조건을 포함한 검증

기동과 기능

Artifact 호환

경계와 보안

구현 체크리스트

Stage 설계

Runtime

공급망

마무리

Multi-stage Build는 한 Dockerfile 안에서 build와 runtime의 filesystem을 분리한다. builder에는 source, compiler, development dependency를 둘 수 있지만 final image에는 선택한 artifact와 runtime dependency만 전달한다.

이 경계가 명확하면 image 전송량과 package 수가 줄고, production에서 사용할 수 있는 도구와 노출되는 source도 줄어든다. 하지만 가장 작은 base를 고르는 것만으로 끝나지 않는다. architecture, libc, shared library, CA certificate, time zone data, 파일 권한이 실제 application과 맞아야 한다.

또한 shell과 진단 도구를 제거했다면 관측 가능성과 debug 절차를 다른 방식으로 제공해야 한다. 크기 감소 수치만큼 clean build, non-root 기동, native dependency, 보안 scan, 장애 대응을 함께 검증해야 한다.

좋은 final image는 단순히 작은 image가 아니다. 무엇이 들어 있고 왜 필요한지 설명할 수 있으며, build 환경의 불필요한 권한과 자료가 production 경계를 넘지 않는 image다.

관련 노트

참고 자료