Docker Multi-Stage Build 활용 가이드

SB신범
6분 읽기
조회수 로딩 중...

Docker Multi-Stage Build 활용 가이드

들어가며

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

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

Multi-Stage Build란?

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

전통적인 방식의 문제점

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

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

    dockerfile
    1FROM golang:1.18 2WORKDIR /app 3COPY . . 4RUN go build -o myapp 5EXPOSE 8080 6CMD ["./myapp"]
  2. 빌더 패턴 사용: 별도의 빌더 이미지와 실행 이미지를 사용하는 방식으로, 스크립트가 복잡해지고 관리가 어려움

    bash
    1# 빌드 스크립트 2docker build -t myapp-builder -f Dockerfile.build . 3docker run --name builder myapp-builder 4docker cp builder:/app/myapp ./myapp 5docker build -t myapp -f Dockerfile.run .

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

Multi-Stage Build 기본 구조

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

dockerfile
1# 첫 번째 단계: 빌드 환경 2FROM golang:1.18 AS builder 3WORKDIR /app 4COPY . . 5RUN go build -o myapp 6 7# 두 번째 단계: 실행 환경 8FROM alpine:3.16 9WORKDIR /app 10COPY --from=builder /app/myapp . 11EXPOSE 8080 12CMD ["./myapp"]

이 Dockerfile에서:

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

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

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

Node.js 애플리케이션

dockerfile
1# 빌드 단계 2FROM node:16 AS builder 3WORKDIR /app 4COPY package*.json ./ 5RUN npm install 6COPY . . 7RUN npm run build 8 9# 실행 단계 10FROM node:16-alpine 11WORKDIR /app 12COPY --from=builder /app/dist ./dist 13COPY --from=builder /app/node_modules ./node_modules 14COPY --from=builder /app/package.json . 15EXPOSE 3000 16CMD ["npm", "start"]

Java (Spring Boot) 애플리케이션

dockerfile
1# 빌드 단계 2FROM maven:3.8-openjdk-17 AS builder 3WORKDIR /app 4COPY pom.xml . 5COPY src ./src 6RUN mvn package -DskipTests 7 8# 실행 단계 9FROM openjdk:17-jdk-slim 10WORKDIR /app 11COPY --from=builder /app/target/*.jar app.jar 12EXPOSE 8080 13ENTRYPOINT ["java", "-jar", "app.jar"]

Python 애플리케이션

dockerfile
1# 빌드 단계 2FROM python:3.10 AS builder 3WORKDIR /app 4COPY requirements.txt . 5RUN pip install --user -r requirements.txt 6COPY . . 7 8# 실행 단계 9FROM python:3.10-slim 10WORKDIR /app 11COPY --from=builder /root/.local /root/.local 12COPY --from=builder /app . 13ENV PATH=/root/.local/bin:$PATH 14EXPOSE 5000 15CMD ["python", "app.py"]

C++ 애플리케이션

dockerfile
1# 빌드 단계 2FROM gcc:11 AS builder 3WORKDIR /app 4COPY . . 5RUN g++ -o myapp main.cpp -static 6 7# 실행 단계 8FROM scratch 9WORKDIR /app 10COPY --from=builder /app/myapp . 11EXPOSE 8080 12CMD ["./myapp"]

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

고급 Multi-Stage Build 기법

1. 조건부 빌드 단계 선택

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

bash
1# 빌드 단계만 실행 2docker build --target builder -t myapp-builder . 3 4# 전체 빌드 실행 5docker build -t myapp .

2. 여러 개의 FROM 문 사용

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

dockerfile
1# API 서버 빌드 2FROM golang:1.18 AS api-builder 3WORKDIR /app/api 4COPY api/ . 5RUN go build -o server 6 7# 웹 UI 빌드 8FROM node:16 AS ui-builder 9WORKDIR /app/ui 10COPY ui/ . 11RUN npm install && npm run build 12 13# API 서버 이미지 14FROM alpine:3.16 AS api 15WORKDIR /app 16COPY --from=api-builder /app/api/server . 17EXPOSE 8080 18CMD ["./server"] 19 20# 웹 UI 이미지 21FROM nginx:alpine AS ui 22COPY --from=ui-builder /app/ui/dist /usr/share/nginx/html 23EXPOSE 80 24CMD ["nginx", "-g", "daemon off;"]

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

bash
1docker build --target api -t myapp-api . 2docker build --target ui -t myapp-ui .

3. 빌드 캐시 최적화

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

dockerfile
1FROM node:16 AS builder 2WORKDIR /app 3 4# 종속성 파일만 먼저 복사하고 설치 5COPY package*.json ./ 6RUN npm install 7 8# 소스 코드 복사 및 빌드 9COPY . . 10RUN npm run build 11 12FROM node:16-alpine 13WORKDIR /app 14COPY --from=builder /app/dist ./dist 15# ...

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

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

빌드 방식이미지 크기빌드 시간
단일 단계~1.2GB45초
Multi-Stage~150MB55초

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

주의사항 및 팁

주의사항

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

    dockerfile
    1# 잘못된 예 2FROM node:16 AS builder 3COPY .npmrc . # 인증 정보 포함 4RUN npm install 5 6# 올바른 예 7FROM node:16 AS builder 8ARG NPM_TOKEN 9RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc 10RUN npm install 11RUN rm -f .npmrc
  2. 최소 권한 원칙: 최종 이미지에서는 애플리케이션을 루트가 아닌 사용자로 실행하는 것이 좋습니다.

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

유용한 팁

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

    dockerfile
    1ARG ENV=production 2RUN if [ "$ENV" = "production" ]; then npm run build:prod; else npm run build:dev; fi
  2. .dockerignore 파일 사용: 불필요한 파일이 Docker 컨텍스트에 포함되지 않도록 합니다.

    # .dockerignore node_modules npm-debug.log .git .gitignore
  3. 레이어 수 최소화: 관련 명령을 하나의 RUN 문으로 결합하여 레이어 수를 줄입니다.

    dockerfile
    1# 여러 레이어 2RUN apt-get update 3RUN apt-get install -y curl 4RUN apt-get clean 5 6# 단일 레이어 7RUN apt-get update && \ 8 apt-get install -y curl && \ 9 apt-get clean

CI/CD 파이프라인 통합

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

GitHub Actions 예제

yaml
1name: Docker Build and Push 2 3on: 4 push: 5 branches: [ main ] 6 7jobs: 8 build: 9 runs-on: ubuntu-latest 10 steps: 11 - uses: actions/checkout@v3 12 13 - name: Set up Docker Buildx 14 uses: docker/setup-buildx-action@v2 15 16 - name: Login to Docker Hub 17 uses: docker/login-action@v2 18 with: 19 username: ${{ secrets.DOCKERHUB_USERNAME }} 20 password: ${{ secrets.DOCKERHUB_TOKEN }} 21 22 - name: Build and push 23 uses: docker/build-push-action@v3 24 with: 25 context: . 26 push: true 27 tags: username/myapp:latest 28 cache-from: type=registry,ref=username/myapp:buildcache 29 cache-to: type=registry,ref=username/myapp:buildcache,mode=max

GitLab CI/CD 예제

yaml
1stages: 2 - build 3 - deploy 4 5variables: 6 DOCKER_HOST: tcp://docker:2375/ 7 DOCKER_DRIVER: overlay2 8 9build: 10 stage: build 11 image: docker:20.10 12 services: 13 - docker:dind 14 script: 15 - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY 16 - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG . 17 - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

결론

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

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

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

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

참고 자료