Skip to main content

Command Palette

Search for a command to run...

Dockerfile 없이 컨테이너 이미지 만들기: Spring Boot bootBuildImage

Updated
6 min read

Spring Boot 애플리케이션을 컨테이너로 배포할 때 가장 흔한 방법은 직접 Dockerfile을 작성하는 것이다. 그러나 Spring Boot 2.3부터는 Cloud Native Buildpacks(CNB)를 이용해 Dockerfile 없이 OCI 이미지를 만들 수 있는 빌드 태스크가 플러그인에 내장됐다. Gradle에서는 bootBuildImage, Maven에서는 spring-boot:build-image 골이다.

이 글에서는 공식 문서(Gradle Plugin, Maven Plugin) 기준으로 동작 방식과 주요 옵션을 정리하고, 실습 가능한 예제를 제공한다.

1. 왜 bootBuildImage인가

직접 Dockerfile을 작성하면 다음과 같은 문제를 매번 다시 풀어야 한다.

  • Base image 선택 (보안 패치, 아키텍처, glibc/musl)
  • JRE/JDK 설치와 버전 결정
  • Layered JAR을 고려한 캐시 최적화
  • non-root 사용자 설정
  • CVE 패치가 있을 때 전체 이미지 재빌드

bootBuildImagePaketo Buildpacks가 제공하는 builder 이미지를 호출해 위 작업을 자동화한다. 각 빌드팩이 애플리케이션을 검사하고 필요한 레이어(JRE, 의존성, 클래스, 리소스)를 각각 구성하므로 재빌드 시 변경된 레이어만 교체된다.

2. 전체 흐름

기본적으로 Docker daemon(또는 호환 소켓 — Podman, Colima 등)이 필요하다. Builder 이미지와 Run 이미지를 pull한 뒤, Builder 컨테이너 안에서 애플리케이션을 분석·빌드해 최종 이미지를 만든다.

3. 가장 빠른 실습

Spring Initializr로 생성한 프로젝트(java, spring-boot-starter-web)가 있다고 가정한다.

Gradle

./gradlew bootBuildImage

java 또는 war 플러그인이 적용돼 있으면 bootBuildImage 태스크가 자동으로 등록된다. 기본 이미지 이름은 docker.io/library/${project.name}:${project.version} 형식이다.

Maven

./mvnw spring-boot:build-image

기본 이미지 이름은 docker.io/library/${project.artifactId}:${project.version}이다.

빌드가 끝나면 로컬 Docker에 이미지가 올라간다.

docker run --rm -p 8080:8080 docker.io/library/demo:0.0.1-SNAPSHOT

4. 기본값 한눈에 보기

공식 문서에 명시된 기본값이다.

항목기본값
Builderpaketobuildpacks/builder-noble-java-tiny:latest
Run imageBuilder 메타데이터에 지정된 값
Image name (Gradle)docker.io/library/${project.name}:${project.version}
Image name (Maven)docker.io/library/${project.artifactId}:${project.version}
pullPolicyALWAYS
publishfalse
cleanCachefalse
verboseLoggingfalse
createdDate고정된 날짜 (재현 가능한 빌드 목적)
applicationDirectory/workspace
securityOptionsLinux/macOS: ["label=disable"], Windows: []
trustBuilderPaketo/GCR/Heroku 공식 Builder는 true, 그 외 false

주의: 기본 Builder인 builder-noble-java-tiny는 시스템 라이브러리가 축소되어 있고 셸이 없다. 셸이나 특정 시스템 라이브러리가 필요하면 Run image를 paketobuildpacks/ubuntu-noble-run:latest 등으로 교체해야 한다.

5. 핵심 옵션

5.1 이미지 이름과 태그

이미지 이름 포맷은 [domainHost:port/][path/]name[:tag][@digest]이다. 도메인이 없으면 docker.io, path가 없으면 library, tag가 없으면 latest가 적용된다.

Gradle (Groovy):

tasks.named("bootBuildImage") {
    imageName = "ghcr.io/my-org/${project.name}:${project.version}"
    tags = [
        "ghcr.io/my-org/${project.name}:latest"
    ]
}

Maven:

<configuration>
  <image>
    <name>ghcr.io/my-org/${project.artifactId}:${project.version}</name>
    <tags>
      <tag>ghcr.io/my-org/${project.artifactId}:latest</tag>
    </tags>
  </image>
</configuration>

공식 문서는 tags전체 이미지 레퍼런스를 쓰도록 안내한다 (imageName과 동일한 [domainHost:port/][path/]name[:tag][@digest] 포맷).

5.2 JVM 버전 지정

Paketo Java 빌드팩은 BP_JVM_VERSION 환경 변수로 JDK 메이저 버전을 받는다.

Gradle:

tasks.named("bootBuildImage") {
    environment["BP_JVM_VERSION"] = "21"
}

Maven:

<image>
  <env>
    <BP_JVM_VERSION>21</BP_JVM_VERSION>
  </env>
