跳转到内容
123xiao | 无名键客

《Docker 多阶段构建与镜像瘦身实战:从构建缓存到生产环境安全优化》

字数: 0 阅读时长: 1 分钟

Docker 多阶段构建与镜像瘦身实战:从构建缓存到生产环境安全优化

容器已经很常见了,但很多团队的 Dockerfile 还停留在“能跑就行”的阶段。结果往往是:

  • 镜像体积大,拉取慢,发布慢
  • 构建过程一改代码就全量重来
  • 生产镜像里混着编译器、包管理器、调试工具
  • 安全面暴增,漏洞扫描一片红

这篇文章我会带你从一个“能用但臃肿”的 Dockerfile 出发,一步一步改造成:

  • 构建更快
  • 镜像更小
  • 运行更安全
  • 排障更清晰

重点会放在 多阶段构建构建缓存利用镜像瘦身技巧生产环境安全优化 上。文章以 Node.js 示例为主,因为它很典型,也很容易暴露问题;但里面的方法对 Go、Java、Python、前端项目都适用。


背景与问题

先看一个团队里很常见的 Dockerfile 写法:

FROM node:18

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

它的问题不少:

  1. COPY . . 太早
    只要项目里任意文件变化,后面的 npm install 就失去缓存。

  2. 开发文件全进镜像
    例如测试文件、.git、本地缓存、文档、日志,统统带进去。

  3. 生产镜像含构建工具链
    npm、编译依赖、甚至构建中间产物都留在最终镜像里。

  4. 默认 root 运行
    万一容器被利用,风险更高。

  5. 镜像层数和内容没有控制
    体积大,漏洞面也大。

如果项目稍微大一点,你会很快遇到几个现实问题:

  • CI 一次构建动辄几分钟
  • 镜像几百 MB 甚至上 GB
  • 上线时拉镜像耗时长
  • 漏洞扫描报告里有几十个“高危”,但多数来自根本不需要的构建依赖

前置知识与环境准备

建议你至少具备以下基础:

  • 会写基础 Dockerfile
  • 知道镜像、容器、层(layer)是什么
  • 本地安装 Docker 20.10+,最好启用 BuildKit

建议开启 BuildKit,后面缓存能力会更好用:

export DOCKER_BUILDKIT=1

如果是 Docker Desktop,一般默认已经启用。


核心原理

在开始改造前,先把几个关键原理讲透。理解这些后,你写 Dockerfile 就不会只靠“背模板”。

1. Docker 构建缓存是按层工作的

Dockerfile 中每条指令大致都会形成一层。某一层是否能复用缓存,取决于:

  • 指令本身是否变化
  • 指令依赖的文件内容是否变化

比如:

COPY package*.json ./
RUN npm ci
COPY . .

这比先 COPY . .npm ci 好得多。因为当你只改业务代码,不改依赖描述文件时,npm ci 这一层可以直接走缓存。

2. 多阶段构建的核心:把“构建环境”和“运行环境”分开

多阶段构建本质上是在一个 Dockerfile 中定义多个阶段:

  • builder 阶段:安装依赖、编译、打包
  • runtime 阶段:只保留运行所需内容

你可以把前面阶段产出的文件,按需复制到后面阶段,而不是把整个环境都带过去。

3. 镜像瘦身不只是为了“省空间”

体积小带来的收益不止是磁盘占用:

  • 镜像拉取更快
  • 节点扩容更快
  • 漏洞面更小
  • 审计和排障更简单
  • 冷启动更短

4. 安全优化和瘦身常常是同方向的

例如:

  • 去掉编译器、包管理器、shell 工具
  • 不以 root 身份运行
  • 使用更小、更专用的基础镜像
  • 只复制运行必需文件

这些操作既会减少镜像体积,也会缩小攻击面。


一图看懂:从臃肿构建到多阶段发布

flowchart LR
    A[源代码目录] --> B[单阶段构建]
    B --> C[安装全部依赖]
    C --> D[构建产物]
    D --> E[最终镜像包含源码/构建工具/依赖]

    A --> F[多阶段构建]
    F --> G[builder 安装依赖并编译]
    G --> H[runtime 仅复制运行所需文件]
    H --> I[最终镜像更小更安全]

实战代码:从“能跑”到“适合生产”

下面我用一个简单的 Node.js 应用演示完整过程。

