멀티 스테이지 빌드
멀티 스테이지 빌드
멀티 스테이지 빌드는 Dockerfile을 여러 단계로 나눠 빌드 환경과 실행 환경을 분리하는 기법이에요. 최종 이미지의 크기와 보안 위험을 크게 줄일 수 있어서 모든 종류의 애플리케이션에 권장돼요.
출처: 문서
본문
설명
전통적인 빌드에서는 모든 빌드 지시문이 순서대로, 하나의 빌드 컨테이너 안에서 실행돼요. 의존성 다운로드, 코드 컴파일, 애플리케이션 패키징까지 말이죠. 이 모든 레이어가 결국 최종 이미지에 남게 돼요. 이 방식도 동작은 하지만, 불필요한 무게를 실은 거대한 이미지를 만들고 보안 위험까지 키워요. 바로 여기서 멀티 스테이지 빌드가 등장해요.
멀티 스테이지 빌드는 Dockerfile에 목적이 각각 다른 여러 스테이지를 도입해요. 마치 빌드의 서로 다른 부분을 여러 환경에서 동시에 실행할 수 있는 것과 같아요. 빌드 환경을 최종 실행 환경과 분리하면 이미지 크기와 공격 표면(attack surface)을 크게 줄일 수 있어요. 특히 빌드 의존성이 큰 애플리케이션에서 큰 효과를 볼 수 있어요.
멀티 스테이지 빌드는 모든 종류의 애플리케이션에 권장돼요.
- JavaScript나 Ruby, Python 같은 인터프리터 언어라면 한 스테이지에서 코드를 빌드하고 압축(minify)한 뒤, 프로덕션에 사용할 파일만 더 작은 런타임 이미지로 복사할 수 있어요. 이렇게 하면 배포에 최적화된 이미지를 만들 수 있어요.
- C나 Go, Rust 같은 컴파일 언어라면 멀티 스테이지 빌드를 통해 한 스테이지에서 컴파일하고, 컴파일된 바이너리만 최종 런타임 이미지로 복사할 수 있어요. 컴파일러 전체를 최종 이미지에 포함할 필요가 없어요.
다음은 의사 코드로 본 멀티 스테이지 빌드 구조의 간단한 예시예요. FROM 문이 여러 개 있고, AS가 새로 등장하는 걸 볼 수 있어요. 또한 두 번째 스테이지의 COPY 문은 --from으로 이전 스테이지를 참조하고 있어요.
# Stage 1: Build Environment
FROM builder-image AS build-stage
# Install build tools (e.g., Maven, Gradle)
# Copy source code
# Build commands (e.g., compile, package)
# Stage 2: Runtime environment
FROM runtime-image AS final-stage
# Copy application artifacts from the build stage (e.g., JAR file)
COPY --from=build-stage /path/in/build/stage /path/to/place/in/final/stage
# Define runtime configuration (e.g., CMD, ENTRYPOINT)
이 Dockerfile은 두 개의 스테이지를 사용해요.
- 빌드 스테이지는 애플리케이션을 컴파일하는 데 필요한 빌드 도구가 들어 있는 베이스 이미지를 사용해요. 빌드 도구 설치, 소스 코드 복사, 빌드 명령 실행 등의 명령을 포함해요.
- 최종 스테이지는 애플리케이션을 실행하기에 적합한 더 작은 베이스 이미지를 사용해요. 빌드 스테이지에서 컴파일된 산출물(예: JAR 파일)을 복사해요. 마지막으로 애플리케이션을 시작하기 위한 런타임 설정을
CMD나ENTRYPOINT로 정의해요.
직접 해보기
이 실습 가이드에서는 멀티 스테이지 빌드의 힘을 활용해 샘플 Java 애플리케이션을 위한 날렵하고 효율적인 Docker 이미지를 만들어 봐요. Maven으로 빌드한 간단한 "Hello World" Spring Boot 기반 애플리케이션을 예시로 사용해요.
- Docker Desktop을 다운로드하고 설치해요.
- 이 사전 초기화된 프로젝트를 열어 ZIP 파일을 생성해요. 과정은 다음과 같아요. Spring Initializr는 Spring 프로젝트용 빠른 시작 생성기예요. Java, Kotlin, Groovy, Maven 등 여러 공통 개념에 대한 구현을 제공하면서 JVM 기반 프로젝트를 생성할 수 있는 확장 가능한 API를 제공해요. Generate를 선택해 이 프로젝트의 zip 파일을 만들고 다운로드하세요. 이 데모에서는 Maven 빌드 자동화와 Java, Spring Web 의존성, Java 21 메타데이터를 조합했어요.
- 프로젝트 디렉터리를 살펴봐요. 압축을 풀면 다음과 같은 프로젝트 디렉터리 구조가 보여요.
spring-boot-docker ├── HELP.md ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── example │ │ └── spring_boot_docker │ │ └── SpringBootDockerApplication.java │ └── resources │ ├── application.properties │ ├── static │ └── templates └── test └── java └── com └── example └── spring_boot_docker └── SpringBootDockerApplicationTests.java 15 directories, 7 filessrc/main/java디렉터리에는 프로젝트의 소스 코드가,src/test/java디렉터리에는 테스트 소스가 들어 있어요.pom.xml파일은 프로젝트의 Project Object Model(POM)이에요.pom.xml은 Maven 프로젝트 설정의 핵심이에요. 맞춤형 프로젝트를 빌드하는 데 필요한 대부분의 정보를 담고 있는 단일 설정 파일이죠. POM은 방대하고 부담스러워 보일 수 있지만, 효과적으로 사용하기 위해 모든 세부 사항을 처음부터 이해할 필요는 없어요. - "Hello World!"를 표시하는 RESTful 웹 서비스를 만들어요.
src/main/java/com/example/spring_boot_docker/디렉터리 아래의SpringBootDockerApplication.java파일을 다음 내용으로 수정해요.package com.example.spring_boot_docker; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @SpringBootApplication public class SpringBootDockerApplication { @RequestMapping("/") public String home() { return "Hello World"; } public static void main(String[] args) { SpringApplication.run(SpringBootDockerApplication.class, args); } }SpringbootDockerApplication.java파일은com.example.spring_boot_docker패키지를 선언하고 필요한 Spring 프레임워크를 임포트하는 것으로 시작해요. 이 Java 파일은 사용자가 홈페이지를 방문하면 "Hello World"로 응답하는 간단한 Spring Boot 웹 애플리케이션을 만들어요.
Dockerfile 만들기
프로젝트가 준비됐으니 이제 Dockerfile을 만들 차례예요.
- 다른 폴더와 파일(src, pom.xml 등)이 들어 있는 같은 폴더에
Dockerfile이라는 파일을 만들어요. Dockerfile에 다음 줄을 추가해 베이스 이미지를 정의해요.FROM eclipse-temurin:21.0.8_9-jdk-jammy- 이제
WORKDIR지시문으로 작업 디렉터리를 정의해요. 이는 이후 명령이 실행될 위치와 디렉터리 파일이 컨테이너 이미지 안에 복사될 위치를 지정해요.WORKDIR /app - Maven 래퍼 스크립트와 프로젝트의
pom.xml파일을 Docker 컨테이너 안의 현재 작업 디렉터리/app으로 복사해요.COPY .mvn/ .mvn COPY mvnw pom.xml ./ - 컨테이너 안에서 명령을 실행해요.
./mvnw dependency:go-offline명령을 실행하는데, Maven 래퍼(./mvnw)를 사용해 최종 JAR 파일을 빌드하지 않고 프로젝트의 모든 의존성을 다운로드해요. 더 빠른 빌드에 유용해요.RUN ./mvnw dependency:go-offline - 호스트 머신의 프로젝트에서
src디렉터리를 컨테이너 안의/app디렉터리로 복사해요.COPY src ./src - 컨테이너 시작 시 실행할 기본 명령을 설정해요. 이 명령은 컨테이너가 Maven 래퍼(
./mvnw)를spring-boot:run목표와 함께 실행하도록 지시하며, 이를 통해 Spring Boot 애플리케이션이 빌드되고 실행돼요.
이렇게 하면 다음과 같은 Dockerfile이 완성돼요.CMD ["./mvnw", "spring-boot:run"]FROM eclipse-temurin:21.0.8_9-jdk-jammy WORKDIR /app COPY .mvn/ .mvn COPY mvnw pom.xml ./ RUN ./mvnw dependency:go-offline COPY src ./src CMD ["./mvnw", "spring-boot:run"]
컨테이너 이미지 빌드
- 다음 명령을 실행해 Docker 이미지를 빌드해요.
$ docker build -t spring-helloworld . docker images명령으로 Docker 이미지의 크기를 확인해요.
실행하면 다음과 비슷한 출력이 나와요.$ docker images
이 출력은 이미지 크기가 880MB임을 보여줘요. 여기에는 전체 JDK, Maven 툴체인 등이 포함돼 있어요. 프로덕션에서는 이런 것들이 최종 이미지에 필요하지 않아요.REPOSITORY TAG IMAGE ID CREATED SIZE spring-helloworld latest ff708d5ee194 3 minutes ago 880MB
Spring Boot 애플리케이션 실행
- 이미지가 빌드됐으니 이제 컨테이너를 실행할 차례예요.
컨테이너 로그에서 다음과 비슷한 출력을 볼 수 있어요.$ docker run -p 8080:8080 spring-helloworld[INFO] --- spring-boot:3.3.4:run (default-cli) @ spring-boot-docker --- [INFO] Attaching agents: [] . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v3.3.4) 2024-09-29T23:54:07.157Z INFO 159 --- [spring-boot-docker] [ main] c.e.s.SpringBootDockerApplication : Starting SpringBootDockerApplication using Java 21.0.2 with PID 159 (/app/target/classes started by root in /app) …. - 웹 브라우저에서 http://localhost:8080 으로 접속하거나, 이 curl 명령으로 "Hello World" 페이지에 접근해요.
$ curl localhost:8080 Hello World
멀티 스테이지 빌드 사용하기
- 다음 Dockerfile을 살펴봐요.
이 Dockerfile은 두 개의 스테이지로 나뉘어 있는 걸 알 수 있어요. 첫 번째 스테이지는 이전 Dockerfile과 동일하게, 애플리케이션을 빌드하기 위한 Java Development Kit(JDK) 환경을 제공해요. 이 스테이지에는FROM eclipse-temurin:21.0.8_9-jdk-jammy AS builder WORKDIR /opt/app COPY .mvn/ .mvn COPY mvnw pom.xml ./ RUN ./mvnw dependency:go-offline COPY ./src ./src RUN ./mvnw clean install FROM eclipse-temurin:21.0.8_9-jre-jammy AS final WORKDIR /opt/app EXPOSE 8080 COPY --from=builder /opt/app/target/*.jar /opt/app/*.jar ENTRYPOINT ["java", "-jar", "/opt/app/*.jar"]builder라는 이름이 붙어요. - 두 번째 스테이지는
final이라는 새 스테이지예요. 애플리케이션을 실행하는 데 필요한 Java Runtime Environment(JRE)만 담고 있는 더 얇은eclipse-temurin:21.0.8_9-jre-jammy이미지를 사용해요. 이 이미지는 컴파일된 애플리케이션(JAR 파일)을 실행하기에 충분한 JRE를 제공해요.프로덕션 환경에서는 jlink를 사용해 커스텀 JRE와 같은 런타임을 만드는 것을 강력히 권장해요. JRE 이미지는 모든 버전의 Eclipse Temurin에서 사용할 수 있지만,
jlink를 사용하면 애플리케이션에 필요한 Java 모듈만 포함한 최소 런타임을 만들 수 있어요. 이렇게 하면 최종 이미지의 크기를 크게 줄이고 보안을 개선할 수 있어요. 자세한 내용은 이 페이지를 참고하세요. 멀티 스테이지 빌드를 사용하면 Docker 빌드가 컴파일, 패키징, 단위 테스트에는 하나의 베이스 이미지를 사용하고, 애플리케이션 런타임에는 별도의 이미지를 사용해요. 그 결과 개발 도구나 디버깅 도구가 포함되지 않으므로 최종 이미지 크기가 더 작아져요. 빌드 환경을 최종 런타임 환경과 분리하면 이미지 크기를 크게 줄이고 최종 이미지의 보안을 높일 수 있어요. - 이제 이미지를 다시 빌드하고, 바로 사용할 수 있는 프로덕션 빌드를 실행해요.
이 명령은 현재 디렉터리의$ docker build -t spring-helloworld-builder .Dockerfile에서 최종 스테이지를 사용해spring-helloworld-builder라는 Docker 이미지를 빌드해요.Note 멀티 스테이지 Dockerfile에서 최종 스테이지(final)가 기본 빌드 대상이에요. 즉,
docker build명령에서--target플래그로 대상 스테이지를 명시하지 않으면 Docker는 기본적으로 마지막 스테이지를 빌드해요. JDK 환경이 있는 builder 스테이지만 빌드하려면docker build -t spring-helloworld-builder --target builder .를 사용할 수 있어요. docker images명령으로 이미지 크기 차이를 확인해요.
다음과 비슷한 출력을 얻게 돼요.$ docker images
최종 이미지는 원래 빌드 크기 880MB에 비해 단 428MB뿐이에요. 각 스테이지를 최적화하고 필요한 것만 포함함으로써 동일한 기능을 유지하면서 이미지 크기를 크게 줄일 수 있었어요. 이는 성능 개선뿐 아니라 이미지를 더 가볍고 안전하며 관리하기 쉽게 만들어 줘요.spring-helloworld-builder latest c5c76cb815c0 24 minutes ago 428MB spring-helloworld latest ff708d5ee194 About an hour ago 880MB