Reading

Docker 镜像从 1.2GB 瘦到 183MB:多阶段构建实战

镜像大的原因几乎都是把「构建现场」原封不动打进了「运行现场」。用多阶段构建、.dockerignore 和更小的基础镜像,把一个 Next.js 镜像从 1.2GB 压到 183MB。

返回归档
Docker 镜像从 1.2GB 瘦到 183MB:多阶段构建实战 封面图

适用场景:Node.js 或其它编译型项目的 Docker 镜像动辄 1GB 起步,构建慢、上传慢、服务器磁盘也吃紧。这篇记录我把一个 Next.js 项目镜像从 1.2GB 压到 180MB 的完整过程,思路对其它语言同样适用。

背景和思路

给博客做部署脚本的时候,我发现每次发布都要往服务器传一个 1.2GB 的镜像包,家里的上行带宽要传七八分钟。

镜像大的原因几乎都是同一类:把「构建现场」原封不动打进了「运行现场」。源码、devDependencies、编译缓存、包管理器缓存,这些只有构建时需要的东西全被带进了最终镜像。

瘦身的思路按收益排序:

  1. 多阶段构建,只把产物拷进运行镜像;
  2. 换更小的基础镜像;
  3. .dockerignore 挡住不该进构建上下文的东西;
  4. 合理安排层顺序,让缓存尽量命中。

第一步:先搞清楚空间去哪了

不要盲目改 Dockerfile,先看数据:

docker history --no-trunc qingsong-notes:latest

docker history 按层列出每条指令产生的体积。我这里一眼看到两个大头:npm ci 装出来的 800MB node_modules,以及 COPY . . 带进来的 300MB 杂物。

想更细致地逐层浏览文件,可以用开源工具 dive(https://github.com/wagoodman/dive):

dive qingsong-notes:latest

第二步:多阶段构建

核心改动就是把 Dockerfile 拆成 builder 和 runner 两个阶段:

# ---- 构建阶段 ----
FROM node:22-bookworm AS builder
WORKDIR /app

# 先只拷贝依赖清单,让这一层的缓存尽量长寿
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---- 运行阶段 ----
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["npm", "start"]

关键点:

  • 运行阶段从零开始,只 COPY --from=builder 拿构建产物,源码和 devDependencies 都留在了 builder 阶段,不会进最终镜像。
  • npm ci --omit=dev 只装生产依赖;npm cache clean --force 清掉同一层里的下载缓存——清理必须和产生缓存的命令在同一个 RUN 里,分开写的话上一层已经固化,删了也不会变小。
  • Next.js 用户还可以在 next.config.tsoutput: 'standalone',运行阶段连 node_modules 都不用装,直接拷 .next/standalone,能再砍一大截。

第三步:.dockerignore

COPY . . 会把构建上下文里的一切都送进去,必须有一份 .dockerignore

node_modules
.next
.git
*.db
media
test-results
playwright-report
*.log
.env*

这一步有双重收益:镜像更小,而且 .env 这类敏感文件不会被意外打进镜像层里。镜像层是可以被 docker history 和导出翻出来的,删除文件的后一层并不能抹掉前一层的内容。

第四步:基础镜像的取舍

基础镜像大小量级说明
node:22-bookworm~1GB全量 Debian,带编译工具链
node:22-bookworm-slim~200MB去掉了大部分系统包,够跑纯 JS
node:22-alpine~130MB最小,但是 musl libc

我最后选了 slim 而不是 alpine:alpine 用 musl,遇到 sharp 这类带原生二进制的依赖偶尔会踩兼容坑,为了再省 70MB 不值得。slim 是收益和风险平衡得最好的默认选项。

验证

docker build -t qingsong-notes:slim .
docker images | grep qingsong-notes
docker run --rm -p 3000:3000 qingsong-notes:slim

对比结果:

qingsong-notes   latest   1.21GB
qingsong-notes   slim     183MB

再用 curl -I http://127.0.0.1:3000 确认服务正常响应,别只看体积不看能不能跑。

收尾检查

  • 清理命令和产生垃圾的命令写在同一个 RUN 里。
  • .dockerignore 里挡住了 .git.env、数据库文件。
  • 依赖清单的 COPY 在源码 COPY 之前,改代码不会击穿 npm ci 的缓存层。
  • 最终镜像实际跑过一次,接口能正常响应。