테마 전환

Docker 멀티 스테이지 빌드 실전: Go/Java/Rust 이미지를 GB에서 MB로 극한 경량화

Easton editorial illustration: performance tuning console

650MB 이미지가 던진 질문

지난주 금요일 오후, K8s dashboard에서 5분째 이미지를 가져오고 있는 Pod를 바라보다가 짜증이 밀려왔습니다. 간단한 Spring Boot 애플리케이션을 Docker 이미지로 패키징했을 뿐인데 크기가 650MB였습니다. 팀에 새로 온 인턴이 저에게 물었습니다. “이 이미지는 왜 이렇게 큰가요?” 순간 말문이 막혔고, 저 역시 이 문제를 진지하게 생각해 본 적이 없다는 사실을 깨달았습니다.

그날 저녁 집에 돌아가 밤새 조사했습니다. 다음 날 아침, 같은 애플리케이션의 이미지 크기는 89MB가 됐습니다. 배포 시간도 5분에서 1분 미만으로 줄었습니다. 인턴이 저를 보는 눈빛마저 달라졌습니다.

98%
Go 이미지 최적화
295MB → 6.47MB
86%
Java 이미지 최적화
650MB → 89MB
99.4%
Rust 이미지 최적화
2GB → 11MB
89MB
최적화 후 이미지 크기
650MB에서 89MB로 줄고, 배포 시간도 5분에서 1분 미만으로 단축

사실 Dockerfile의 몇 줄만 바꿨습니다. 이 기법을 ‘멀티 스테이지 빌드’라고 합니다.

솔직히 처음에는 이 기능이 별로 쓸모없다고 생각했습니다. 우리 팀의 Go 프로젝트 이미지는 295MB였고 Java 프로젝트는 쉽게 500MB를 넘었지만, 익숙해지고 나니 Docker 이미지는 원래 이렇게 큰 것이라고 여겼습니다. 그러다 누군가 Rust 이미지를 2GB에서 11MB로 줄였다는 사례를 봤습니다. 맞습니다. GB에서 MB로 줄인 것입니다. 그제야 제가 계속 잘못된 방식으로 이미지를 패키징하고 있었을지 모른다는 생각이 들었습니다.

문제의 핵심은 컴파일 언어가 소스 코드를 실행 파일로 만들 때 컴파일러와 빌드 도구를 필요로 하지만, 런타임에는 그런 도구가 전혀 필요하지 않다는 데 있습니다. 기존 Dockerfile은 Maven, Gradle, Go 컴파일러까지 모두 이미지에 넣습니다. 이사할 때 인테리어에 쓰던 전동 드릴과 시멘트까지 새집으로 가져가는 것과 같습니다. 전혀 필요하지 않습니다.

오늘은 Go, Java, Rust의 세 가지 실제 사례를 통해 멀티 스테이지 빌드로 이미지를 경량화하는 방법을 소개합니다. 다음과 같은 결과를 확인할 수 있습니다.

  • Go 애플리케이션: 295MB에서 6.47MB로 감소(98%)
  • Java Spring Boot: 650MB에서 89MB로 감소(86%)
  • Rust 애플리케이션: 2GB에서 11.2MB로 감소(99.4%)

특정 언어에만 관심이 있다면 해당 섹션으로 바로 이동해도 됩니다. 각 사례는 완결된 형태라 코드를 그대로 복사해 실행할 수 있습니다.

이미지는 왜 이렇게 비대해질까요?

먼저 제가 전에 테스트한 실제 데이터를 비교표로 보겠습니다.

언어기존 단일 스테이지 빌드멀티 스테이지 빌드감소율
Go 애플리케이션295 MB6.47 MB98%
Java Spring Boot650 MB89 MB86%
Rust 애플리케이션2.1 GB11.2 MB99.4%

이 표를 처음 봤을 때는 정말 어리둥절했습니다. 특히 Rust 데이터는 2GB에서 11MB로 줄었습니다. 최적화라기보다 마법에 가까워 보였습니다.

나중에 이미지 구성을 자세히 분석하고 나서야 문제를 이해했습니다. 컴파일 언어는 컴파일러를 이용해 소스 코드를 바이너리 실행 파일로 변환합니다. Go에는 golang 컴파일러가, Java에는 Maven이나 Gradle이, Rust에는 Cargo가 필요합니다. 이런 도구는 크기도 작지 않습니다.

  • Go 컴파일러: 약 300MB
  • Maven + OpenJDK: 약 500MB
  • Rust 툴체인: 약 1.5GB

기존 Dockerfile은 다음과 같습니다.

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

문제가 없어 보이지만 실제로는 큰 문제가 있습니다. 이 Dockerfile은 전체 golang:1.21 이미지(295MB)를 기본 이미지로 사용합니다. 그 안에는 Go 컴파일러와 각종 빌드 도구, 디버깅 도구가 들어 있습니다. 애플리케이션 컴파일이 끝나도 이런 도구를 하나도 제거하지 않고 최종 이미지에 모두 포함합니다.

골조만 있는 집을 사서 인테리어 업체에 공사를 맡긴 뒤, 공사가 끝났는데도 시멘트와 전동 드릴, 절단기, 작업자들의 공구함을 모두 방 안에 가둬 둔 채 입주하는 것과 같습니다. 터무니없게 들리지만 많은 Dockerfile이 정확히 이렇게 작동합니다.

