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

《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建加速、体积优化与安全基线配置-125》

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

Docker 多阶段构建与镜像瘦身实战:从“能跑”到“跑得快、传得快、出问题少”

很多团队第一次写 Dockerfile,目标都很朴素:先把服务跑起来

但项目一进迭代期,问题就会一起冒出来:

  • 镜像 1GB 起步,CI 推送和拉取都很慢
  • 构建依赖、编译工具链全被打进生产镜像
  • 一个小改动就全量重建,缓存几乎不起作用
  • 容器默认 root 运行,安全审计一查一个准
  • 线上排障时发现镜像层太乱,不知道哪些文件到底有没有必要

我自己早期也踩过一个很典型的坑:Go 服务明明编译后就一个二进制文件,结果镜像还是接近 800MB。原因很简单——我把整个构建环境、包管理缓存、源码目录都一起塞进了运行时镜像。

这篇文章不讲抽象概念堆砌,而是带你从一个“普通但常见”的 Dockerfile 出发,逐步做到三件事:

  1. 用多阶段构建拆分编译与运行环境
  2. 利用构建缓存提升 CI/CD 速度
  3. 建立可落地的镜像安全基线

适合已经会写 Dockerfile,但希望把镜像质量再提升一个层级的中级开发者。


前置知识与环境准备

你至少需要:

  • 会使用 docker builddocker run
  • 理解 Docker 镜像层和容器的基本概念
  • 本地已安装:
    • Docker 20+
    • 可选:BuildKit(推荐开启)

建议先开启 BuildKit,它对缓存、挂载和构建体验帮助很大。

export DOCKER_BUILDKIT=1

如果你使用 Docker Desktop,新版本通常默认已启用。


背景与问题

先看一个“很常见但不够好”的 Node.js Dockerfile:

FROM node:18

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

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

它的问题并不少:

  1. 构建环境和运行环境混在一起

    • npm install、源码、构建工具全留在最终镜像里
  2. 缓存利用不佳

    • COPY . . 放得太早,只要代码有变化,npm install 就会重新执行
  3. 镜像体积大

    • 如果项目包含测试文件、文档、Git 元数据,也会一起打进去
  4. 默认 root 运行

    • 安全基线太弱
  5. 依赖边界模糊

    • 开发依赖和生产依赖可能都进入运行时

这些问题在本地可能不明显,但到了 CI/CD、Kubernetes 或跨地域部署时,会被无限放大。


核心原理

Docker 多阶段构建的核心思想可以概括成一句话:

把“构建需要的东西”和“运行需要的东西”分开。

编译、打包、测试通常需要很多工具链;而线上运行往往只需要:

  • 编译后的产物
  • 必要的运行时依赖
  • 最小权限配置

也就是说,最终镜像不应该承载“整个开发现场”。

多阶段构建的工作方式

flowchart LR
    A[源码与依赖定义] --> B[builder 阶段<br/>安装依赖/编译]
    B --> C[生成构建产物]
    C --> D[runtime 阶段<br/>仅复制必要文件]
    D --> E[最终生产镜像]

镜像瘦身的几个抓手

镜像变大,通常来自这几类内容:

  • 基础镜像过大
  • 构建工具链留在最终镜像
  • 包管理缓存没清理
  • 无关文件被 COPY 进去
  • 运行时带了开发依赖

因此瘦身通常靠以下方法组合:

  1. 选更合适的基础镜像
  2. 使用多阶段构建
  3. 合理安排 Dockerfile 指令顺序,最大化缓存命中
  4. 配置 .dockerignore
  5. 仅复制运行必需文件
  6. 非 root 用户运行
  7. 尽量固定依赖版本与基础镜像摘要

构建缓存为什么会快很多

Docker 是按层缓存的。某一层内容不变,后续构建就能复用。

对于 Node 项目,正确顺序通常是:

  1. 先复制 package.json / package-lock.json
  2. 安装依赖
  3. 再复制业务代码
  4. 再执行构建

这样业务代码变更时,不会让依赖安装层失效。

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

    Dev->>Docker: COPY package*.json
    Docker->>Cache: 检查依赖层缓存
    Cache-->>Docker: 命中/未命中
    Dev->>Docker: RUN npm ci
    Dev->>Docker: COPY src/
    Dev->>Docker: RUN npm run build
    Docker-->>Dev: 输出最终镜像

实战代码(可运行)

下面我们用一个简单的 Node.js 示例,完整走一遍从“普通写法”到“可上线写法”。

示例项目结构

demo-node-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── src
    └── index.js

示例代码

package.json

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

src/index.js

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

const port = process.env.PORT || 3000;

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

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", "run", "start"]

