《SpringBoot4进阶实战》第23招 包和容器怎么真正构建
# 从可执行 Jar 到 OCI 镜像:Petclinic 容器构建的工程冲突与分层价值 ```text 旧方案:COPY target/app.jar app.jar → 镜像层巨大 → 每次发布全量传输 冲突:开发频繁、带宽有限、发布窗口短 后果:构建慢、推送慢、回滚慢、排障难 ``` Spring Petclinic 已经能通过 `mvn package` 打出可执行 Jar,本地 `java -jar` 也能启动。 但到了容器发布阶段,团队发现一个尴尬事实:Jar 能跑,不代表镜像构建方案成熟。 我是老李,负责把 Petclinic 推到测试环境。运维催镜像,开发催发布,网络只有 10Mbps 专线。 为什么 `COPY target/app.jar app.jar` 能运行,却仍然不是一个成熟的 Java 容器构建方案? 是继续用传统 Dockerfile 手写分层,还是切换到 Cloud Native Buildpacks 自动构建 OCI 镜像? 如果选错,会不会让每次发布都变成几百 MB 的全量推送,最终拖垮交付节奏? # 01、故事 ```text 周四 23:40 → 测试环境发布机 → 推送 Petclinic 镜像 → 带宽占满被运维叫停 ``` 晚上十一点四十分,我还在测试环境发布机前。任务是推送 Petclinic 新镜像到 Harbor。 开发刚修完一个登录 Bug,催我赶紧发。运维在群里说专线流量异常,让我先停。 我打开 Dockerfile,只有一行 `COPY target/petclinic.jar app.jar`。镜像 680MB,推送进度条像卡住一样。 > 夜深镜像推不动, > 专线告急催更凶。 > 一行拷贝虽能跑, > 全量传输路难通。 # 02、问题 ```text 需求变化:每周多次发布 → 镜像全量构建 → 推送与部署耗时剧增 冲突:Jar 可运行但镜像不可增量 后果:发布阻塞、回滚缓慢、存储膨胀 ``` 传统 Dockerfile 把整个 Fat Jar 放进一层。任何业务代码改动都会导致该层哈希变化。 Docker 构建缓存失效后,后续层全部重建。推送时,客户端与仓库之间传输的是完整层数据。 在 10Mbps 专线下,680MB 镜像推送需要约 9 分钟。一次发布至少构建、推送、部署三步串行。 可验证验收标准:业务代码只改一个 Controller 时,镜像推送增量应小于 5MB,而不是全量 680MB。 > 一层打包百兆重, > 每次发布全网送。 > 缓存虽在难复用, > 交付节奏被打痛。 # 03、原理 ```text Boot Loader 启动 → 解析 Jar 内依赖 → 按 Layer Index 分层 → 镜像层缓存复用 ``` Spring Boot 可执行 Jar 由 Boot Loader 负责启动。它先读取 `META-INF/MANIFEST.MF` 中的 `Main-Class`。 然后通过 `LaunchedURLClassLoader` 加载 `BOOT-INF/lib` 下的依赖 Jar,再加载 `BOOT-INF/classes` 下的业务类。 这种结构天然适合分层:依赖变化频率低,业务类变化频率高。把变化频率相近的内容放同一层,能最大化缓存复用。 反直觉判断:镜像体积大不一定慢,层变化频繁才慢。一个 680MB 但分层合理的镜像,可能比 300MB 单层镜像发布更快。 > 启动加载有顺序, > 依赖业务本分离。 > 分层若按频率切, > 缓存命中省力气。 # 04、架构 ```text 源码 → Maven 构建 → 可执行 Jar → 分层索引 → OCI 镜像 → Registry ``` 本模块位于构建与发布之间。输入是 Maven 产出的可执行 Jar,输出是符合 OCI 规范的容器镜像。 分层边界包括:`dependencies`、`spring-boot-loader`、`snapshot-dependencies`、`application`。 收益是构建缓存复用、推送增量传输、回滚时只拉变化层。代价是需要维护分层策略或引入 Buildpacks 工具链。 适用条件:应用依赖稳定、发布频繁、网络带宽受限。如果应用从不更新,分层收益可以忽略。 > 输入 Jar 包输出镜像, > 分层边界要划清。 > 依赖稳定业务变, > 缓存复用才可行。 # 05、第一版实战 ```text 环境:JDK 17 + Maven 3.9 + Docker 24 → 配置 spring-boot-maven-plugin → 构建 Jar → 传统 Dockerfile → 验证运行 → V1 ``` 当前 Petclinic 使用 Spring Boot 3.x,构建工具为 Maven。先确认 `pom.xml` 中插件版本。 ```xml org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} ``` 执行打包命令,生成可执行 Jar。 ```bash mvn clean package -DskipTests ``` 查看 Jar 内部结构,确认 Boot Loader 与依赖位置。 ```bash jar tf target/petclinic.jar | head -30 ``` 示例输出: ```text META-INF/MANIFEST.MF org/springframework/boot/loader/Launcher.class BOOT-INF/lib/spring-core-6.1.x.jar BOOT-INF/classes/org/springframework/samples/petclinic/PetClinicApplication.class ``` 编写传统 Dockerfile。 ```dockerfile FROM eclipse-temurin:17-jre COPY target/petclinic.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"] ``` 构建镜像并运行。 ```bash docker build -t petclinic:v1 . docker run -p 8080:8080 petclinic:v1 ``` 验证请求: ```bash curl http://localhost:8080/owners?lastName= ``` 返回 200 与 HTML 页面,说明 V1 可运行。成功标准是容器能启动并响应 HTTP 请求。 > 插件打包 Jar 已成, > 一行拷贝镜像生。 > 容器启动能响应, > 初版虽通隐患增。 # 06、排查 ```text 诊断树:推送慢 → 层是否复用?→ 否 → 单层是否过大?→ 是 → 根因:全量 Jar 单层 ``` 第一个问题:推送慢。怀疑镜像层没有复用。检查镜像构建历史。 ```bash docker history petclinic:v1 ``` 示例输出: ```text IMAGE CREATED SIZE abc123 2 minutes ago 680MB ``` 证据显示只有一个 680MB 的大层。任何代码改动都会让该层哈希变化,缓存完全失效。 第二个问题:镜像体积大。怀疑基础镜像包含不必要的 JDK。检查镜像内 Java 版本。 ```bash docker run --rm petclinic:v1 java -version ``` 示例输出: ```text openjdk version "17.0.9" ``` 根因是使用 `eclipse-temurin:17-jre` 但 Jar 内已包含全部依赖,基础镜像仍有 200MB 以上运行时开销。 **错误尝试:** 我曾尝试用 `docker save` 压缩镜像再传输,结果压缩耗时比推送还长,且解压后仍需完整存储。 > 历史一层六百八, > 缓存失效全白搭。 > 基础镜像也偏大, > 压缩传输更抓瞎。 # 07、优化 ```text V1 单层全量 → 证据:docker history 显示 680MB 单层 → V2 使用 Buildpacks 自动分层 ``` 改用 Spring Boot Maven Plugin 的 `build-image` 目标,底层使用 Cloud Native Buildpacks。 ```bash mvn spring-boot:build-image -Dspring-boot.build-image.imageName=petclinic:v2 ``` 该命令会自动完成:分析 Jar 结构、生成 Layer Index、调用 Paketo Buildpacks 构建 OCI 镜像。 查看生成的镜像分层。 ```bash docker history petclinic:v2 ``` 示例输出: ```text IMAGE CREATED SIZE COMMENT abc456 1 minute ago 2.1MB application abc455 1 minute ago 0B spring-boot-loader abc454 1 minute ago 45MB snapshot-dependencies abc453 1 minute ago 210MB dependencies abc452 1 minute ago 180MB base image ``` 业务代码改动只影响 `application` 层,推送时只需传输 2.1MB 增量。 > 构建镜像换工具, > 自动分层有依据。 > 业务改动只传小, > 推送时间降到底。 # 08、演进 ```text V2 分层镜像 → 能力:增量推送、缓存复用 → 遗留:基础镜像与 JVM 参数仍需治理 ``` 用 V1 同一组请求验证 V2 镜像。 ```bash docker run -p 8080:8080 petclinic:v2 curl http://localhost:8080/owners?lastName= ``` 返回 200,功能与 V1 一致。收益是推送增量从 680MB 降到 2.1MB,构建时间因缓存复用缩短。 代价是引入 Buildpacks 工具链,本地首次构建需要拉取构建器镜像,约 500MB。 适用场景:频繁发布、依赖稳定、网络受限。遗留问题:容器默认以 root 运行,JVM 内存参数未显式设置。 > 功能不变收益显, > 增量推送省时间。 > 首次构建有代价, > 用户权限待完善。 # 09、洞见 ```text 从现象到判断:镜像慢不是体积问题,是层变化问题 ``` ## 分层比瘦身更重要 ```text 单层 300MB vs 多层 680MB → 多层发布更快 ``` 反直觉判断:镜像总体积小不一定发布快。多层镜像虽然总体积大,但业务改动只触发小层变化,推送增量远小于单层瘦身镜像。 ## 可运行不等于可发布 ```text java -jar 成功 → 容器启动成功 → 但发布效率可能极低 ``` `COPY app.jar` 能跑通功能,但每次发布都是全量传输。成熟方案必须考虑构建缓存、推送增量、回滚粒度。 ## 工具链切换有隐藏成本 ```text Buildpacks 首次构建 → 拉取构建器 → 本地缓存 → 后续才快 ``` 反直觉判断:切换到 Buildpacks 后第一次构建可能比传统 Dockerfile 更慢。因为要下载构建器镜像和依赖元数据,但第二次开始收益显著。 ## 分层策略依赖变化频率 ```text 依赖稳定 → 分层收益大;依赖频繁变 → 分层收益小 ``` 如果项目每周都升级大量依赖,`dependencies` 层也会频繁变化,分层缓存收益会打折扣。 > 镜像快慢看层变, > 能跑未必能上线。 > 工具切换有成本, > 分层收益看频变。 # 10、系统落地 ```text 入口:mvn spring-boot:build-image → 服务:Buildpacks 分析 Jar → 本模块:分层镜像 → 存储:Registry → 未实现:容器用户与 JVM 参数治理 ``` 原来 Petclinic 只有可执行 Jar,容器构建靠手写 Dockerfile 单层拷贝。 本篇增加 Buildpacks 自动分层构建 OCI 镜像能力,实现依赖层、快照层、应用层分离。 现在能做什么:业务代码改动只推送 2MB 左右增量,构建缓存跨发布复用。 下一篇缺什么:容器内用户权限、JVM 内存与 GC 参数、镜像体积进一步优化。 官方文档可参考 Spring Boot Maven Plugin 文档首页与 Paketo Buildpacks 文档首页。 > 入口插件构建镜像, > 服务分析 Jar 内层。 > 本模块分层落地, > 存储推送增量轻。 # 11、小结 ```text 三问三答:为什么单层不成熟?→ 全量传输;选 Dockerfile 还是 Buildpacks?→ 分层优先;选错后果?→ 发布阻塞 ``` 第一问:`COPY app.jar` 能运行但不成熟,因为单层导致任何改动都全量传输,缓存失效。 第二问:传统 Dockerfile 手写分层可行但易错,Buildpacks 自动分层更适合频繁发布的 Java 应用。 第三问:选错会导致每次发布推送几百 MB,专线占满,回滚缓慢,交付节奏被打乱。 当前状态:Petclinic 已具备分层 OCI 镜像构建能力,推送增量从 680MB 降到 2.1MB。 > 单层全量不成熟, > 自动分层解忧愁。 > 选错方案发布堵, > 当前增量已轻舟。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring Boot 可执行 Jar 适合分层? 因为 Boot Loader 将依赖放在 `BOOT-INF/lib`,业务类放在 `BOOT-INF/classes`,两者变化频率不同。分层后依赖层可长期缓存,业务层每次发布只更新小层。 ## 实战题:用 Buildpacks 构建 Petclinic 镜像并验证分层 执行 `mvn spring-boot:build-image`,然后用 `docker history` 查看分层。修改一个 Controller 后重新构建,对比推送增量是否小于 5MB。 ## 排障题:Buildpacks 构建时拉取构建器镜像失败 检查网络是否能访问 `ghcr.io` 或配置的镜像仓库。可设置 `DOCKER_HOST` 或代理。若公司网络受限,需配置私有构建器镜像缓存。 ## 架构判断题:依赖频繁变化的项目是否适合 Buildpacks 分层? 不适合。依赖层频繁变化会导致缓存命中率低,分层收益被抵消。此时应优先稳定依赖版本,或考虑将应用拆分为更小服务。 > 理解分层看频率, > 实战构建验增量。 > 排障网络查代理, > 架构判断看依赖。 # 13、升华 ```text 工程结论:容器构建的成熟度不在能跑,而在能增量、能缓存、能治理 ``` 有依据的结论:分层是 Java 容器构建的核心工程手段,Buildpacks 将分层自动化,降低维护成本。 下一篇将治理容器内用户权限与 JVM 参数,让镜像不仅构建快,运行也更安全、更可控。 > 能跑只是第一步, > 增量缓存才成熟。 > 分层自动化落地, > 安全可控待下篇。 **系列项目:** Spring Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据层、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置 **本篇:** 从可执行 Jar 到 OCI 镜像的分层构建能力 **下一篇:** 容器用户权限与 JVM 参数治理 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。