런타임에 실제로 필요한 것은 컴파일된 바이너리 파일뿐입니다. Go로 컴파일한 바이너리는 보통 몇 MB에서 수십 MB에 불과합니다. Java jar 파일은 조금 더 클 수 있지만 역시 수십 MB 정도입니다. Rust 바이너리도 작습니다. 나머지 수백 MB는 모두 컴파일 도구와 기본 이미지에서 발생하는 오버헤드입니다.

이런 비대함은 저장 공간만 낭비하는 것이 아닙니다. 진짜 문제는 다음과 같습니다.

  1. 느린 이미지 가져오기: CI/CD 파이프라인에서는 배포할 때마다 이미지를 가져와야 합니다. 네트워크 상태가 좋지 않을 때 650MB 이미지를 기다리다 보면 인생을 돌아보게 됩니다.
  2. 보안 위험: 프로덕션 이미지에 컴파일러, 소스 코드, 빌드 도구를 포함하면 공격자에게 다양한 도구를 제공하는 셈입니다.
  3. 빌드 캐시 낭비: 컴파일러와 코드가 결합되어 있으므로 코드 한 줄만 바꿔도 전체 이미지를 다시 빌드해야 합니다.

예전에 Alibaba Cloud에 배포할 때 팀원 다섯 명이 동시에 새 버전을 배포한 적이 있습니다. 각 이미지가 500MB를 넘어서 내부 네트워크 대역폭을 완전히 소진했습니다. 그 일을 겪고 이미지 최적화를 제대로 연구하기로 결심했습니다.

멀티 스테이지 빌드: Dockerfile 하나로 두 환경 해결하기

멀티 스테이지 빌드는 Docker 17.05에서 도입된 기능입니다. 핵심 아이디어는 매우 간단합니다. 하나의 Dockerfile에 여러 단계를 정의하고, 앞 단계는 컴파일을 담당하며 뒤 단계는 실행을 담당합니다. 단계 사이에는 필요한 결과물만 전달합니다.

쉽게 말하면 첫 번째 단계에서 인테리어 업체가 집을 꾸미고, 공사가 끝나면 완성된 집만 두 번째 단계로 옮깁니다. 시멘트와 전동 드릴은 첫 번째 단계에 남겨 둡니다.

가장 간단한 예를 보겠습니다.

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

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

핵심이 보이나요?

  1. 첫 번째 FROM 뒤에 AS builder를 붙여 이 단계에 builder라는 이름을 지정했습니다.
  2. 두 번째 FROM부터 새 단계가 시작되며 경량 alpine 이미지를 사용합니다.
  3. COPY —from=builder로 builder 단계에서 컴파일된 바이너리만 복사하고 나머지는 모두 버립니다.

첫 번째 단계의 golang:1.21 이미지는 295MB지만 최종 이미지는 alpine 크기(5MB)에 바이너리 파일 몇 MB를 더한 정도이므로 전체가 십여 MB에 불과합니다.

이 원리를 처음 이해했을 때 문득 이런 생각이 들었습니다. 바로 ‘과정은 버리고 결과만 취한다’는 것입니다. 컴파일러, 소스 코드, 중간 파일은 모두 과정이고 최종 바이너리만 결과입니다. Docker는 첫 번째 단계의 이미지를 자동으로 버리고 두 번째 단계만 보존합니다.

첫 번째 단계를 디버깅하려면 어떻게 해야 할까요? Docker에는 매우 유용한 명령이 있습니다.

docker build --target builder -t myapp:debug .

--target builder는 Docker에 builder 단계까지만 빌드하고 두 번째 단계는 실행하지 말라고 지시합니다. 그러면 첫 번째 단계의 컨테이너에 들어가 디버깅할 수 있습니다.

이 설계는 정말 우아합니다. 단계는 세 개, 네 개 또는 그 이상이어도 됩니다.

FROM node:18 AS frontend-builder
# 프론트엔드 빌드

FROM golang:1.21 AS backend-builder
# 백엔드 빌드

FROM nginx:alpine
# 프론트엔드와 백엔드 결과물을 모두 복사
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api

각 단계가 독립적으로 작업을 마친 뒤 마지막 실행 단계에서 결과를 합칩니다. 이런 구성 방식 덕분에 Dockerfile의 논리가 매우 명확해집니다.

솔직히 이제는 컴파일 언어용 Dockerfile을 작성할 때 기본적으로 멀티 스테이지 빌드를 사용합니다. 이미 습관이 됐습니다.

Go 애플리케이션 극한 경량화

Go는 이미지 최적화 효과를 확인하기 가장 좋은 언어입니다. 이유는 무엇일까요? Go가 컴파일한 바이너리는 정적 링크 방식이라 시스템 라이브러리에 의존하지 않고 빈 이미지에서도 바로 실행할 수 있기 때문입니다.

먼저 기존 방식부터 보겠습니다. 따라 하지는 마세요.

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

빌드해 봅니다.

docker build -t myapp:old .
docker images myapp:old
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        old    abc123def456   295MB

295MB입니다. 간단한 HTTP 서비스 하나가 이만큼이나 큽니다.

이제 멀티 스테이지 최적화 버전을 보겠습니다.

# 빌드 단계
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 scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

다시 빌드합니다.

docker build -t myapp:new .
docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    def456ghi789   6.47MB

6.47MB입니다! 295MB에서 6.47MB로 98% 줄었습니다.

