빌드용 스테이지와 실행용 스테이지를 나눠, 산출물만 복사해 오는 방식.
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /src/target/app.jar ./app.jar
USER 10001:10001
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
무엇을 얻나
-
이미지 크기가 수백 MB → 수십 MB
- JDK · maven · 소스 · 중간 산출물이 최종 이미지에 없다
-
공격 표면이 줄어든다
- 컴파일러 · 패키지 매니저 · 셸이 없으면 침투 후 할 수 있는 일이 적다
-
빌드 도구의 취약점이 운영 이미지에 안 딸려 온다
- 스캔 결과가 극적으로 깨끗해진다
스테이지를 골라 쓸 수 있다
docker build --target build . # 빌드 스테이지까지만
활용
- 테스트 전용 스테이지를 만들어 CI 에서 그것만 실행
- 개발용/운영용 최종 스테이지를 나눠 둔다
- 공통 베이스 스테이지를 만들어 중복을 줄인다
FROM base AS test RUN mvn test
외부 이미지에서도 복사할 수 있다
COPY --from=curlimages/curl /usr/bin/curl /usr/bin/curl
스테이지가 아니라 이미지 이름을 줘도 된다 도구 하나만 필요할 때 패키지 매니저 없이 가져온다
- 다만 의존 라이브러리까지 함께 와야 동작한다
- 정적 바이너리일 때 안전하다
캐시와 함께 설계한다
의존성 해석과 소스 컴파일을 다른 레이어로 나눈다
- COPY pom.xml → 의존성 받기 → COPY src → 빌드
캐시 마운트를 쓰면 레이어에 굽지 않고도 재사용된다
- --mount=type=cache,target=/root/.m2
두 기법을 함께 쓰면 소스만 바뀐 빌드가 수십 초 → 수 초가 된다
언어별 형태
# Go — 정적 바이너리라 scratch 까지 갈 수 있다
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER 65532:65532
ENTRYPOINT ["/app"]
CGO_ENABLED=0 이 핵심이다 — 켜져 있으면 glibc 에 동적 링크된다 그 상태로 scratch·distroless/static 에 올리면 종료 코드 127
최종 이미지에 무엇이 남았는지 확인한다
docker run --rm myapp:1.0 sh -c 'which sh curl wget apt npm 2>/dev/null'
docker history myapp:1.0
멀티스테이지를 썼는데도 빌드 도구가 남아 있는 경우가 있다
- 최종 스테이지의 베이스가 여전히 SDK 이미지다
- COPY --from 을 빠뜨리고 그냥 COPY 했다
크기가 기대만큼 안 줄었으면 이 둘을 먼저 본다
면접 함정
- ❌ "멀티스테이지는 이미지를 여러 개 만든다" → 최종 스테이지 하나만 이미지가 된다.
- ❌ "크기만 줄이는 기법" → 공격 표면 감소가 그만큼 중요하다.