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

《Docker 多阶段构建与镜像瘦身实战:从构建优化到安全交付》

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

Docker 多阶段构建与镜像瘦身实战:从构建优化到安全交付

很多团队在刚开始容器化时,往往先把应用“装进 Docker”再说,跑起来就算成功。但一旦进入 CI/CD、环境推广、线上发布阶段,问题会很快冒出来:

  • 镜像动不动几百 MB,拉取慢、传输慢
  • Dockerfile 一改就全量重建,构建时间长
  • 镜像里残留编译工具、包管理器、临时文件,安全面过大
  • 运行容器还在用 root,存在额外风险
  • 同一个应用,本地能跑,生产却因为基础镜像差异出现问题

我自己早期也踩过典型坑:为了图省事,把 node_modules、源码、构建工具、调试命令全塞进一个镜像里,结果镜像体积大、漏洞扫描一堆告警、上线前还得临时“手工减肥”。后来真正把多阶段构建、缓存策略、最小化运行时镜像串起来之后,构建链路才算稳下来。

这篇文章就从**“为什么镜像会胖”**开始,带你一步步做一个可运行的示例,把镜像体积、构建速度和交付安全一起优化。


背景与问题

一个“能用但不优雅”的 Dockerfile,通常长这样:

FROM node:18

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

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

它的问题很集中:

  1. 构建环境和运行环境混在一起
    • 编译工具、依赖下载缓存、源码都进入最终镜像
  2. 缓存利用差
    • COPY . . 太早,任意源码改动都会导致依赖层失效
  3. 最终镜像不够精简
    • 运行时根本不需要 TypeScript、构建脚本、测试工具
  4. 安全边界模糊
    • 默认 root 用户
    • 可能带 shell、包管理器、调试工具
  5. 交付链条效率低
    • 镜像大导致推送、拉取、回滚都慢

从工程视角看,容器镜像不只是“应用打包格式”,它还是:

  • 构建产物
  • 交付载体
  • 安全边界
  • 线上可观测与回滚单位

所以,镜像瘦身不是单纯“省磁盘”,而是影响构建效率、发布速度、漏洞数量、运行安全的系统性优化。


前置知识与环境准备

本文示例使用一个简单的 Node.js Web 应用演示,因为它比较典型:有构建依赖、有生产依赖,也容易体现多阶段构建的价值。

环境要求

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 本机可执行:
    • docker build
    • docker run
    • docker image ls

建议先开启 BuildKit:

export DOCKER_BUILDKIT=1

如果你在 Windows PowerShell:

$env:DOCKER_BUILDKIT=1

核心原理

1. 多阶段构建是什么

多阶段构建的核心思想是:把“构建”与“运行”拆开

  • 第一阶段:安装依赖、编译、打包
  • 第二阶段:只复制运行所需文件

这样最终镜像里不再包含构建工具链。

flowchart LR
    A[源码] --> B[构建阶段 Builder]
    B --> C[编译产物 dist]
    C --> D[运行阶段 Runtime]
    D --> E[最终镜像]

2. 为什么它能瘦身

因为容器镜像是分层的。你在 builder 阶段装了 gcc、make、devDependencies,不代表要把这些层带到 runtime 阶段。

关键点在于:

  • FROM ... AS builder
  • FROM ... AS runtime
  • COPY --from=builder ...

最终镜像只包含 runtime 阶段自己的层,以及从 builder 拷贝进来的必要产物。

3. 镜像变胖的常见来源

flowchart TD
    A[镜像过大] --> B[基础镜像过重]
    A --> C[把源码和构建工具一起打包]
    A --> D[依赖安装策略不合理]
    A --> E[未清理缓存/临时文件]
    A --> F[上下文过大]
    A --> G[复制了无关文件]

常见“增肥项”包括:

  • ubuntudebian 做运行时,但应用其实只需最小运行环境
  • 没写 .dockerignore
  • .git、测试文件、文档、日志一起复制进镜像
  • npm/pip/apk/apt 缓存残留
  • 开发依赖进入生产镜像
  • 静态资源构建后,原始源码还留着

4. 缓存为什么重要

构建慢很多时候不是 CPU 不够,而是 Dockerfile 层次写得不合理。

例如:

COPY . .
RUN npm ci

只要任意一个源码文件变了,npm ci 这一层就要重跑。

更好的方式是先复制依赖描述文件:

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

这样源码改动不会让依赖层每次失效。

5. 安全交付为什么要和瘦身一起做

镜像越大,通常意味着:

  • 软件包越多
  • 漏洞暴露面越大
  • 被扫描出的 CVE 越多
  • 排查和修复成本越高

所以镜像瘦身和安全交付并不是两件事,而是一件事的两个面。


示例项目结构

下面准备一个最小可运行示例:

