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

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

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

Docker 多阶段构建与镜像瘦身实战:中级开发者的构建加速、体积优化与安全基线指南

很多团队开始用 Docker 时,最先感受到的不是“优雅”,而是几个很现实的问题:

  • 镜像越做越大,动不动几百 MB 甚至上 GB
  • CI 构建越来越慢,改一行代码也要等半天
  • 线上容器里什么工具都有,安全面巨大
  • Dockerfile 越写越长,后面的人根本不敢动

我自己在项目里也踩过类似的坑:一个 Node.js 服务,初版镜像接近 1.1GB,因为直接拿 node 基础镜像构建,依赖、源码、编译缓存、调试工具全都进了生产镜像。结果是构建慢、分发慢、漏洞扫描结果一片红。

这篇文章不讲“概念罗列”,而是带你从一个常见项目出发,做一遍多阶段构建 + 镜像瘦身 + 安全基线。读完后,你至少能做到:

  • 把构建镜像和运行镜像分离
  • 让 Docker 缓存真正发挥作用
  • 明确哪些文件不该进镜像
  • 给生产镜像加上最基本的安全约束
  • 知道构建慢、镜像大时该从哪排查

背景与问题

先看一个很常见、也很“原始”的 Dockerfile。

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

这个写法的问题非常典型:

  1. 构建环境和运行环境混在一起
    • npm install、构建工具链、源码、缓存都进入最终镜像。
  2. 缓存利用率低
    • COPY . . 太早,只要代码有一点变化,依赖层就失效。
  3. 不区分生产依赖和开发依赖
    • 测试包、TypeScript、打包器都进了生产镜像。
  4. 攻击面大
    • 默认 root 用户运行,基础镜像较大,系统包过多。
  5. 上下文污染
    • 如果没有 .dockerignorenode_modules、日志、.git 都可能被带进构建上下文。

这些问题放在本地也许只是“有点慢”,但一旦进 CI/CD 和生产环境,代价会被放大:

  • 推镜像慢
  • 拉镜像慢
  • 漏洞数量多
  • 回滚耗时变长
  • 磁盘和仓库成本上升

前置知识与环境准备

建议你本地准备以下环境:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一个可运行的 Node.js 示例项目

启用 BuildKit:

export DOCKER_BUILDKIT=1

如果你用的是 Docker Desktop,一般默认已开启。

假设我们的示例项目结构如下:

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

示例 package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "scripts": {
    "build": "mkdir -p dist && cp -r src/* dist/",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

示例 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',
    time: new Date().toISOString()
  });
});

app.listen(port, () => {
  console.log(`server running at http://0.0.0.0:${port}`);
});

核心原理

1. 多阶段构建到底解决了什么

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

  • 前面的阶段负责“造东西”
  • 最后的阶段只负责“运行东西”

比如:

  • 构建阶段:安装依赖、编译、打包
  • 运行阶段:只复制构建产物和最少运行依赖

这样最终镜像就不必包含:

  • 编译器
  • 调试工具
  • 源码
  • 缓存目录
  • 开发依赖

下面这张图可以直观看出差异:

flowchart LR
    A[源码与依赖清单] --> B[builder阶段<br/>安装依赖/编译/打包]
    B --> C[产物 dist/]
    C --> D[runtime阶段<br/>仅复制运行所需文件]
    D --> E[最终生产镜像]

2. Docker 缓存为什么经常失效

Docker 是按层构建的。每一条指令都会形成一层。
如果上层内容变了,后面的层通常都要重建。

所以这两种写法差别很大:

低效写法:

COPY . .
RUN npm install

只要源码有变化,npm install 就会重跑。

更优写法:

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

只有依赖描述文件变化时,依赖安装层才会失效。

缓存命中流程可以理解成:

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker构建器
    participant Cache as 本地/远程缓存

    Dev->>Docker: docker build
    Docker->>Cache: 检查 COPY package*.json 是否变化
    Cache-->>Docker: 未变化,可复用依赖层
    Docker->>Cache: 检查 COPY src 是否变化
    Cache-->>Docker: 已变化,重建后续层
    Docker-->>Dev: 输出新镜像

3. 瘦身不只是“小”,更是“少暴露”

镜像瘦身常常被理解成“减少 MB 数量”,但我更建议你把它理解成:

  • 减少无用依赖
  • 减少攻击面
  • 减少漏洞数量
  • 减少分发成本

越少的内容,通常意味着越少的风险。


实战代码(可运行)

下面我们从“普通写法”升级到“可用于生产的中级写法”。


第一步:先写好 .dockerignore

这是最容易忽略、但收益很高的一步。

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

作用:

  • 避免把无关文件发给 Docker daemon
  • 减少构建上下文大小
  • 提升构建速度
  • 避免本地 node_modules 污染镜像

