Docker 多阶段构建实战:把镜像从 1.2GB 压到 180MB
第一次把一个 Node.js 服务打进 Docker 镜像时,我被 1.2GB 的体积吓了一跳——拉取要等两分钟,推到私有仓库更是要十几分钟。 后来花了两周时间做了一轮优化,最终把镜像压到了 180MB。这篇笔记记录完整思路和几个容易被忽略的细节。 一、为什么镜像这么大 先看问题。原始 Dockerfile 大概是这样: FROM node:20 WORKDIR /app COPY . . RUN npm install CMD ["node", "server.js"] 三个问题叠加在一起: 1. 基础镜像 node:20 本身就有 1GB 左右(完整版包含包管理器、编译工具链、各种语言的运行时)。 2. COPY . . 把整个项目目录都拷进去,包括 node_modules、.git、测试文件、文档。 3. npm install 没有分层,导致每次改一行代码,所有依赖都要重新下载。 二、第一刀:分层缓存 最直接的改进是把「装依赖」和「拷源码」分成两层: FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . CMD ["node", "server.js"] COPY package*.json 这一层只依赖清单文件。只要依赖没变,这一层就会命中缓存,改代码时只需要重新拷源码那一层。 有个细节:如果你用 npm ci,就必须同时 COPY package.json 和 package-lock.json,而且 lock 文件要和 package.json 严格一致,否则 npm ci 会直接报错退出。这其实是好事——它保证你装的就是锁定的版本。 三、第二刀:别把不需要的东西拷进去 .dockerignore 的作用经常被低估。一个典型的 .dockerignore: node_modules .git .github *.log .env .env.* coverage dist .DS_Store Dockerfile .dockerignore README.md docs/ 注意 .env 一定要排除。构建镜像时通常需要传一些构建期参数,但如果你不小心把带密钥的 .env 打进镜像,那就是永久泄漏——镜像一旦推上去,任何有 pull 权限的人都能拿到。 四、第三刀:换基础镜像 Alpine 版本的 node:20-alpine 只有 200MB 左右,比完整版小得多,因为 Alpine 用的是 musl libc 而不是 glibc。 但要注意兼容性问题:musl libc 和 glibc 在某些原生模块上有差异,尤其是那些依赖预编译二进制的包(比如某些 sharp、canvas 的旧版本)。如果你的项目里有原生依赖,切 Alpine 前先在 CI 里跑一遍完整测试。 如果做到了极致,可以考虑 distroless 运行时镜像——它只有几十 MB,里面连 shell 都没有。但失去了调试手段,排查问题会比较痛苦。我的取舍是:开发用完整镜像便于调试,生产用 slim 或者 alpine。 五、第四刀:多阶段构建 如果构建过程需要编译工具链,但运行期并不需要,那就可以用多阶段构建把工具链留在构建阶段: FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules ENV NODE_ENV=production CMD ["node", "dist/main.js"] 注意这里有个坑:builder 阶段装的是包含 devDependencies 的完整依赖,运行阶段如果直接 COPY 整个 node_modules,dev 依赖也跟着进来了。严格一点的话,运行阶段应该单独 npm ci --omit=dev。 六、第五刀:非 root 运行 镜像应该用非 root 用户运行,这既是安全最佳实践,也能在某些场景下略微减少体积: RUN useradd -r -u 10001 appuser USER appuser 很多基础镜像(node 的官方镜像)已经内置了一个 node 用户,可以直接 USER node。 七、效果与代价 优化前后对比: 优化前:1.2GB,拉取 2 分 10 秒,CI 每次构建 6 分钟 优化后:180MB,拉取 22 秒,CI 每次构建 2 分 40 秒 镜像小了,CI 快了,最直接的好处是迭代节奏变快了——以前推一次代码要等 6 分钟才知道有没有问题,现在两分半。 八、几个容易踩的坑 第一,COPY 顺序要按「变化频率低到高」排。变化最少的放最前面,这样缓存命中率最高。很多人把 COPY . . 放最前面,等于把缓存全废了。 第二,别在 Dockerfile 里写密码。ARG 和 ENV 都会留在镜像层里,用 docker history 能看到。需要密钥就用 BuildKit 的 secret mount。 第三,.dockerignore 和 .gitignore 是两回事,别以为加了 .gitignore 就够了,Docker 构建上下文根本不看 git。 第四,多加一个 HEALTHCHECK,容器健康状态能被编排系统感知: HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/health || exit 1 小结 镜像优化看着是体力活,但每一步背后都有明确的理由:分层缓存利用的是「依赖不变」这个假设,基础镜像选择是体积与兼容性的权衡,多阶段构建利用的是「构建工具链运行期不需要」这个事实。 建议先用 docker history 看看空间到底被谁吃掉了,再决定动哪一刀。
- 点赞
- 收藏
- 关注作者
评论(0)