[Daily morning study] Docker 이미지 최적화 (멀티스테이지 빌드)

#daily morning study

Image


Docker 이미지 크기가 왜 중요한가

이미지가 크면 클수록:

  • 레지스트리 푸시/풀 시간이 길어진다
  • CI/CD 파이프라인 전체 속도가 느려진다
  • 컨테이너 시작 시간이 늘어난다
  • 저장소 비용이 올라간다
  • 공격 표면(attack surface)이 넓어진다 — 패키지가 많을수록 취약점도 많다

이미지가 커지는 이유

# 문제 있는 예시
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]

위 Dockerfile의 문제점:

  1. node:20은 Debian 기반 전체 OS 이미지 — 약 1GB
  2. node_modules (개발 의존성 포함)가 최종 이미지에 남는다
  3. 빌드 도구, 컴파일러 등 런타임에 불필요한 도구가 모두 포함된다
  4. 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.5MBmusl libc 기반, 매우 작음
distroless~2MB셸 없음, 보안에 강함
scratch0MB완전 빈 이미지, 정적 바이너리 전용

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배 이상 줄이는 것이 일반적이다.