你可以先执行:

docker build -t demo-app:naive .

观察构建日志中的上下文大小。如果上下文很大,通常就是 .dockerignore 不够干净。


第二步:先看一个“单阶段但较规范”的版本

FROM node:18-slim

WORKDIR /app

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

COPY . .
RUN npm run build

EXPOSE 3000

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

比最原始写法已经好不少,但仍然有问题:

  • 构建命令依然在最终镜像里执行过
  • 源码和构建依赖仍可能残留
  • 如果构建阶段需要开发依赖,这里会比较尴尬

第三步:多阶段构建正式版

这是更推荐的方案。

# syntax=docker/dockerfile:1.4

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

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

FROM node:18-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production

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

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

USER node

EXPOSE 3000

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

这个版本做了什么

deps 阶段

只做一件事:安装完整依赖。
这样依赖安装和源码变更解耦,缓存更稳定。

builder 阶段

复制依赖和源码,执行 npm run build
如果你的项目需要 TypeScript、Webpack、Vite,这个阶段很合适。

runtime 阶段

最终只保留:

  • 生产依赖
  • 构建产物
  • 运行命令

而且切到了非 root 用户 node


第四步:构建与运行验证

构建镜像:

docker build -t demo-app:multi .

运行容器:

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

验证服务:

curl http://localhost:3000

预期输出:

{"message":"hello docker multi-stage build","time":"2023-08-28T01:15:21.000Z"}

第五步:进一步加速构建 —— 利用 BuildKit 缓存挂载

如果你的 CI 构建频繁,依赖安装会非常耗时。
这时可以结合 BuildKit 的缓存挂载。

# syntax=docker/dockerfile:1.4

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

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

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

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force

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

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

这个优化在本地和 CI 中都很实用,尤其是依赖多、网络波动大的场景。


逐步验证清单

如果你想确认“多阶段构建真的生效了”,可以按这个顺序检查。

1. 看镜像体积

docker images | grep demo-app

重点比较:

  • demo-app:naive
  • demo-app:multi

2. 看构建历史层

docker history demo-app:multi

你会看到最终镜像不再包含某些构建动作的完整上下文。

3. 进入容器查看内容

docker run --rm -it demo-app:multi sh

检查是否只剩必要文件:

ls -lah

理想情况下一般只有:

  • dist
  • package.json
  • package-lock.json
  • node_modules

不应该看到:

  • src
  • .git
  • 测试文件
  • 本地日志

4. 验证运行身份

id

如果输出用户不是 root,说明非 root 运行已生效。


常见坑与排查

这一部分很重要。多阶段构建本身不难,难的是“为什么我照着写了还是不对”。


坑 1:COPY . . 太早,缓存全废

现象:

  • 改一行业务代码,npm ci 重新执行
  • CI 构建时间没明显下降

错误示例:

COPY . .
RUN npm ci

修正方式:

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

排查思路:

  • 看 Docker 构建日志有没有 CACHED
  • 改一个源码文件,再构建一次,观察依赖层是否复用

坑 2:本地 node_modules 把镜像搞脏了

现象:

  • 容器里依赖异常
  • 本地能跑,容器里报错
  • 出现平台相关问题,比如 Mac 本地装的包拿到 Linux 容器里失效

根因:

没有写 .dockerignore,把本地 node_modules 带进去了。

排查命令:

docker build --no-cache -t demo-app:debug .

如果 COPY . . 步骤上传上下文很大,通常就是这个问题。


坑 3:构建阶段能过,运行阶段启动失败

典型报错:

Error: Cannot find module 'xxx'

原因之一:

你在 builder 阶段用了完整依赖构建,但在 runtime 阶段只装了生产依赖,而应用实际运行时还在引用开发依赖。

排查方法:

  • 检查 dependenciesdevDependencies 是否分错
  • 不要把真正运行时依赖放到 devDependencies

坑 4:切换非 root 用户后权限出错

现象:

  • 容器启动时报权限问题
  • 写日志、写临时文件失败

原因:

复制文件后所属用户还是 root,而运行用户变成了 node

解决办法:

可以在复制时设置拥有者:

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

如果你需要写某些目录,也要提前创建并授权。


坑 5:alpine 不是永远更好

很多人看到“瘦身”就立刻上 alpine,但这不是银弹。

可能的问题:

  • 某些原生依赖编译更麻烦
  • 基于 musl 而不是 glibc
  • 调试复杂度增加

经验建议:

  • 想要一个兼顾兼容性与体积的默认选项,可以优先考虑 *-slim
  • 明确知道依赖兼容性后,再考虑 alpine

安全/性能最佳实践

这一部分我会按“能马上落地”的方式来讲。


1. 生产镜像尽量使用更小、职责更单一的基础镜像

