用 buildpack 构建 Spring Boot 镜像
目录
Spring Boot 的 Maven 插件带了一个 spring-boot:build-image,项目里不用放 Dockerfile,一条命令就能出一个能跑的镜像。拿 start.spring.io 生成的 Spring Boot 4.1.1 空 web 项目,在一台 Apple Silicon 机器上试了一遍:48 秒出镜像,347MB,里面装的是 BellSoft Liberica JRE 25.0.4。而 pom 里写的是 <java.version>21</java.version>。
镜像能跑,该有的也都有,代价是里面几乎每一样东西都由 buildpack 替你挑,包括那个 JRE 25。Spring I/O 2026 上 Anthony Dahanne 那场 Paketo Buildpacks 的分享讲的就是这套东西。下面是照着它在本机跑一遍的结果。
buildpack、builder、run image 各管什么#
Dockerfile 的模型是每一步都由你写出来:从哪个基础镜像起、把什么复制进去、装什么、用什么命令启动。镜像里有什么、缺什么,都算在写的人头上。
buildpack 换了个分法。一个 buildpack 是一对脚本,detect 判断这个项目用不用得上它,build 在用得上的时候往镜像里加东西。每个只管一件小事:一个装 JRE,一个装 CA 证书,一个把 Spring Boot 的 jar 切成几层。谁该出场不用人指定,由 detect 自己判断。
调度这套流程的程序叫 lifecycle,构建日志里那几行大写的 ===> DETECTING 就是它的阶段名。它要用到两个镜像:
- builder:构建时用的镜像,里面装着一整组 buildpack、lifecycle 本身,以及编译要用的 JDK 之类的工具。
- run image:最终镜像的底座,应用的那几层叠在它上面。
构建工具链不进最终镜像,就是靠这两个分开:JDK 待在 builder 里,最终镜像里只剩 JRE。
这套 detect 加 build 的分工由 Cloud Native Buildpacks 规范定义,规范本身在 2026-08-11 从 CNCF 毕业。Paketo 是它的实现之一,上面那些具体的 buildpack 都来自它;Google 和 Heroku 各有一套,后面会拿同一个项目跑给它们看。按规范驱动构建的程序叫 platform,官方的 pack CLI 是一个,Spring Boot 的 Maven 插件也是一个,所以用同一个 builder 时,两边走的是同一批 buildpack。
不写 Dockerfile 能省掉哪些活#
先说这套东西值在哪,下面每一条后面都有对应的数字。
一个什么都没配的空项目,跑完就能拿到一个非 root 启动、按依赖和代码分好层、自带组件清单的镜像。
分层的好处是改一行代码不用重传整个 jar。后面实测下来,20 个层里只有 1 个变了,构建从 48 秒掉到 9.5 秒。
堆大小是容器启动那一刻按 cgroup 限制算出来的。同样 1GB 的容器,它给堆 449MB,JVM 按自己的默认值只给 256MB。
嫌启动慢有个开关,打开之后这个项目从 1.05 秒降到 0.45 秒,代价是镜像多 72MB。
再往上一层,底座镜像出了 CVE,可以只换掉底座,不重新构建整个镜像。服务一多,这条比前面几条都值钱。
上面这些都不用配。代价在后面几节:哪些值由 buildpack 替你定、定成了什么、哪些得自己接管。
一条命令跑完,镜像里装了什么#
命令本身没有参数:
./mvnw spring-boot:build-image
这个 goal 会自己 fork 一次 package,所以不用另外写 mvn package。它也不需要装 pack CLI,插件直接和 Docker daemon 说话。构建过程分几个阶段,日志里能直接看到:
===> ANALYZING
Image with name "docker.io/library/imgdemo:0.0.1-SNAPSHOT" not found
===> DETECTING
6 of 26 buildpacks participating
paketo-buildpacks/ca-certificates 3.13.0
paketo-buildpacks/bellsoft-liberica 11.9.0
paketo-buildpacks/syft 2.41.0
paketo-buildpacks/executable-jar 6.16.0
paketo-buildpacks/dist-zip 5.13.0
paketo-buildpacks/spring-boot 5.37.0
===> RESTORING
===> BUILDING
===> EXPORTING
builder 里带了 26 个 buildpack,这次有 6 个在 DETECTING 阶段认领了项目:jar 是可执行 jar,于是 executable-jar 认领;MANIFEST.MF 里有 Spring-Boot-Version,于是 spring-boot 认领。剩下 20 个判断跟这个项目无关,不参与,也不会在镜像里留下东西。
日志开头拉的就是前面说的那两个镜像:
> Pulling builder image 'docker.io/paketobuildpacks/builder-noble-java-tiny:latest'
> Pulling run image 'docker.io/paketobuildpacks/ubuntu-noble-run-tiny:0.0.130' for platform 'linux/arm64'
builder-noble-java-tiny 是 Spring Boot 插件的默认 builder,这一点写在插件文档里;对应的 run image 只有 34.8MB。两个都没在项目里配过,是插件自己选的。
成品是个普通 OCI 镜像,但入口不是 java -jar:
docker inspect imgdemo:0.0.1-SNAPSHOT \
--format 'User={{.Config.User}} Entrypoint={{.Config.Entrypoint}} WorkingDir={{.Config.WorkingDir}}'
User=1002:1001 Entrypoint=[/cnb/process/web] WorkingDir=/workspace
默认非 root 跑,/cnb/process/web 是 lifecycle 的 launcher,它负责在 exec 应用之前把一串环境变量准备好。后面几节里那些数字,都是这个 launcher 在容器启动那一刻现算的。
SBOM 也一起打进去了,不用额外开关。SBOM 是 software bill of materials,一份「这个镜像里装了哪些组件、各是什么版本」的机读清单,出了 CVE 时用来回答「哪些镜像受影响」:
cid=$(docker create imgdemo:0.0.1-SNAPSHOT)
docker cp "$cid:/layers/sbom" ./sbom
docker rm "$cid"
/layers/sbom/launch/ 下面是各个 buildpack 各自贡献的清单,executable-jar 那份列了 37 个 Java 组件,JRE、CA 证书、spring-cloud-bindings 各有各的一份。
JRE 版本取自 jar 的 MANIFEST.MF#
日志里这两行是开头那个疑问的答案:
Using Java version 25 extracted from MANIFEST.MF
BellSoft Liberica JRE 25.0.4: Contributing to layer
pom 里的 <java.version>21</java.version> 决定的是编译参数,跟运行时无关。bellsoft-liberica buildpack 读的是 jar 清单里的这一行:
unzip -p target/imgdemo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF | grep Build-Jdk-Spec
Build-Jdk-Spec: 25
Build-Jdk-Spec 记的是打这个 jar 的那台机器上的 JDK 版本,这台机器上是 25。于是编译目标 21、运行时 25。这样能跑,但 CI 换台机器、装了别的 JDK,镜像里的 JRE 就跟着变,而 pom 一个字没动。
要定死就设 BP_JVM_VERSION。这里有个容易踩的地方:env 这个参数在 Spring Boot 插件里没有对应的 user property,命令行上写 -Dspring-boot.build-image.env.BP_JVM_VERSION=21 传不进去,也不报错,构建照常成功,JRE 还是 25。得写在 pom 里:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
写对之后日志换了个说法,镜像也从 347MB 降到 274MB:
Using Java version 21 from BP_JVM_VERSION
BellSoft Liberica JRE 21.0.12: Contributing to layer
堆大小在容器启动时算,512MB 直接起不来#
先说为什么需要算。现在的 JVM 本身已经认识容器,在这个镜像里查一下就能看到:
docker run --rm -m 1g \
--entrypoint /layers/paketo-buildpacks_bellsoft-liberica/jre/bin/java \
imgdemo:0.0.1-SNAPSHOT -XX:+PrintFlagsFinal -version | grep -E 'UseContainerSupport|MaxRAMPercentage|MaxHeapSize'
size_t MaxHeapSize = 268435456 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseContainerSupport = true {product} {default}
UseContainerSupport 默认开着,JVM 会去读 cgroup 的内存限制,再按 MaxRAMPercentage 切一块给堆。1GB 的容器,堆 256MB,剩下 75% 全留给堆外。
留这么宽是有原因的:JVM 占的内存远不止堆,metaspace、JIT 的 code cache、每条线程的栈、direct memory 都在堆外。容器被 OOMKill,常常是这些加起来超了限制,而不是堆自己涨破。JVM 不知道你的应用会开多少线程、加载多少类,只好留一大块空白。
memory calculator 走的是反方向:先把堆外那几块按固定值一项项扣掉,剩下多少就全给堆。好处是堆能用得更满,代价是这笔账得算得平,算不平就不让启动。下面这些数字都是它在容器启动那一刻算出来的。
容器一启动,日志第一行就是内存计算:
Calculating JVM memory based on 12665872K available memory
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx12077283K
-XX:MaxMetaspaceSize=76588K -XX:ReservedCodeCacheSize=240M -Xss1M
(Total Memory: 12665872K, Thread Count: 250, Loaded Class Count: 11108, Headroom: 0%)
没加 -m 的时候它看到的是整台机器的内存,于是 -Xmx 给到了 11.5GB。加上限制就按限制算:
docker run --rm -m 1g imgdemo:0.0.1-SNAPSHOT
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx459987K
-XX:MaxMetaspaceSize=76588K -XX:ReservedCodeCacheSize=240M -Xss1M
(Total Memory: 1G, Thread Count: 250, Loaded Class Count: 11108, Headroom: 0%)
1GB 里刨掉 metaspace、code cache、direct memory 和 250 个线程的栈,堆剩下约 449MB,比 JVM 自己按 25% 给的 256MB 多了近一倍。同一个算式在 512MB 上会失败:
docker run --rm -m 512m imgdemo:0.0.1-SNAPSHOT
unable to calculate memory configuration
fixed memory regions require 588588K which is greater than 512M available for allocation:
-XX:MaxDirectMemorySize=10M, -XX:MaxMetaspaceSize=76588K,
-XX:ReservedCodeCacheSize=240M, -Xss1M * 250 threads
ERROR: failed to launch: exec.d: failed to execute exec.d file at path
'/layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/memory-calculator': exit status 1
容器没起来,应用一行日志都没打。报错里已经把账算清楚了:固定部分要 588MB,其中 250 个线程乘 1MB 栈就占了一半。250 这个数是 spring-boot buildpack 给 servlet 应用写的默认值,构建日志里对应的是 Web Application Type: Contributing to layer / Servlet web application detected 这一段。
一个空项目用不到 250 个线程,把它改小就能起来:
docker run --rm -m 512m -e BPL_JVM_THREAD_COUNT=50 imgdemo:0.0.1-SNAPSHOT
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx140499K
-XX:MaxMetaspaceSize=76588K -XX:ReservedCodeCacheSize=240M -Xss1M
(Total Memory: 512M, Thread Count: 50, Loaded Class Count: 11108, Headroom: 0%)
Started ImgdemoApplication in 0.648 seconds
BP_ 前缀的变量构建时读,BPL_ 前缀的运行时读。BPL_JVM_THREAD_COUNT 属于后者,改它不用重新构建镜像,在 Kubernetes 的 env 里加一条就行。但这个数要和应用实际会开的线程数对得上:它只是内存计算的输入,并不限制应用真能开多少线程,报小了堆就分得多,真开到 250 个线程时总量仍可能超过 cgroup 限制。
spring-cloud-bindings 默认开着#
构建时没配任何东西,它自己下载了一个 jar:
Spring Cloud Bindings 2.0.4: Contributing to layer
Downloading from https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-bindings/2.0.4/spring-cloud-bindings-2.0.4.jar
启动时也是开的:
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS: ... -Dorg.springframework.cloud.bindings.boot.enable=true
spring-cloud-bindings 干的事,是把容器里 /bindings 目录下按 Service Binding 约定摆好的文件,翻译成 Spring 的配置属性。在 Kubernetes 上挂一个 Secret 进去就能连上数据库,应用里不用写读取逻辑。
不用这套约定的话,这个 jar 和这个启动参数都是多余的。关掉:
<env>
<BP_SPRING_CLOUD_BINDINGS_DISABLED>true</BP_SPRING_CLOUD_BINDINGS_DISABLED>
</env>
AOT cache 默认关着,JRE 21 上会回落成 CDS#
Java 应用启动慢,其中一块花在读取、解析、加载、链接类上。JDK 5 就有的 CDS(class data sharing)把读取和解析的结果存成归档,启动时直接映射进来;JEP 483 在 Java 24 把加载和链接也提前做了,这份产物叫 AOT cache。两者都得先真跑一次应用、把加载了哪些类记下来,这一步叫 training run。
spring-boot buildpack 把这几个开关整排打出来,默认都是 false:
$BP_JVM_AOTCACHE_ENABLED false whether to enable JVM AOT Cache & perform JVM training run
$BP_JVM_CDS_ENABLED false whether to enable CDS & perform JVM training run
$BP_SPRING_AOT_ENABLED false whether to enable Spring AOT
把 BP_JVM_AOTCACHE_ENABLED 设成 true,构建时就多出这一步:buildpack 把 fat jar 解开,真的启动一次应用再停掉,把记录存成一个缓存层。构建日志里能看到应用的 banner 和一串归档警告:
Performance: Contributing to layer
Extracting Jar
... Starting ImgdemoApplication v0.0.1-SNAPSHOT using Java 25.0.4 ... (/workspace/runner.jar started by cnb)
[0.505s][warning][aot] class org/springframework/core/type/classreading/ClassFileMetadataReaderFactory
cannot be archived because it was not defined from /workspace/lib/spring-core-7.0.9.jar as claimed
同一个应用、同样 -m 1g,各跑 4 次:没开是 1.035、1.042、1.061、1.064 秒,开了是 0.439、0.453、0.456、0.461 秒。镜像从 347MB 涨到 419MB。
这里有个容易看错的地方:同一个开关落成哪种缓存,取决于 JRE 版本。把 JRE 钉在 21 再打开它,启动日志里出来的是 CDS:
Spring CDS Enabled, contributing -XX:SharedArchiveFile=application.jsa to JAVA_TOOL_OPTIONS
JRE 25 上才是 AOT cache:
-XX:AOTCache=application.aot
构建日志里的警告前缀也跟着变,21 上是 [warning][cds],25 上是 [warning][aot]。AOT cache 要 Java 24 以上,低于这个版本 buildpack 回落到 CDS,不报错也不提示,只能从这两行认出来。两者都能缩短启动,但提前做的工作不一样,别拿 21 上量到的数去估 25 的收益。
这组数只说明一个空 web 项目在这台机器上的情况,类更多的应用能省多少得在自己的应用上测;72MB 的镜像增量倒是明摆着要付的。
改一行代码,20 层里只有 1 层变了#
OCI 镜像是一叠只读层,每层按内容算一个 digest。registry 和本地 daemon 都按 digest 认层:推送和拉取时,digest 没变的那几层不会重传。所以镜像分得越合理,改一次代码要动的字节就越少。
Spring Boot 打 fat jar 时会在里面留一份分层说明,BOOT-INF/layers.idx,写明哪些路径算哪一层:
unzip -p target/imgdemo-0.0.1-SNAPSHOT.jar BOOT-INF/layers.idx
- "dependencies":
- "BOOT-INF/lib/"
- "spring-boot-loader":
- "org/"
- "snapshot-dependencies":
- "application":
- "BOOT-INF/classes/"
- "BOOT-INF/classpath.idx"
- "BOOT-INF/layers.idx"
- "META-INF/"
spring-boot buildpack 照着这份索引把应用切成几片,构建日志里的分片和上面的条目一一对上:
Creating slices from layers index
dependencies (18.8 MB)
spring-boot-loader (398.1 KB)
snapshot-dependencies (0.0 B)
application (4.7 KB)
依赖和业务代码分开放,改代码时要重新生成的只有最后那 4.7KB。下面日志里是 5 个应用层,比索引里的 4 项多一个;多出来那层和 snapshot-dependencies 一样,digest 是空层那个值。
在主类上加一行注释再构建一次:
Reusing layer 'paketo-buildpacks/bellsoft-liberica:jre'
Reusing layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
Reusing layer 'paketo-buildpacks/executable-jar:classpath'
Added 1/5 app layer(s)
20 个层里 digest 变了的只有 1 个,构建耗时从 48 秒降到 9.5 秒。JRE 那层直接复用,没有重新下载和校验。
手写 Dockerfile 要做到同样的效果,得自己写多阶段构建、自己排 COPY 的顺序、自己用 jarmode 把 jar 拆开再分层复制。这里是默认就给的。
运行镜像里没有 shell#
默认的 tiny run image 只有 34.8MB,因为除了跑 JRE 需要的东西之外几乎什么都没装。想借 launcher 执行一句命令,会得到这个:
docker run --rm --entrypoint /cnb/lifecycle/launcher imgdemo:jvm21 'ls /bin'
ERROR: failed to launch: bash exec: no such file or directory
把镜像导出来数一遍,bin 类目录下的可执行文件总共 4 个:
cid=$(docker create imgdemo:jvm21)
docker export "$cid" | tar -tf - | grep -E '^(bin|usr/bin|sbin|usr/sbin)/[^/]+$'
docker rm "$cid"
usr/bin/c_rehash
usr/bin/locale-check
usr/bin/openssl
usr/sbin/update-ca-certificates
sh、bash、ps、curl、jcmd 全都不在,整个镜像里也搜不到。docker exec 和 kubectl exec 因此进不去。攻击面小了,排查手段也少了。
需要一个能进去的镜像时可以换 builder:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.builder=paketobuildpacks/ubuntu-noble-builder
这个 builder 的 run image 是 paketobuildpacks/ubuntu-noble-run,157MB,/bin/sh 和 bash 都在,但 ps、curl 仍然没有。另一条路是留着 tiny,排查时用 kubectl debug 挂一个临时容器进去看,生产镜像本身不用变胖。
换一家的 buildpack,默认值跟着全变#
Paketo 不是唯一的实现。buildpack 这个概念 2011 年出自 Heroku,2018 年 1 月由 Pivotal 和 Heroku 一起标准化成 CNB,所以今天几家各自维护着一套:Paketo、Google(gcr.io/buildpacks/builder,Cloud Run 和 App Engine 背后用的就是它)、Heroku(heroku/builder)。builder 在插件里能直接换,这个参数有 user property,命令行传得进去:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.builder=gcr.io/buildpacks/builder:google-22
同一个项目换成 Google 的 builder,出来的东西差得很远:
| Paketo(默认) | ||
|---|---|---|
| 参与的 buildpack | 6 个 | 3 个 |
| JRE | Liberica 25.0.4 | Google canonicaljdk 21.0.12 |
| 镜像大小 | 347MB | 589MB |
| 应用层 | 5 个 | 1 个 |
| 启动时算内存 | 有 | 没有 |
| run image 架构 | linux/arm64 | 只有 linux/amd64 |
前面几节里那些东西,memory calculator、照 layers.idx 切出来的 5 个应用层、spring-cloud-bindings、AOT cache 的开关,全是 Paketo 这套 buildpack 自己加的,换一家就都没有了。改一行代码要重传的,Paketo 这边是 4.7KB 那一层,Google 这边是整个应用层。Google 的 run image 这次只拉到 amd64 的,在 Apple Silicon 上跑就得靠模拟。
规范管得住的只是外壳:两个镜像的 entrypoint 都是 /cnb/process/web,都不用 root 跑,/layers/sbom/ 下都有清单,因为 lifecycle 和镜像格式是 CNB 定的。
Heroku 的 builder 则直接没跑起来:
ERROR: No buildpack groups passed detection.
ERROR: failed to detect: no buildpacks participating
它里面有 heroku/java、heroku/maven、heroku/jvm 这些,但都是从源码构建的 buildpack,要看到 pom.xml 才认领;而 Spring Boot 插件递过去的是已经打好又解开的 jar,不是源码目录,于是没人接。Google 那边能成,靠的是 google.java.exploded-jar 这个专门认这种布局的 buildpack。想用 Heroku 的 builder,得改走 pack build 从源码构建那条路。
什么时候用 buildpack,什么时候还是写 Dockerfile#
前面这些默认值指向的是同一类应用:打成 fat jar 的标准 JVM 服务,除了 JRE 和 CA 证书之外没有别的系统依赖。这类应用交给 buildpack,省下的不只是一次性写 Dockerfile 的工夫。
规模上去之后差距更大的是换底座。底座镜像出了 CVE,buildpack 这边可以只换 run image:lifecycle 改掉镜像的层元数据让它指向新底座,不重跑 buildpack、不碰源码、不重新编译,这个操作叫 rebase。几十个服务统一打补丁靠的是它;手写 Dockerfile 要做同样的事,得逐个仓库改 FROM 再各自重建一遍。
还是写 Dockerfile 更省事的,大致这几类:
- 要装系统包。 Paketo 有 apt buildpack,但它自己写明「只在带 shell 和 apt 的容器上有效」,而默认那个 tiny 底座正好两样都没有。要装浏览器、字体或某个 C 库,就得先换成带包管理器的 builder,绕这一圈之后 Dockerfile 反而直接。
- 镜像内容要能逐层交代。 合规审计要求说清每一层从哪来时,「六个 buildpack 各自贡献的层」比一份自己写的 Dockerfile 难解释。
- 底座只能用自己维护的镜像。 run image 可以换,但换成自己的,就得自己保证它满足 CNB 对 run image 的要求,这份维护成本是新加的。
- 构建流程本来就不标准。 从多模块里挑几个产物拼起来、构建期要连内网服务、最终产物不是 jar,这些
detect接不住。
还有一条不在这张清单里,也不算技术判断:buildpack 的默认值合理,但不由你定,也不写在仓库里。JRE 版本从 jar 清单读、内存参数在启动时算、镜像里没有 shell,每条单独看都说得通,可排查的人得先知道它们存在。这份知识没人接手的时候,一份看得见的 Dockerfile 可能更省事。
小结#
项目里没有 Dockerfile,也没配过基础镜像,这条命令给出的镜像已经是:以 uid 1002 而不是 root 启动,按依赖和业务代码分成 5 个应用层,-Xmx 在启动时按 cgroup 限制现算,/layers/sbom/ 下带着一份 37 个组件的清单。
这四样手写 Dockerfile 都能做到,只是每一样都得记得写:漏了 USER 就是 root 跑;COPY target/*.jar 一把梭就只有一层,改一行代码整个 jar 重传;-Xmx 不写就是容器内存的 25%,1GB 的容器里堆只有 256MB;SBOM 得另外接一个工具。
需要自己接管的是这几个,都写在 pom 的 <image><env> 里,命令行 -D 传不进去:
| 变量 | 默认取值 | 什么时候要动 |
|---|---|---|
BP_JVM_VERSION |
取自 jar 的 Build-Jdk-Spec |
不想让运行时 JRE 跟着构建机的 JDK 漂 |
BPL_JVM_THREAD_COUNT |
servlet 应用 250 | 小内存容器,这次 512MB 直接起不来 |
BP_SPRING_CLOUD_BINDINGS_DISABLED |
false,即装且启用 |
不用 Service Binding 那套约定 |
BP_JVM_AOTCACHE_ENABLED |
false |
愿意拿 72MB 镜像换启动时间,且 JRE 在 24 以上 |
其中 BPL_JVM_THREAD_COUNT 是运行时变量,在部署的 env 里改就行,不用重新构建。
还没验证过的是多模块项目和带 native image 的情况,那两种的 detect 结果跟这次不一样,得另外跑。