멀티 스테이지 빌드로 Docker 이미지 최적화: 종합 가이드

Docker 멀티 스테이지 빌드를 마스터하여 이미지 크기를 획기적으로 줄이고, 배포 속도를 높이며, 보안을 강화하세요. 이 종합 가이드는 Go 및 Node.js에 대한 단계별 지침, 실용적인 예제 및 필수 모범 사례를 제공합니다. 빌드 종속성을 분리하고 필요한 구성 요소만 최종 런타임 이미지에 포함시켜 Dockerfile을 최적화하는 방법을 알아보세요. 효율적이고 안전한 컨테이너화된 애플리케이션을 구축하려는 모든 사람에게 필수적인 읽을거리입니다.

멀티 스테이지 빌드로 Docker 이미지 최적화: 종합 가이드

멀티 스테이지 빌드는 매우 일반적인 Docker 문제를 해결합니다. 애플리케이션을 빌드하는 데 필요한 도구는 일반적으로 실행하는 데 필요한 도구가 아닙니다.

Go 컴파일러, Node 패키지 캐시, Maven 저장소, 테스트 프레임워크 및 빌드 헤더는 이미지 빌드 중에는 유용합니다. 런타임 이미지에서는 불필요한 무게일 뿐입니다. 풀 속도를 늦추고, 패치해야 하는 소프트웨어의 양을 늘리며, 프로덕션에서 실제로 실행 중인 것이 무엇인지 이해하기 어렵게 만듭니다.

멀티 스테이지 Dockerfile을 사용하면 한 스테이지에서 빌드하고 완성된 아티팩트만 더 작은 런타임 스테이지로 복사합니다. 최종 이미지는 명시적으로 파일을 복사하지 않는 한 빌드 스테이지를 상속하지 않습니다.

단일 스테이지 이미지의 문제점

일반적인 Go 애플리케이션을 고려해 보겠습니다. 컴파일하려면 Go 툴체인이 필요합니다. Linux 바이너리가 있으면 컴파일러는 더 이상 필요하지 않습니다. 단일 스테이지 이미지는 어쨌든 유지합니다.

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

이것은 실행되지만 최종 이미지에는 여전히 Go 툴체인과 빌드 캐시가 포함됩니다. 동일한 패턴이 Node, Java, Rust, 네이티브 확장 기능이 있는 Python 패키지 및 프론트엔드 빌드에서도 나타납니다.

비용은 실용적입니다.

  • 이미지 크기 증가: 더 많은 레이어, 더 많은 데이터를 풀하고 저장해야 합니다.
  • 배포 시간 연장: 더 큰 이미지는 전송하는 데 시간이 더 오래 걸립니다.
  • 보안 위험 초래: 불필요한 소프트웨어로 인한 더 큰 공격 표면.
  • 런타임 환경 모호화: 실제로 필요한 것이 무엇인지 이해하기 어렵게 만듭니다.

더 작은 이미지가 런타임에 자동으로 더 빠르지는 않지만 CI, 레지스트리 및 배포 시스템을 통해 이동하는 속도는 더 빠릅니다. 또한 보안 검토를 덜 번거롭게 만듭니다.

멀티 스테이지 빌드의 역할

FROM 명령어는 새 스테이지를 시작합니다. 스테이지에 이름을 지정하고 나중에 파일을 복사할 수 있습니다.

FROM golang:1.21-alpine AS builder
# 여기서 파일 빌드

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

두 번째 스테이지는 새로 시작합니다. 복사하지 않는 한 첫 번째 스테이지의 /usr/local/go, 소스 파일, 패키지 캐시 또는 빌드 도구를 포함하지 않습니다.

깔끔한 Go 예제

다음은 작은 애플리케이션입니다.

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello from optimized Docker image!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Server starting on :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

멀티 스테이지 Dockerfile:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

go.modgo.sum 파일은 전체 소스 트리보다 먼저 복사되어 Docker가 애플리케이션 코드만 변경될 때 종속성 다운로드 레이어를 재사용할 수 있도록 합니다. CGO_ENABLED=0은 정적 바이너리를 원할 때 유용합니다. 애플리케이션이 C 라이브러리에 의존하는 경우 정적 빌드를 강제하는 대신 해당 라이브러리를 포함하는 런타임 이미지가 필요할 수 있습니다.

빌드 및 비교:

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

블로그의 예제 크기에 의존하지 마십시오. 자신의 이미지를 확인하십시오. 종속성 선택, 기본 이미지 버전, 디버그 기호, 인증서, 시간대 데이터 및 네이티브 라이브러리가 모두 결과에 영향을 미칩니다.

