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

《Docker 多阶段构建与镜像瘦身实战:为中级开发者打造高效、可维护的生产级镜像》

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

Docker 多阶段构建与镜像瘦身实战:为中级开发者打造高效、可维护的生产级镜像

很多团队第一次把应用容器化时,关注点往往是“能跑就行”。但一旦进入测试、预发、线上环境,问题就会很快冒出来:

  • 镜像动不动就 800MB、1GB+
  • 构建速度慢,CI 排队久
  • 镜像里带着编译工具、包管理器,安全面过大
  • 同一个 Dockerfile 既负责编译又负责运行,维护起来越来越乱
  • 排查问题时发现层缓存失效,改一行代码就全量重建

这些问题,我基本都踩过。尤其是早期用一个 Dockerfile 从头装依赖、编译、打包、运行,最后镜像里不仅有业务代码,还有 gccmake、临时缓存和测试文件。能运行没错,但离“生产级镜像”还差得远。

这篇文章我会从中级开发者真正会遇到的场景出发,带你做一遍:

  1. 为什么单阶段构建容易把镜像做胖
  2. 多阶段构建到底解决了什么
  3. 如何写一个可运行、可维护、可迭代优化的 Dockerfile
  4. 怎么排查构建慢、镜像大、缓存失效、权限异常等常见坑
  5. 如何兼顾安全、性能和可维护性

前置知识与环境准备

建议你已经具备这些基础:

  • 会写基础 Dockerfile
  • 了解镜像、容器、层(layer)的概念
  • 能使用 docker builddocker run
  • 本文示例使用一个 Node.js 应用演示,但思路同样适用于 Go、Java、Python、Rust 等项目

环境建议:

  • Docker 20.10+
  • 推荐启用 BuildKit(缓存体验更好)

可以先在本地开启 BuildKit:

export DOCKER_BUILDKIT=1

或者在构建时显式指定:

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

背景与问题

先看一个很典型、也很常见的“能用但不优雅”的单阶段 Dockerfile:

FROM node:18

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

RUN npm run build

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

它的问题并不在“写错了”,而在于把所有工作都塞进一个阶段里

  • 构建依赖和运行依赖混在一起
  • 镜像里包含源码、构建缓存、开发依赖
  • npm install 默认可能装上 devDependencies
  • node:18 完整版基础镜像通常比 slim/alpine 更大
  • 如果源码一变,后续缓存可能全失效

结果就是:

  • 镜像大:传输、存储、拉取都慢
  • 安全风险高:镜像里工具链越多,攻击面越大
  • 运行环境不干净:构建产物和源代码、测试文件混杂
  • 维护成本高:后续换基础镜像、做安全加固时很痛苦

我们真正想要的是:

  • 构建阶段有完整工具链,方便编译
  • 运行阶段只保留最小必需文件
  • 利用缓存加速依赖安装
  • 让 Dockerfile 结构清晰,方便团队协作

这正是**多阶段构建(Multi-stage Build)**擅长的事。


核心原理

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

用一个或多个“构建阶段”生成产物,再把最终运行所需内容复制到一个更干净、更轻量的“运行阶段”里。

单阶段与多阶段的差异

flowchart LR
    A[源码] --> B[单阶段镜像]
    B --> C[包含构建工具]
    B --> D[包含源码]
    B --> E[包含缓存]
    B --> F[包含运行文件]

    G[源码] --> H[构建阶段]
    H --> I[编译产物]
    I --> J[运行阶段镜像]
    J --> K[仅保留运行所需文件]

单阶段像是“把厨房、食材、锅、垃圾桶一起打包带上桌”;
多阶段则像是“在厨房做好菜,只把成品端出来”。

多阶段构建的关键能力

1. 多个 FROM

每个 FROM 都是一个新的阶段:

FROM node:18 AS builder
# 构建逻辑

FROM node:18-slim AS runner
# 运行逻辑

2. 用 COPY --from= 跨阶段复制

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