docker-node-demo/
├── package.json
├── package-lock.json
├── server.js
├── .dockerignore
└── Dockerfile

package.json

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

server.js

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

app.get("/", (req, res) => {
  res.json({
    message: "Hello from optimized Docker image",
    time: new Date().toISOString()
  });
});

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

.dockerignore

这个文件特别重要,很多人会忽略它。我一开始就吃过亏:本地 node_modules 足足几百 MB,全被作为构建上下文传给 Docker daemon。

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

实战代码(可运行)

第 1 步:先写一个“普通版” Dockerfile

先看一个不够理想但能跑的版本,便于对比。

FROM node:18-alpine

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

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

构建并运行:

docker build -t demo:fat .
docker run --rm -p 3000:3000 demo:fat

访问:

curl http://localhost:3000

查看镜像体积:

docker image ls

这个版本的问题:

  • npm install 会安装所有依赖
  • 源码、构建产物、依赖都在同一个镜像里
  • 没有区分构建依赖和运行依赖
  • 缓存命中也一般

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

下面是推荐实践版本。

FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build
RUN npm prune --omit=dev

FROM node:18-alpine AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

USER node

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

构建

docker build -t demo:slim .

运行

docker run --rm -p 3000:3000 demo:slim

核心变化说明

1)先复制 package*.json

COPY package*.json ./
RUN npm ci

这样依赖层能最大化复用缓存。

2)构建完成后裁剪依赖

RUN npm prune --omit=dev

如果项目中有 devDependencies,这一步会把开发依赖去掉。

3)最终镜像只复制必要内容

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

不再复制整个源码目录。

4)切换非 root 用户

USER node

这一步很小,但收益很大,是安全基线的一部分。


第 3 步:进一步优化缓存与构建速度

如果你使用 BuildKit,还可以挂载 npm 缓存。

# 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
RUN npm prune --omit=dev

FROM node:18-alpine AS runtime

WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

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

构建过程会更像下面这样:

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker BuildKit
    participant Cache as 依赖缓存
    participant Registry as 镜像仓库

    Dev->>Docker: docker build
    Docker->>Cache: 检查 npm 缓存
    Cache-->>Docker: 命中已有依赖
    Docker->>Docker: 执行构建与裁剪
    Docker->>Registry: 推送更小的最终镜像

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

查看镜像列表

docker image ls | grep demo

查看镜像历史层

docker history demo:slim

你会看到多阶段构建后的最终镜像层更少、更聚焦。

进入容器检查文件

docker run --rm -it demo:slim sh

在容器内查看:

ls -lah
find . -maxdepth 2 -type f

重点确认:

  • 是否只有 dist/node_modules/package.json
  • 是否没有源码测试文件
  • 是否没有多余缓存目录

逐步验证清单

如果你想在团队里推广这套方式,我建议每次改 Dockerfile 后都按这个清单走一遍:

  • 应用能正常启动
  • 健康检查或首页请求正常
  • 镜像体积比旧版更小
  • 容器内不包含源码仓库、测试文件、构建工具
  • 运行用户不是 root
  • 构建缓存对依赖层有效
  • 漏洞扫描数量没有明显恶化
  • CI 中可稳定复现

常见坑与排查

1. COPY --from=builder 路径写错

现象:

  • 构建通过不了
  • 或运行时报找不到文件,比如 Cannot find module dist/server.js

排查方法:

RUN ls -lah /app
RUN ls -lah /app/dist

可以临时在 builder 阶段打印目录,确认产物路径。


2. .dockerignore 没生效,构建上下文过大

现象:

  • docker build 一开始就很慢
  • 终端显示 Sending build context 很大

排查点:

  • .dockerignore 是否写在构建上下文根目录
  • 是否排除了 node_modules.git、日志、构建产物

这个问题非常常见,而且很“隐形”。很多人只盯着镜像体积,却忘了构建上下文传输本身也耗时


3. Alpine 兼容性问题

alpine 很小,但不是所有应用都适合它。某些原生依赖、动态库依赖可能在 musl 环境下出问题。

现象:

  • 本地构建通过,运行时报动态库错误
  • 某些 npm 原生模块无法正常执行

建议:

  • 能用 alpine 就用
  • 遇到兼容性问题时,不要硬扛,改用 debian-slim 也是合理选择

边界条件很重要:更小不一定永远更好,稳定性优先


4. 使用 npm install 导致依赖不稳定

现象:

  • 同一份 Dockerfile,不同时间构建结果不一致
  • CI 和本地依赖版本不同

建议优先使用:

npm ci

前提是仓库中提交了 package-lock.json


5. 容器改成非 root 后权限报错

现象:

  • 启动时报没有写权限
  • 某些目录无法访问

排查思路:

  • 运行时是否真的需要写文件
  • 需要写的目录是否提前 chown
  • 应用日志最好输出到 stdout/stderr,而不是写容器文件