示例项目结构

demo-app/
├── src/
│   └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile

示例应用代码

src/index.js

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({
    message: 'hello docker',
    time: new Date().toISOString()
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`server started on ${port}`);
});

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage demo",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

先生成锁文件:

npm install

第一步:先写一个“普通但较规范”的 Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY src ./src

EXPOSE 3000
CMD ["npm", "start"]

这个版本已经比最开始那个示例强不少:

  • 先复制依赖描述文件,便于缓存
  • 使用 npm ci,更适合 CI 和可重复构建
  • 不再直接 COPY . .

构建并运行:

docker build -t demo-app:v1 .
docker run -p 3000:3000 demo-app:v1

验证:

curl http://localhost:3000

第二步:加入 .dockerignore,先砍掉无关文件

这个步骤特别容易被忽略,但收益非常大。构建上下文越大,Docker 发送给守护进程的内容越多,构建越慢,缓存也越容易失效。

.dockerignore

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env

为什么这一步很重要?

如果没有 .dockerignore,以下内容可能都被打进构建上下文:

  • 本地 node_modules
  • Git 历史
  • 测试报告
  • 编辑器临时文件
  • 本地构建产物

这些内容有些不会直接进入最终镜像,但会影响构建速度和缓存命中。我自己就踩过这个坑:项目根目录里一个频繁变化的日志文件,让缓存几乎次次失效。


第三步:使用多阶段构建

如果项目需要编译,比如 TypeScript、前端项目、原生扩展模块,单阶段就不太合适了。下面给出一个更接近生产的多阶段版本。

多阶段 Dockerfile 示例

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:18-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build || echo "no build step"

FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY --from=build /app/src ./src

USER node
EXPOSE 3000
CMD ["npm", "start"]

这里示例项目没有真正的打包步骤,所以 npm run build || echo "no build step" 只是为了兼容演示。真实项目里请替换成明确的构建命令。

这个写法解决了什么?

  • deps 阶段负责完整依赖安装,便于复用
  • build 阶段用于编译/打包
  • runtime 阶段只安装生产依赖并复制运行需要的内容
  • 最终镜像中不包含构建阶段的大量中间文件
  • 容器使用 node 用户运行,而不是 root

多阶段构建流程图

flowchart TD
    A[package.json package-lock.json] --> B[deps 阶段: npm ci]
    B --> C[build 阶段: 复制源码并构建]
    C --> D[runtime 阶段]
    A --> D
    D --> E[仅安装生产依赖]
    C --> F[复制构建产物/运行文件]
    E --> G[最终生产镜像]
    F --> G

第四步:更进一步,针对真正有构建产物的项目

如果你的项目是 TypeScript 或前端构建,推荐把“构建结果”和“运行环境”彻底分离。下面是一个更典型的版本。

TypeScript/前端风格 Dockerfile

FROM node:18-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY --from=build /app/dist ./dist

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

这样最终镜像里通常只包含:

  • dist/
  • 生产依赖
  • 少量启动配置

而不会包含:

  • 源码
  • 测试代码
  • 编译工具链
  • 开发依赖

第五步:利用 BuildKit 缓存,加速依赖安装

很多人以为优化缓存就是调整 COPY 顺序,其实在 BuildKit 下还能做得更好。

使用缓存挂载

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .
RUN npm run build

构建时:

DOCKER_BUILDKIT=1 docker build -t demo-app:buildkit .

这和普通缓存有什么区别?

普通 Docker 层缓存的特点是:某层失效就要重跑。

而 BuildKit 的缓存挂载可以让像 npmpipapt 这种包管理器复用下载缓存,即使这一层需要重新执行,也不一定重新下载所有内容。

对于依赖很多的项目,这能明显缩短 CI 时间。


构建缓存命中逻辑时序图

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 构建缓存
    participant NPM as npm registry/cache

    Dev->>Docker: 修改业务代码后执行 docker build
    Docker->>Cache: 检查 COPY package*.json 与 RUN npm ci 层
    Cache-->>Docker: 依赖层未变化,命中缓存
    Docker->>Docker: 执行后续 COPY src
    Docker->>Docker: 生成新应用层
    Dev->>Docker: 修改 package-lock.json 后再次构建
    Docker->>Cache: 检查依赖层
    Cache-->>Docker: 缓存失效
    Docker->>NPM: 重新安装依赖

