把一个 Node 镜像从 1.2 GB 压到 90 MB

·约 1700 字 · DockerCI/CD

起因是发布变慢。一个不到两万行的 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 install340 MB
COPY . .82 MB
npm run build18 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 MBDebian 精简版,glibc
node:20-alpine约 135 MBAlpine,musl libc

Alpine 最小,但用的是 musl 而不是 glibc。带原生扩展的包(sharpcanvasnode-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 GB3m40s
换 slim 基础镜像412 MB3m10s
多阶段 + 仅生产依赖168 MB2m30s
.dockerignore121 MB1m50s
调整层顺序 + 构建缓存121 MB25s
依赖清理(见下)89 MB25s

最后那 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,为了健康检查再装一个不值得。

← 返回首页