1. 起因:客户的一句吐槽

我们有一个 Java 产品,使用 Docker 镜像进行部署和升级。在产品早期,这种方式很方便:

镜像进行部署和升级整个流程:

- 构建镜像

- 打成升级包

- 发给客户

- 客户现场加载镜像

- 使用 `docker-compose` 启动服务

流程很标准,也很稳。但随着产品进入频繁迭代阶段,问题开始变得明显。每次只是修几个问题、改几个接口,升级包却还是几个 GB。客户现场网络环境又不一定好,传输、解压、加载镜像都很慢。客户说得很直接:你们这次到底改了多少东西?为什么升级包还是这么大?这句话让我意识到:我们不能再把“完整镜像”当成唯一交付方式了。

 2. 问题本质:变的是少数 jar,交付的是整个镜像

我们的后端服务是 Spring Boot 应用。一个应用 jar 解开后,大概是这样的结构:

BOOT-INF/

classes/

lib/

META-INF/

org/

但在大多数升级中,真正变化的内容通常只有:

  • 少量业务代码

  • 个别依赖 jar

  • 配置或启动信息

大量三方依赖 jar 其实没有变化。

也就是说:

实际变化可能只有几十 KB 或几 MB,
但我们每次都把几个 GB 的完整镜像重新发了一遍。这就是升级包过大的根源。

3. 方案思路:构建阶段做减法,升级阶段做还原

我最终采用的思路是:

在 CI/CD 打包时,只保留变化的 jar;
到客户现场升级时,再从旧镜像里把未变化的 jar 补回来。

整个流程可以理解成两段。

镜像重构整个阶段


新镜像 
↓ 
解开镜像 layer 
↓ 
解压 Spring Boot jar 
↓ 
对比 BOOT-INF/lib 下 jar 的 MD5 
↓ 
删除未变化 jar 
↓ 
生成删除清单、保留清单、顺序清单 
↓ 
重新打包成增量升级包

一句话总结:

新包只带变化内容,旧内容从客户现场已有镜像中复用。

4. 构建阶段:先把镜像拆开

Docker 镜像本质上也是一组 layer 文件。脚本会先找到包含应用目录的 layer,例如:项目家目录home/app/ 然后解开对应的layer.tar,再解压里面的 Spring Boot jar。核心目标是进入这个目录:BOOT-INF/lib 因为这里才是 jar 依赖的主要聚集地。接下来,脚本会为每个 jar 计算 MD5:

md5sum xxx.jar

并生成当前版本的 jar 信息文件:

jars_info.json

这个文件会记录每个 jar 的名称和 MD5,作为后续版本对比的依据。

5. MD5 对比:找出真正变化的 jar

有了上一版本的jars_info.json,就可以判断当前 jar 是否变化。

判断逻辑很简单:

如果jar名称相同,并且MD5相同说明jar没有变化,可以删除如果jar不存在,或者MD5不同说明jar是新增或发生变化,需要保留

于是构建阶段会生成两个关键文件。

删除清单

xxx_del.json

记录那些没有变化、已经从新升级包中删除的 jar。

示例:

保留清单

xxx_kep.json

记录新增或发生变化的 jar。

示例:

这样,新升级包里就只剩真正变化的内容。这也是升级包能从 GB 级缩小到 KB 级的关键。

6. 为什么还要保存 jar 顺序

最开始我以为,只要 jar 都在,应用就一定能跑。后来才发现,事情没那么简单。Spring Boot 应用在启动时会涉及 classpath 加载。某些情况下,jar 的物理顺序、classpath.idx、依赖覆盖关系都会影响最终运行效果。所以脚本还会额外生成一个顺序文件:

xxx_order.txt

它记录原始BOOT-INF/lib 中 jar 的排列顺序。升级阶段恢复 jar 时,会按照这个顺序重新组织依赖,避免出现“包都在,但启动行为不一致”的问题。这个细节不大,但非常关键。

7. 版本快照:让每次增量都有基准

增量升级必须知道“上一版本是什么样”。所以每次构建完成后,都会保存一份版本快照:

versions/
20240601_xxx_jars_info.json 
20240615_xxx_jars_info.json

下一次构建时,脚本会读取最新快照作为对比基准。如果遇到版本回退,也可以指定历史快照:使用指定版本的 jars_info.json 作为基线这样,整个方案不仅能支持正常升级,也能支持回退场景。在客户现场,回退能力非常重要。因为升级不是实验室里的单向流程,真实环境里一定要考虑失败、重试和恢复。

