LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 20분

Dockerfile 최적화와 멀티스테이지 빌드

느린 빌드와 큰 이미지는 설계 문제다

docker build가 느리거나 이미지가 불필요하게 크다면 대부분 Dockerfile 설계 문제다. Node.js 앱 이미지가 1GB를 넘거나, 코드 한 줄 바꿀 때마다 npm install이 다시 실행된다면 레이어 순서와 캐시를 이해하지 못한 것이다.

이 편에서는 Docker 레이어 캐시가 어떻게 동작하는지 원리부터 이해하고, 멀티스테이지 빌드와 BuildKit 고급 기능으로 빌드를 빠르게, 이미지를 작게 만드는 방법을 다룬다.

레이어와 캐시의 원리

Dockerfile의 각 명령어는 새 레이어를 만든다. FROM, RUN, COPY, ADD가 레이어를 쌓는다. ENV, LABEL, EXPOSE, CMD 같은 메타데이터 명령어도 레이어를 만들지만 용량에는 거의 영향을 주지 않는다.

캐시 무효화 규칙: 어느 레이어 하나라도 변경되면, 그 이후의 모든 레이어가 다시 빌드된다.

❌ 잘못된 순서 FROM node:20-alpine COPY . /app ← 코드 변경 시 RUN npm ci ← 항상 재실행 ❌ RUN npm run build ← 재실행 ❌
✅ 올바른 순서 FROM node:20-alpine COPY package*.json . ← 자주 안 바뀜 RUN npm ci ← 캐시 재사용 ✅ COPY . /app ← 코드 변경 RUN npm run build ← 코드만 재빌드
핵심 원칙: 자주 바뀌는 레이어는 아래에, 드물게 바뀌는 레이어는 위에
레이어 캐시 무효화 파급 효과

.dockerignore — 빌드 컨텍스트 최소화

docker build . 실행 시 Docker 데몬은 현재 디렉터리 전체를 빌드 컨텍스트로 전송한다. .dockerignore가 없으면 node_modules, .git, 테스트 파일, 로그 등 불필요한 파일이 모두 포함되어 빌드가 느려지고 캐시가 불필요하게 무효화된다.

