[Daily morning study] Docker 이미지 최적화 (멀티스테이지 빌드)
#daily morning study
Docker 이미지 크기가 왜 중요한가
이미지가 크면 클수록:
- 레지스트리 푸시/풀 시간이 길어진다
- CI/CD 파이프라인 전체 속도가 느려진다
- 컨테이너 시작 시간이 늘어난다
- 저장소 비용이 올라간다
- 공격 표면(attack surface)이 넓어진다 — 패키지가 많을수록 취약점도 많다
이미지가 커지는 이유
# 문제 있는 예시
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
위 Dockerfile의 문제점:
node:20은 Debian 기반 전체 OS 이미지 — 약 1GBnode_modules(개발 의존성 포함)가 최종 이미지에 남는다- 빌드 도구, 컴파일러 등 런타임에 불필요한 도구가 모두 포함된다
npm install이후 레이어에 패키지 캐시가 남는다
레이어(Layer) 원리 복습
Docker 이미지는 여러 레이어의 스택이다. RUN, COPY, ADD 명령어 하나하나가 레이어를 만든다.
핵심 특성:
- 레이어는 한 번 생성되면 불변(immutable)이다
- 이전 레이어에서 파일을 삭제해도 그 레이어 자체는 여전히 이미지 크기에 포함된다
RUN apt-get update && apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/* # ← 이 삭제는 이전 레이어를 줄이지 않는다
올바른 방법은 한 RUN 명령어 안에서 처리하는 것이다:
RUN apt-get update && apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
멀티스테이지 빌드 (Multi-stage Build)
멀티스테이지 빌드는 하나의 Dockerfile 안에 여러 FROM을 사용하는 기법이다. 빌드 환경과 런타임 환경을 완전히 분리할 수 있다.
기본 구조
# Stage 1: 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 런타임 스테이지
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]
--from=builder로 이전 스테이지의 결과물만 복사한다. 빌드 의존성, 소스 코드, 캐시는 최종 이미지에 포함되지 않는다.
Go 애플리케이션 예시
Go는 정적 바이너리로 컴파일되기 때문에 멀티스테이지 빌드 효과가 극적이다.
# Stage 1: 빌드
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: 최소 런타임
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
scratch는 완전히 비어있는 베이스 이미지다. 바이너리만 들어가므로 최종 이미지 크기가 수십 MB 수준이 된다.
베이스 이미지 선택 전략
| 베이스 이미지 | 크기 (압축) | 특징 |
|---|---|---|
ubuntu:22.04 | ~30MB | 범용, 익숙한 명령어 |
debian:slim | ~25MB | 최소 Debian |
alpine:3.19 | ~3.5MB | musl libc 기반, 매우 작음 |
distroless | ~2MB | 셸 없음, 보안에 강함 |
scratch | 0MB | 완전 빈 이미지, 정적 바이너리 전용 |
Alpine 주의점: musl libc를 사용하기 때문에 glibc에 의존하는 일부 패키지와 충돌이 생길 수 있다. 이럴 때는 debian:slim이 더 안전한 선택이다.
.dockerignore 활용
.gitignore처럼 빌드 컨텍스트에서 제외할 파일을 지정한다. 불필요한 파일이 이미지로 들어가는 것을 막는다.
# .dockerignore
node_modules
.git
.github
*.log
.env
dist
coverage
README.md
.dockerignore가 없으면 node_modules 전체가 COPY . . 시 빌드 컨텍스트에 포함되어 빌드 자체가 느려진다.
레이어 캐시 최적화
Docker는 레이어 캐시를 활용한다. 변경된 레이어 이후의 모든 레이어는 캐시가 무효화된다.
# 나쁜 예: 소스 코드를 먼저 복사하면 코드 변경마다 npm install이 다시 실행된다
COPY . .
RUN npm install
# 좋은 예: 의존성 파일만 먼저 복사해서 캐시를 최대한 활용한다
COPY package*.json ./
RUN npm install
COPY . .
자주 변경되는 파일은 최대한 나중에 COPY하는 것이 핵심이다.
실전 최적화 체크리스트
- 멀티스테이지 빌드로 빌드/런타임 환경 분리
- 베이스 이미지를
alpine또는slim변형으로 변경 .dockerignore작성RUN apt-get ...명령어는 한 레이어에서 install + clean 처리package*.json을 먼저 복사하여 의존성 캐시 활용- 프로덕션 의존성만 설치 (
--omit=dev,--production) docker history <이미지>명령어로 레이어별 크기 확인dive도구로 레이어 내용 시각적으로 분석
이미지 크기 비교 예시
Node.js 앱 기준 (동일한 애플리케이션):
| 방법 | 이미지 크기 |
|---|---|
node:20 + 개발 의존성 전체 | ~1.2GB |
node:20-alpine | ~300MB |
멀티스테이지 + node:20-alpine | ~150MB |
멀티스테이지 + distroless | ~80MB |
멀티스테이지 빌드 하나만으로도 이미지 크기를 10배 이상 줄이는 것이 일반적이다.