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

《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全落地》

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

Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全落地

很多团队开始用 Docker 时,往往先把“能跑起来”放在第一位:
FROM ubuntu、装一堆工具、复制整个项目、直接 docker build。短期看没问题,但一到 CI/CD、生产发布、漏洞扫描、镜像分发阶段,问题就会一起冒出来:

  • 镜像体积大,拉取慢,构建也慢
  • 依赖混乱,编译工具和运行环境混在一起
  • 安全面暴露太多,基础镜像臃肿、攻击面大
  • 构建缓存命中率低,每次改一行代码都要重来
  • 本地能跑,线上却因为缺少动态库、权限不对而翻车

这篇文章我会带你从**“为什么镜像会又大又慢”讲到“如何用多阶段构建把它拆干净”,最后落到生产环境可用的安全与性能实践**。内容偏实战,示例可以直接跑。


背景与问题

先看一个很常见、但不太理想的 Dockerfile:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

它的问题并不少:

  1. COPY . . 太早
    • 只要项目里任意文件变动,后续 npm install 缓存就容易失效。
  2. 构建依赖和运行依赖混在一起
    • npm install 可能带上 devDependencies。
    • 打包工具、源码、测试文件都进了生产镜像。
  3. 基础镜像偏大
    • node:18 默认不是最轻量的运行时。
  4. 安全面较大
    • 默认 root 用户运行。
    • 不必要工具都在镜像里。
  5. 镜像分发成本高
    • 对 CI、边缘节点、跨地域部署都不友好。

如果你的服务每天发布几次,或者公司内部有几十上百个微服务,这些问题都会被放大。


前置知识与环境准备

建议你具备下面这些基础:

  • 会写基础 Dockerfile
  • 知道镜像、容器、层(layer)的基本概念
  • 机器已安装:
    • Docker 20+
    • 推荐启用 BuildKit

开启 BuildKit 的方式:

export DOCKER_BUILDKIT=1

或在构建时显式指定:

DOCKER_BUILDKIT=1 docker build -t demo-app .

核心原理

多阶段构建(Multi-stage Build)的核心思想,其实很朴素:

把“编译/打包”阶段和“运行”阶段分开,最后只把运行所需的最小产物带进最终镜像。

1. 为什么它能瘦身

因为很多构建工具在生产运行时根本不需要,比如:

  • gcc、make、python
  • npm/yarn/pnpm 的完整缓存
  • TypeScript 源码
  • 测试文件
  • .git
  • 打包中间产物

传统单阶段镜像会把这些都装进去,多阶段构建可以把它们留在前面的阶段,最终镜像不继承这些“历史包袱”。

2. 为什么它能加速

Docker 构建本质上依赖层缓存。
如果你把“变化少的步骤”放前面,把“变化快的步骤”放后面,缓存命中率就会高很多。

典型优化顺序:

  1. 复制依赖描述文件
  2. 安装依赖
  3. 复制业务源码
  4. 构建产物

这样平时只改业务代码时,依赖安装层可以复用。

3. 为什么它更安全

最终生产镜像里:

  • 没有编译器
  • 没有 shell(某些极简镜像中)
  • 没有调试工具
  • 没有源码和敏感文件
  • 可以切换为非 root 用户运行

攻击面自然就小很多。


一张图看懂单阶段与多阶段差异

flowchart LR
    A[源码] --> B[单阶段 Dockerfile]
    B --> C[安装依赖]
    C --> D[构建应用]
    D --> E[最终镜像]
    E --> E1[包含源码]
    E --> E2[包含构建工具]
    E --> E3[包含 dev 依赖]

    A --> F[多阶段 Dockerfile]
    F --> G[builder 阶段]
    G --> H[安装依赖并构建]
    H --> I[runner 阶段]
    I --> J[仅复制运行产物]
    J --> J1[更小]
    J --> J2[更快]
    J --> J3[更安全]

实战场景:以 Node.js Web 服务为例

为了把过程讲清楚,我们用一个最常见的场景:
一个 Express 应用,构建阶段需要安装完整依赖并生成 dist/,运行阶段只需要最少文件。

项目结构

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

示例代码

package.json

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

src/server.js

const express = require('express');

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

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

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

第一步:先写一个“能用但不优”的版本

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

构建:

docker build -t demo-app:fat .

运行:

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

验证:

curl http://localhost:3000

虽然能跑,但这个镜像往往偏大,而且构建缓存效果也一般。


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

下面这个版本,才是更适合生产落地的思路。

生产可用的 Dockerfile

# syntax=docker/dockerfile:1

FROM node:18-alpine AS base
WORKDIR /app

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

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

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

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

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

USER node

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

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

  • deps 阶段只负责安装依赖
  • builder 阶段复制源码并构建产物
  • runner 阶段只安装生产依赖,并复制 dist
  • 最终使用 USER node,不以 root 运行

一张图看懂构建阶段流转

flowchart TD
    A[base] --> B[deps]
    B --> C[复制 package.json / lock 文件]
    C --> D[npm ci]
    D --> E[builder]
    E --> F[复制 src]
    F --> G[npm run build]
    G --> H[runner]
    H --> I[npm ci --omit=dev]
    I --> J[从 builder 复制 dist]
    J --> K[非 root 用户启动]

第三步:补上 .dockerignore

这个文件我非常建议认真写。
很多镜像莫名变大、构建莫名变慢,根源就是上下文(build context)传了太多没用的文件。

.dockerignore

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

这样做的好处:

  • 减少发送给 Docker daemon 的文件量
  • 避免本地 node_modules 污染容器构建
  • 避免敏感文件误入镜像

我自己就踩过一次坑:本地 .env 带着测试库地址,被 COPY . . 顺手打进镜像,结果预发环境连错库。后来我基本都会先检查 .dockerignore


第四步:构建、运行与验证清单

构建镜像

docker build -t demo-app:multi .

启动容器

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

访问服务

curl http://localhost:3000

返回示例:

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

查看镜像层历史

docker history demo-app:multi

查看镜像大小

docker images | grep demo-app

你通常会看到多阶段版本比“胖镜像”明显更精简。


构建缓存优化:不仅瘦,还要快

除了多阶段,真正影响日常体验的还有缓存设计

推荐的层顺序

FROM node:18-alpine AS deps
WORKDIR /app

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

COPY src ./src
RUN npm run build

这里的关键点是:

  • 依赖文件先复制
  • 安装依赖先执行
  • 源码后复制

这样当你只改 src/server.js 时,不需要重新执行 npm ci

使用 BuildKit 缓存挂载

如果你在 CI 或本地频繁构建,BuildKit 缓存会更香:

# 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

构建时:

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

这会显著减少重复下载依赖的时间,尤其网络一般时特别明显。


常见坑与排查

这一部分很重要。多阶段构建不是写完就万事大吉,线上翻车常常出在细节上。

1. 运行阶段缺少动态库

现象:

  • 容器启动时报错
  • 比如某些 Node 原生模块、Python 包、Java JNI 依赖,在 Alpine 上不兼容

常见报错类似:

Error: libstdc++.so.6: cannot open shared object file

排查思路:

  1. 看最终镜像是不是从 alpine 切过去了
  2. 看依赖是否包含原生编译模块
  3. 在 builder 和 runner 的系统环境是否一致

处理建议:

  • 如果有复杂原生依赖,不要盲目追求最小镜像
  • 可以考虑 debian-slim,兼容性通常更稳

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

现象:

COPY failed: stat ... no such file or directory

排查要点:

  • builder 阶段里产物实际生成在哪
  • WORKDIR 是否一致
  • npm run build 是否真的输出了 dist/

建议直接在 builder 阶段临时加一句调试:

RUN ls -R /app

虽然有点“土”,但非常有效。


3. 切换非 root 用户后权限报错

现象:

EACCES: permission denied

原因通常是:

  • 拷贝进来的文件归属是 root
  • 应用运行时还要写某些目录

处理方式:

COPY --from=builder /app/dist ./dist
RUN chown -R node:node /app
USER node

如果目录很多,也可以在 COPY 时直接指定所有者:

COPY --chown=node:node --from=builder /app/dist ./dist

4. npm installnpm ci 混用导致结果不一致

经验上,生产构建更推荐:

npm ci

原因:

  • 更适合 CI/CD
  • 基于 lock 文件,结果更可预测
  • 通常比 npm install 更稳定

边界条件:

  • 前提是你有可靠的 package-lock.json

5. .dockerignore 没配好,导致缓存频繁失效

例如你把下面这些带进上下文:

  • .git
  • coverage
  • 本地构建产物
  • 编辑器临时文件

任何变动都可能让 COPY . . 的哈希变化,从而拖垮缓存命中率。


安全最佳实践

镜像瘦身和安全,经常是同一件事的两面。

1. 使用更小但合适的基础镜像

建议优先级不是“越小越好”,而是:

满足兼容性的前提下,尽量小。

常见选择:

  • alpine:小,但某些原生依赖兼容性要注意
  • debian-slim:比完整版小,兼容性更稳
  • distroless:更安全更纯净,但调试不方便

2. 不要把构建工具带进生产镜像

最终镜像只保留:

  • 可执行程序
  • 运行时依赖
  • 配置所需目录

不要保留:

  • gcc / make
  • git / curl(除非业务确实需要)
  • 测试脚本
  • 源码(如果只需编译产物)

3. 使用非 root 用户

USER node

或者对其他语言镜像,显式创建用户:

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

4. 不把密钥写进镜像

不要这样做:

ENV ACCESS_KEY=xxxxx
ENV DB_PASSWORD=xxxxx

更好的方式:

  • 运行时通过环境变量注入
  • 使用 Docker secrets / K8s Secret / 外部密钥管理系统

5. 固定基础镜像版本

不要长期依赖这种写法:

FROM node:latest

建议固定版本:

FROM node:18-alpine

更严格一点,可以固定 digest。这样可重复性更好,也方便漏洞回溯。


性能最佳实践

1. 减少无效层

例如下面这种写法层数多、缓存也不一定友好:

RUN apk update
RUN apk add curl
RUN rm -rf /var/cache/apk/*

更推荐合并:

RUN apk add --no-cache curl

2. 依赖安装与业务代码分层

这是最实用的一条,直接影响日常构建速度。

3. 在 CI 中复用构建缓存

例如:

  • GitHub Actions cache
  • Docker buildx cache
  • 私有镜像仓库缓存层

4. 尽量缩小构建上下文

除了 .dockerignore,还要避免在仓库根目录塞太多无关内容。


生产环境落地建议

如果你准备把这套方式推广到团队,我建议按下面顺序推进。

建议一:先统一 Dockerfile 模板

不同服务可按语言做模板化:

  • Node.js 模板
  • Go 模板
  • Java 模板
  • Python 模板

这样团队不会每个人都“自由发挥”。

建议二:把检查项放进 CI

可以自动校验:

  • 是否使用多阶段构建
  • 是否存在 latest
  • 是否使用非 root 用户
  • 是否镜像过大
  • 是否通过漏洞扫描

建议三:设定镜像体积阈值

比如:

  • Node API 服务目标小于 200MB
  • Go 静态编译服务目标小于 50MB

不是绝对值,但有阈值团队才会持续优化。


一个更贴近生产的流程图

sequenceDiagram
    participant Dev as 开发者
    participant CI as CI流水线
    participant Builder as 构建阶段
    participant Registry as 镜像仓库
    participant Prod as 生产环境

    Dev->>CI: 提交代码
    CI->>Builder: docker build
    Builder->>Builder: 安装依赖/编译产物
    Builder->>CI: 输出最小运行镜像
    CI->>CI: 漏洞扫描/策略检查
    CI->>Registry: 推送镜像
    Registry->>Prod: 拉取镜像
    Prod->>Prod: 非 root 启动服务

适用边界:不是所有场景都要极限瘦身

这一点我想特别提醒。

多阶段构建几乎都值得用,但“极限瘦身”不一定总是最优解。比如:

  • 你依赖很多原生库,alpine 可能带来更多兼容性问题
  • 你需要在线 debug,distroless 可能不方便
  • 你的构建产物本身就大,例如包含模型文件、前端静态资源、字体包

所以正确思路不是“越小越先进”,而是:

在可维护、可调试、可兼容的前提下,尽量小。


总结

把这篇文章的重点浓缩成几句可执行的话:

  1. 一定要用多阶段构建
    • 构建环境和运行环境分离,是镜像治理的起点。
  2. 先复制依赖描述文件,再安装依赖
    • 这是提升缓存命中率最直接的方法。
  3. 认真写 .dockerignore
    • 很多“镜像大”“构建慢”其实是上下文污染。
  4. 生产镜像只保留运行必需内容
    • 不带源码、不带编译器、不带 dev 依赖。
  5. 非 root 运行,固定基础镜像版本
    • 这是安全落地的基本盘。
  6. 不要盲目追求最小
    • 优先保证兼容性、稳定性和可维护性。

如果你现在维护的 Dockerfile 还是“一个阶段装到底”,最值得做的第一步不是大改架构,而是先把一个服务拆成 builder + runner 两段。通常只这一改,构建速度、镜像体积和安全性就会一起变好。


分享到:

上一篇
《Docker 镜像瘦身与启动加速实战:多阶段构建、构建缓存与安全基线优化》
下一篇
《从 0 到生产可用:基于开源项目搭建企业内部知识库与检索增强问答系统实战》