我们的 Node.js 服务 Docker 镜像 1.2GB,每次 CI 推到镜像仓库 4 分钟,Pod 拉镜像 + 启动 3 分钟。优化两轮后降到 78MB,推送 + 启动加起来 40 秒。这篇文章把整个优化过程拆开讲,每一步省了多少、原理是什么、潜在风险是什么。
优化前 Dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这是新人最常见的写法。问题一堆:
- 基础镜像太大:node:20 有 1.1GB(基于 Debian)
- COPY 顺序错:任何代码改动都让 npm install 缓存失效
- 装了所有依赖:devDependencies 在生产里没用
- 源码、测试、文档全打进去:几十 MB 浪费
- 用 root 跑:安全隐患
构建出来 docker images 看一下:
$ docker build -t my-app:before .
$ docker images my-app
REPOSITORY TAG SIZE
my-app before 1.24GB
优化 1:基础镜像换 alpine
node:20 → node:20-alpine,基础镜像从 1.1GB 缩到 180MB。但 alpine 用 musl libc,有些 native 模块(node-gyp 编译的 C++ 扩展)需要装 build 工具:
FROM node:20-alpine
WORKDIR /app
# 如果你的依赖里有 sharp / sqlite3 / bcrypt 这种 native 模块
# 装 build 依赖,装完再删
RUN apk add --no-cache --virtual .build-deps \
python3 make g++ \
&& apk add --no-cache \
ca-certificates tini
COPY . .
RUN npm install && npm run build
# 删 build 依赖
RUN apk del .build-deps
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
这一步:1.24GB → 480MB。
关键技巧:
--no-cache:不缓存 apk 包索引,省 ~50MB--virtual .build-deps:把 build 依赖打成虚拟包,装完一键apk del删干净tini:Alpine 默认没有 init,容器内 PID 1 是你的 node 进程,信号处理有坑(SIGTERM 不会被处理)。tini 当 PID 1 转发信号
优化 2:多阶段构建
构建阶段需要 npm、devDeps、build 工具,但运行时不需要。把这些分离:
# ============ 阶段 1: 安装依赖 ============
FROM node:20-alpine AS deps
WORKDIR /app
# 只复制 package.json,缓存友好
COPY package.json package-lock.json ./
# ci 比 install 严格:必须有 lockfile,版本完全一致
RUN npm ci --only=production --no-audit --no-fund && \
npm cache clean --force
# ============ 阶段 2: 构建 ============
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund # 这里装全部依赖(包括 devDeps)
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ============ 阶段 3: 运行时 ============
FROM node:20-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache tini && \
addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# 只 copy 必要文件
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./
USER nodejs
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
这一步:480MB → 195MB。
多阶段构建的精髓:每个阶段独立,只把最终产物 COPY 到最后阶段。中间阶段的 build 工具 / source 代码 / devDependencies 全部丢弃。
优化 3:.dockerignore 大扫除
有没有发现 COPY . . 把整个工程目录都复制进去了?node_modules、.git、tests、docs、IDE 配置全在里面。
# .dockerignore
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# Tests & docs
test
tests
__tests__
*.test.ts
*.spec.ts
docs
README.md
LICENSE
CHANGELOG.md
# Build artifacts (我们要自己重新 build)
dist
build
.cache
coverage
# Env files
.env
.env.local
.env.*.local
# CI / 配置
.github
.gitlab-ci.yml
Dockerfile*
docker-compose*.yml
这一步 build context 从 380MB 降到 4.2MB,docker build 速度大幅提升(主要是 COPY 阶段)。
优化 4:层缓存友好的 COPY 顺序
Docker 每条 RUN / COPY 都生成一层。如果某层变了,后面所有层的缓存都失效。把"变化频率低"的放前面、"变化频率高"的放后面:
# 错:任何代码改动都让 npm ci 缓存失效
FROM node:20-alpine
WORKDIR /app
COPY . . # ← 任何文件改了,这层就变
RUN npm ci # ← 重新装依赖
# 对:依赖文件先 copy 单独装
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # ← 这两个文件不常变
RUN npm ci # ← 缓存命中率高
COPY src ./src # ← 业务代码改了只重 build,不重装
实测:改一行业务代码后重新 build,优化前 4 分 30 秒,优化后 18 秒。
优化 5:用 distroless 或 scratch
更激进:用 Google 的 distroless 镜像(只有 glibc / libssl 等运行时,没有 shell、包管理器、cron 这些):
# 最终阶段用 distroless
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"] # distroless 镜像默认 ENTRYPOINT 已经是 node
distroless 镜像:
- 体积:Node.js 版约 130MB
- 没有 shell —— 攻击者拿到 RCE 也没法直接执行命令
- USER nonroot 默认非 root
- 但 debug 麻烦:进不去容器执行命令
对 Go / Rust 这种编译型语言,可以更进一步用 scratch(完全空镜像):
# Go 示例:scratch 最终镜像可能小到 8MB
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静态链接编译
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /app/server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["/server"]
用 scratch 注意:不能 shell 进容器、glibc 没了所以 native 库可能跑不起来、没有 ca-certificates 要自己 COPY。
优化 6:BuildKit + 缓存挂载
Docker BuildKit 默认在新版 Docker 已启用。用 cache mount 把 npm / pip / go mod 缓存放到独立的可复用 volume,避免每次重新下载:
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# --mount=type=cache 把 npm cache 放到 buildkit 缓存目录,跨 build 复用
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production --no-audit --no-fund
DOCKER_BUILDKIT=1 docker build -t my-app:cached .
第二次 build 时,npm cache 命中率 95%+,下载 100 个包变成几乎不下载。
优化 7:别忘了 squash(慎用)
多层 RUN 会留下大量小层,可以用 --squash 合并:
docker build --squash -t my-app:squashed .
但 squash 会丢失中间层缓存优势(下次完全重新 build)。我们后来没用这个,因为多阶段 + .dockerignore 已经把体积压下来了,没必要损失缓存换体积。
最终对比
优化阶段 镜像大小 build (无缓存) build (有缓存)
原版 1.24GB 4m 30s 4m 30s
+ alpine 480MB 3m 50s 3m 50s
+ 多阶段 195MB 3m 20s 3m 20s
+ .dockerignore 195MB 2m 40s 2m 40s
+ 缓存友好 COPY 顺序 195MB 2m 40s 18s (业务改)
+ distroless 130MB 2m 50s 22s
+ BuildKit cache mount 130MB 2m 50s 12s
+ Go scratch (改成 Go 服务) 8MB 45s 8s
核心收益不是体积本身,是CI / CD 时间。从一次 build 4 分钟降到 12 秒,团队迭代速度直接翻倍。
常见误区
- "alpine 一定更快":错。alpine 用 musl libc,对某些 Python / Node 包不友好。
node:slim(基于 Debian 但裁剪)有时更好 - "层越少越好":错。层多但每层小,缓存命中率高;层少但每层大,任何改动都失效
- "FROM scratch 适合所有":错。只适合静态链接 + 不需要 DNS/cert/shell 的服务
- "用 docker-slim 自动瘦身":工具确实存在,但它通过 strace 分析运行时实际用到的文件来裁剪,误删风险高,生产慎用
容器安全顺手做的事
# 必须非 root
USER 1001
# 只读根文件系统(运行时配合 K8s readOnlyRootFilesystem: true)
# 写入需要的目录用 volume / emptyDir
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/healthz || exit 1
# 元数据(给运维 / 安全扫描器看的)
LABEL org.opencontainers.image.source="https://github.com/myorg/my-app" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.revision="${GIT_SHA}"
镜像扫描必装
# Trivy:开源,扫 CVE
trivy image my-app:latest
# Grype:类似 Trivy
grype my-app:latest
# CI 集成,发现 critical 漏洞直接挂掉
trivy image --severity CRITICAL --exit-code 1 my-app:latest
# 看具体某个包为啥被引入
trivy image --show-suppressed my-app:latest
我们 CI pipeline 加了一个 trivy step,发现 CRITICAL 直接拒绝合并 PR。一年下来挡掉了 23 个高危漏洞 —— 全是某个依赖的传递依赖里悄悄藏的。
10 条 Dockerfile 黄金法则
- 基础镜像选 alpine / slim / distroless,不用 latest 用具体版本号
- 多阶段构建分离 build 和 runtime
- .dockerignore 必须有,排除 node_modules / .git / tests
- COPY 顺序:依赖文件 → 装依赖 → 业务代码
- RUN 合并:
apt-get update && apt-get install -y x y z && rm -rf /var/lib/apt/lists/* - 非 root 用户:USER 1001 或 USER nonroot
- tini 当 PID 1,正确处理信号
- HEALTHCHECK 给 K8s 用
- BuildKit cache mount 加速 CI
- CI 跑 trivy 扫漏洞
把这 10 条做齐,你的镜像基本是行业一线水平。我们厂里 80+ Go / Node / Python 服务,所有镜像都遵循这套规则,运维 / 安全 / 上云成本大幅下降。
—— 别看了 · 2026