# .dockerignore 예시
node_modules/
.git/
.gitignore
*.log
dist/          # 빌드 아티팩트 (멀티스테이지에서 직접 생성)
.env*          # 환경 변수 파일 (보안상 제외)
**/*.test.ts
coverage/
README.md

기본 Dockerfile 최적화 패턴

RUN 명령어 체이닝

RUN은 레이어를 추가한다. 관련 명령어를 &&로 연결하고, 임시 파일은 같은 레이어에서 제거한다.

# ❌ 레이어 낭비, 캐시 패키지가 다른 레이어에 남음
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*

# ✅ 하나의 레이어, 캐시 파일도 같은 레이어에서 제거
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl git && \
    rm -rf /var/lib/apt/lists/*

베이스 이미지 선택

태그크기용도
node:20~1GB개발·디버깅용
node:20-slim~200MB일반 프로덕션
node:20-alpine~50MB경량 프로덕션 (musl libc)
gcr.io/distroless/nodejs20~120MB쉘 없는 최소 런타임

Alpine은 작지만 glibc 대신 musl libc를 사용해 일부 네이티브 모듈에서 호환성 문제가 생길 수 있다. 프로덕션에서 문제가 발생하면 -slim 태그를 검토한다.

멀티스테이지 빌드

멀티스테이지 빌드는 하나의 Dockerfile 안에서 여러 FROM을 사용해 빌드 환경과 런타임 환경을 분리한다. 컴파일러·빌드 도구·테스트 프레임워크 등 런타임에 불필요한 것들이 최종 이미지에 들어가지 않는다.

Stage 1: builder FROM golang:1.24-alpine AS builder WORKDIR /app COPY go.mod go.sum . RUN go mod download COPY . . RUN CGO_ENABLED=0 go build \
  -o /app/server ./cmd/server
Go 컴파일러 (~700MB) 포함
COPY --from 바이너리만
Stage 2: runtime (최종) FROM gcr.io/distroless/static-debian12 COPY --from=builder /app/server /server ENTRYPOINT ["/server"] 바이너리만 포함 (~8MB)
이미지 크기: ~700MB → ~8MB (99% 감소)
멀티스테이지 빌드 흐름 (Go 애플리케이션 예시)

실용적인 Node.js 멀티스테이지 예시

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json .
RUN npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 런타임 의존성만 복사 (devDependencies 제외)
COPY --from=deps /app/node_modules ./node_modules
# 빌드 아티팩트만 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

특정 스테이지만 빌드

# 개발 환경용 스테이지만 빌드
$ docker build --target builder -t myapp:dev .

# 특정 스테이지까지만 실행 (디버깅용)
$ docker build --target deps -t myapp:deps .

BuildKit 고급 기능

Docker 23.0+에서 BuildKit은 기본 활성화된다. # syntax=docker/dockerfile:1 주석을 Dockerfile 첫 줄에 추가하면 최신 문법을 사용할 수 있다.

cache mount — 빌드 간 캐시 유지

--mount=type=cache는 BuildKit 전용 기능으로, 레이어 캐시와 별도로 빌드 간 영속적인 캐시 디렉터리를 제공한다.

# syntax=docker/dockerfile:1

FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum .
# go 모듈 다운로드 결과를 빌드 간 캐시에 보관
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
# 컴파일 캐시도 유지
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=0 go build -o /app/server ./cmd/server

Node.js에서의 npm 캐시:

# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

secret mount — 보안 자격증명 처리

빌드 중에만 필요한 API 키나 SSH 키를 이미지 레이어에 남기지 않고 전달한다.

# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN --mount=type=secret,id=pip_token \
    pip install --extra-index-url https://token:$(cat /run/secrets/pip_token)@pypi.company.com/simple/ private-pkg
$ docker build --secret id=pip_token,src=./pip_token.txt .

시크릿은 빌드 레이어에 포함되지 않아 docker history나 이미지 레이어 검사로 노출되지 않는다.

병렬 스테이지 빌드

BuildKit은 의존성이 없는 스테이지를 자동으로 병렬 실행한다.

# syntax=docker/dockerfile:1

# 이 두 스테이지는 서로 의존하지 않으므로 병렬 실행
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json .
RUN npm ci && npm run build

FROM golang:1.24-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum .
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY backend/ .
RUN go build -o /backend/server ./cmd/server

# 두 결과를 하나의 최종 이미지로 합침
FROM nginx:alpine AS runner
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /backend/server /usr/local/bin/server

이미지 크기 줄이기 체크리스트

파일시스템 최소화 - 멀티스테이지로 빌드툴 제외 - --no-install-recommends - apt 캐시 같은 레이어에서 제거 - .dockerignore로 컨텍스트 제한
베이스 이미지 선택 - alpine / slim 태그 우선 검토 - distroless (쉘·패키지 매니저 없음) - scratch (정적 바이너리 전용) - 명시적 다이제스트 핀닝 (보안)
보안 강화 - USER 비루트 사용자로 전환 - 비밀 키는 secret mount 사용 - READ_ONLY_ROOTFS 설정 - docker scout cves로 취약점 검사
이미지 크기 최적화 전략

이미지 분석 도구

# 레이어별 크기와 명령어 확인
$ docker history myapp:latest

# dive: 레이어별 파일 변경사항 시각화
$ dive myapp:latest

# Docker Scout (공식 취약점 분석)
$ docker scout cves myapp:latest

# 이미지 압축 크기 확인
$ docker image inspect myapp:latest | grep Size

정리: 빌드 최적화 우선순위

  1. .dockerignore 작성 — 빌드 컨텍스트 최소화 (가장 쉬운 개선)
  2. COPY 순서 최적화package.jsonnpm install → 소스 코드
  3. 멀티스테이지 빌드 — 빌드 도구와 런타임 분리 (이미지 크기 50~99% 감소)
  4. BuildKit cache mount — CI에서 의존성 캐시 유지 (빌드 시간 40~70% 단축)
  5. 경량 베이스 이미지 — alpine·slim·distroless 중 요건에 맞게 선택

다음 편에서는 컨테이너 네트워킹과 볼륨을 다룬다: bridge·host·overlay 네트워크 드라이버, 볼륨 vs 바인드 마운트, tmpfs, 그리고 컨테이너 간 데이터 공유 패턴.

References