生产镜像为什么有 600M:一次 Dockerfile 瘦身实录
一个 Node SSR 应用的的生产镜像到了 600 多 MB。第一反应是「依赖太多了」,但 docker history 拆开一看,应用本体(构建产物 + 源码 + 内容文件)只有 2MB,体积全在另外两处:基础镜像约 160MB,node_modules 一层接近 400MB。
最大的头:devDependencies 进了生产镜像
Dockerfile 是标准的多阶段构建,构建阶段 pnpm install 装全量依赖,运行阶段这样拷贝:
COPY --from=build /app/node_modules ./node_modules
这一行把包括 devDependencies 在内的全部依赖搬进了生产镜像。实际清点:playwright 20M、typescript 23M、sharp 的两份 libvips 共 34M、vitest、drizzle-kit、四份不同版本的 esbuild 二进制共 40M——全是测试和构建期工具,运行时一行都不会执行。
修法不是换依赖管理器,而是在构建末尾剪掉开发依赖:
RUN pnpm build
# 生产镜像只带运行时依赖;运行期要用的 tsx 先移入 dependencies
RUN pnpm prune --prod
pnpm prune --prod 按 dependencies 字段重建 node_modules,devDependencies 全部移除。前提是运行期真正需要的东西别留在 devDependencies 里——这个项目用 tsx 在容器启动时跑数据库迁移脚本,所以 tsx 必须先挪进 dependencies,否则 prune 完容器起不来。判断方法:把运行期会执行的脚本(entrypoint、运维脚本)的 import 全部列一遍,凡是第三方包都必须能在 dependencies 里找到。
剩下的体积是结构性的
剪完之后 node_modules 还有 270 多 MB(alpine/musl 版本比 x64 略大),但构成已经合理:astro 及其自带的 typescript/vite、ORM、鉴权库、三份版本被不同上游锁定的 esbuild。这些都删不得——astro 把 typescript 和 vite 列为硬依赖,运行时确实加载。
这类结构性体积再往下抠(强行对齐 esbuild 版本、删 astro 的可选依赖 sharp)收益小、风险大。sharp 尤其不要删:它是 astro 图片优化的可选依赖,文章里一旦用了本地图片,SSR 模式下运行时会真的调用它。
体积问题的另一半:镜像只进不出
单镜像 560MB 不算致命,真正会撑爆磁盘的是每次 CI 部署打一个时间戳标签、服务器上旧标签从不删除。两个月下来光历史镜像就是几十 GB。修法是在部署脚本里、新版本健康检查通过后,只保留当前加最近一两个标签,其余 docker rmi:
docker images "$REPO" --format '{{.CreatedAt}}\t{{.Tag}}' \
| sort -r | cut -f2 | grep -vx "$CURRENT_TAG"
# 跳过要保留的 N 个,其余 docker rmi;再 docker image prune -f 清悬空层
注意顺序:必须先确认新版本 healthy 再删旧镜像,否则回滚路径也被自己拆掉了。
结论
镜像体积排查的固定动作只有三步:docker history 看分层、du 看 node_modules 构成、按「运行期是否真的执行」给每个大依赖定罪。这个案例里 100 多 MB 是一行 COPY 顺手带走开发依赖造成的,一行 pnpm prune --prod 就能省掉;剩下的部分是框架基线,接受它比折腾它更划算。