멀티스테이지 빌드로 이미지 크기를 확 줄이는 법

멀티스테이지 빌드로 이미지 크기를 확 줄이는 법

Dockerfile을 처음 짤 때는 어지간해서 "컴파일 도구와 실행 환경을 한 이미지에 다 넣자"라는 생각을 하게 돼요. 그런데 이렇게 하면 빌드에 쓰는 컴파일러·SDK 같은 것들이 최종 이미지에 그대로 남아서, 배포 이미지가 불필요하게 커지고 보안 표면도 넓어지죠. 이미지는 작게, Dockerfile은 읽기 쉽게 유지하려면 **멀티스테이지 빌드(multi-stage build)**를 쓰면 됩니다.

멀티스테이지 빌드는 Dockerfile 안에 FROM을 여러 번 쓰는 기법이에요. 각 FROM이 새 빌드 스테이지를 시작하고, 필요한 산출물만 골라 다음 스테이지로 복사해요. 최종 이미지에는 정말 필요한 것만 남기고 나머지는 버릴 수 있죠.

출처: Multi-stage builds — Docker Docs

왜 두 개의 FROM일까

빌드 전체를 한 스테이지에 두지 않고 두 단계로 나누는 예시를 볼게요. 첫 스테이지에서 바이너리를 만들고, 두 번째 스테이지에 그 바이너리만 복사해 넣어요.

# syntax=docker/dockerfile:1
FROM golang:1.26
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]

docker build -t hello . 한 번이면 끝나요. 별도 빌드 스크립트가 필요 없고요. 두 번째 FROM scratch가 새 스테이지를 시작하고, COPY --from=0이 그 앞 스테이지(0번)에서 만든 산출물만 복사해요. Go SDK를 비롯한 중간 산출물은 최종 이미지에 남지 않아서, 결과물은 바이너리 하나뿐인 매우 작은 프로덕션 이미지가 돼요.

스테이지에 이름 붙이기

기본적으로 스테이지에는 이름이 없고 첫 FROM부터 0, 1, 2처럼 정수 번호로 참조해요. FROM <이미지> AS <이름>으로 이름을 주면 COPY --from=이름처럼 알아보기 쉽게 쓸 수 있죠.

# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

특정 스테이지까지만 빌드하기

매번 전체 스테이지를 빌드할 필요는 없어요. --target 옵션으로 원하는 스테이지까지만 만들어 볼 수 있어요. 예를 들어 앞의 Dockerfile에서 build라는 이름의 스테이지만 대상으로 하려면 이렇게 해요.

$ docker build --target build -t hello .

외부 이미지를 스테이지로 활용하기

COPY --from은 같은 Dockerfile 안의 앞선 스테이지뿐 아니라, 별도 이미지에서도 복사할 수 있어요. 로컬 이미지 이름, 레지스트리의 태그, 태그 ID를 가리켜도 되고요. 예컨대 Nginx 설정 파일을 그냥 가져다 쓸 때 유용해요.

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

이전 스테이지를 이어서 새 스테이지 만들기

FROM 지시어에서 앞선 스테이지를 가리키면 그 지점부터 이어서 새 스테이지를 만들 수 있어요. 같은 기반에서 서로 다른 산출물을 만들 때 딱이에요.

# syntax=docker/dockerfile:1

FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp

BuildKit과 레거시 빌더의 차이

빌드 엔진에 따라 스테이지 처리 방식이 달라요. 레거시 Docker Engine 빌더는 선택한 --target로 이어지는 스테이지를 전부 처리해서, 대상이 의존하지 않는 스테이지까지 빌드해요. 반면 BuildKit은 대상 스테이지가 의존하는 스테이지만 빌드해요. 다음 Dockerfile에서 stage2를 대상으로 빌드한다고 할게요.

# syntax=docker/dockerfile:1
FROM ubuntu AS base
RUN echo "base"

FROM base AS stage1
RUN echo "stage1"

FROM base AS stage2
RUN echo "stage2"

BuildKit을 켜면 stage2를 빌드할 때 basestage2만 처리돼요. stage1은 의존 관계가 없어서 건너뛰죠. 반면 레거시 빌더로 DOCKER_BUILDKIT=0 docker build --no-cache -f Dockerfile --target stage2 .를 실행하면 stage2가 의존하지 않아도 stage1까지 처리하는 걸 로그에서 볼 수 있어요.

더 알아보기