Docker Multi-Stage Build 활용 가이드

Docker Multi-Stage Build 활용 가이드

들어가며

Docker를 사용하다 보면 이미지 크기가 너무 커서 배포와 관리에 어려움을 겪는 경우가 많습니다. 특히 빌드 도구와 종속성이 많은 애플리케이션은 최종 이미지에 불필요한 파일들이 포함되어 이미지 크기가 기하급수적으로 커지는 문제가 발생합니다. Docker 17.05 버전부터 도입된 Multi-Stage Build는 이러한 문제를 효과적으로 해결할 수 있는 방법입니다.

이 글에서는 Docker Multi-Stage Build 기법을 사용하여 이미지 크기를 획기적으로 줄이고 빌드 프로세스를 최적화하는 방법을 다루겠습니다.

Multi-Stage Build란?

Multi-Stage Build는 하나의 Dockerfile 내에서 여러 FROM 명령을 사용하여 여러 단계로 빌드 프로세스를 나누는 기법입니다. 각 단계는 이전 단계의 결과물을 사용할 수 있으며, 최종 이미지에는 필요한 파일만 포함시킬 수 있습니다.

전통적인 방식의 문제점

기존에는 두 가지 접근 방식이 일반적이었습니다:

빌더 패턴 사용: 별도의 빌더 이미지와 실행 이미지를 사용하는 방식으로, 스크립트가 복잡해지고 관리가 어려움

# 빌드 스크립트
docker build -t myapp-builder -f Dockerfile.build .
docker run --name builder myapp-builder
docker cp builder:/app/myapp ./myapp
docker build -t myapp -f Dockerfile.run .

단일 Dockerfile에서 모든 작업 수행: 빌드에 필요한 모든 도구와 런타임 환경을 포함하여 이미지 크기가 커지는 문제 발생

FROM golang:1.18
WORKDIR /app
COPY . .
RUN go build -o myapp
EXPOSE 8080
CMD ["./myapp"]

Multi-Stage Build는 이러한 문제점을 해결하면서도 간결한 Dockerfile을 유지할 수 있게 해줍니다.

Multi-Stage Build 기본 구조

Multi-Stage Build의 기본 구조는 다음과 같습니다:

# 첫 번째 단계: 빌드 환경
FROM golang:1.18 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# 두 번째 단계: 실행 환경
FROM alpine:3.16
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

이 Dockerfile에서:

  1. 첫 번째 단계에서는 golang:1.18 이미지를 사용하여 애플리케이션을 빌드합니다.
  2. 두 번째 단계에서는 매우 가벼운 alpine:3.16 이미지를 기반으로 새로운 이미지를 생성합니다.
  3. COPY --from=builder 명령을 사용하여 첫 번째 단계(builder)에서 빌드한 실행 파일만 새 이미지로 복사합니다.

결과적으로 최종 이미지는 빌드 도구나 소스 코드를 포함하지 않고 실행 파일만 포함하므로 크기가 크게 줄어듭니다.

다양한 언어별 Multi-Stage Build 예제

Node.js 애플리케이션

# 빌드 단계
FROM node:16 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# 실행 단계
FROM node:16-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
EXPOSE 3000
CMD ["npm", "start"]

Java (Spring Boot) 애플리케이션

# 빌드 단계
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn package -DskipTests

# 실행 단계
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Python 애플리케이션

# 빌드 단계
FROM python:3.10 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .

# 실행 단계
FROM python:3.10-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 5000
CMD ["python", "app.py"]

C++ 애플리케이션

# 빌드 단계
FROM gcc:11 AS builder
WORKDIR /app
COPY . .
RUN g++ -o myapp main.cpp -static

# 실행 단계
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

scratch는 완전히 비어 있는 이미지로, C++ 애플리케이션의 경우 정적으로 링크된 실행 파일만 포함하면 되기 때문에 이미지 크기를 최소화할 수 있습니다.

고급 Multi-Stage Build 기법

1. 조건부 빌드 단계 선택

특정 빌드 단계만 실행하도록 지정할 수 있습니다:

# 빌드 단계만 실행
docker build --target builder -t myapp-builder .

# 전체 빌드 실행
docker build -t myapp .

2. 여러 개의 FROM 문 사용

하나의 Dockerfile에서 여러 독립적인 이미지를 빌드할 수 있습니다:

# API 서버 빌드
FROM golang:1.18 AS api-builder
WORKDIR /app/api
COPY api/ .
RUN go build -o server

# 웹 UI 빌드
FROM node:16 AS ui-builder
WORKDIR /app/ui
COPY ui/ .
RUN npm install && npm run build

