Dockerfile 없이 컨테이너 이미지 만들기: Spring Boot bootBuildImage
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 패치가 있을 때 전체 이미지 재빌드
bootBuildImage는 Paketo 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. 기본값 한눈에 보기
공식 문서에 명시된 기본값이다.
| 항목 | 기본값 |
| Builder | paketobuildpacks/builder-noble-java-tiny:latest |
| Run image | Builder 메타데이터에 지정된 값 |
| Image name (Gradle) | docker.io/library/${project.name}:${project.version} |
| Image name (Maven) | docker.io/library/${project.artifactId}:${project.version} |
pullPolicy | ALWAYS |
publish | false |
cleanCache | false |
verboseLogging | false |
createdDate | 고정된 날짜 (재현 가능한 빌드 목적) |
applicationDirectory | /workspace |
securityOptions | Linux/macOS: ["label=disable"], Windows: [] |
trustBuilder | Paketo/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 인증 우선순위
공식 문서에 따르면 자격 증명은 다음 순서로 조회된다.
- Credential Helper (예:
osxkeychain,ecr-login) - Credential Store (예: Docker Desktop)
$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_HOST | Docker daemon URL |
DOCKER_CONTEXT | Docker context 이름 (DOCKER_HOST보다 우선) |
DOCKER_TLS_VERIFY | 1이면 HTTPS 사용 |
DOCKER_CERT_PATH | HTTPS 인증서 경로 |
DOCKER_CONFIG | Docker 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 내 Buildpack | urn:cnb:builder:paketo-buildpacks/java 또는 paketo-buildpacks/java |
| 로컬 디렉토리 | file:///path/to/buildpack/ |
| 로컬 tgz | file:///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 측에 위임하고 싶다면 전환을 검토할 만하다. 공식 문서를 한 번 훑어두면 옵션 이름이 헷갈릴 때 빠르게 찾을 수 있다.