런타임 기본 이미지 선택

alpine은 작기 때문에 인기가 있지만 작은 것이 항상 호환성과 같은 것은 아닙니다. Alpine은 musl libc를 사용하는 반면, 많은 일반적인 Linux 배포판은 glibc를 사용합니다. 대부분의 Go 정적 바이너리는 잘 실행됩니다. 일부 Python, Node, Java 또는 네이티브 패키지는 다르게 동작합니다.

일반적인 런타임 옵션:

런타임 기본 적합한 용도 절충점
alpine 작은 이미지, 간단한 바이너리 musl 호환성 차이
debian:bookworm-slim 광범위한 Linux 호환성 Alpine보다 큼
Distroless 이미지 더 적은 도구를 사용하는 프로덕션 런타임 컨테이너 내부 디버깅이 더 어려움
scratch 정적 바이너리 전용 복사하지 않으면 셸, CA 인증서 또는 패키지 관리자 없음

앱이 HTTPS 엔드포인트를 호출하는 경우 최종 이미지에 CA 인증서가 포함되어 있는지 확인하십시오. 인증서가 없는 scratch 이미지는 네트워크 문제처럼 보이는 방식으로 실패할 수 있습니다.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

다른 언어/프레임워크를 위한 멀티 스테이지 빌드

빌드 단계가 있는 모든 곳에서 동일한 아이디어가 작동합니다.

Node 프론트엔드의 경우:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Node API의 경우 개발 종속성이 포함된 경우 node_modules를 개발 설치에서 복사하지 마십시오.

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Java의 경우:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

빌드 캐시도 중요합니다.

멀티 스테이지 빌드는 최종 이미지 크기를 줄이지만 Dockerfile 순서는 여전히 캐시 동작을 제어합니다. 안정적인 종속성 파일을 변동성이 큰 소스 파일보다 먼저 배치하십시오. 재현 가능한 빌드에서는 npm install 대신 npm ci를 사용하십시오. 프로덕션에서 latest에 의존하지 않고 기본 이미지 버전을 고정하십시오.

BuildKit을 사용하면 캐시 마운트가 캐시를 최종 이미지에 굽지 않고 패키지 다운로드 속도를 높일 수 있습니다.

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

해당 캐시는 런타임 이미지가 아닌 빌드 머신용입니다.

복사할 항목과 복사하지 말아야 할 항목

가장 작은 완전한 런타임 세트를 복사하십시오. 컴파일된 서비스의 경우 하나의 바이너리와 구성 템플릿 및 CA 인증서일 수 있습니다. 프론트엔드의 경우 dist 디렉토리일 수 있습니다. Java의 경우 jar와 JRE일 수 있습니다.

소스 코드, 패키지 관리자 캐시, 테스트 픽스처, 로컬 .env 파일, SSH 키 또는 실행하지 않는 빌드 출력을 복사하지 마십시오. .dockerignore 파일을 사용하여 이러한 파일이 빌드 컨텍스트에 처음부터 들어가지 않도록 하십시오.

.git
node_modules
coverage
dist
*.log
.env

.dockerignore 파일은 신중한 COPY 명령어를 대체하지 않지만 우발적인 컨텍스트 비대화 및 비밀 유출을 방지합니다.

멀티 스테이지 빌드 디버깅

스테이지 이름을 지정하십시오. 명명된 스테이지는 대상으로 지정하기 쉽습니다.

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

이것은 빌드는 성공했지만 파일이 잘못된 경로에 복사되었거나 런타임 라이브러리가 누락되어 최종 이미지가 실패하는 경우 유용합니다.

최종 이미지에 복사된 파일을 검사할 수도 있습니다.

docker run --rm -it --entrypoint sh my-image

이미지에 셸이 없는 경우 진단하는 동안 최종 스테이지를 디버그 친화적인 베이스로 일시적으로 전환한 다음 프로덕션 베이스를 다시 넣으십시오.

실용적인 규칙

각각의 고유한 작업(종속성, 빌드, 테스트, 런타임)에 대해 하나의 스테이지를 사용하십시오. 런타임 스테이션을 단순하게 유지하십시오. 누군가 최종 Dockerfile 스테이지를 열면 한 가지 질문에 빠르게 답할 수 있어야 합니다. 이 컨테이너가 실행하는 데 실제로 필요한 파일은 무엇입니까?

이것이 멀티 스테이지 빌드의 진정한 가치입니다. 더 작은 이미지는 좋습니다. 명확한 런타임 경계는 더 좋습니다.