第六步:验证镜像是否真的变小了

你不能只“感觉它更优雅了”,最好做实际验证。

查看镜像体积

docker images | grep demo-app

查看镜像层历史

docker history demo-app:v1
docker history demo-app:buildkit

对比构建耗时

简单粗暴一点:

time docker build -t demo-app:test .

查看容器内实际文件

docker run --rm -it demo-app:test sh

进入后检查:

ls -lah
du -sh /app

如果你发现镜像里还有:

  • .git
  • 测试目录
  • 文档
  • 编译缓存
  • shell 工具一大堆

那说明瘦身还没做到位。


可运行的完整生产版示例

下面给一个适合多数 Node.js 服务的完整版本。

Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:18-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build || echo "skip build"

FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --only=production \
    && npm cache clean --force

COPY --from=build /app/src ./src

RUN chown -R node:node /app
USER node

EXPOSE 3000
CMD ["npm", "start"]

.dockerignore

node_modules
.git
.gitignore
Dockerfile
.dockerignore
README.md
coverage
dist
.env
*.log

构建命令

DOCKER_BUILDKIT=1 docker build -t demo-app:prod .

运行命令

docker run --rm -p 3000:3000 demo-app:prod

验证命令

curl http://localhost:3000

逐步验证清单

建议你照着下面的清单一项项确认,而不是一股脑改完就上线。

1. 缓存是否生效

修改 src/index.js,重新构建,观察:

  • npm ci 是否复用缓存
  • 只有应用层重新生成

2. 依赖变化是否触发重装

修改 package.jsonpackage-lock.json,重新构建,确认依赖层重建。

3. 镜像中是否残留不需要文件

运行容器后进入 shell 检查:

docker run --rm -it demo-app:prod sh

4. 是否以非 root 用户运行

docker exec -it <container_id> id

应看到类似:

uid=1000(node) gid=1000(node)

5. 服务是否仍能正常工作

尤其要验证:

  • 端口监听正常
  • 配置文件路径正常
  • 日志输出正常
  • 生产依赖未缺失

常见坑与排查

这部分很重要。很多人会写出“理论上很优雅”的 Dockerfile,但一跑就出问题。

坑 1:COPY . . 导致缓存频繁失效

现象:

  • 改个 README 都会触发依赖重装
  • 构建很慢

排查:

看 Dockerfile 顺序,是否先 COPY . . 再安装依赖。

修复:

先复制依赖描述文件,再安装依赖,再复制源码。

COPY package*.json ./
RUN npm ci
COPY . .

坑 2:.dockerignore 缺失,构建上下文巨大

现象:

构建时看到:

Sending build context to Docker daemon  800MB

排查:

检查项目目录是否存在:

  • node_modules
  • .git
  • 大文件目录
  • 测试输出目录

修复:

补全 .dockerignore


坑 3:多阶段构建后应用启动失败

现象:

容器启动报错:

Error: Cannot find module ...

原因:

最终阶段没复制必要文件,或者生产依赖没装全。

排查思路:

  1. 进入容器看目录结构
  2. 检查 COPY --from=build ... 路径是否正确
  3. 检查运行命令是否仍指向旧路径

修复示例:

COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]

坑 4:npm ci --only=production 与实际项目脚本不兼容

现象:

运行时报缺包,但开发环境正常。

原因:

有些项目把运行时需要的包错误地放在 devDependencies

排查:

检查 package.json 中依赖归类。

修复:

  • 运行时需要的包放到 dependencies
  • 构建工具放到 devDependencies

这个问题我见过很多次,尤其是老项目迁移容器化时最容易暴露。


坑 5:使用 Alpine 后某些原生依赖异常

现象:

  • 安装某些 npm 包失败
  • 运行时报二进制兼容问题

原因:

Alpine 使用 musl,有些预编译二进制依赖针对 glibc

排查:

查看报错是否涉及原生模块、动态链接库。

修复建议:

  • 能不用原生依赖就不用
  • 必要时改用 debian-slim 基础镜像
  • 不要为了“追求最小”牺牲稳定性

这就是一个很典型的边界条件:最小镜像不一定是最合适镜像


坑 6:非 root 用户运行后权限报错

现象:

应用无法写日志、创建临时文件或访问目录。

排查:

检查目录属主和写权限。

修复:

RUN chown -R node:node /app
USER node

如果应用必须写某个目录,也要提前授权。


安全/性能最佳实践

下面这部分我建议你直接当作生产清单。

1. 优先使用多阶段构建

把构建依赖和运行环境拆开,是瘦身和安全的起点。

适用边界:

  • 几乎所有需要编译、打包、转译的项目都适用
  • 纯脚本型项目也建议拆阶段,便于统一规范

2. 只复制必要文件

最终镜像应尽量只包含:

  • 运行产物
  • 必要配置
  • 生产依赖

避免复制:

  • 测试代码
  • 文档
  • 构建缓存
  • Git 元数据

3. 固定依赖版本并使用锁文件

例如:

  • package-lock.json
  • yarn.lock
  • poetry.lock
  • go.sum

这样能提高构建可重复性,也减少“今天能跑、明天不行”的情况。


4. 使用 npm ci 而不是 npm install

在 CI/CD 场景里:

  • 更稳定
  • 更可预测
  • 与锁文件更一致

5. 生产容器不要默认 root 运行

USER node

或者创建专用用户:

RUN addgroup -S app && adduser -S app -G app
USER app

6. 基础镜像别盲目追求最小,先追求合适

常见选择:

  • alpine:小,但兼容性要关注
  • debian-slim:更稳,稍大
  • distroless:更安全更纯粹,但调试不方便

建议:

  • 普通业务服务优先 slim 或验证过的 alpine
  • 高安全场景可考虑 distroless
  • 调试困难时,别硬上过于极致的精简镜像

7. 定期扫描镜像漏洞

例如可用:

docker scan demo-app:prod

或者使用企业常见扫描平台。

注意一点:漏洞数量下降,不只是靠“修补”,更要靠“不把无关软件装进去”


8. 合理利用层缓存与 BuildKit 缓存挂载

  • 依赖安装前只复制锁文件/依赖描述文件
  • 使用 --mount=type=cache
  • 在 CI 中尽量保留可复用缓存

9. 减少镜像层中的无效文件残留

例如某些命令会留下缓存文件:

RUN npm ci --only=production && npm cache clean --force

如果是 apt 场景,也要清理包索引缓存。


10. 区分“构建优化”和“运行优化”

很多人把两者混在一起:

  • 构建优化关注:缓存、层顺序、上下文大小
  • 运行优化关注:镜像体积、启动速度、安全权限、运行时依赖

这两者相关,但不是一回事。设计 Dockerfile 时最好分开思考。


一个简洁的优化路线图

如果你要在现有项目上落地,我建议按下面顺序改,不要一次改太多:

stateDiagram-v2
    [*] --> 补充dockerignore
    补充dockerignore --> 调整COPY顺序
    调整COPY顺序 --> 引入多阶段构建
    引入多阶段构建 --> 切换生产依赖安装
    切换生产依赖安装 --> 非root运行
    非root运行 --> 引入BuildKit缓存
    引入BuildKit缓存 --> 镜像扫描与验证
    镜像扫描与验证 --> [*]

总结

把 Docker 镜像做好,关键不在于背多少“高级指令”,而在于建立一套清晰的思路:

  1. 先控制构建上下文:写好 .dockerignore
  2. 再优化缓存命中:先复制依赖文件,再安装依赖
  3. 用多阶段构建隔离构建与运行环境
  4. 最终镜像只保留运行所需内容
  5. 生产容器尽量非 root 运行
  6. 结合 BuildKit 做进一步提速
  7. 用实际体积、构建时长、漏洞扫描结果来验证优化效果

如果你现在的 Dockerfile 还只是“能跑”,最值得先做的三件事是:

  • 加上 .dockerignore
  • 调整 COPY 与依赖安装顺序
  • 拆成多阶段构建

这三步通常就能立刻看到很明显的收益:构建更快、镜像更小、生产更干净

如果你的项目有原生依赖、Alpine 兼容性问题、或需要更高安全等级,再继续往 slim / distroless、非 root 用户、漏洞治理方向深入。别一开始就追求“最极致”,先做到“稳定、可验证、可维护”,这才是生产环境里真正有价值的优化。


分享到:

上一篇
《从提示工程到工作流编排:构建可落地的企业级 AI Agent 实战指南》
下一篇
《从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战-445》