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

《Docker 多阶段构建与镜像瘦身实战:从构建加速到安全优化的完整方案》

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

Docker 多阶段构建与镜像瘦身实战:从构建加速到安全优化的完整方案

很多团队刚开始用 Docker 时,都会先追求“能跑起来”。结果过一阵子就发现几个很现实的问题:

  • 镜像体积越来越大,拉取慢、发布慢
  • Dockerfile 越写越长,维护困难
  • 构建阶段混入了编译工具、调试工具,生产镜像不够干净
  • 容器默认 root 运行,安全风险偏高
  • CI/CD 一跑就是十几分钟,开发体验也差

我自己第一次接手一个历史项目时,就见过一个 1GB+ 的 Java 镜像,构建时间和传输时间加起来,比代码真正编译的时间还长。后来回过头梳理,问题并不神秘:构建环境、运行环境、依赖缓存、安全策略,全都混在一起了

这篇文章就从这个角度出发,带你系统做一遍:用 Docker 多阶段构建把镜像做小、把构建做快、把运行时做安全


背景与问题

先看几个典型的“坏味道” Dockerfile:

FROM node:18

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

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

这个写法很常见,但问题不少:

  1. 源码、依赖、构建产物全在同一层
    • 修改任意文件,缓存命中率都变差
  2. 构建依赖和运行依赖混在一起
    • 比如 gccmakepython 这些只在编译时需要,却进了生产镜像
  3. 上下文过大
    • .git、日志、node_modules、测试文件都可能被 COPY . . 带进去
  4. 安全基线弱
    • 默认 root 用户、镜像中包过多、攻击面更大

很多人以为“镜像瘦身”只是为了省磁盘空间,其实它影响的是一整条链路:

  • 本地开发拉镜像速度
  • CI 构建耗时
  • 仓库存储成本
  • 发布速度
  • 容器启动时间
  • 安全面

所以它不是“可做可不做”的优化,而是稍微上规模就必须面对的工程问题。


前置知识与环境准备

本文默认你已经了解这些基础概念:

  • Dockerfile 基本语法
  • 镜像与容器的区别
  • docker build / docker run 基本用法
  • 至少知道 Node.js 或 Go 项目的基本构建方式

建议环境:

  • Docker 20.10+
  • 开启 BuildKit
  • 一台能联网拉基础镜像的开发机

建议先启用 BuildKit:

export DOCKER_BUILDKIT=1

或者直接使用:

DOCKER_BUILDKIT=1 docker build -t demo-app .

BuildKit 会显著改善构建缓存与高级特性体验,后面提到的缓存挂载也依赖它。


核心原理

1. 什么是多阶段构建

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

在一个 Dockerfile 里拆分“构建阶段”和“运行阶段”,最后只把真正需要的产物带进最终镜像。

比如:

  • 第一阶段:安装依赖、编译代码
  • 第二阶段:只复制编译好的二进制文件或静态资源,作为运行镜像

这样,构建工具链不会进入最终镜像。

2. 为什么它能瘦身

因为最终镜像不再包含这些内容:

  • 编译器
  • 构建缓存
  • 源代码
  • 单元测试文件
  • 开发依赖
  • 包管理器临时文件

对比一下流程:

flowchart LR
    A[源码目录] --> B[传统单阶段构建]
    B --> C[依赖安装]
    C --> D[编译工具保留在镜像]
    D --> E[最终镜像体积大]

    A --> F[多阶段构建]
    F --> G[构建阶段安装依赖并编译]
    G --> H[仅复制产物到运行阶段]
    H --> I[最终镜像更小更安全]

3. Docker 缓存为什么会失效

Docker 构建是按层执行的。某一层输入发生变化,该层之后的缓存通常都不能复用。

最典型的例子:

COPY . .
RUN npm install

只要项目里任意一个文件变了,COPY . . 层就会变化,后面的 npm install 就得重跑。

更好的顺序应该是:

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

这样如果你只是改了业务代码,而 package.json 没变,那么依赖安装层就还能复用。

4. 安全优化为什么和瘦身是同一件事

镜像越大,意味着:

  • 软件包越多
  • 潜在漏洞面越大
  • 攻击者可利用工具越多

所以“瘦身”本质上也是“减少攻击面”。

可以理解成:

flowchart TD
    A[镜像瘦身] --> B[减少无用文件]
    A --> C[减少系统包]
    A --> D[减少工具链残留]
    B --> E[更快拉取和启动]
    C --> F[更少漏洞暴露]
    D --> G[更低运行时风险]

实战代码(可运行)

下面我用一个 Node.js 前端静态站点 做示例,因为它特别适合展示多阶段构建的价值。

目录结构如下:

demo-web/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── server.js
└── public/
    └── index.html

示例应用代码

package.json

{
  "name": "demo-web",
  "version": "1.0.0",
  "description": "demo web app for docker multi-stage build",
  "main": "server.js",
  "scripts": {
    "build": "mkdir -p dist && cp -r public/* dist/",
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

server.js

const express = require("express");
const path = require("path");

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

app.use(express.static(path.join(__dirname, "dist")));

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(port, () => {
  console.log(`server started on port ${port}`);
});

public/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Docker Multi-stage Demo</title>
</head>
<body>
  <h1>Hello Docker Multi-stage Build</h1>
  <p>镜像瘦身成功。</p>
</body>
</html>

先写一个“普通版” Dockerfile

先不要急着优化,我们先写一个很多项目里都见过的版本。

Dockerfile.bad

FROM node:18

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

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

构建:

docker build -f Dockerfile.bad -t demo-web:bad .

运行:

docker run --rm -p 3000:3000 demo-web:bad

验证:

curl http://localhost:3000/health

这个版本能跑,但不够好。


改造成多阶段构建

下面是推荐版本。

Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS builder

WORKDIR /app

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

COPY . .
RUN npm run build

FROM node:18-alpine AS runtime

WORKDIR /app

ENV NODE_ENV=production

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

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

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

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

这个 Dockerfile 做了几件关键的事:

  1. builder 阶段负责构建静态资源
  2. runtime 阶段只保留运行所需内容
  3. npm ci 代替 npm install
    • 构建更稳定,适合 CI
  4. 通过 --mount=type=cache 利用 BuildKit 做包缓存
  5. 切换到非 root 用户运行

构建镜像

docker build -t demo-web:multi .

启动容器

docker run --rm -p 3000:3000 demo-web:multi

验证服务

curl http://localhost:3000/
curl http://localhost:3000/health

.dockerignore 继续瘦身

很多人做了多阶段构建,但忘了 .dockerignore,结果构建上下文还是很大。这个问题在 monorepo 或有大量日志文件时尤其明显。

.dockerignore

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

它的价值在于:

  • 减少传给 Docker daemon 的上下文体积
  • 避免本地 node_modules 污染镜像
  • 提升构建速度
  • 降低误拷贝风险

这一点我建议养成习惯:写 Dockerfile 的同时就写 .dockerignore


逐步验证清单

教程类文章里,我很建议你按下面这套最小闭环去验证,不然很容易“以为优化了,其实没有”。

1. 看镜像大小

docker images | grep demo-web

对比 demo-web:baddemo-web:multi 的体积。

2. 看镜像层历史

docker history demo-web:multi

你能明显看到最终镜像层更干净,没有冗余的构建步骤残留。

3. 验证运行用户

docker run --rm demo-web:multi id

如果输出不是 root,说明非 root 运行生效了。

4. 验证功能正常

curl http://localhost:3000/health

5. 验证缓存命中

第一次构建:

docker build -t demo-web:multi .

第二次只修改 public/index.html 后再构建:

docker build -t demo-web:multi .

如果 Dockerfile 分层合理,依赖安装步骤应尽量复用缓存。


进一步实战:Go 项目的多阶段构建

如果你做的是 Go 服务,多阶段构建会更“爽”,因为最终镜像往往可以很小。

示例 main.go

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
	})
	http.ListenAndServe(":8080", nil)
}

Go 版本 Dockerfile

FROM golang:1.21-alpine AS builder

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app main.go

FROM alpine:3.19

WORKDIR /app

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /src/app /app/app

USER appuser

EXPOSE 8080
CMD ["/app/app"]

构建与运行:

docker build -t demo-go .
docker run --rm -p 8080:8080 demo-go
curl http://localhost:8080/health

Go 的好处是编译产物通常是单文件,最终镜像可以非常克制。


常见坑与排查

这一部分我尽量写得实一点,都是平时比较容易踩中的坑。

坑 1:多阶段构建了,但镜像还是很大

常见原因:

  • 最终阶段仍然使用了过大的基础镜像
  • 把整个项目目录又 COPY . . 到 runtime 阶段了
  • 安装了不必要的系统包
  • 没有清理包管理缓存

错误示例:

FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

FROM node:18
WORKDIR /app
COPY . .
COPY --from=builder /app/dist ./dist
CMD ["node", "server.js"]

这里 runtime 阶段的 COPY . .,基本把前面的努力抵消了。

排查建议:

docker history your-image
docker image inspect your-image

重点看哪些层突然变大。


坑 2:缓存不生效,构建总是很慢

常见原因:

  • COPY . . 放在依赖安装之前
  • lock 文件经常变化
  • 构建上下文包含无关文件
  • 没启用 BuildKit

推荐顺序:

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

排查思路:

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 构建缓存

    Dev->>Docker: docker build
    Docker->>Cache: 检查 package*.json 层
    alt 未变化
        Cache-->>Docker: 命中依赖安装缓存
    else 已变化
        Cache-->>Docker: 重新执行 npm ci
    end
    Docker->>Cache: 检查源码复制层
    Docker-->>Dev: 输出构建结果

坑 3:alpine 不是万能药

很多文章会直接说“想瘦身就用 alpine”,但实际项目里不能机械套用。

可能遇到的问题:

  • 某些 Node 原生模块需要特定 libc 环境
  • Python/Java/Node 某些依赖在 alpine 上编译更麻烦
  • 调试体验较差

建议:

  • 优先做多阶段和分层优化
  • 再考虑是否切换到 alpine
  • 如果业务依赖复杂,debian-slim 往往更稳

也就是说,先把结构优化好,再谈基础镜像极限压缩


坑 4:容器切成非 root 后程序跑不起来

这是很常见的,尤其在这些场景:

  • 应用需要写临时目录
  • 复制进来的文件属主不对
  • 端口绑定权限问题
  • 日志目录没有写权限

例如:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

如果你的应用还想写 /app/logs,那就要提前处理权限。

修正方式:

RUN mkdir -p /app/logs && chown -R appuser:appgroup /app
USER appuser

坑 5:生产镜像里仍然有 secrets

有些人会在 Dockerfile 里写:

ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN

这样如果处理不当,凭证可能出现在镜像层历史里。

更安全的方式是使用 BuildKit secret mount,而不是把 secrets 写死进层里。比如:

RUN --mount=type=secret,id=npm_token \
    sh -c 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && npm ci'

构建时:

docker build --secret id=npm_token,src=.npm_token -t demo-web:secure .

安全/性能最佳实践

这一部分给你一套比较实用的落地清单,不求“最极致”,但足够应对大多数团队场景。

1. 优先使用明确版本的基础镜像

不要长期依赖漂移标签:

FROM node:18-alpine

比起 latest,明确版本更可控。生产环境建议进一步固定到更具体的版本标签。


2. 运行时镜像尽量最小化

原则是:

  • 只放运行所需文件
  • 不要把源码、测试、文档、构建缓存带进去
  • 不要安装调试工具,除非业务明确要求

适合的思路:

  • Node:runtime 只保留生产依赖 + 构建产物
  • Go:runtime 只保留二进制
  • Java:runtime 只保留 jar 和 JRE

3. 使用非 root 用户运行

这是容器安全的基础动作,不应该省。

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

边界条件:

  • 如果容器必须监听 1024 以下端口,可能需要额外能力配置
  • 如果需要写文件,提前处理目录权限

4. 配合健康检查与最小权限

运行时也建议加上健康检查,例如:

HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1

如果基础镜像没有 wgetcurl,你要评估是否值得为此引入额外包。对于极简镜像,有时更适合把健康探针放到编排层,比如 Kubernetes 的 liveness/readiness probe。


5. 定期扫描漏洞

镜像瘦了不代表没漏洞,仍然需要做扫描。

可以使用:

  • Docker Scout
  • Trivy
  • Grype

例如用 Trivy:

trivy image demo-web:multi

建议在 CI 中加入扫描门禁。


6. 合理利用缓存,而不是盲目追求“单层合并”

有些人会过度追求少层,把所有命令塞进一个 RUN。这并不总是对。

更实用的原则是:

  • 高频变动的内容放后面
  • 低频变动的内容放前面
  • 依赖安装与源码复制分离
  • 让缓存命中逻辑符合团队实际开发模式

这比“层数少一点”更重要。


7. 在 CI/CD 中显式使用目标阶段

有时我们不仅想构建最终镜像,还想单独调试 builder 阶段。

例如:

docker build --target builder -t demo-web:builder .

这对排查编译问题非常有帮助,尤其是在复杂前端或原生依赖项目里。


8. 控制构建上下文

如果你的仓库很大,构建上下文本身就可能成为性能瓶颈。

建议:

  • 善用 .dockerignore
  • 在 monorepo 中缩小构建目录
  • 避免无脑 COPY . .

一个可复用的优化模板

如果你想把这套方法迁移到自己的项目,可以按下面这个模板检查。

flowchart TD
    A[选择基础镜像] --> B[拆分 builder/runtime]
    B --> C[先复制依赖描述文件]
    C --> D[安装依赖并利用缓存]
    D --> E[复制源码并构建]
    E --> F[仅复制运行所需产物]
    F --> G[切换非 root 用户]
    G --> H[添加 .dockerignore]
    H --> I[镜像扫描与体积验证]

对应的执行顺序建议是:

  1. 先拆多阶段
  2. 再优化 COPY 顺序
  3. .dockerignore
  4. 切非 root
  5. 引入缓存挂载
  6. 做镜像扫描
  7. 观察体积、构建时间、运行稳定性

方案取舍:什么时候不用“极致瘦身”

这里补一句很重要的话:不是所有项目都需要把镜像压到最小。

例如这些情况,你可以适当保守:

  • 业务依赖复杂,切换 alpine 成本过高
  • 团队调试依赖 shell、curl、ca-certificates 等工具
  • 构建时间比镜像体积更关键
  • 运行环境本身已经有镜像分发加速

更合理的目标通常是:

  • 明确构建阶段与运行阶段分离
  • 体积明显下降
  • 缓存可复用
  • 安全基线达标
  • 排障成本没有明显增加

换句话说,工程优化不是比赛,看的是整体收益,不是数字越小越好


总结

把 Docker 用好,关键不在于记住多少条命令,而是建立一个清晰的判断框架:

  1. 构建和运行要分离
    • 用多阶段构建,把工具链留在 builder
  2. 缓存要可复用
    • 依赖描述文件先复制,源码后复制
  3. 上下文要收敛
    • .dockerignore 必不可少
  4. 运行时要最小化
    • 只保留必要产物和生产依赖
  5. 安全基线要补齐
    • 非 root、漏洞扫描、凭证不落层

如果你准备马上动手,我建议按这个最小行动路径开始:

  • 先把现有 Dockerfile 改成多阶段
  • 再补 .dockerignore
  • 再检查 COPY 顺序和缓存命中
  • 最后加非 root 与漏洞扫描

这套顺序的好处是:改动可控、收益明显、风险也相对低

很多时候,镜像优化不需要大改架构,只要把 Dockerfile 从“能跑”提升到“适合长期维护”,效果就已经很可观了。


分享到:

上一篇
《微服务架构中分布式事务的实战方案:基于 Saga 模式的设计、实现与落地优化》
下一篇
《区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-354》