背景
在微服务架构盛行的今天,Docker 已成为应用部署的标准方式。然而,很多开发者的 Docker 镜像体积臃肿,动辄几百 MB 甚至 GB 级别,导致构建慢、传输慢、部署慢。以 Node.js 应用为例,一个简单的 Express 服务,如果直接使用 node:18 基础镜像,体积大约 1.5GB。但通过多阶段构建,我们可以将其缩小到 150MB 左右,同时保留运行所需的一切。
本文将从零开始,带你实践 Docker 多阶段构建,并分享一些独特的优化技巧。
什么是多阶段构建?
多阶段构建(Multi-stage Build)是 Docker 17.05 引入的特性。它允许在同一个 Dockerfile 中使用多个 FROM 指令,每个 FROM 可以基于不同的基础镜像,并且只有最后一个阶段会生成最终镜像。这样,我们可以将构建环境和运行环境分离,只将必要的产物复制到最终镜像中。
实战:优化一个 Node.js 应用
我们以一个简单的 Node.js Web 应用为例,它使用 Express 框架,并且需要编译 TypeScript 和打包前端资源。
初始 Dockerfile(单阶段)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个镜像包含 node 运行时、所有开发依赖、源码、以及构建产物,体积约为 1.5GB。
优化第一步:多阶段构建
我们将构建过程拆分为两个阶段:
- 构建阶段:使用
node:18镜像,安装所有依赖(包括开发依赖),执行构建。 - 运行阶段:使用更轻量的
node:18-alpine镜像,仅复制构建产物和运行时依赖。
# 构建阶段
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# 运行阶段
FROM node:18-alpine
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
此时,镜像体积从 1.5GB 降至约 300MB。但还不够,因为 node_modules 中包含了大量开发依赖(如 TypeScript、测试框架等),这些在运行时并不需要。
优化第二步:分离依赖
更好的做法是,在构建阶段安装所有依赖,但仅复制运行时依赖到最终镜像。我们可以通过 npm ci --production 或 npm prune --production 来实现。
# 构建阶段
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# 运行阶段
FROM node:18-alpine
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/dist ./dist
# 复制 package.json 并仅安装生产依赖
COPY package*.json ./
RUN npm ci --production
EXPOSE 3000
CMD ["node", "dist/index.js"]
💡 注意:npm ci --production 会安装 dependencies 中的包,忽略 devDependencies。确保你的 package.json 正确区分了运行时和开发依赖。
现在镜像体积降至约 150MB。
优化第三步:利用缓存加速构建
Docker 构建时,每层都会缓存。我们可以通过调整指令顺序,最大化利用缓存。例如,先复制 package.json 并安装依赖,再复制源码,这样只要 package.json 不变,依赖层就会被缓存。
# 构建阶段
FROM node:18 AS builder
WORKDIR /app
# 先复制依赖文件,安装依赖(利用缓存)
COPY package*.json ./
RUN npm install
# 再复制源码
COPY . .
RUN npm run build
# 运行阶段
FROM node:18-alpine
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/dist ./dist
# 安装生产依赖
COPY package*.json ./
RUN npm ci --production
EXPOSE 3000
CMD ["node", "dist/index.js"]
优化第四步:使用更小的基础镜像
node:18-alpine 已经很小,但我们可以更进一步:使用 scratch 或 distroless 镜像。不过,Node.js 应用需要 glibc 等系统库,所以通常选择 alpine 或 slim。
对于静态文件服务,甚至可以不用 Node 镜像,直接用 Nginx。
高级技巧:多阶段构建的更多应用
1. 构建多个产物
如果你需要构建前端和后端,可以在一个 Dockerfile 中完成:
# 前端构建
FROM node:18 AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# 后端构建
FROM node:18 AS backend-builder
WORKDIR /app
COPY backend/package*.json ./
RUN npm install
COPY backend/ ./
RUN npm run build
# 运行阶段
FROM node:18-alpine
WORKDIR /app
COPY --from=backend-builder /app/dist ./dist
COPY --from=frontend-builder /app/dist ./public
CMD ["node", "dist/index.js"]
2. 使用缓存挂载(BuildKit)
Docker 18.09+ 支持 BuildKit,可以使用 --mount=type=cache 加速依赖安装:
# syntax=docker/dockerfile:1
FROM node:18 AS builder
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
RUN npm run build
这会将 npm 缓存持久化,避免每次构建都重新下载。
3. 安全最佳实践
- 运行阶段使用非 root 用户:
FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# ...
- 避免复制敏感文件(如
.env),使用 Docker 的.dockerignore。
常见坑与踩坑经验
- 坑1:忘记复制
package.json导致npm ci失败。 - 坑2:
npm ci需要package-lock.json,确保它存在于源码中。 - 坑3:多阶段构建中,阶段名称必须唯一,否则会报错。
- 坑4:Alpine 镜像中,某些原生模块可能需要编译,需要安装
build-base。
总结
通过多阶段构建,我们成功将镜像体积从 1.5GB 压缩到 150MB,同时提升了构建速度和安全性。关键要点:
- 分离构建和运行环境
- 只复制必要文件
- 利用缓存优化构建速度
- 选择合适的基础镜像
下一步,你可以探索 Docker BuildKit 的高级特性,或者尝试将镜像体积压缩到 50MB 以下(使用 distroless 或 scratch)。