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

《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速与安全优化指南》

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

Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速与安全优化指南

很多团队刚开始用 Docker 时,最先关注的是“能跑起来”。但项目一旦进入持续集成、频繁发布、多人协作阶段,问题就会集中爆发:

  • 镜像太大,拉取慢、构建慢、发布慢
  • Dockerfile 越写越长,维护困难
  • 构建工具、测试文件、包管理缓存全被带进生产镜像
  • 容器以 root 运行,安全风险上升
  • 明明改了一行代码,却从头开始构建

这些问题并不罕见。我自己早期也踩过坑:一个 Node 服务镜像做到 1GB 以上,CI 每次构建都像“重新装一台机器”。后来真正把多阶段构建、缓存策略和安全基线整理好,构建时间和镜像体积都明显下降。

这篇文章就从实战视角带你完整做一遍:不仅讲“什么是多阶段构建”,更讲为什么这样拆、怎么验证、出了问题怎么排查


背景与问题

先看一个典型的“能用但不优雅”的 Dockerfile。

FROM node:18

WORKDIR /app

COPY . .
RUN npm install
RUN npm run build

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

它的问题很集中:

  1. 构建依赖和运行依赖混在一起

    • npm install 会安装开发依赖
    • TypeScript、测试工具、打包工具都进了最终镜像
  2. 缓存利用率低

    • COPY . . 太早执行,任意文件变更都会让 npm install 失去缓存
  3. 镜像体积膨胀

    • 源码、测试文件、.git、日志、缓存都可能被拷进去
  4. 安全面扩大

    • 默认 root 用户
    • 基础镜像功能太全,攻击面更大

所以,我们的目标不只是“减小镜像”,而是同时优化:

  • 构建速度
  • 运行镜像大小
  • 供应链安全
  • Dockerfile 可维护性

前置知识与环境准备

建议你具备以下基础:

  • 会写基本的 Dockerfile
  • 理解镜像层和缓存
  • 知道 COPYRUNCMD 的作用
  • 有 Node.js 或类似编译型 Web 项目经验

本文演示环境:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 示例项目:Node.js + TypeScript

启用 BuildKit:

export DOCKER_BUILDKIT=1

或者临时使用:

DOCKER_BUILDKIT=1 docker build -t demo-app .

核心原理

1. 什么是多阶段构建

多阶段构建的核心思想很简单:

把“构建环境”和“运行环境”分开。

在第一阶段里,你可以安装编译工具、依赖、执行打包;
在最后阶段里,只复制运行真正需要的产物,例如:

  • 编译后的二进制文件
  • 前端静态资源
  • 生产依赖
  • 配置模板

这样最终镜像就不会包含中间垃圾。

flowchart LR
    A[源码] --> B[构建阶段 builder]
    B --> C[编译/打包产物]
    C --> D[运行阶段 runner]
    D --> E[最终生产镜像]

2. Docker 层缓存为什么会影响构建速度

Docker 会按指令逐层构建。前面的层没变化,后面的构建就可以复用缓存。

最经典的优化就是:先复制依赖声明文件,再安装依赖,最后复制业务代码

错误顺序:

COPY . .
RUN npm install

推荐顺序:

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

这样只有在 package.jsonpackage-lock.json 变化时,依赖层才会失效。

flowchart TD
    A[COPY package.json/package-lock.json] --> B[RUN npm ci]
    B --> C[COPY src]
    C --> D[RUN npm run build]

    A2[仅修改业务代码] --> C2[重新 COPY src]
    C2 --> D2[重新 build]
    B -. 依赖缓存复用 .-> D2

3. 镜像瘦身的本质

镜像瘦身不是一招鲜,而是几件事叠加:

  • 选择更小、更合适的基础镜像
  • 减少无关文件进入构建上下文
  • 用多阶段构建去掉中间产物
  • 合并无意义层
  • 清理包管理缓存
  • 只保留运行所需依赖
  • 以非 root 用户运行

可以把它理解成一句话:

让最终镜像里只留下“线上运行必须存在的东西”。


实战代码(可运行)

下面我用一个 Node + TypeScript 服务做完整演示。

项目结构

demo-app/
├── src/
│   └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile

示例代码

src/server.ts

import express from "express";

const app = express();
const port = process.env.PORT || 3000;

app.get("/", (_req, res) => {
  res.json({
    message: "hello docker multi-stage build",
    time: new Date().toISOString()
  });
});

app.listen(port, () => {
  console.log(`server is running at http://localhost:${port}`);
});

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage build demo",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.7.4",
    "typescript": "^5.6.2"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true
  }
}

第一步:先看一个“不够好”的版本

FROM node:18

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

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

这个版本能跑,但不适合生产。


第二步:改造成多阶段构建

推荐版 Dockerfile

# syntax=docker/dockerfile:1

FROM node:18-bookworm-slim AS base
WORKDIR /app

FROM base AS deps
COPY package*.json ./
RUN npm ci