이 Dockerfile에는 몇 가지 핵심 포인트가 있습니다.

1. CGO_ENABLED=0

이 환경 변수는 Go 컴파일러에 CGO를 비활성화하고 완전히 정적으로 링크된 바이너리를 만들도록 지시합니다. 코드에서 SQLite 같은 C 라이브러리를 사용한다면 이 방법은 통하지 않으므로 다른 기본 이미지를 사용해야 합니다.

2. -ldflags=“-w -s”

컴파일러 최적화 옵션입니다.

  • -w: 디버깅 정보를 제거합니다.
  • -s: 심볼 테이블을 제거합니다.

이렇게 하면 바이너리 크기를 20~30% 더 줄일 수 있습니다. 프로덕션 환경에서는 이런 정보를 거의 사용하지 않으므로 제거해도 괜찮습니다.

3. FROM scratch

scratch는 아무것도 들어 있지 않은 Docker의 빈 이미지이며 크기는 0바이트입니다. Go의 정적 바이너리는 시스템 라이브러리 없이 scratch에서 바로 실행할 수 있습니다.

4. 의존성 캐시 활용법

제가 먼저 go.modgo.sum을 복사한 다음 go mod download를 실행한 점에 주목하세요. 이 두 파일이 바뀌지 않으면 Docker가 캐시된 의존성 레이어를 사용하므로 다시 다운로드하지 않습니다. 비즈니스 코드만 수정했을 때는 의존성을 다시 받을 필요가 없어 빌드 속도가 훨씬 빨라집니다.

직접 테스트했을 때 이 방법을 적용하자 코드 수정 후 재빌드 시간이 2분에서 15초로 줄었습니다.

고급: 시간대와 CA 인증서 처리

scratch 이미지는 너무 깨끗해서 시간대 데이터와 CA 인증서조차 없습니다. 애플리케이션에서 HTTPS 요청을 보내거나 시간대를 처리해야 한다면 오류가 발생합니다. builder 단계에서 다음 파일을 복사하면 해결할 수 있습니다.

FROM scratch
WORKDIR /app
# 시간대 데이터 복사
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# CA 인증서 복사
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]

또는 scratch 대신 gcr.io/distroless/static-debian11을 사용해도 됩니다. 이 이미지는 2MB에 불과하지만 시간대 데이터와 CA 인증서를 포함합니다.

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]

저는 이제 대부분 distroless를 사용합니다. 훨씬 편합니다.

Java/Spring Boot를 우아하게 경량화하기

Java 이미지 최적화는 Go보다 조금 복잡합니다. Java에는 JRE 런타임 환경이 필요하므로 Go처럼 scratch 이미지를 쓸 수 없습니다. 그래도 올바른 방법을 사용하면 크기를 크게 줄일 수 있습니다.

기존 방식은 다음과 같습니다. 많은 Java 프로젝트가 이렇게 합니다.

FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]

빌드된 이미지는 650MB입니다. Maven 이미지 자체가 500MB이고 jar 파일과 각종 캐시까지 더해지니 크기가 감당할 수 없게 되는 것이 당연합니다.

멀티 스테이지 최적화 버전은 다음과 같습니다.

# 빌드 단계
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app

# pom.xml을 먼저 복사하고 의존성 다운로드(캐시 활용)
COPY pom.xml .
RUN mvn dependency:go-offline -B

# 소스 코드를 복사하고 패키징
COPY src ./src
RUN mvn package -DskipTests

# 실행 단계
FROM openjdk:17-jre-slim
WORKDIR /app

