为什么你的 Docker 镜像总是这么大?
很多团队在容器化应用时,常常直接使用 node:18 这样的基础镜像,然后把源代码、依赖、构建工具全部塞进去。结果一个简单的 Express 应用镜像轻松突破 1GB,不仅拖慢 CI/CD 速度,还增加了安全风险。
多阶段构建(Multi-stage Build)是 Docker 17.05 引入的特性,它允许你在一个 Dockerfile 中定义多个阶段,每个阶段可以使用不同的基础镜像,最终只将必要的产物复制到最终镜像中。本文将通过一个真实的前后端项目(React + Node.js),带你一步步优化镜像,并分享我在生产环境中总结的 5 个关键技巧。
项目背景与初始 Dockerfile
假设我们有一个典型的全栈项目:
my-app/
├── client/ # React 前端(Vite 构建)
│ ├── src/
│ ├── package.json
│ └── vite.config.js
├── server/ # Node.js 后端(Express)
│ ├── src/
│ └── package.json
└── docker-compose.yml
最初的 Dockerfile 可能是这样的(单阶段):
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "server/src/index.js"]
构建后镜像大小:1.2GB。问题很明显:包含了完整的 Node.js 镜像、devDependencies、源代码、构建缓存等。
第一步:拆分构建阶段与运行阶段
多阶段构建的核心思想是:构建环境与运行环境分离。我们定义两个阶段:
builder阶段:安装所有依赖(包括 devDependencies),执行构建runner阶段:仅复制构建产物和 production 依赖
优化后的 Dockerfile:
# 阶段1:构建
FROM node:18-alpine AS builder
WORKDIR /app
# 先复制 package.json 以利用缓存
COPY package*.json ./
COPY client/package*.json ./client/
COPY server/package*.json ./server/
RUN npm ci --workspaces --include-workspace-root
# 复制源代码并构建
COPY . .
RUN npm run build --workspace=client
# 阶段2:运行
FROM node:18-alpine AS runner
WORKDIR /app
# 只复制生产依赖和构建产物
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/server/package*.json ./server/
RUN npm ci --omit=dev --workspaces --include-workspace-root
COPY --from=builder /app/server/src ./server/src
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
CMD ["node", "server/src/index.js"]
构建后镜像大小:约 350MB。已经大幅减小,但还有优化空间。
> 💡 注意:npm ci 比 npm install 更快且更可靠,它会严格遵循 package-lock.json。使用 --workspaces 可以处理 monorepo 结构。
第二步:使用更小的基础镜像与清理缓存
node:18-alpine 本身约 170MB,我们可以进一步减小。但 Alpine 使用 musl libc,某些原生模块可能不兼容。一个折中方案是使用 node:18-slim(基于 Debian,约 240MB),或者继续用 Alpine 但确保依赖兼容。
此外,npm ci 会产生缓存文件,我们可以通过设置 npmconfigcache 到临时目录并清理来减少层大小:
FROM node:18-alpine AS builder
WORKDIR /app
# 设置 npm 缓存到 /tmp,避免污染镜像层
ENV npm_config_cache=/tmp/npm-cache
COPY package*.json ./
COPY client/package*.json ./client/
COPY server/package*.json ./server/
RUN npm ci --workspaces --include-workspace-root
COPY . .
RUN npm run build --workspace=client
# 清理缓存(虽然多阶段构建中不必须,但能减少中间层大小)
RUN rm -rf /tmp/npm-cache
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/server/package*.json ./server/
RUN npm ci --omit=dev --workspaces --include-workspace-root && \
npm cache clean --force
COPY --from=builder /app/server/src ./server/src
COPY --from=builder /app/client/dist ./client/dist
# 使用非 root 用户运行
USER node
EXPOSE 3000
CMD ["node", "server/src/index.js"]
镜像大小:约 120MB。
第三步:使用 distroless 或 scratch 镜像(进阶)
对于追求极致体积的场景,可以考虑 Google 的 distroless 镜像,它只包含应用和运行时依赖,没有 shell 和包管理器,安全性极高。
FROM node:18-alpine AS builder
# ... 同上构建步骤 ...
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/server/package*.json ./server/
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server/src ./server/src
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
CMD ["server/src/index.js"]
注意:distroless 镜像中 node 命令是入口点,所以 CMD 直接写脚本路径。镜像大小可降至 约 80MB。
> ⚠️ 坑点:distroless 没有 shell,无法进入容器调试。建议仅在稳定环境使用,并确保日志输出到 stdout。
第四步:优化构建缓存与 CI 加速
多阶段构建的另一个优势是充分利用缓存。以下是几个关键实践:
- 依赖与源码分离:先复制
package.json并安装依赖,再复制源码。这样修改源码不会触发依赖重装。 - 使用 BuildKit:Docker 18.09+ 支持 BuildKit,可以并行构建阶段,并支持
--mount=type=cache持久化缓存。
启用 BuildKit 并利用缓存挂载:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY client/package*.json ./client/
COPY server/package*.json ./server/
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspaces --include-workspace-root
COPY . .
RUN npm run build --workspace=client
# ... 运行阶段同上
构建命令:DOCKER_BUILDKIT=1 docker build -t my-app .
这样在 CI 中,npm 缓存可以跨构建复用,大幅减少安装时间。
第五步:安全与体积的额外技巧
- 合并 RUN 指令:减少镜像层数,但可读性会下降,需权衡。
- 使用
.dockerignore:排除node_modules、.git、测试文件等,避免复制无用内容。 - 多平台构建:使用
docker buildx构建 arm64/amd64 镜像,便于在 Apple Silicon 和云服务器上运行。
示例 .dockerignore:
**/node_modules
**/dist
**/.git
**/*.md
**/Dockerfile
**/docker-compose.yml
完整 Dockerfile 与验证
最终优化后的 Dockerfile(使用 BuildKit 和 distroless):
# syntax=docker/dockerfile:1.4
# ---- 构建阶段 ----
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY client/package*.json ./client/
COPY server/package*.json ./server/
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspaces --include-workspace-root
COPY . .
RUN npm run build --workspace=client
# ---- 运行阶段 ----
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/server/package*.json ./server/
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server/src ./server/src
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
CMD ["server/src/index.js"]
构建并查看大小:
DOCKER_BUILDKIT=1 docker build -t my-app:optimized .
docker images my-app:optimized
输出类似:
REPOSITORY TAG IMAGE ID SIZE
my-app optimized abc123def456 82.3MB
总结
通过多阶段构建,我们成功将镜像从 1.2GB 压缩到 80MB 左右,同时提升了安全性和 CI 效率。关键点回顾:
- 分离构建与运行阶段,只复制必要产物。
- 使用 Alpine 或 distroless 减小基础镜像体积。
- 利用 BuildKit 缓存挂载加速构建。
- 遵循最小权限原则,使用非 root 用户。
- 编写
.dockerignore避免上下文膨胀。
下一步,你可以探索:
- 使用
docker scout分析镜像漏洞 - 结合 GitHub Actions 实现自动化构建与推送
- 尝试
kaniko在 Kubernetes 中构建镜像
希望这篇教程能帮你打造更轻量、更安全的容器镜像!