Docker Multi-Stage Build로 컨테이너 이미지 크기 줄이기
(labs.iximiuz.com)- Docker 컨테이너 이미지를 빌드할 때, Dockerfile이 Multi-Stage 구조가 아니라면 불필요한 파일이 포함될 가능성이 큼
- 이는 이미지 크기 증가 및 보안 취약성 증가로 이어짐
- 컨테이너 이미지에서 발생할 수 있는 “불필요한 파일”의 주요 원인을 분석하고, Multi-Stage Build를 통해 이를 해결하는 방법을 설명
이미지 크기가 커지는 원인
- 애플리케이션은 빌드 타임과 런타임의 의존성을 가짐.
- 빌드 타임 의존성은 런타임보다 많고 보안 취약점(CVEs)이 더 많음.
- 같은 이미지를 빌드와 실행에 사용하면, 불필요한 빌드 타임 의존성(컴파일러, 린터 등)이 포함됨.
- 빌드와 런타임 이미지는 분리되어야 하지만, 이를 간과하는 경우가 많음.
잘못된 Dockerfile 구조 예시
Go 애플리케이션용의 잘못된 예
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o binary
CMD ["/app/binary"]
- golang:1.23 이미지는 컴파일용이지만, 이를 그대로 프로덕션 환경에 사용하면 전체 Go 컴파일러와 의존성까지 포함됨.
- 이미지 크기: 800MB 이상, 800개 이상의 보안 취약점 존재.
Node.js 애플리케이션의 잘못된 예
FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "/app/.output/index.mjs"]
- node_modules 폴더가 런타임에 필요 없는 개발 의존성까지 포함하게 됨.
- npm ci --omit=dev로 수정할 수 없으며, 빌드 과정에서 필요한 개발 의존성이 제거될 수 있음.
Multi-Stage Build 이전의 Lean 이미지 제작 방법
Builder 패턴
- Dockerfile.build에서 애플리케이션을 빌드함:
FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
- 빌드된 아티팩트를 호스트로 복사:
docker cp $(docker create build:v1):/app/.output .
- Dockerfile.run에서 런타임 이미지를 생성:
FROM node:lts-slim
WORKDIR /app
COPY .output .
CMD ["node", "/app/.output/index.mjs"]
• 문제점: 여러 Dockerfile 작성, 빌드 순서 관리 필요, 추가 스크립트 요구.
Multi-Stage Build의 이해
- Multi-Stage Build는 Docker 내부에 Builder 패턴을 구현한 기능임.
- 여러 FROM 명령을 사용하여 하나의 Dockerfile에서 빌드와 런타임 스테이지를 정의 가능.
- COPY --from=<stage> 명령을 사용해 이전 스테이지에서 빌드한 파일을 가져옴.
Multi-Stage Dockerfile 예시 (Node.js)
# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# Runtime stage
FROM node:lts-slim AS runtime
WORKDIR /app
COPY --from=build /app/.output .
ENV NODE_ENV=production
CMD ["node", "/app/.output/index.mjs"]
- COPY --from=build로 빌드된 아티팩트를 직접 복사함으로써, 호스트를 거치지 않고 파일을 이동 가능.
Multi-Stage Build 실전 예시
React 애플리케이션
# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# Runtime stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
ENTRYPOINT ["nginx", "-g", "daemon off;"]
- React 애플리케이션은 빌드 후 정적 파일이 되며, Nginx로 서빙 가능.
Go 애플리케이션
# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN go build -o binary
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/binary /app/binary
ENTRYPOINT ["/app/binary"]
- distroless 이미지를 사용해 최소화된 런타임 환경 제공.
Java 애플리케이션
# Build stage
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /build
COPY . .
RUN ./mvnw package -DskipTests
# Runtime stage
FROM eclipse-temurin:21-jre-jammy
COPY --from=build /build/target/app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
- 빌드에는 JDK를 사용하고, 런타임에는 더 가벼운 JRE 사용.
결론
- Multi-Stage Build는 빌드와 런타임 환경을 분리하여 불필요한 개발 의존성으로 인한 이미지 크기 증가를 방지함
- 이를 통해 이미지 크기를 줄이고, 보안을 강화하며, 빌드 프로세스를 간소화할 수 있음
- Multi-Stage Build는 효율적인 컨테이너 이미지를 만들기 위한 표준적인 방법이며, 고급 기능(예: 분기 조건, 빌드 중 유닛 테스트)도 지원함
자바의 경우 jlink가 9버전부터 도입됐지만 의존 모듈을 jdeps 로 찾아서 명시해줘야 하는 등 사용성이 안좋습니다. 사람들이 저런 방법을 모르거나 JRE를 찾는 걸 보면 자바 도구의 홍보가 부족한 것 같고, 명령어 하나로 JRE가 나오게 개선이 필요해 보입니다