</image>

5.3 런타임 JVM 옵션

컨테이너 실행 시 JAVA_TOOL_OPTIONS를 덧붙이고 싶다면 BPE_APPEND_* / BPE_DELIM_*를 쓴다.

Gradle:

tasks.named("bootBuildImage") {
    environment["BPE_DELIM_JAVA_TOOL_OPTIONS"] = " "
    environment["BPE_APPEND_JAVA_TOOL_OPTIONS"] = "-XX:+HeapDumpOnOutOfMemoryError"
}

5.4 Builder / Run image 교체

에어갭 환경이나 사내 Builder를 쓰는 경우.

tasks.named("bootBuildImage") {
    builder = "registry.internal/cnb-builder:2025-01"
    runImage = "registry.internal/cnb-run:2025-01"
}

커맨드라인으로도 오버라이드 가능하다.

./gradlew bootBuildImage \
  --builder=registry.internal/cnb-builder:2025-01 \
  --runImage=registry.internal/cnb-run:2025-01

5.5 Pull 정책

의미
ALWAYS항상 원격 pull (기본값)
IF_NOT_PRESENT로컬에 없을 때만 pull
NEVER로컬 이미지만 사용, 없으면 실패

CI 캐시나 에어갭 환경에서는 IF_NOT_PRESENT 또는 NEVER가 유용하다.

./gradlew bootBuildImage --pullPolicy=IF_NOT_PRESENT

5.6 플랫폼(OS/아키텍처)

ARM Mac에서 x86 이미지를 만들거나 그 반대의 경우:

./gradlew bootBuildImage --imagePlatform=linux/amd64

포맷은 OS[/architecture[/variant]]이다.

6. 레지스트리로 발행(Publish)

publish = true로 주면 빌드 직후 원격 레지스트리에 push한다. 이 때 이미지 이름은 반드시 도메인을 포함한 전체 레퍼런스여야 한다.

6.1 Gradle

tasks.named("bootBuildImage") {
    imageName = "ghcr.io/my-org/${project.name}:${project.version}"
    publish = true
    docker {
        publishRegistry {
            username = providers.environmentVariable("REGISTRY_USER").get()
            password = providers.environmentVariable("REGISTRY_TOKEN").get()
            url = "https://ghcr.io"
        }
    }
}

커맨드라인:

./gradlew bootBuildImage \
  --imageName=ghcr.io/my-org/demo:1.0.0 \
  --publishImage

6.2 Maven

<configuration>
  <image>
    <name>ghcr.io/my-org/${project.artifactId}:${project.version}</name>
    <publish>true</publish>
  </image>
  <docker>
    <publishRegistry>
      <username>${env.REGISTRY_USER}</username>
      <password>${env.REGISTRY_TOKEN}</password>
      <url>https://ghcr.io</url>
    </publishRegistry>
  </docker>
</configuration>

6.3 인증 우선순위

공식 문서에 따르면 자격 증명은 다음 순서로 조회된다.

  1. Credential Helper (예: osxkeychain, ecr-login)
  2. Credential Store (예: Docker Desktop)
  3. $HOME/.docker/config.json의 정적 자격 증명

즉, 로컬에서 docker login이 되어 있으면 별도 설정 없이 push가 동작할 가능성이 높다. CI에서는 토큰 기반 설정을 명시하는 것이 안전하다.

7. Docker 호환 데몬 연결

기본은 unix:///var/run/docker.sock이다. 다른 런타임을 쓰려면 docker 블록으로 지정한다.

Podman

tasks.named("bootBuildImage") {
    docker {
        host = "unix:///run/user/1000/podman/podman.sock"
        bindHostToBuilder = true
    }
}

소켓 경로는 다음 명령으로 확인한다.

podman info --format='{{.Host.RemoteSocket.Path}}'

Colima

tasks.named("bootBuildImage") {
    docker {
        host = "unix://${System.properties['user.home']}/.colima/docker.sock"
    }
}

환경 변수로 제어

변수설명
DOCKER_HOSTDocker daemon URL
DOCKER_CONTEXTDocker context 이름 (DOCKER_HOST보다 우선)
DOCKER_TLS_VERIFY1이면 HTTPS 사용
DOCKER_CERT_PATHHTTPS 인증서 경로
DOCKER_CONFIGDocker CLI config 위치 (기본 $HOME/.docker)

8. 캐시 설정

기본 캐시는 Docker 볼륨으로 관리된다. 공유 빌드 서버에서는 볼륨 이름을 고정하거나 bind mount로 바꿔 효과를 극대화할 수 있다.

tasks.named("bootBuildImage") {
    buildCache {
        volume { name = "cache-${rootProject.name}.build" }
    }
    launchCache {
        volume { name = "cache-${rootProject.name}.launch" }
    }
}

bind mount 버전:

tasks.named("bootBuildImage") {
    buildWorkspace {
        bind { source = "/tmp/cache-${rootProject.name}.work" }
    }
    buildCache {
        bind { source = "/tmp/cache-${rootProject.name}.build" }
    }
}

레이어가 꼬여 문제를 일으키면 cleanCache = true 한 번으로 초기화한다.

9. 커스텀 Buildpack

특정 기능(예: SBOM 생성, APM 에이전트 주입)을 추가하고 싶다면 Builder의 기본 Buildpack 목록을 덮어쓸 수 있다.

tasks.named("bootBuildImage") {
    buildpacks = [
        "urn:cnb:builder:paketo-buildpacks/java",
        "docker://my-org/apm-buildpack:1.2.0",
        "file:///opt/buildpacks/custom.tgz"
    ]
}

지원 포맷:

포맷예시
Builder 내 Buildpackurn:cnb:builder:paketo-buildpacks/java 또는 paketo-buildpacks/java
로컬 디렉토리file:///path/to/buildpack/
로컬 tgzfile:///path/to/buildpack.tgz
OCI 이미지docker://example/buildpack:latest

10. 빌드 재현성(createdDate)

기본값으로 이미지의 created 메타데이터는 고정된 날짜로 세팅된다. 이는 입력이 동일하면 digest가 동일해지도록 만들기 위함이다. 실제 빌드 시각이 필요하면 다음과 같이 덮어쓴다.

./gradlew bootBuildImage --createdDate=now
# 또는 ISO 8601
./gradlew bootBuildImage --createdDate=2026-04-25T09:00:00Z

11. Maven에서 package 단계에 묶기

Maven에서 mvn package만으로 이미지를 생성하려면 build-image-no-fork 골을 package phase에 바인딩한다. (build-image는 라이프사이클을 fork하므로 이 용도에서는 no-fork를 쓴다.)

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>build-image-no-fork</goal>
      </goals>
    </execution>
  </executions>
</plugin>

12. 옵션 선택 가이드

13. 요약

  • bootBuildImage / spring-boot:build-image는 Cloud Native Buildpacks 기반으로 Dockerfile 없이 OCI 이미지를 만든다.
  • 기본 Builder는 paketobuildpacks/builder-noble-java-tiny:latest, Run image는 Builder 메타데이터로 결정된다.
  • 이미지 이름, JVM 버전, 런타임 옵션, 레지스트리 인증, 캐시, 플랫폼, 커스텀 Buildpack까지 세밀하게 제어 가능하다.
  • 빌드 재현성을 위해 createdDate가 기본적으로 고정돼 있으며 --createdDate=now로 덮어쓸 수 있다.
  • Podman, Colima 등 Docker 호환 데몬과도 소켓 경로만 지정하면 연동된다.

Dockerfile을 직접 관리하는 부담을 줄이고, CVE 대응을 Paketo/Builder 측에 위임하고 싶다면 전환을 검토할 만하다. 공식 문서를 한 번 훑어두면 옵션 이름이 헷갈릴 때 빠르게 찾을 수 있다.

참고

More from this blog

카프카 입문 시리즈 2편: 토픽, 파티션, 오프셋

이 글은 Apache Kafka 입문 시리즈의 두 번째 글입니다. 1편에서 살펴본 구성 요소들 위에서, 메시지가 실제로 어떤 구조로 저장되고 관리되는지 알아보겠습니다. 1편을 마치며 세 가지 질문을 남겼습니다. 메시지는 브로커 안에서 어떤 구조로 저장될까? 토픽과 파티션은 정확히 무엇이고, 왜 필요할까? 컨슈머의 오프셋은 어떻게 동작할까? 이번 편에서 이 질문들에 하나씩 답하겠습니다. Topic: 메시지의 논리적 분류 토픽(Topic)은...

Mar 19, 202612 min read7

Java GC의 진화 — Serial에서 Generational ZGC까지

Java가 약속한 것 중 하나는 "메모리는 내가 관리할게"였다. C/C++ 개발자들이 malloc과 free로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다. 하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 멈추는 것이다. 이 멈춤을 Stop-The-World(STW) 일시 정지...

Mar 16, 20269 min read1

Spring의 3대 철학 — DI, AOP, PSA가 만드는 코드의 품격

Spring을 처음 배울 때, 나는 어노테이션 수집가였다. @Autowired를 붙이면 객체가 알아서 들어오고, @Transactional을 붙이면 트랜잭션이 알아서 관리되고, @Cacheable을 붙이면 캐시가 알아서 동작했다. "알아서"라는 말 뒤에 숨은 원리를 몰랐다. 그냥 마법이라고 생각했다. 그러다 문제가 생겼다. @Transactional을 붙였는데 롤백이 안 됐다. 같은 클래스 안에서 메서드를 호출했기 때문이었다. 원인을 찾는 데 ...

Mar 16, 202611 min read9

끄적끄적 테크 블로그

33 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ

Dockerfile 없이 컨테이너 이미지 만들기: Spring Boot bootBuildImage