FROM deps AS builder
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:18-bookworm-slim AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

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

RUN useradd -r -s /usr/sbin/nologin appuser \
    && chown -R appuser:appuser /app

USER appuser

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

这个版本做了几件关键事:

  • deps 阶段安装完整依赖,用于构建
  • builder 阶段只负责编译
  • runner 阶段只安装生产依赖,并复制 dist
  • 使用 slim 镜像减少基础体积
  • 使用非 root 用户运行

第三步:使用 .dockerignore 减少构建上下文

.dockerignore

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

这个文件非常重要,很多人反而忽略了。

如果没有 .dockerignore

  • 本地 node_modules 可能被复制进去
  • Git 历史会扩大上下文
  • 日志、测试报告、缓存文件全会参与构建

这不仅使镜像变大,也会拖慢 docker build 上传上下文的速度。


第四步:构建并运行

构建镜像

docker build -t demo-app:multi-stage .

运行容器

docker run --rm -p 3000:3000 demo-app:multi-stage

验证接口

curl http://localhost:3000

预期输出:

{"message":"hello docker multi-stage build","time":"2024-01-01T00:00:00.000Z"}

逐步验证清单

建议你不要一次性“盲改”,而是按下面顺序验证:

1. 验证镜像体积

docker images | grep demo-app

对比改造前后的体积变化。

2. 验证运行用户

docker run --rm demo-app:multi-stage id

你应该看到不是 root。

3. 验证最终镜像内容是否干净

docker run --rm -it demo-app:multi-stage sh

进入后检查:

ls

确认只保留了必要文件,例如:

  • dist
  • package.json
  • package-lock.json
  • node_modules

4. 验证依赖缓存是否生效

修改 src/server.ts,重新构建:

docker build -t demo-app:multi-stage .

观察日志,npm ci 那层应该大概率命中缓存。


多阶段构建的执行过程图

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Deps as deps 阶段
    participant Builder as builder 阶段
    participant Runner as runner 阶段

    Dev->>Docker: docker build
    Docker->>Deps: 复制 package*.json
    Deps->>Deps: npm ci
    Docker->>Builder: 复制源码与 tsconfig
    Builder->>Builder: npm run build
    Docker->>Runner: 安装生产依赖
    Builder-->>Runner: COPY dist
    Runner-->>Dev: 输出最终镜像

常见坑与排查

这一部分很关键。多阶段构建本身不复杂,复杂的是“你以为已经瘦了,但结果没瘦下来”。

1. COPY . . 放太早,缓存全废

现象

每次修改一点点代码,npm ci 都重新执行。

原因

你先复制了整个项目,导致源码任意变化都会影响依赖层缓存。

解决

把依赖文件和源码分开复制:

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

2. 最终镜像里仍然有开发依赖

现象

镜像还是很大,甚至能看到 TypeScript、测试框架。

原因

你在最终阶段直接复制了整个 /app,把 builder 里的 node_modules 一起带过来了。

错误写法

COPY --from=builder /app /app

正确思路

只复制必要产物:

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

然后在运行阶段安装生产依赖:

RUN npm ci --omit=dev

3. Alpine 不一定总是更合适

很多文章会直接建议“用 alpine 就好了”,但这句话要加条件。

优点

  • 体积更小

风险

  • 基于 musl libc,某些原生模块兼容性可能有坑
  • 构建依赖复杂时,排障成本会上升

如果你的项目依赖原生扩展,或者团队对 Alpine 不熟,我更建议从 debian slim 起步,比如:

FROM node:18-bookworm-slim

不是最小,但通常更稳。


4. 容器里找不到文件

现象

报错类似:

Error: Cannot find module '/app/dist/server.js'

排查顺序

  1. 本地 npm run build 是否成功
  2. Dockerfile 中 COPY src ./srcCOPY tsconfig.json ./ 是否正确
  3. dist 输出目录是否与 CMD 一致
  4. COPY --from=builder /app/dist ./dist 路径是否正确

我自己的经验是:80% 是路径写错,20% 是构建根本没成功。


5. .dockerignore 写错导致文件缺失

现象

构建时报找不到源码或配置文件。

原因

你把必要文件排除了,比如:

src
*.json

解决

检查构建上下文中到底传了什么,必要时精简排除规则。


6. 非 root 用户导致权限问题

现象

应用启动时报权限不足,比如不能写日志、不能创建临时目录。

解决思路

  • 提前创建目录并授权
  • 不要把运行时写入路径放在系统目录
  • 尽量把应用设计成无状态,日志输出到 stdout/stderr

例如:

RUN mkdir -p /app/tmp \
    && chown -R appuser:appuser /app

安全/性能最佳实践

这部分给你的是“可落地”的建议,不是口号。

1. 优先选择可信且精简的基础镜像

推荐原则:

  • 官方镜像优先
  • 固定大版本,必要时固定更具体的 tag
  • 能用 slim 就别用 full
  • 不盲目追求最小,先保证兼容性