如果确实要写临时目录,可在构建阶段处理权限。


安全/性能最佳实践

1. 使用最小必要基础镜像

优先级不是固定的,但一般可参考:

  • 运行纯静态产物:nginx:alpine 或更小运行时
  • Node 应用:node:alpinenode:slim
  • 有兼容性要求:debian-slim

不要默认拿大而全的基础镜像做运行时。


2. 只把“运行必需品”放进最终镜像

最终镜像尽量只包括:

  • 可执行文件或编译产物
  • 生产依赖
  • 必要配置文件

不要包括:

  • 测试代码
  • 构建脚本
  • 包管理缓存
  • 调试工具
  • Git 元数据

3. 固定基础镜像版本

不要写这种:

FROM node:latest

建议写明确版本,例如:

FROM node:18.19-alpine

这样能减少“今天能跑,明天挂了”的不确定性。


4. 使用非 root 用户运行

这是非常值得坚持的一条:

USER node

如果是自定义应用,也可以创建专用用户。

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

5. 做漏洞扫描,但别只盯着扫描结果数字

安全扫描是必须的,但不要陷入“漏洞数越少越好”的机械指标。更重要的是:

  • 漏洞是否可利用
  • 是否暴露在运行路径上
  • 是否有修复版本
  • 是否影响生产环境

镜像瘦身的价值之一,就是减少无关包,自然也会减少无意义告警。


6. 在 CI/CD 中分层优化

推荐流水线思路:

flowchart LR
    A[代码提交] --> B[构建阶段]
    B --> C[单元测试]
    C --> D[镜像构建]
    D --> E[安全扫描]
    E --> F[推送镜像仓库]
    F --> G[部署发布]

建议在 CI 中做到:

  • 构建与测试分离
  • 镜像构建使用多阶段
  • 安全扫描在推送前或推送后自动执行
  • 镜像标签使用 commit sha 或语义版本
  • 重要环境禁止使用 latest

7. 善用标签与不可变交付

不要只推:

myapp:latest

更推荐:

myapp:1.3.2
myapp:git-8f3c2d1

这样回滚更直接,问题定位也更快。


8. 控制层数,但不要为了“少层”牺牲可维护性

有些人会极端地把很多命令塞到一个 RUN 里。确实能减少层数,但 Dockerfile 可读性会明显变差。

经验上:

  • 清理缓存的命令适合合并
  • 不同语义步骤适当拆开,便于维护和排障

优化不是比赛,维护性也要算进去。


一个更贴近生产的 Dockerfile 参考

下面给一个更完整一点的模板,适合 Node 服务类应用参考:

# syntax=docker/dockerfile:1.4

FROM node:18.19-alpine AS builder

WORKDIR /app

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

COPY . .
RUN npm run build
RUN npm prune --omit=dev

FROM node:18.19-alpine AS runtime

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

USER node

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

对应构建与运行命令:

docker build -t myapp:1.0.0 .
docker run --rm -p 3000:3000 myapp:1.0.0

什么时候不必过度追求极致瘦身

这点我想单独说一下。不是所有项目都值得为“再省 20MB”投入很多工程时间。

你可以优先优化这些场景:

  • 镜像超过几百 MB,发布明显变慢
  • CI 构建频繁,缓存效率低
  • 安全扫描告警很多
  • 边缘环境、带宽受限、节点扩缩容频繁

而下面这些场景可以适度即可:

  • 内部工具,部署频率低
  • 构建链路简单,镜像体积已可接受
  • 为兼容性必须使用较完整运行时

所以正确目标不是“最小镜像”,而是足够小、足够快、足够安全、足够稳定


总结

如果把 Docker 多阶段构建浓缩成一句话,那就是:

让构建环境服务于产物生成,而不是进入最终交付物。

你可以把今天的内容落成几个可执行动作:

  1. 先补 .dockerignore
  2. 把 Dockerfile 改成多阶段构建
  3. 先复制依赖描述文件,再安装依赖
  4. 最终镜像只保留运行必需文件
  5. 使用非 root 用户运行
  6. 在 CI 中加入镜像扫描与版本化标签

如果你现在手里的 Dockerfile 还是“一把梭 COPY . .”,那就从这篇文章里的示例开始改。通常不需要大动应用代码,就能看到明显收益:镜像更小、构建更快、交付更稳、安全面更清晰

这类优化最适合在项目还不太复杂的时候尽早建立规范。因为越往后,镜像里“历史包袱”越多,清理成本就越高。早点做,回报很直接。


分享到:

上一篇
《Web3 中级实战:从钱包登录到链上签名验证的完整接入方案》
下一篇
《Java Web开发实战:基于Spring Boot与JWT实现中后台系统的登录鉴权与权限控制》