# jar 파일만 복사
COPY --from=builder /app/target/*.jar app.jar

# JVM 옵션 튜닝
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"

EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar

빌드 결과는 다음과 같습니다.

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    xyz789abc012   89MB

650MB에서 89MB로 86% 줄었습니다.

핵심 포인트:

1. JDK vs JRE

빌드 단계에서는 컴파일러가 포함된 openjdk-17을 사용하고, 실행 단계에서는 런타임만 있는 openjdk-17-jre-slim을 사용합니다. JRE는 JDK보다 절반 이상 작습니다.

  • OpenJDK 17: 약 500MB
  • OpenJDK 17 JRE: 약 200MB
  • OpenJDK 17 JRE Slim: 약 80MB

2. Maven 의존성 캐시

먼저 pom.xml을 복사하고 mvn dependency:go-offline을 실행해 모든 의존성을 다운로드합니다. pom.xml이 바뀌지 않는 한 이 레이어는 캐시됩니다. 비즈니스 코드를 수정해도 의존성을 다시 다운로드하지 않습니다.

이 방법은 정말 중요합니다. 예전에는 이 방식을 쓰지 않아 코드 한 줄만 바꿔도 다시 빌드할 때 의존성을 모두 받아야 했습니다. Maven 저장소가 해외에 있어 십여 분 동안 멈추는 일도 흔했습니다. 이제는 몇 초면 됩니다.

3. JVM 옵션 튜닝

-XX:+UseContainerSupport는 JVM이 컨테이너의 메모리 제한을 인식하도록 합니다. 이 옵션이 없으면 JVM이 호스트 메모리를 기준으로 힙 크기를 할당해 컨테이너에서 OOM이 발생하기 쉽습니다.

-Xms128m -Xmx512m은 힙 메모리 범위를 설정합니다. 애플리케이션의 실제 요구 사항에 맞게 조정하고 무조건 1G로 설정하지 마세요.

Gradle 버전

Gradle을 사용한다면 조금만 바꾸면 됩니다.

FROM gradle:8.5-jdk17 AS builder
WORKDIR /app

# Gradle 설정 파일 복사
COPY build.gradle settings.gradle ./
COPY gradle ./gradle

# 의존성 다운로드
RUN gradle dependencies --no-daemon

# 소스 코드를 복사하고 빌드
COPY src ./src
RUN gradle bootJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar

주의할 점: Spring Boot 레이어드 빌드

Spring Boot 2.3+는 jar 레이어링을 지원합니다. 의존성과 비즈니스 코드를 분리해 캐시를 더 효율적으로 사용할 수 있습니다. 조금 더 고급 방식이며 코드는 다음과 같습니다.

FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract

FROM openjdk:17-jre-slim
WORKDIR /app
# 레이어 순서대로 복사하며 의존성 레이어의 변경 빈도가 가장 낮음
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]

이렇게 하면 비즈니스 코드를 수정해도 의존성 레이어는 무효화되지 않아 빌드가 더 빨라집니다. 하지만 솔직히 저는 이 방법을 자주 쓰지 않습니다. 유지보수 비용이 조금 높고 앞의 간단한 버전만으로도 충분하기 때문입니다.

Rust 애플리케이션 최소 배포

Rust의 이미지 최적화 효과는 가장 극적입니다. Rust 툴체인은 매우 크지만(1.5GB 이상), 컴파일된 바이너리는 아주 작습니다. 최적화 결과를 처음 봤을 때 놀랐습니다. 2.1GB에서 11.2MB라니, 믿기 어려운 수준이었습니다.

기존 방식은 다음과 같습니다.

FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]

빌드가 끝난 이미지 크기는 2.1GB입니다. Rust 툴체인이 너무 커서 rustc 컴파일러, cargo, 각종 의존성이 모두 이미지에 들어갑니다.

멀티 스테이지 최적화 버전은 다음과 같습니다.

# 빌드 단계
FROM rust:1.75 AS builder
WORKDIR /app

# 의존성 파일을 복사하고 의존성을 먼저 컴파일(캐시 활용)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# 실제 코드를 복사하고 애플리케이션 컴파일
COPY src ./src
RUN touch src/main.rs  # 타임스탬프를 갱신해 재컴파일 유도
RUN cargo build --release

# 실행 단계
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]

빌드 후에는 다음과 같습니다.

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    rst345uvw678   11.2MB

11.2MB입니다! 99.4% 줄었습니다.

Rust 고유의 캐시 최적화

Rust의 의존성 컴파일은 매우 느려 십여 분씩 걸리는 경우가 많습니다. 위 방법의 핵심은 가짜 main.rs를 먼저 만들어 cargo가 의존성을 컴파일하게 한 다음, 가짜 파일을 삭제하고 실제 코드를 복사해 컴파일하는 것입니다.

이렇게 하면 Cargo.tomlCargo.lock이 바뀌지 않는 한 의존성 레이어가 캐시됩니다. 비즈니스 코드를 수정했을 때는 의존성을 다시 컴파일하지 않고 내 코드만 다시 컴파일하면 됩니다.

중간 규모 프로젝트에서 직접 테스트했을 때, 이 방법을 적용하자 코드 수정 후 재빌드 시간이 15분에서 2분으로 줄었습니다.

정적 링크 vs 동적 링크

Rust가 기본적으로 컴파일한 바이너리는 glibc에 의존할 수 있습니다. 코드가 완전히 순수 Rust이고 C 라이브러리를 사용하지 않는다면 musl로 완전히 정적 링크된 바이너리를 만든 뒤 scratch 이미지를 사용할 수 있습니다.

FROM rust:1.75 AS builder
WORKDIR /app

# musl 툴체인 설치
RUN rustup target add x86_64-unknown-linux-musl

COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src

COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl

# 실행 단계
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]

이렇게 하면 이미지 크기를 더 줄여 5~8MB 정도까지 만들 수 있습니다.

하지만 솔직히 저는 gcr.io/distroless/cc를 바로 사용합니다. 호환성이 더 좋고 크기도 몇 MB 차이밖에 나지 않아 충분히 가치가 있습니다.

주의할 점: Cargo 캐시 위치

일부 글에서는 /usr/local/cargo 디렉터리도 캐시하라고 권합니다. 그렇게 하지 마세요. 이 디렉터리에는 컴파일 중간 결과물이 대량으로 들어 있어 캐시 레이어가 지나치게 커지고 오히려 느려집니다. 저도 이 문제를 겪었는데, 당시 캐시 레이어가 800MB에 달해 빌드할 때마다 오래 기다려야 했습니다.

빌드를 더 빠르고 안정적으로 만드는 고급 기법

앞에서는 멀티 스테이지 빌드의 기본 사용법을 설명했습니다. 이번에는 빌드를 더 빠르고 안정적으로 만드는 몇 가지 기법을 살펴보겠습니다.

1. .dockerignore 파일

매우 중요하지만 많은 사람이 놓치는 부분입니다. .dockerignore.gitignore와 비슷하게 Docker에 빌드 컨텍스트로 복사하지 않을 파일을 알려 줍니다.

예전에는 이 파일을 쓰지 않아 빌드할 때마다 Docker가 전체 프로젝트 디렉터리(node_modules, .git, target 등 포함)를 묶어 Docker daemon으로 전송했습니다. 프로젝트 하나가 수백 MB라 컨텍스트 전송에만 30초가 걸렸습니다.

이제는 프로젝트마다 .dockerignore를 만듭니다.

# 버전 관리
.git
.gitignore

# 의존성 디렉터리
node_modules
target
dist
build

# IDE
.vscode
.idea
*.swp

# 테스트와 문서
**/*_test.go
**/*_test.rs
*.md
docs/

# 환경 변수와 비밀 정보
.env
.env.local
*.key
*.pem

이 파일을 추가하자 빌드 속도가 3~5배 빨라졌습니다. 특히 CI/CD 파이프라인에서 효과가 매우 큽니다.

2. 멀티 스테이지 빌드 디버깅

앞에서 --target 옵션을 언급했으니 조금 더 자세히 살펴보겠습니다. builder 단계에서 빌드가 실패하면 어떻게 디버깅할까요?

# builder 단계까지만 빌드
docker build --target builder -t myapp:debug .

# 이 이미지에 진입
docker run -it myapp:debug sh

# 컨테이너 안에서 빌드 명령을 직접 실행해 오류 지점 확인

이 방법은 매우 유용합니다. 한 번은 Go 프로젝트 빌드에서 이상한 오류가 발생했습니다. 이 방법으로 builder 컨테이너에 들어가 go build를 직접 실행한 뒤에야 Go 버전과 의존성이 호환되지 않는다는 사실을 발견했습니다.

3. 빌드 단계 이름 지정하기

각 단계에는 의미 있는 이름을 붙이고 stage1, stage2 같은 이름은 피하세요. 비교해 보겠습니다.

# 나쁜 예
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3

# 좋은 예
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime

몇 달 뒤 코드를 다시 볼 때 이름을 성실하게 붙였던 자신에게 고마워질 것입니다.

4. BuildKit 병렬 빌드

Docker BuildKit은 병렬 빌드와 개선된 캐시 메커니즘을 지원하는 차세대 빌드 엔진입니다. 다음과 같이 활성화합니다.

export DOCKER_BUILDKIT=1
docker build .

또는 빌드할 때 임시로 활성화할 수 있습니다.

DOCKER_BUILDKIT=1 docker build .

BuildKit의 장점은 다음과 같습니다.

  • 서로 독립된 여러 단계를 병렬로 빌드할 수 있습니다.
  • 더 지능적인 캐시 전략을 사용합니다.
  • 빌드 출력이 더 명확합니다.

저는 이제 BuildKit을 기본으로 사용하고 CI/CD에도 DOCKER_BUILDKIT=1을 설정합니다.

5. 기본 이미지 버전 고정하기

FROM golang:latest는 사용하지 마세요. 함정입니다. 프로덕션 환경에서는 반드시 버전을 고정해야 합니다.

# 나쁜 예 - 동작을 예측할 수 없음
FROM golang:latest

# 좋은 예 - 명확한 버전
FROM golang:1.21.5-alpine3.18

# 더 좋은 예 - SHA256으로 고정
FROM golang@sha256:abc123...

예전에 이 문제로 곤란을 겪었습니다. 어느 날 갑자기 배포가 실패해 한참 조사했더니 golang:latest가 업데이트됐고 새 버전이 우리 의존성과 호환되지 않았습니다. 그 뒤로는 항상 버전을 고정합니다.

6. COPY 캐시를 합리적으로 사용하기

COPY 명령의 순서는 중요합니다. 자주 바뀌지 않는 파일을 앞에 두고 자주 바뀌는 파일을 뒤에 둡니다.

# 좋은 순서
FROM golang:1.21-alpine AS builder
WORKDIR /app

# 1. 의존성 파일을 먼저 복사(거의 바뀌지 않음)
COPY go.mod go.sum ./
RUN go mod download

# 2. 소스 코드를 나중에 복사(자주 바뀜)
COPY . .
RUN go build -o myapp

# 나쁜 순서
COPY . .  # 모든 파일을 한 번에 복사
RUN go mod download && go build -o myapp

첫 번째 방식에서는 코드를 수정해도 의존성을 다시 다운로드하지 않습니다. 두 번째 방식에서는 어떤 파일이든 하나만 바뀌면 의존성을 다시 다운로드해야 합니다.

앞의 사례에서도 이 기법을 반복해서 사용했지만 다시 강조할 가치가 있습니다. 이것이 캐시 최적화의 핵심 원리입니다.

기본 이미지 선택 가이드

기본 이미지를 고르는 일은 꽤 어렵습니다. Alpine, Slim, Distroless, Scratch를 저마다 추천하는데 무엇을 선택해야 할까요? 실제 경험을 바탕으로 비교표를 정리했습니다.

이미지 유형크기포함 내용장점단점적합한 사용 사례
scratch0 MB완전히 비어 있음가장 작고 공격 표면이 최소shell, 디버깅 도구, CA 인증서가 없음Go 정적 컴파일, Rust 정적 컴파일
distroless2-20 MB런타임 라이브러리, CA 인증서shell이 없어 안전하고 크기가 작음디버깅이 어려움Go, Java, Rust, Node.js
alpine5-40 MBmusl libc, 패키지 관리자작고 shell이 있음musl libc 호환성과 DNS 문제glibc에 의존하지 않는 애플리케이션
slim70-120 MB최소화한 Debian/Ubuntu완전한 glibc와 좋은 호환성크기가 조금 더 큼C 라이브러리에 의존하는 애플리케이션
전체 이미지200MB+완전한 시스템모든 도구 포함크고 보안 위험이 높음프로덕션 사용 비추천

제 선택 기준은 다음과 같습니다.

Go 애플리케이션

  • 첫 번째 선택: gcr.io/distroless/static-debian11(CA 인증서 포함)
  • 대안: scratch(CA 인증서와 시간대 데이터를 직접 복사해야 함)
  • CGO를 사용한다면: gcr.io/distroless/base-debian11 또는 alpine

Java 애플리케이션

  • 첫 번째 선택: openjdk:17-jre-slim(완전한 JRE와 좋은 호환성)
  • 고급 선택: gcr.io/distroless/java17-debian11(더 작고 안전하지만 디버깅이 어려움)
  • 피할 것: openjdk:17-alpine(Alpine에서 JVM에 일부 버그가 있음)

Rust 애플리케이션

  • 첫 번째 선택: gcr.io/distroless/cc-debian11(C 런타임 라이브러리 포함)
  • 완전한 정적 컴파일이라면: scratch
  • 피할 것: 완전한 rust 이미지(프로덕션 환경에서는 불필요함)

Alpine 사용 시 주의할 점

많은 글에서 Alpine을 추천하지만 저는 몇 가지 문제를 겪었습니다.

  1. musl libc 호환성: Alpine은 glibc가 아닌 musl libc를 사용합니다. 일부 프로그램, 특히 Java와 Python에서 예상치 못한 버그가 발생할 수 있습니다.
  2. DNS 조회 문제: Go 프로그램이 Alpine에서 DNS 조회 시간 초과를 겪을 때가 있어 별도 설정이 필요합니다.
  3. 시간대 문제: 기본적으로 시간대 데이터가 없으므로 tzdata 패키지를 직접 설치해야 합니다.

Alpine에 아주 익숙하지 않다면 -slim 이미지를 쓰는 편이 더 안정적입니다. 크기가 수십 MB 더 클 수 있지만 시행착오를 줄일 수 있습니다.

Distroless 소개

Distroless는 Google이 만든 최소 이미지로, shell과 패키지 관리자가 없고 런타임에 꼭 필요한 요소만 포함합니다.

장점:

  • 공격 표면이 극히 작아 침입자가 활용하기 어렵습니다. shell이 없으면 명령도 실행하기 어렵습니다.
  • 크기가 작습니다.
  • 공식적으로 유지보수되며 보안 패치가 정기적으로 업데이트됩니다.

단점:

  • 디버깅이 어려워 docker exec -it로 들어가 확인할 수 없습니다.
  • 빌드 단계에서 필요한 모든 파일을 준비해야 합니다.

저는 이제 프로덕션 환경에서 대부분 Distroless를 사용합니다. 디버깅할 때는 --target builder로 빌드 단계 컨테이너에 들어가고, 프로덕션에서는 Distroless를 실행합니다.

의사 결정 트리

무엇을 선택해야 할지 모르겠다면 다음 흐름을 따르세요.

애플리케이션 언어는 무엇인가요?
├─ Go
│  ├─ 순수 Go 코드 → distroless/static 또는 scratch
│  └─ CGO 사용 → distroless/base 또는 alpine
├─ Java
│  ├─ 안정성 우선 → openjdk:jre-slim
│  └─ 크기 우선 → distroless/java
├─ Rust
│  ├─ 순수 Rust → distroless/cc 또는 scratch
│  └─ C 라이브러리 사용 → distroless/cc
└─ 기타
   └─ 먼저 slim을 사용하고 문제가 있으면 교체

실제 프로젝트 경험을 바탕으로 만든 이 의사 결정 트리는 약 90%의 상황에 적용할 수 있습니다.

긴 설명을 세 가지 핵심으로 정리하면

멀티 스테이지 빌드의 핵심은 사실 간단합니다.

  1. 빌드 단계: 완전한 이미지로 코드를 컴파일합니다.
  2. 실행 단계: 최소 이미지로 애플리케이션을 실행합니다.
  3. COPY —from: 필요한 결과물만 전달합니다.

이것이 전부입니다. 하지만 효과는 매우 큽니다. 크기는 7090% 줄어들고, 빌드 캐시를 최적화하면 속도는 23배 빨라지며, 보안성도 크게 향상됩니다.

이제 저는 새 프로젝트를 시작하면 가장 먼저 멀티 스테이지 Dockerfile을 작성합니다. Go, Java, Rust를 가리지 않고 기본적으로 이 방식을 사용합니다. 완전히 습관이 됐습니다.

몇 가지 실행 제안을 드리겠습니다.

오늘 바로 할 수 있는 일:

  • 기존 프로젝트 하나를 골라 멀티 스테이지 빌드를 적용해 보세요.
  • docker images로 최적화 전후 크기를 비교해 보세요.
  • 효과가 좋다면 다른 프로젝트에도 바로 확대 적용하세요.

더 깊이 학습할 주제:

  • Docker 공식 문서의 모범 사례를 살펴보세요.
  • dive 도구로 이미지 레이어를 분석해 보세요(docker run --rm -it wagoodman/dive:latest your-image).
  • BuildKit의 고급 기능을 연구해 보세요.

장기 최적화 방향:

  • CI/CD 파이프라인에 이미지 캐시를 설정합니다.
  • 기본 이미지 버전을 정기적으로 업데이트해 보안 취약점을 수정합니다.
  • Trivy 같은 보안 스캔 도구로 이미지를 검사합니다.

마지막으로 솔직한 이야기를 하겠습니다. Docker 이미지 최적화는 기술적으로 어렵지 않습니다. 어려운 것은 문제의식입니다. 많은 팀이 몇 년 전에 만든 이미지를 계속 방치해 갈수록 비대해집니다. 어느 날 배포가 견딜 수 없을 만큼 느려진 뒤에야 최적화를 떠올립니다.

그날까지 기다리지 마세요. 오늘 멀티 스테이지 빌드를 적용해 보면 이미지가 이렇게 작아지고 빌드가 이렇게 빨라질 수 있다는 사실을 발견할 것입니다.

댓글로 여러분 프로젝트의 이미지 크기와 최적화 결과를 알려 주세요. 어떤 문제를 겪었는지도 궁금합니다.

Docker 멀티 스테이지 빌드 전체 최적화 과정

Go/Java/Rust 이미지를 GB에서 MB로 극한 경량화하는 과정과 완전한 Dockerfile 코드, 시행착오 경험을 소개합니다.

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: 멀티 스테이지 빌드 원리 이해하기

    멀티 스테이지 빌드의 원리:
    • 컴파일 언어에는 소스 코드를 실행 파일로 변환하는 컴파일러와 빌드 도구가 필요합니다.
    • 하지만 런타임에는 이런 도구가 전혀 필요하지 않습니다.
    • 기존 Dockerfile은 Maven, Gradle, Go 컴파일러를 모두 이미지에 포함합니다.
    • 이사할 때 인테리어에 쓰던 전동 드릴과 시멘트까지 새집으로 가져가는 것과 같습니다. 전혀 필요하지 않습니다.

    문제의 핵심:
    • 컴파일 언어에는 소스 코드를 실행 파일로 변환하는 컴파일러와 빌드 도구가 필요합니다.
    • 하지만 런타임에는 이런 도구가 전혀 필요하지 않습니다.

    멀티 스테이지 빌드 절차:
    • 첫 번째 단계(빌드 단계): 컴파일러와 빌드 도구가 포함된 완전한 빌드 이미지로 소스 코드를 컴파일해 실행 파일을 만듭니다.
    • 두 번째 단계(실행 단계): 런타임만 포함한 최소 이미지를 사용하고 첫 번째 단계에서 실행 파일을 복사합니다.
    • 최종 이미지에는 런타임과 실행 파일만 남습니다.
  2. 2

    Step 2: Go 멀티 스테이지 빌드 실전

    Go 멀티 스테이지 빌드:

    첫 번째 단계(빌드 단계):
    • FROM golang:1.21 AS builder
    • WORKDIR /app
    • COPY go.mod go.sum ./
    • RUN go mod download
    • COPY . .
    • RUN go build -o app

    두 번째 단계(실행 단계):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/app .
    • CMD ["./app"]

    최적화 효과:
    • 컴파일된 바이너리 파일만 복사합니다.
    • 이미지가 295MB에서 6.47MB로 줄었습니다(98% 감소).
    • 배포 시간이 5분에서 1분 미만으로 단축됐습니다.
    • 이미지 크기가 크게 줄었습니다.
  3. 3

    Step 3: Java와 Rust 멀티 스테이지 빌드 실전

    Java 멀티 스테이지 빌드:

    첫 번째 단계(빌드 단계):
    • FROM maven:3.9 AS builder
    • WORKDIR /app
    • COPY pom.xml .
    • RUN mvn dependency:go-offline
    • COPY src ./src
    • RUN mvn clean package -DskipTests

    두 번째 단계(실행 단계):
    • FROM eclipse-temurin:17-jre-alpine
    • WORKDIR /app
    • COPY --from=builder /app/target/app.jar app.jar
    • CMD ["java", "-jar", "app.jar"]

    최적화 효과: 이미지가 650MB에서 89MB로 줄었습니다(86% 감소).

    Rust 멀티 스테이지 빌드:

    첫 번째 단계(빌드 단계):
    • FROM rust:1.75 AS builder
    • WORKDIR /app
    • COPY Cargo.toml Cargo.lock .
    • RUN cargo fetch
    • COPY src ./src
    • RUN cargo build --release

    두 번째 단계(실행 단계):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/target/release/app .
    • CMD ["./app"]

    최적화 효과: 이미지가 2GB에서 11MB로 줄었습니다(99.4% 감소).
  4. 4

    Step 4: 모범 사례와 장기 최적화

    모범 사례:
    1. 적절한 런타임 이미지를 선택합니다.
    • Go는 distroless/static 또는 scratch를 사용합니다.
    • Java는 openjdk:jre-slim 또는 distroless/java를 사용합니다.
    • Rust는 distroless/cc 또는 scratch를 사용합니다.

    2. .dockerignore로 불필요한 파일을 제외합니다.

    3. 빌드 캐시를 활용합니다(의존성 파일을 먼저 복사한 뒤 소스 코드를 복사합니다).

    4. 보안 취약점을 수정할 수 있도록 기본 이미지 버전을 정기적으로 업데이트합니다.

    장기 최적화 방향:
    • CI/CD 파이프라인에 이미지 캐시를 설정합니다.
    • 보안 취약점을 수정할 수 있도록 기본 이미지 버전을 정기적으로 업데이트합니다.
    • Trivy 같은 보안 스캔 도구로 이미지를 검사합니다.

    Docker 이미지 최적화는 기술적으로 어렵지 않습니다. 어려운 것은 문제의식입니다. 많은 팀이 몇 년 전에 만든 이미지를 계속 방치해 갈수록 비대해지고, 어느 날 배포 속도를 견딜 수 없게 된 뒤에야 최적화를 떠올립니다.

FAQ

Docker 멀티 스테이지 빌드란 무엇이며, 왜 필요한가요?
멀티 스테이지 빌드의 원리는 간단합니다. 컴파일 언어에는 소스 코드를 실행 파일로 만드는 컴파일러와 빌드 도구가 필요하지만 런타임에는 전혀 필요하지 않습니다. 기존 Dockerfile은 Maven, Gradle, Go 컴파일러까지 모두 이미지에 포함합니다. 이사할 때 인테리어에 쓰던 전동 드릴과 시멘트까지 새집으로 가져가는 것과 같습니다. 전혀 필요하지 않습니다.

문제의 핵심은 컴파일 언어에는 소스 코드를 실행 파일로 만드는 컴파일러와 빌드 도구가 필요하지만, 런타임에는 이런 도구가 전혀 필요하지 않다는 것입니다.

멀티 스테이지 빌드 절차:
• 첫 번째 단계(빌드 단계)에서는 컴파일러와 빌드 도구가 포함된 완전한 빌드 이미지로 소스 코드를 컴파일해 실행 파일을 만듭니다.
• 두 번째 단계(실행 단계)에서는 런타임만 포함한 최소 이미지를 사용하고 첫 번째 단계에서 실행 파일을 복사합니다.
• 최종 이미지에는 런타임과 실행 파일만 남습니다.
멀티 스테이지 빌드의 최적화 효과는 어느 정도인가요?
최적화 효과:
• Go 애플리케이션은 295MB에서 6.47MB로 줄었습니다(98% 감소).
• Java Spring Boot는 650MB에서 89MB로 줄었습니다(86% 감소).
• Rust 이미지는 2GB에서 11MB로 줄었습니다(99.4% 감소).
• 배포 시간은 5분에서 1분 미만으로 단축됐습니다.

실제 사례로, 간단한 Spring Boot 애플리케이션을 Docker 이미지로 패키징했을 때는 650MB였지만 멀티 스테이지 빌드를 적용한 뒤 89MB가 됐고, 배포 시간도 5분에서 1분 미만으로 줄었습니다.
Go 멀티 스테이지 빌드는 어떻게 구현하나요?
Go 멀티 스테이지 빌드:

첫 번째 단계에서는 golang:1.21 이미지로 컴파일합니다.
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app

두 번째 단계에서는 alpine:latest 이미지로 실행합니다.
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]

컴파일된 바이너리 파일만 복사하면 이미지가 295MB에서 6.47MB로 줄어 98% 감소합니다. 배포 시간도 5분에서 1분 미만으로 단축되고 이미지 크기가 크게 줄어듭니다.
Java와 Rust 멀티 스테이지 빌드는 어떻게 구현하나요?
Java 멀티 스테이지 빌드:

첫 번째 단계에서는 maven:3.9 이미지로 컴파일합니다.
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests

두 번째 단계에서는 eclipse-temurin:17-jre-alpine 이미지로 실행합니다.
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]

이미지가 650MB에서 89MB로 줄어 86% 감소합니다.

Rust 멀티 스테이지 빌드:

첫 번째 단계에서는 rust:1.75 이미지로 컴파일합니다.
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release

두 번째 단계에서는 alpine:latest 이미지로 실행합니다.
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]

이미지가 2GB에서 11MB로 줄어 99.4% 감소합니다.
멀티 스테이지 빌드의 모범 사례는 무엇인가요?
모범 사례:
1) 적절한 런타임 이미지를 선택합니다.
• Go는 distroless/static 또는 scratch를 사용합니다.
• Java는 openjdk:jre-slim 또는 distroless/java를 사용합니다.
• Rust는 distroless/cc 또는 scratch를 사용합니다.

2) .dockerignore로 불필요한 파일을 제외합니다.

3) 빌드 캐시를 활용합니다(의존성 파일을 먼저 복사한 뒤 소스 코드를 복사합니다).

4) 보안 취약점을 수정할 수 있도록 기본 이미지 버전을 정기적으로 업데이트합니다.

장기 최적화 방향:
• CI/CD 파이프라인에 이미지 캐시를 설정합니다.
• 보안 취약점을 수정할 수 있도록 기본 이미지 버전을 정기적으로 업데이트합니다.
• Trivy 같은 보안 스캔 도구로 이미지를 검사합니다.

Docker 이미지 최적화는 기술적으로 어렵지 않습니다. 어려운 것은 문제의식입니다. 많은 팀이 몇 년 전에 만든 이미지를 계속 방치해 갈수록 비대해지고, 어느 날 배포 속도를 견딜 수 없게 된 뒤에야 최적화를 떠올립니다.

5분 읽기 · 게시일: 2025년 12월 17일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog