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

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

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

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

很多团队刚上 Docker 时,最先解决的是“能跑起来”;真正到 CI/CD、镜像仓库、线上发布阶段,才会发现另一个问题更扎心:镜像太大、构建太慢、上线不稳、安全风险还高

我自己第一次接手一个 Node.js 服务时,镜像接近 1GB,构建一次要好几分钟。更离谱的是,线上镜像里还带着编译工具链、源码和测试文件。后来做了一轮多阶段构建和镜像瘦身,镜像体积和构建耗时都降了不少,发布过程也更可控。

这篇文章不讲空泛概念,直接从一个可运行的例子入手,带你把 Docker 镜像从“能用”优化到“适合上线”。


背景与问题

在日常开发里,Docker 镜像臃肿通常来自这几个原因:

  1. 构建环境和运行环境混在一起
    • 编译器、依赖管理工具、缓存文件都被带进最终镜像。
  2. 基础镜像选得过大
    • 例如直接用完整版 Ubuntu、Node、Python 镜像。
  3. Dockerfile 层设计不合理
    • 一点小变更就导致缓存失效,全量重建。
  4. 上下文太大
    • .git、测试数据、日志、node_modules 全被发送给 Docker daemon。
  5. 以 root 运行
    • 不是“不能用”,而是上线后风险更高。
  6. 把密钥、配置、调试工具打进镜像
    • 这类问题平时不显眼,出事时很难补救。

如果你遇到以下现象,基本就该优化了:

  • docker build 越来越慢
  • 镜像推送到仓库耗时长
  • 容器启动慢
  • 安全扫描报警一堆
  • 线上镜像和本地构建不一致

前置知识与环境准备

本文示例基于一个简单的 Node.js 应用,环境如下:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • Linux / macOS / Windows + Docker Desktop 均可

先开启 BuildKit,后面的缓存优化会更明显:

export DOCKER_BUILDKIT=1

准备一个最小可运行项目结构:

mkdir docker-multistage-demo
cd docker-multistage-demo
mkdir src

示例文件

package.json

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

src/index.js

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

app.get("/", (req, res) => {
  res.json({
    message: "hello docker multi-stage build",
    hostname: process.env.HOSTNAME || "unknown"
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`server running at http://0.0.0.0:${port}`);
});

核心原理

什么是多阶段构建

多阶段构建的思路很直接:

  • 前面阶段负责安装依赖、编译、打包
  • 最后阶段只保留运行必需产物

也就是说,构建工具链留在“中间层”,不进入最终镜像。

flowchart LR
    A[源码与依赖清单] --> B[构建阶段<br/>安装依赖/编译]
    B --> C[产出构建结果]
    C --> D[运行阶段<br/>只复制必要文件]
    D --> E[最终瘦身镜像]

为什么它能同时提升速度与安全性

多阶段构建的收益通常有三层:

  1. 体积变小
    • 最终镜像不再包含 gcc、make、npm cache、测试文件等。
  2. 构建更快
    • 合理拆分 COPYRUN 后,可以更充分利用缓存。
  3. 攻击面更小
    • 少装一个 shell、少一个包管理器、少一套工具链,就少一部分风险。

Docker 缓存命中原理

Docker 构建本质上是逐层执行。某一层的输入变了,后面的层大概率都会失效。

所以 Dockerfile 里一个非常关键的策略是:

  • 先复制依赖描述文件
  • 先安装依赖
  • 最后再复制业务源码

这样如果你只改了 src/index.js,依赖层通常还能复用。

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

    Dev->>Docker: COPY package.json
    Docker->>Cache: 检查依赖层缓存
    Cache-->>Docker: 命中
    Dev->>Docker: COPY src/
    Docker->>Cache: 检查源码层缓存
    Cache-->>Docker: 未命中
    Docker->>Docker: 仅重建后续层

先看一个“能跑但不优雅”的 Dockerfile

这是很多项目里常见的写法:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["npm", "start"]

它的问题有几个:

  • COPY . . 太早,任何源码变动都会让依赖安装层失效
  • 基础镜像偏大
  • 最终镜像包含完整源码、可能还有无关文件
  • 默认 root 用户运行
  • 没有利用多阶段构建

实战代码:从普通构建到多阶段瘦身

下面我们一步一步改。


第一步:加上 .dockerignore

这个文件很容易被忽略,但收益很直接。它控制哪些内容不参与构建上下文上传

.dockerignore

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

为什么这一步重要

如果不加 .dockerignore

  • 本地 node_modules 可能被错误复制进镜像
  • Git 历史会增加构建上下文
  • 测试报告、日志、文档都会拖慢构建

我见过一个项目仅仅排除 .git 后,构建上下文就从几百 MB 掉到了几十 MB。


第二步:优化层缓存

先做一个不带多阶段、但缓存更友好的版本:

FROM node:18-alpine

WORKDIR /app

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

COPY src ./src

EXPOSE 3000

CMD ["node", "src/index.js"]

这里做了什么

  • node:18-alpine 比完整版更轻
  • 先复制 package.json,让依赖安装层更稳定
  • 使用 npm ci 保证依赖安装可重复
  • 只复制运行所需源码

不过,这个版本仍然没有真正做到“构建阶段”和“运行阶段”分离。


第三步:使用多阶段构建

对于 Node.js 这种场景,多阶段即使没有编译产物,也依然有价值。因为你可以在 builder 阶段完成依赖安装、测试或打包,在 runtime 阶段只留下必要结果。

推荐 Dockerfile

Dockerfile

FROM node:18-alpine AS base
WORKDIR /app

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

FROM base AS runtime
ENV NODE_ENV=production
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

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

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

这个版本已经比最初版本好很多:

  • 把依赖安装放到单独阶段
  • 运行阶段只复制需要的内容
  • 使用非 root 用户运行

但还有一个细节:npm ci 默认会装开发依赖。如果你的项目里 devDependencies 很多,最终体积还是会偏大。


第四步:只保留生产依赖

更适合上线的版本如下:

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

FROM node:18-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production

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

COPY --from=prod-deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

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

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

如果你有测试、构建、打包步骤,可以把 deps 阶段用于测试,把 prod-deps 阶段用于最终运行依赖。


第五步:带测试/构建阶段的完整上线版本

为了更贴近真实项目,下面给一个“构建 + 测试 + 运行”的标准模板。

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

FROM deps AS test
WORKDIR /app
COPY . .
RUN npm test --if-present

FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run build --if-present

FROM node:18-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production

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

COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

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

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

这个模板适合以下场景:

  • TypeScript 编译
  • 前端静态资源打包
  • Babel / Webpack / Vite 构建
  • 单元测试先行,构建失败即阻断发布

构建与运行验证

构建镜像

docker build -t docker-multistage-demo:1.0 .

运行容器

docker run --rm -p 3000:3000 docker-multistage-demo:1.0

验证接口

curl http://localhost:3000

你应该能看到类似输出:

{"message":"hello docker multi-stage build","hostname":"..."}

查看镜像体积

docker images | grep docker-multistage-demo

查看镜像层历史

docker history docker-multistage-demo:1.0

这一步很实用。很多人只看最终镜像大小,却不知道究竟是哪一层最重。docker history 往往一下就能看出问题。


逐步验证清单

如果你准备在自己的项目里落地,建议按这个清单逐项验证:

  • 是否存在 .dockerignore
  • 是否先复制依赖描述文件,再安装依赖
  • 是否使用 npm ci / pip install -r / go mod download 这类可重复安装方式
  • 是否使用多阶段构建隔离编译环境
  • 最终镜像是否只包含运行所需文件
  • 容器是否使用非 root 用户
  • 是否避免把密钥写入镜像
  • 是否做过镜像扫描
  • 是否验证过容器启动与健康检查

常见坑与排查

这一部分我尽量说得“像在现场排问题”,因为这些坑真是很常见。

1. COPY --from=build 报文件不存在

例如:

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

报错:

COPY failed: stat /app/dist: no such file or directory

原因

  • 构建阶段根本没生成 dist
  • 构建命令写错
  • 工作目录不一致

排查方式

可以单独构建到某个中间阶段:

docker build --target build -t demo-build-stage .

然后进入镜像查看:

docker run --rm -it demo-build-stage sh

检查 dist 是否真的存在。


2. 改一行代码却重新安装全部依赖

常见原因

Dockerfile 写成了:

COPY . .
RUN npm ci

只要任何文件变化,npm ci 这一层都会失效。

正确做法

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

3. Alpine 镜像导致某些依赖编译失败

alpine 很轻,但它使用 musl libc,不是所有原生依赖都兼容得完美。

典型现象

  • Node 原生模块安装失败
  • Python / Java / 二进制依赖异常
  • 运行期出现动态链接库问题

处理建议

如果你的依赖里包含复杂原生库,不要强行追求最小镜像。可以改用:

  • node:18-slim
  • python:3.11-slim
  • debian:bookworm-slim

边界条件很重要:更小不一定更合适。


4. 容器里用非 root 用户后,文件权限报错

例如:

EACCES: permission denied

原因

复制进来的文件归属、运行目录权限不匹配。

处理方式

可以在复制时直接指定归属,或提前创建目录并授权。

COPY --chown=appuser:appgroup src ./src
COPY --chown=appuser:appgroup package.json ./

或者:

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

5. 明明删了文件,镜像还是很大

这是 Docker 分层机制导致的经典误区。

例如你在一层里安装了很多包,下一层再删除,它们不一定真的从镜像历史里消失。

错误示例

RUN apk add --no-cache curl gcc make
RUN apk del gcc make

虽然最终容器里可能看不到这些工具,但镜像层历史依然可能偏大。

更好的方式

  • 直接用多阶段构建
  • 把构建工具留在 builder 阶段
  • 运行阶段完全不安装这些工具

安全/性能最佳实践

这部分是上线前最值得执行的清单。

1. 基础镜像尽量小,但不要盲目极限压缩

建议优先级:

  • 先选官方镜像
  • 再选 slim / alpine
  • 最后根据兼容性决定

经验上:

  • 业务简单、依赖纯净:优先 alpine
  • 原生依赖较多:优先 slim

2. 固定基础镜像版本,不要只写 latest

不推荐:

FROM node:latest

推荐:

FROM node:18-alpine

更严谨一点还可以固定 digest,不过这通常在生产环境和供应链要求更高时再做。


3. 使用非 root 用户运行

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

这是低成本、高收益的安全加固动作。


4. 使用只读文件系统与资源限制

运行时可以进一步收紧权限:

docker run --read-only --tmpfs /tmp --memory=256m --cpus=1 docker-multistage-demo:1.0

适合无状态服务,但前提是应用不要依赖写本地磁盘。


5. 不要把密钥写进镜像

不要这样:

ENV ACCESS_KEY=xxxxx
ENV SECRET_KEY=yyyyy

更合理的方式:

  • 运行时注入环境变量
  • 使用 Docker secrets / K8s Secret / 外部密钥管理服务

6. 做镜像漏洞扫描

常见工具:

  • Trivy
  • Docker Scout
  • Grype

例如使用 Trivy:

trivy image docker-multistage-demo:1.0

安全不是“构建一次就完事”,而是持续扫描、持续修复。


7. 利用 BuildKit 缓存提升构建速度

例如 npm/yarn/pip 这类依赖下载很适合加缓存挂载。

Node 示例:

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

这对 CI 构建提速很明显,尤其是依赖多、构建频繁的项目。


8. 关注镜像内容而不是只盯总大小

一个 120MB 的镜像未必比 200MB 的镜像更安全。关键要看:

  • 是否包含编译工具
  • 是否包含 shell 和调试工具
  • 是否包含源码和敏感配置
  • 是否有不必要的软件包

也就是说,瘦身不是目的,最小化攻击面才是更长期的目标


一张图看完整优化路径

flowchart TD
    A[原始 Dockerfile] --> B[添加 .dockerignore]
    B --> C[调整 COPY 顺序以命中缓存]
    C --> D[拆分构建阶段与运行阶段]
    D --> E[仅复制生产依赖与产物]
    E --> F[使用非 root 用户]
    F --> G[镜像扫描与上线验证]

推荐的通用模板

如果你想快速迁移现有项目,可以从这个模板开始改:

# syntax=docker/dockerfile:1.4

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

FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run build --if-present

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

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

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

COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

RUN chown -R appuser:appgroup /app
USER appuser

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

方案取舍:什么时候不用多阶段构建

虽然我很推荐多阶段构建,但也不是任何项目都必须上来就搞得很复杂。

以下情况可以适当简化:

  1. 极小型脚本服务
    • 没有编译步骤,依赖极少,镜像本身已很小。
  2. 构建产物天然很简单
    • 比如 Go 静态二进制,一阶段也能跑,但多阶段依然更干净。
  3. 团队对 Docker 还不熟
    • 先把 .dockerignore、缓存命中、非 root 这些做好,收益已经很大。

也就是说,优化要分层推进,不一定一上来就追求“最强模板”。


总结

如果要把这篇文章压缩成几条最实用的建议,我会给你这份落地版清单:

  1. 先加 .dockerignore
    • 这是最低成本的提速手段。
  2. 调整 Dockerfile 顺序
    • 先复制依赖描述文件,再安装依赖,最后复制源码。
  3. 使用多阶段构建
    • 编译归编译,运行归运行,不要把工具链带到线上。
  4. 只保留生产依赖
    • devDependencies、测试工具、缓存文件都不该进最终镜像。
  5. 使用非 root 用户
    • 这是上线安全的基础动作。
  6. 做镜像扫描
    • 瘦身只是第一步,安全才是上线底线。
  7. 根据依赖兼容性选择 alpineslim
    • 别为了小体积牺牲稳定性。

最后给一个边界判断:
如果你的项目已经具备 CI/CD、镜像仓库、自动部署能力,那么多阶段构建和镜像瘦身基本不是“锦上添花”,而是必须补齐的工程化能力。它能直接影响构建效率、发布稳定性和安全质量。

如果你准备改造现有项目,建议不要一次性重写全部 Dockerfile。最稳的路径通常是:

  • 先加 .dockerignore
  • 再优化层缓存
  • 再切换到多阶段构建
  • 最后补齐非 root、扫描、运行时限制

这样改,风险最小,收益也最容易量化。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口响应抖动与内存飙升》
下一篇
《集群架构实战:基于 Kubernetes 的高可用控制平面设计与故障切换优化》