示例:

FROM node:18-bookworm-slim

2. 运行时不要带构建工具链

如果最终镜像里还保留:

  • gcc
  • make
  • python
  • git
  • TypeScript
  • 测试框架

那就说明多阶段构建没真正做好。

最终镜像只应该包含:

  • 运行时解释器或二进制
  • 生产依赖
  • 编译产物

3. 使用非 root 用户运行

这是最基础也最容易落地的容器安全实践之一。

RUN useradd -r -s /usr/sbin/nologin appuser \
    && chown -R appuser:appuser /app
USER appuser

边界条件也要说清楚:

  • 如果应用需要绑定 1024 以下端口,可能涉及额外权限
  • 如果需要写挂载卷,要确保宿主机目录权限匹配

4. 减少无效层与缓存残留

例如:

RUN npm ci --omit=dev && npm cache clean --force

如果你使用 apt 安装工具,也要及时清理:

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

5. 尽量固定依赖,保证构建可重复

生产构建最怕“今天能过,明天挂掉”。

建议:

  • 提交锁文件,如 package-lock.json
  • 使用 npm ci 而不是 npm install
  • 在 CI 中统一 Docker 构建方式

6. 配合扫描工具做漏洞检查

镜像瘦身不等于安全,但通常能减少漏洞暴露面。

可以引入工具做镜像扫描,例如:

docker scan demo-app:multi-stage

或者使用团队内部的安全平台、Trivy、Grype 等工具。


7. 结合 BuildKit 提升构建体验

对于频繁构建的项目,BuildKit 能提供更好的缓存能力和输出体验。

虽然本文没有展开缓存挂载高级玩法,但如果你的 CI 构建很重,后面可以继续研究:

  • RUN --mount=type=cache
  • registry cache
  • inline cache

这些对大型项目很有帮助。


多阶段构建优化决策图

flowchart TD
    A[开始优化 Dockerfile] --> B{项目需要编译/打包吗?}
    B -- 是 --> C[使用多阶段构建]
    B -- 否 --> D[直接精简运行镜像]

    C --> E[拆分依赖安装与源码复制]
    E --> F[仅复制构建产物到最终镜像]
    F --> G[安装生产依赖]
    G --> H[切换非 root 用户]
    H --> I[增加 .dockerignore]
    I --> J[扫描漏洞并验证体积]

    D --> H

一个更进一步的思路:前端与后端项目的差异

虽然本文用的是 Node 服务端示例,但多阶段构建在不同项目里的思路略有不同。

前端项目

典型流程:

  • builder 阶段:npm ci && npm run build
  • runner 阶段:使用 nginx:alpine 或静态文件服务器
  • 最终只复制 dist/ 静态资源

Go 项目

典型流程:

  • builder 阶段:编译成单个二进制
  • runner 阶段:用 scratch 或 distroless
  • 最终镜像可以非常小

Java 项目

典型流程:

  • builder 阶段:Maven/Gradle 打包
  • runner 阶段:只保留 JRE 或更轻量基础镜像
  • 注意分层 jar 与依赖缓存

也就是说:

多阶段构建的方法论是通用的,但“复制什么到最终镜像”要看语言生态。


总结

如果你只记住一件事,我希望是这句:

多阶段构建不是为了“写得高级”,而是为了让生产镜像只保留真正需要的内容。

这篇文章我们解决了几个核心问题:

  • 用多阶段构建拆分构建环境与运行环境
  • 通过合理的 COPY 顺序提升缓存命中率
  • .dockerignore 缩小构建上下文
  • 通过 slim 基础镜像与生产依赖安装减少体积
  • 通过非 root 用户运行提升安全性
  • 给出常见坑和排查路径,便于你在项目里落地

最后给你一份可执行建议,适合直接带回项目实践:

  1. 先改 Dockerfile 顺序

    • 先复制依赖声明,再安装依赖,最后复制源码
  2. 把构建和运行拆成两个阶段以上

    • builder 负责产物,runner 负责运行
  3. 补上 .dockerignore

    • 这是最容易漏、但收益很高的一步
  4. 最终镜像只保留必要文件

    • 不要整目录复制 builder 的工作区
  5. 默认使用非 root 用户

    • 安全基线从这里开始
  6. 构建后做三项验证

    • 镜像大小
    • 运行用户
    • 文件内容是否最小化

如果你的项目当前镜像已经很大,不必一口气重构到底。我的建议是:先做“可观测的最小改造”,例如先引入多阶段构建和 .dockerignore,确认收益后再继续优化基础镜像、缓存策略和漏洞扫描。这样最稳,也最容易在团队里推广。


分享到:

上一篇
《Docker 镜像瘦身实战:从多阶段构建到层缓存优化的中级指南》
下一篇
《Spring Boot 中基于 Redis 与 Caffeine 的多级缓存实战:一致性、穿透防护与性能优化》