这个版本能跑,但不够“生产化”。


改造成多阶段构建版本

Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

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

FROM node:18-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY --from=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 ["npm", "run", "start"]

这个版本做了几件关键事情:

  • deps 阶段只负责安装依赖
  • build 阶段负责构建产物
  • runtime 阶段只拿运行需要的内容
  • 使用 Alpine 作为较轻量基础镜像
  • 使用非 root 用户运行
  • 使用 BuildKit 缓存 npm 包目录

配置 .dockerignore

这是很多人容易忽略、但收益非常直接的文件。

.dockerignore

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

作用很简单:减少构建上下文
如果不加它,Docker 在构建时会把很多无关文件一起发送给 daemon,这本身就会拖慢构建,并可能污染镜像层。


构建与运行

构建镜像

docker build -t demo-node-app:1.0 .

运行容器

docker run --rm -p 3000:3000 demo-node-app:1.0

验证接口

curl http://localhost:3000

预期输出:

{"message":"hello docker multi-stage build","hostname":"<container-id>"}

逐步验证清单

建议你不要只看“能不能跑”,而是按下面顺序验证。

1. 验证镜像体积

docker images | grep demo-node-app

对比改造前后体积,通常会明显下降。

2. 验证运行用户

docker run --rm demo-node-app:1.0 id

你应该看到的是普通用户,而不是 root

3. 验证构建缓存是否生效

第一次构建后,修改 src/index.js 中的返回文本,再次执行:

docker build -t demo-node-app:1.0 .

如果 Dockerfile 顺序合理,npm ci 这层应该会命中缓存,不会重新安装依赖。

4. 验证镜像内容是否足够“干净”

docker run --rm -it demo-node-app:1.0 sh

进入容器后检查:

ls -lah

你应该只看到运行所需内容,而不是整个源码仓库。


进一步优化:生产依赖与更小运行时

上面的示例为了讲清流程,直接把 node_modules 从依赖阶段复制到了运行阶段。
如果你的项目区分了开发依赖和生产依赖,可以继续优化。

优化思路

  • 构建阶段安装完整依赖用于打包
  • 运行阶段只安装生产依赖,或者只复制生产依赖

下面给出一种更贴近真实项目的写法。

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS build
WORKDIR /app

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

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

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

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

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

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

这里的重点是:

RUN npm prune --omit=dev

它会移除开发依赖,让最终运行时更轻一些。


常见坑与排查

这一部分很重要,因为多阶段构建不是“写完就万事大吉”。很多问题都出在细节上。

1. COPY . . 太早,导致缓存失效

现象

明明只是改了一行业务代码,结果 npm installnpm ci 每次都重新执行。

原因

你先复制了整个项目目录,任何文件变化都会让依赖安装层失效。

修正

先复制依赖清单,再安装依赖。

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

COPY src ./src

2. Alpine 镜像不一定总是最优

现象

某些 Node、Python、Java 或带原生扩展的项目,在 Alpine 下构建失败,或者运行时出现兼容性问题。

原因

Alpine 使用 musl libc,有些依赖默认按 glibc 生态构建。

排查建议

  • 看错误是否与原生模块、动态库有关
  • 若项目依赖较复杂,可尝试 debian-slim 系列镜像

边界条件

不是所有项目都适合盲目切 Alpine。
如果为省几十 MB,换来构建不稳定和排障困难,通常不划算。


3. 运行阶段缺少必要文件

现象

容器启动时报错:

  • 找不到入口文件
  • 找不到配置文件
  • 找不到静态资源

原因

多阶段构建时,只复制了部分产物,遗漏了运行必需文件。

排查方法

进入容器看文件是否存在:

docker run --rm -it demo-node-app:1.0 sh

检查目录:

find /app -maxdepth 2 -type f

4. 使用非 root 用户后权限报错

现象

应用启动时报“permission denied”。

原因

切换用户后,应用尝试写入当前无权限目录,例如日志目录、临时目录、上传目录。

修正方式

  • 在镜像构建阶段创建并授权目录
  • 尽量把可写目录范围控制到最小

示例:

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

5. .dockerignore 配错,导致文件没被复制

现象

本地有文件,但镜像构建时报找不到。

原因

.dockerignore 排除了。

排查建议

先检查 .dockerignore,尤其是:

  • dist
  • .env
  • 配置目录
  • 证书目录

我自己就遇到过一次,把 dist 忽略掉了,结果 CI 构建一切正常,运行阶段却找不到打包产物。这个坑非常隐蔽。


安全/性能最佳实践

这一部分可以直接当作团队 Dockerfile review 清单。

1. 默认使用非 root 用户

不建议生产容器默认 root 运行。

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

这是最基本、也最容易落地的安全基线。