这意味着最终镜像可以完全不包含构建工具链

3. 精准控制内容

你不再是“把整个工作目录带进去”,而是只复制真正需要的文件:

  • 编译产物
  • 生产依赖
  • 必要配置
  • 启动脚本

构建流程视图

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

    Dev->>Docker: docker build
    Docker->>Builder: 安装依赖
    Docker->>Builder: 复制源码并构建
    Builder-->>Docker: 生成 dist/ 与生产依赖
    Docker->>Runner: 复制 dist/ package.json node_modules
    Runner-->>Dev: 产出精简运行镜像

镜像瘦身不只是“换小底座”

很多人一提瘦身,第一反应是:

  • alpine
  • 删除缓存
  • 合并 RUN 指令

这些当然有用,但只是表层优化。真正有效的瘦身通常来自三件事:

  1. 不把不需要的东西打进最终镜像
  2. 减少层中无意义的文件
  3. 让缓存命中稳定,避免重复安装依赖

也就是说,多阶段构建往往是“瘦身的主干”,其他技巧是“锦上添花”。


实战代码(可运行)

下面我们用一个简单的 Node.js Web 应用做完整演示。

项目结构

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

示例应用代码

src/server.js

const express = require('express');

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

app.get('/', (req, res) => {
  res.json({
    message: 'Hello, production image!',
    time: new Date().toISOString()
  });
});

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "Docker multi-stage build 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"
  }
}

这里的 build 很简单,只是模拟“构建产物输出到 dist”。真实项目里可以是 TypeScript 编译、前端打包、Go 编译、Java 打包等。

第一步:先写一个更合理的 .dockerignore

这是很多人最容易忽略,但收益极高的一步。

.dockerignore

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

为什么它重要?

Docker 构建时会把“上下文”发送给 Docker daemon。
如果你的项目里有 .gitnode_modules、测试产物、日志文件,这些都会拖慢构建,还可能污染缓存。

我自己就遇到过:本地 node_modules 很大,结果每次 docker build 都像搬家。


从单阶段到多阶段:一步一步优化

版本一:基础多阶段构建

FROM node:18 AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:18-slim AS runner

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

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

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

这个版本已经比单阶段好很多:

  • builder 负责构建
  • runner 只负责运行
  • runnernode:18-slim 更轻
  • 最终镜像不再需要构建过程中的源码产物(理论上可继续裁剪)

但它还有一个问题:
依赖安装做了两次。一次在 builder,一次在 runner

在某些场景下这是合理的,因为构建依赖和运行依赖可能不同;但如果你想进一步优化,还可以继续拆。


版本二:拆分依赖阶段、构建阶段、运行阶段

这是我在生产中更常用的结构,职责更清楚。

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

FROM node:18 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-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

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

这个版本的思路

  • deps:只负责安装依赖,便于缓存
  • builder:基于依赖进行构建
  • runner:重新安装生产依赖,并复制构建产物

适合:

  • 需要完整依赖来构建
  • 最终运行阶段只想要生产依赖
  • 想让 package-lock.json 稳定控制依赖版本

版本三:更接近生产级的强化版

下面这个版本加入了更多实践细节:

  • 使用非 root 用户
  • 精简镜像内容
  • 减少权限风险
  • 更清晰的复制范围
FROM node:18 AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

FROM node:18-slim AS runner

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts \
  && 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"]

构建与运行

构建镜像:

docker build -t demo-app:multi .

运行容器:

docker run -d --name demo-app -p 3000:3000 demo-app:multi

验证:

curl http://localhost:3000/

预期输出类似:

{"message":"Hello, production image!","time":"2025-01-01T12:00:00.000Z"}

逐步验证清单

做多阶段构建时,我建议不要一上来就追求“最极致”,而是每改一步都验证。

验证 1:镜像能否正常运行

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

检查:

  • 容器是否正常启动
  • 端口是否暴露正确
  • 环境变量是否生效

验证 2:最终镜像里是否真的没有源码

进入容器:

docker exec -it demo-app sh

查看目录:

ls -la /app

你应该重点确认:

  • 是否只有 distpackage.jsonpackage-lock.jsonnode_modules
  • 是否没有 src、测试目录、构建缓存等

验证 3:镜像层是否合理

docker history demo-app:multi

可以帮助你判断:

  • 有没有明显过大的层
  • 哪一步引入了不必要内容
  • 是否有重复安装依赖的问题

验证 4:镜像大小变化

docker images | grep demo-app

对比单阶段和多阶段构建结果,通常能看到明显差异。


常见坑与排查

多阶段构建不复杂,但实战里确实有一些高频坑。

1. COPY --from 路径写错

现象:

  • 构建时报错:no such file or directory
  • 最终容器启动时报找不到产物

比如:

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

但实际上构建产物在 /app/dist

排查方式:

RUN ls -la /app
RUN ls -la /app/dist

可以临时加在 builder 阶段确认目录结构。


2. 缓存总是失效,构建很慢

常见错误写法:

COPY . .
RUN npm ci

这样只要代码有变化,npm ci 就会重跑。

更合理的写法是先复制依赖描述文件:

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

这样只有 package.jsonpackage-lock.json 改变时才会重新安装依赖。

缓存命中逻辑图

flowchart TD
    A[复制 package.json/package-lock.json] --> B[npm ci]
    B --> C[复制业务源码]
    C --> D[构建应用]

    E[仅修改源码] --> C
    F[修改依赖文件] --> A

如果只是改源码,通常可以直接复用依赖层缓存。


3. 本地能跑,容器里跑不了

很常见的原因有:

  • 构建产物路径不一致
  • 启动命令写错
  • 环境变量缺失
  • 文件权限不足
  • 只复制了 dist,但运行还依赖配置文件

排查思路:

  1. 先进入容器看文件是否存在
  2. 再手动执行启动命令
  3. 再看日志

命令:

docker logs demo-app
docker exec -it demo-app sh
node dist/server.js

4. 使用 Alpine 后出现原生依赖兼容问题

alpine 很小,这点很诱人,但它基于 musl libc,不是所有 Node 原生模块都能愉快兼容。
如果你的项目依赖 sharpbcrypt 之类模块,可能会遇到编译或运行问题。

我的经验是:

  • 没有 native 依赖时,可以优先考虑 alpine
  • 有 native 依赖时,优先试 slim
  • 如果是生产关键服务,稳定通常比极致瘦身更重要

别为了省几十 MB,把兼容性搞得很脆弱。


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

你切到非 root 用户后,可能会遇到:

  • 无法写日志目录
  • 无法读取某些文件
  • 应用启动即报权限错误

比如:

USER appuser

/app 目录还是 root 所有。

解决方式:

RUN chown -R appuser:appuser /app
USER appuser

6. .dockerignore 配错,把必要文件排除了

有时候构建失败不是 Dockerfile 问题,而是 .dockerignore 把关键文件过滤掉了。

例如你写了:

dist

但又在某些场景下依赖本地已有 dist
或者把配置文件排掉了。

排查方法:

  • 重新审视构建上下文
  • 检查 COPY 语句依赖的文件是否真的存在于上下文中

安全/性能最佳实践

生产级镜像,不只是“体积小”,还要兼顾安全和可维护性。

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

不建议:

FROM node:latest

建议:

FROM node:18-slim

更好的做法是固定到更明确的版本标签。
原因很简单:latest 漂移太大,今天能构建,明天未必行为一致。


2. 用 npm ci 代替 npm install

对于 CI/CD 和生产构建,npm ci 更稳:

  • 基于 lock 文件
  • 安装更可预测
  • 更适合自动化环境
RUN npm ci

运行阶段只装生产依赖:

RUN npm ci --omit=dev

3. 运行阶段尽量只保留必要文件

你可以把最终镜像理解成“上线交付物”,它不应该包含:

  • 源码
  • 测试脚本
  • 文档
  • 构建缓存
  • 编译工具
  • 包管理器缓存

原则是:

能不带进去,就别带进去。


4. 使用非 root 用户运行应用

这是很基础但很重要的一条。

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

这样做的价值:

  • 降低容器逃逸后的危害面
  • 满足很多安全扫描和合规要求
  • 形成统一的生产运行习惯

5. 减少无意义层与临时文件

例如:

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

尽量把相关操作放在同一层完成,避免缓存和中间文件残留。


6. 根据项目类型选择基础镜像,不要盲目追求最小

可以大致这样判断:

  • 极简静态二进制应用(如 Go):可考虑 scratch 或 distroless
  • Node/Python 应用:优先 slim,再评估 alpine
  • 需要排障工具的场景:不要过度精简到难以调试

边界条件很重要:
越小的镜像,通常越难排障;越全的镜像,通常越大、攻击面越广。
要根据团队成熟度平衡。


7. 使用多阶段隔离测试、构建、运行

如果你的流水线更完整,可以继续扩展:

  • deps
  • test
  • builder
  • runner

例如先在 test 阶段跑测试,通过后再生成 runner
这样构建链路更清晰,也更方便 CI 接入。

阶段职责关系图

classDiagram
    class deps {
      安装依赖
      缓存友好
    }
    class test {
      运行单元测试
      静态检查
    }
    class builder {
      编译构建
      生成产物
    }
    class runner {
      最小运行环境
      非root用户
    }

    deps --> test
    deps --> builder
    builder --> runner

进阶建议:什么时候值得继续“抠细节”

很多同学会问:镜像从 900MB 降到 250MB 当然值,那从 250MB 再降到 180MB 还值不值?

我通常这样判断:

值得继续优化的情况

  • 服务实例很多,镜像拉取频繁
  • CI/CD 构建次数高,累计时间明显
  • 节点网络带宽受限
  • 安全扫描暴露出太多无关软件包
  • 边缘节点/私有环境存储紧张

不必过度优化的情况

  • 团队对 Docker 还不熟,先保证可维护
  • 服务更新频率不高
  • 当前瓶颈不在镜像体积
  • 过度使用极小镜像导致调试困难

一句话总结:

生产级优化不是“越极致越好”,而是“在稳定、可维护、安全之间找到合适平衡”。


一个可复用的生产级模板

如果你想要一个更通用的 Node 服务模板,可以直接从这个起步:

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

FROM node:18 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-slim AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts \
  && 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"]

如果你的项目是前端应用,也可以把 builder 阶段打包出的静态文件复制到 Nginx 镜像里;
如果你的项目是 Go 应用,往往还能进一步做到更小,因为最终只需要一个二进制文件。


总结

把 Docker 镜像做成“生产级”,关键不是会写几个 RUN,而是建立正确的构建思路:

  1. 构建和运行分离
  2. 只把最终需要的文件带入运行镜像
  3. 优先优化缓存命中
  4. 从安全角度减少工具链和权限暴露
  5. 根据项目特点选择基础镜像,而不是盲目追求最小

如果你现在的 Dockerfile 还是单阶段、全量复制、镜像巨大,我建议按下面顺序改:

  • 先补 .dockerignore
  • 再调整 COPY package*.json 与依赖安装顺序
  • 再拆成 builder + runner
  • 最后再考虑非 root 用户、slim/alpine、测试阶段、缓存优化

这样改最稳,也最容易让团队接受。

多阶段构建不是“高级技巧”,它应该成为你写生产 Dockerfile 的默认起点。只要做过两三个项目,你会明显感觉到:镜像更小了,构建更快了,线上也更干净了。这个收益,真的很值。


分享到:

上一篇
《Java 开发踩坑实战:排查与修复线程池误用导致的接口超时、内存飙升和任务堆积》
下一篇
《Web3 中级实战:基于 Solidity 与 Ethers.js 构建可升级智能合约的完整方案》