从 GB 到 KB:我用 Shell 脚本把 Docker镜像瘦身到客户都不敢信

发布时间:2026/7/6 1:52:49
从 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 计算 MD5md5sum 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.json20240615_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完整的新版本应用 jar9. 重建 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 layerapp/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 结构和增量复用之后就会发现这件事并不是魔法。它只是把原本重复交付的内容变成了现场复用。而这正是工程优化最迷人的地方如果你也遇到了类似问题想参考这套增量升级脚本可以私聊我 回复「增量升级」源码脚本发您。后面我会坚持每周更新一篇高质量技术文章分享真实项目里的工程实践、项目交付经验、踩坑记录和解决方案。关注我不迷路。 技术路上我们一起少踩坑、多交付。