# API 서버 이미지
FROM alpine:3.16 AS api
WORKDIR /app
COPY --from=api-builder /app/api/server .
EXPOSE 8080
CMD ["./server"]

# 웹 UI 이미지
FROM nginx:alpine AS ui
COPY --from=ui-builder /app/ui/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

이 Dockerfile에서 두 개의 독립적인 이미지를 빌드할 수 있습니다:

docker build --target api -t myapp-api .
docker build --target ui -t myapp-ui .

3. 빌드 캐시 최적화

종속성 파일을 먼저 복사하고 설치한 후 소스 코드를 복사하면 코드가 변경되어도 종속성 레이어는 캐시를 활용할 수 있습니다:

FROM node:16 AS builder
WORKDIR /app

# 종속성 파일만 먼저 복사하고 설치
COPY package*.json ./
RUN npm install

# 소스 코드 복사 및 빌드
COPY . .
RUN npm run build

FROM node:16-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
# ...

실제 사례 분석: 이미지 크기 비교

아래는 Node.js 애플리케이션을 빌드할 때 Multi-Stage Build를 사용한 경우와 사용하지 않은 경우의 이미지 크기 비교입니다:

빌드 방식 이미지 크기 빌드 시간
단일 단계 ~1.2GB 45초
Multi-Stage ~150MB 55초

Multi-Stage Build를 사용하면 이미지 크기를 약 87% 줄일 수 있었습니다. 빌드 시간은 약간 증가했지만, 이미지 크기 감소로 인한 배포 시간 단축과 리소스 사용량 감소가 그 비용을 상쇄합니다.

주의사항 및 팁

주의사항

최소 권한 원칙: 최종 이미지에서는 애플리케이션을 루트가 아닌 사용자로 실행하는 것이 좋습니다.

FROM node:16-alpine
RUN addgroup -g 1001 -S appuser && adduser -u 1001 -S appuser -G appuser
USER appuser
COPY --from=builder --chown=appuser:appuser /app/dist ./dist

보안 인증 정보 처리: 빌드 단계에서 사용한 보안 인증 정보가 최종 이미지에 포함되지 않도록 주의해야 합니다.

# 잘못된 예
FROM node:16 AS builder
COPY .npmrc .  # 인증 정보 포함
RUN npm install

# 올바른 예
FROM node:16 AS builder
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
RUN npm install
RUN rm -f .npmrc

유용한 팁

레이어 수 최소화: 관련 명령을 하나의 RUN 문으로 결합하여 레이어 수를 줄입니다.

# 여러 레이어
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# 단일 레이어
RUN apt-get update && \
    apt-get install -y curl && \
    apt-get clean

.dockerignore 파일 사용: 불필요한 파일이 Docker 컨텍스트에 포함되지 않도록 합니다.

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore

빌드 인자 활용: 빌드 환경에 따라 다른 설정을 적용할 수 있습니다.

ARG ENV=production
RUN if [ "$ENV" = "production" ]; then npm run build:prod; else npm run build:dev; fi

CI/CD 파이프라인 통합

Multi-Stage Build를 CI/CD 파이프라인에 통합하는 방법을 간단히 살펴보겠습니다.

GitHub Actions 예제

name: Docker Build and Push

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      
      - name: Build and push
        uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: username/myapp:latest
          cache-from: type=registry,ref=username/myapp:buildcache
          cache-to: type=registry,ref=username/myapp:buildcache,mode=max

GitLab CI/CD 예제

stages:
  - build
  - deploy

variables:
  DOCKER_HOST: tcp://docker:2375/
  DOCKER_DRIVER: overlay2

build:
  stage: build
  image: docker:20.10
  services:
    - docker:dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

결론

Docker Multi-Stage Build는 이미지 크기를 줄이고 빌드 프로세스를 최적화하는 강력한 기법입니다. 특히 마이크로서비스 아키텍처에서 여러 컨테이너를 배포하는 경우, 이미지 크기 최적화는 리소스 사용량과 배포 시간에 큰 영향을 미칩니다.

이 글에서 다룬 기법들을 적용하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 이미지 크기 감소: 실행에 필요한 파일만 포함하여 이미지 크기를 최소화
  2. 보안 강화: 빌드 도구와 소스 코드가 최종 이미지에 포함되지 않아 공격 표면 감소
  3. 빌드 프로세스 간소화: 단일 Dockerfile로 복잡한 빌드 과정 관리
  4. 일관된 빌드 환경: 개발, 테스트, 프로덕션 환경 간의 일관성 유지

Docker를 사용하여 애플리케이션을 배포하는 개발자라면 Multi-Stage Build를 도입하여 컨테이너 이미지를 최적화하는 것을 적극 권장합니다.

참고 자료