起因是发布变慢。一个不到两万行的 Node 服务,镜像 1.2 GB,每次滚动更新,节点拉取镜像要花两三分钟,回滚更是煎熬。这篇记录整个瘦身过程,以及每一步实际省下了多少。
起点长什么样
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个 Dockerfile 有五个问题,但先别急着改——第一步永远是测量。
第一步:看清楚胖在哪里
$ docker history myapp:latest --no-trunc --format \
'table {{.Size}}\t{{.CreatedBy}}' | head
结果很明确:
| 层 | 大小 |
|---|---|
node:20 基础镜像 | 1.1 GB |
npm install | 340 MB |
COPY . . | 82 MB |
npm run build | 18 MB |
基础镜像就占了大头。node:20 是完整的 Debian,带着编译器工具链、Python、Git,全都是构建时才需要的东西。
想看得更细可以用 dive,它能逐层展开文件树,直观地看到哪个目录最占地方:
dive myapp:latest --ci --lowestEfficiency=0.9
第二步:换基础镜像
Node 官方镜像的几个变体:
| 标签 | 体积 | 说明 |
|---|---|---|
node:20 | 约 1.1 GB | 完整 Debian,含构建工具 |
node:20-slim | 约 220 MB | Debian 精简版,glibc |
node:20-alpine | 约 135 MB | Alpine,musl libc |
Alpine 最小,但用的是 musl 而不是 glibc。带原生扩展的包(sharp、canvas、node-gyp 编译出来的东西)在 musl 下可能没有预编译产物,要现场编译,构建时间从两分钟变成十分钟;个别库在 musl 下还有已知的兼容问题。
我最后选了 slim。它比 alpine 大 80 MB,但省掉了所有兼容性风险,在一个 90 MB 量级的目标下这点差距不重要。如果依赖里没有原生模块,alpine 是更好的选择。
第三步:多阶段构建
这是收益最大的一步。构建阶段需要 devDependencies、编译器、源码;运行阶段只需要产物和生产依赖。分开之后,前者的任何东西都不会进最终镜像:
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM node:20-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
几个要点:
- 用
npm ci而不是npm install。前者严格按 lock 文件安装,可重现,而且更快。 - 生产依赖单独一个阶段装,避免从 build 阶段的
node_modules里删——删除操作在联合文件系统里不会真正减小体积,只会新增一个白障层。 USER node不影响体积,但没有理由让容器以 root 跑。
第四步:.dockerignore
COPY . . 那 82 MB 里,大部分是 .git 和本地遗留的 node_modules。.dockerignore 不只影响镜像体积,还决定了发给 daemon 的构建上下文大小——上下文大,每次构建光传输就要好几秒。
.git
.github
node_modules
dist
coverage
*.log
.env*
.vscode
**/*.test.ts
Dockerfile
docker-compose.yml
README.md
把 .env* 排除掉还有安全意义:镜像层是可以被任何拿到镜像的人解包翻看的,凭证一旦进了某一层,后续删除也无济于事。
第五步:层顺序决定缓存命中率
Docker 逐层缓存,某一层失效之后的所有层都要重建。所以变化频率低的放前面:先复制 package.json 和 lock 文件装依赖,再复制源码。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
反过来写的话,改一行业务代码就会重装全部依赖。这一步不减小镜像,但把日常构建时间从 3 分 40 秒降到了 25 秒。
CI 里还要注意:每次都是干净环境,本地层缓存不存在。BuildKit 的缓存导出可以解决:
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:$GIT_SHA \
--push .
另外,npm 的下载缓存可以用挂载缓存保留,且不会进入镜像层:
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
结果
| 阶段 | 镜像体积 | 构建耗时 |
|---|---|---|
| 初始 | 1.21 GB | 3m40s |
| 换 slim 基础镜像 | 412 MB | 3m10s |
| 多阶段 + 仅生产依赖 | 168 MB | 2m30s |
| .dockerignore | 121 MB | 1m50s |
| 调整层顺序 + 构建缓存 | 121 MB | 25s |
| 依赖清理(见下) | 89 MB | 25s |
最后那 30 MB 来自一次依赖审查。用 npm ls --prod --depth=0 逐个过了一遍生产依赖,发现三个只在脚本里用过的包被错误地放在 dependencies 里,还有一个完整的 moment 带着全部 locale 数据,换成了原生的 Intl.DateTimeFormat。
没有做的两件事
没用 distroless 或 scratch。 理论上还能再省几十 MB,代价是镜像里没有 shell,docker exec 进不去,排查线上问题只能靠日志。对我们的运维习惯来说不划算。如果确实要用,记得配合 kubectl debug 的临时容器。
没有把所有 RUN 合并成一条。 这条建议在多阶段构建普及之前很有价值,现在收益有限,反而会破坏层缓存的粒度。只有在同一条 RUN 里产生又删除的临时文件(比如 apt-get update 的索引)才值得合并:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
顺带做的健康检查
既然改 Dockerfile,把健康检查也补上了:
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
用 node -e 而不是 curl,是因为 slim 镜像里本来就没有 curl,为了健康检查再装一个不值得。