从 GB 到 KB:我用 Shell 脚本把 Docker镜像瘦身到客户都不敢信
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.MFBOOT-INF/classpath.idxBOOT-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 结构和增量复用之后,就会发现这件事并不是魔法。它只是把原本重复交付的内容,变成了现场复用。而这,正是工程优化最迷人的地方:
如果你也遇到了类似问题,想参考这套增量升级脚本,可以私聊我:
> 回复「增量升级」,源码脚本发您。
后面我会坚持每周更新一篇高质量技术文章,分享真实项目里的工程实践、项目交付经验、踩坑记录和解决方案。关注我,不迷路。 技术路上,我们一起少踩坑、多交付。
更多推荐


所有评论(0)