8. 升级阶段:从旧镜像里把 jar 找回来

客户现场执行升级时,第二段脚本开始工作。它会先读取当前环境中的旧镜像,并通过docker save 导出:

docker save -o old-image.tar image-name

然后解开旧镜像,找到旧版本应用 jar,解压出:BOOT-INF/lib

接着读取构建阶段生成的删除清单:

xxx_del.json

这些被删除的 jar,恰好就是新包里没有携带、但旧镜像里已经存在的 jar。于是脚本会把它们从旧 jar 中移动到新 jar 的BOOT-INF/lib 中。

最终效果是:

新包中的变化 jar
 + 
旧镜像中的未变化 jar 

完整的新版本应用 jar

9. 重建 classpath:让应用稳定启动

jar 补齐之后,还不能急着结束。

为了保证 Spring Boot 应用稳定启动,脚本还会重新处理:

  • MANIFEST.MF
  • BOOT-INF/classpath.idx
  • BOOT-INF/lib 的物理写入顺序

其中MANIFEST.MF 会补充或修正:

Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx

然后根据之前保存的顺序文件,重新生成classpath.idx

最后再重新打包 Spring Boot jar。

这一步的意义是:

不只是把文件凑齐,而是尽量还原完整构建时的运行结构。

10. 重新打包 Docker layer

应用 jar 重建完成后,还需要重新打包 Docker layer:

app/soft/
 ↓ 
layer.tar

但这里还有一个坑。

layer.tar 内容变化之后,它的 SHA256 也会变化。
Docker 镜像配置文件中的rootfs.diff_ids 必须同步更新。

否则镜像加载时,可能出现 layer 校验不一致的问题。

所以脚本会重新计算:

sha256sum layer.tar
然后更新镜像配置 JSON 中对应的diff_id。

这个步骤让增量包最终重新变成一个 Docker 可以识别、可以加载、可以运行的完整镜像。

11. 最终效果:升级包从 GB 到 KB

改造完成后,效果非常明显。

原来的升级方式:

每次交付完整镜像 升级包大小:几个 GB

新的升级方式:

只交付变化 jar 和元数据 升级包大小:可缩减到 KB 级

对客户来说,变化非常直接:

上传更快

  • 解压更快

  • 镜像加载更快

  • 升级等待时间明显减少

  • 高频升级不再那么痛苦

对我们内部来说,也同样有收益:

  • CI/CD 产物更小

  • 发布效率更高

  • 失败重试成本更低

  • 多版本管理更清晰

  • 客户现场交付体验更好

12. 这次实践带来的思考

这次优化让我感触很深。它不是一次单纯的压缩包优化,也不是一段 shell 脚本的小技巧。它真正解决的是:

如何把一个笨重的完整交付流程,改造成轻量、可复用、可回退的增量交付流程。

在这个过程中,我也踩到了一些关键点:

  • 不能只比较文件名,还要比较 MD5

  • 不能只删除 jar,还要记录删除清单

  • 不能只恢复 jar,还要保证 jar 顺序

  • 不能只重打 layer,还要更新 Docker diff_id

  • 不能只考虑升级,还要考虑回退

  • 不能只关注脚本成功,还要关注客户现场是否稳定

最后我越来越觉得,工程价值不只体现在业务代码里。有时候,一个好的交付方案,能让产品体验提升一大截。

13. 总结

这次方案可以用一句话概括:

在 CI/CD 阶段删除未变化 jar,在客户现场从旧镜像中恢复它们,最终重组出完整新镜像。

它解决了我们长期被客户吐槽的升级包过大问题,也让产品在频繁迭代时拥有了更轻量的交付能力。从几个 GB 到 KB,听起来有点夸张。但当你真正理解 Docker layer、Spring Boot jar 结构和增量复用之后,就会发现这件事并不是魔法。它只是把原本重复交付的内容,变成了现场复用。而这,正是工程优化最迷人的地方:

如果你也遇到了类似问题,想参考这套增量升级脚本,可以私聊我:

> 回复「增量升级」,源码脚本发您。

后面我会坚持每周更新一篇高质量技术文章,分享真实项目里的工程实践、项目交付经验、踩坑记录和解决方案。关注我,不迷路。  技术路上,我们一起少踩坑、多交付。

更多推荐