2. 固定基础镜像版本,最好固定摘要

不要只写:

FROM node:18-alpine

更稳妥的做法是固定更具体的版本,甚至 digest。
这样可以减少“今天构建正常,明天同样代码却行为变化”的情况。


3. 只安装生产依赖

例如 Node 项目中:

  • 构建阶段可以完整安装
  • 运行阶段尽量只保留生产依赖

这能同时改善体积、安全面和启动效率。


4. 利用 BuildKit 缓存包管理器目录

例如 npm:

RUN --mount=type=cache,target=/root/.npm npm ci

对于 Maven、pip、apt 也有类似思路。
这在 CI 中的收益尤其明显。


5. 减少镜像层中的无效文件

例如不要在同一镜像里长期保留:

  • 构建缓存
  • 测试报告
  • Git 历史
  • 本地 IDE 文件
  • 临时压缩包

6. 单进程、明确入口、可观测

容器启动命令尽量明确,避免多层脚本嵌套到自己都看不懂。

CMD ["npm", "run", "start"]

同时建议应用支持:

  • 健康检查接口
  • 结构化日志输出到 stdout/stderr
  • 通过环境变量配置运行参数

7. 配合镜像扫描工具做基线检查

多阶段构建能减少攻击面,但不能替代漏洞治理。
建议在 CI 中引入镜像扫描,例如:

  • Trivy
  • Grype
  • Docker Scout

一个典型流程如下:

flowchart TD
    A[提交代码] --> B[Docker Build 多阶段构建]
    B --> C[单元测试/集成测试]
    C --> D[镜像漏洞扫描]
    D --> E{是否通过基线}
    E -- 是 --> F[推送镜像仓库]
    E -- 否 --> G[阻断发布并修复]

8. 尽量避免在镜像中放密钥

不要把以下内容写死进镜像:

  • API Key
  • 数据库密码
  • 云厂商访问密钥
  • 私有证书

正确方式是通过:

  • 环境变量
  • Secret 管理系统
  • 编排平台的密文注入能力

方案对比:不是所有项目都要同一种写法

中级开发者很容易陷入一个误区:
“学会了多阶段构建,就给所有项目套一个模板。”

其实应该按项目特征选策略。

场景推荐方案说明
Go 静态编译服务多阶段 + scratch/distroless最容易做到极小镜像
Node Web 服务多阶段 + slim/alpine关注依赖裁剪与原生模块兼容
Java 应用多阶段 + JRE 精简镜像重点是分层与 JVM 参数
Python 应用多阶段 + venv/依赖缓存重点在 wheels、系统依赖与体积控制

一个很实用的判断标准是:

  • 如果项目有编译步骤,多阶段构建几乎是标配
  • 如果项目依赖复杂且有本地扩展/动态库,要优先考虑稳定性,再追求极限瘦身
  • 如果镜像已经很小,但构建慢,重点应该放在缓存设计,而不是继续换更小基础镜像

一份可复用的检查清单

上线前,我建议至少过一遍这份清单:

  • 是否使用多阶段构建拆分编译与运行环境
  • 是否避免过早 COPY . .
  • 是否有 .dockerignore
  • 是否只保留运行必需文件
  • 是否使用非 root 用户
  • 是否设置 NODE_ENV=production 或等价环境变量
  • 是否清理开发依赖/构建缓存
  • 是否固定基础镜像版本
  • 是否接入镜像漏洞扫描
  • 是否避免把密钥打进镜像

总结

Docker 多阶段构建的价值,不只是“镜像变小一点”,而是同时改善三件事:

  • 构建更快:缓存命中率更高,CI 更稳定
  • 镜像更小:传输、拉取、启动更高效
  • 安全更稳:减少工具链暴露,建立最小权限运行基线

如果你准备在团队里真正落地,我建议按这个顺序推进:

  1. 先加 .dockerignore
  2. 再调整 Dockerfile 指令顺序,提高缓存命中
  3. 把构建与运行拆成多阶段
  4. 默认改为非 root 用户
  5. 最后接入镜像扫描与版本固定

边界条件也要记住:
瘦身不是唯一目标,稳定构建和可维护性更重要。
比如 Alpine 并非万能,distroless 也不是所有场景都方便排障。选择方案时,要看你的语言生态、团队经验和交付要求。

如果你现在手上就有一个“又大又慢”的 Docker 镜像,最值得先做的一件事,就是把现有 Dockerfile 拿出来,对照本文的实战版本改一遍。通常第一轮优化,就能看到比较明显的收益。


分享到:

上一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-449》
下一篇
《集群架构中服务发现与负载均衡的实战设计:从注册中心选型到高可用故障切换》