常见选择:

  • node:18:全量,方便但偏大
  • node:18-slim:更平衡,通常够用
  • distroless:更极致,但调试门槛更高

建议中级开发者的默认策略:

  • 先用 slim
  • 有明确收益再上更极致方案

2. 永远不要默认用 root 跑业务

至少做到:

USER node

更进一步:

  • 业务目录设定正确权限
  • 挂载卷时注意宿主机权限映射

容器不是虚拟机,root 一旦被拿到,风险不小。


3. 显式区分开发依赖和生产依赖

Node 项目里建议:

RUN npm ci --omit=dev

好处:

  • 减少镜像体积
  • 降低漏洞数量
  • 降低启动时依赖扫描负担

4. 合理排序 Dockerfile 指令

一个实用原则:

  1. 最稳定的层放前面
  2. 最常变化的层放后面

典型顺序:

基础镜像
-> 系统依赖
-> 依赖清单
-> 安装依赖
-> 复制源码
-> 编译构建
-> 设置运行参数

这条原则对缓存命中率影响非常大。


5. 清理缓存,但别过度“手工打扫”

例如:

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

但如果你已经在多阶段构建里把构建垃圾留在前一阶段,很多时候不需要写一堆复杂的清理命令。
多阶段构建本身,就是最有效的清理方式。


6. 做漏洞扫描,但要理解结果

常用命令:

docker scan demo-app:multi

或者接入企业里的镜像扫描平台。

注意:

  • 漏洞数量不是唯一指标
  • 要看是否可利用、是否在运行路径上
  • 优先修复高危且可被触达的问题

7. 为容器增加基础运行约束

运行时可加入:

docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop ALL \
  -p 3000:3000 \
  demo-app:multi

这几个参数的意义:

  • --read-only:根文件系统只读
  • --tmpfs /tmp:给临时目录可写空间
  • --cap-drop ALL:去掉多余 Linux capabilities

当然,这有边界条件:
如果你的应用确实要写文件、生成缓存、落盘日志,就要针对性开放,而不是硬套。


一个更完整的生产示例

如果你想把“权限、缓存、运行镜像”一起纳入考虑,可以参考这个版本:

# syntax=docker/dockerfile:1.4

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

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

FROM node:18-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
    && npm cache clean --force \
    && chown -R node:node /app

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

USER node

EXPOSE 3000

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

它背后的结构可以总结成:

flowchart TD
    A[package.json/package-lock.json] --> B[deps阶段 npm ci]
    B --> C[builder阶段 复制源码]
    C --> D[执行 npm run build]
    D --> E[runtime阶段 安装生产依赖]
    E --> F[复制 dist]
    F --> G[非root启动应用]

方案取舍:什么时候值得“更狠地瘦身”

中级开发者常见困惑不是“会不会写多阶段”,而是“要不要继续优化到极致”。

我给一个比较务实的判断标准:

适合继续深挖的情况

  • CI 时间已经影响团队效率
  • 镜像分发频繁,带宽成本明显
  • 漏洞扫描压力大
  • 服务数量多,镜像规范急需统一

不必过度优化的情况

  • 小型内部工具,更新频率低
  • 团队对 Docker 调试能力还比较弱
  • 当前瓶颈根本不在镜像构建

也就是说,优化要服务业务,不要为了“镜像再小 30MB”把可维护性搞没了。


总结

如果你只记住这篇文章里的 5 件事,我建议是这 5 条:

  1. 一定用多阶段构建
    • 构建和运行分离,是镜像治理的起点。
  2. 先复制依赖清单,再安装依赖
    • 这是提升缓存命中率最直接的办法。
  3. 写好 .dockerignore
    • 很多“镜像莫名其妙变大”的根因就在这里。
  4. 生产镜像只保留运行必要内容
    • 产物、生产依赖、启动命令,够了就行。
  5. 默认非 root 运行
    • 这是最基础、最划算的安全基线。

如果你准备在团队里落地,我建议按下面的顺序推进,不容易翻车:

  • 第一步:统一 .dockerignore
  • 第二步:所有服务改成多阶段构建
  • 第三步:切换到 slim 类基础镜像
  • 第四步:默认使用非 root 用户
  • 第五步:为 CI 增加 BuildKit 缓存和镜像扫描

最后给一句很实在的话:
镜像瘦身不是“美化 Dockerfile”,而是把构建效率、发布成本和安全基线一起往前推。
你不一定要一上来做到极致,但至少要先把“构建环境”和“运行环境”分开。这一步,通常就已经能解决 70% 的问题。


分享到:

上一篇
《Web3 中级实战:基于智能合约与钱包登录构建去中心化会员积分系统》
下一篇
《区块链节点数据索引与查询优化实战:面向中级开发者的架构设计与性能调优-277》