第一次构建出来的镜像 1.2GB,推送要三分钟,部署慢、攻击面还大。改完之后 96MB。

反面教材长什么样

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

问题一目了然:

  • node:20 完整镜像自带编译工具链,1GB 起步
  • COPY . . 把 node_modules、测试、.git 全拷进去了
  • npm install 装了 devDependencies(构建工具、测试框架)
  • 以 root 运行

第一步:多阶段构建

核心思想:构建阶段可以很重,运行阶段必须很轻。

# ---------- 构建阶段 ----------
FROM node:20-alpine AS builder
WORKDIR /app

# 先只拷贝依赖清单,利用 Docker 层缓存
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build          # TypeScript 编译 / 打包

# 只装生产依赖
RUN npm ci --omit=dev

# ---------- 运行阶段 ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

CMD ["node", "dist/server.js"]

关键点:把 package.json 单独 COPY 再 install。只要依赖清单没变,npm ci 这一层就一直命中缓存,改业务代码不会重新装依赖。

第二步:选对基础镜像

基础镜像 大小 适用场景
node:20 ~1.1GB 需要完整工具链调试
node:20-slim ~240MB 通用选择
node:20-alpine ~140MB 追求最小体积
gcr.io/distroless/nodejs20 ~110MB 安全优先,无 shell

Alpine 用的是 musl libc,个别原生模块(早期版本的 sharp、bcrypt)需要重新编译。遇到 Error: not found 之类的诡异报错,先换 slim 试试,别硬刚。

第三步:.dockerignore 比想象中重要

node_modules
.git
.github
*.log
.env*
coverage
tests
docs
.vscode

少了这一行 node_modules,宿主机上的依赖会被 COPY 进镜像,把本地平台的原生模块带进容器,运行时直接崩。

第四步:依赖分层缓存

当依赖很多、且经常只改一个包时,可以再细一层:

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

npm cache clean 能省下几十 MB——npm 的缓存目录默认留在镜像层里。

用 pnpm 的话:

RUN corepack enable && pnpm install --prod --frozen-lockfile

pnpm 的硬链接机制让 node_modules 体积天然比 npm 小 30% 以上。

第五步:非 root 运行

FROM node:20-alpine AS runner
WORKDIR /app

# alpine 自带 node 用户(uid 1000)
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

容器逃逸的前提通常是「容器内是 root」。改成非 root,攻击者拿到 shell 也只是个普通用户。

第六步:健康检查与优雅退出

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

不用装 curl,直接用 Node 自带 fetch,镜像不用变大。

对应的应用侧:

const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('收到 SIGTERM,停止接收新请求');
  server.close(() => {
    console.log('已排空连接,退出');
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();  // 兜底
});

Kubernetes 滚动更新时会先发 SIGTERM,如果应用不处理,就会被强杀导致 502。

效果对比

项目 优化前 优化后
镜像大小 1.21 GB 96 MB
构建耗时(有缓存) 95s 12s
推送耗时 183s 14s
运行用户 root node
漏洞数(trivy) 47 3

一份可以直接抄的模板

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build && pnpm prune --prod

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production TZ=Asia/Shanghai
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/package.json ./
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]

最后三条经验

  1. 每次都看 docker history,找出体积最大的那几层,通常就是依赖和缓存
  2. docker images 里的 dangling 镜像要清理,docker builder prune 能救回几十 GB
  3. 镜像越小,冷启动越快,这对弹性伸缩和 Serverless 容器的收益是直接的