第一次构建出来的镜像 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"]
最后三条经验
- 每次都看
docker history,找出体积最大的那几层,通常就是依赖和缓存 docker images里的 dangling 镜像要清理,docker builder prune能救回几十 GB- 镜像越小,冷启动越快,这对弹性伸缩和 Serverless 容器的收益是直接的