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

《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建优化、缓存加速与安全加固指南》

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

Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建优化、缓存加速与安全加固指南

很多团队一开始用 Docker,目标都很朴素:能跑就行。但项目一旦进入 CI/CD、频繁发布、多环境部署,你很快就会碰到几个现实问题:

  • 镜像动不动几百 MB,拉取慢、推送慢
  • 构建时间越来越长,CI 队列堵住
  • Dockerfile 越写越乱,缓存总是失效
  • 镜像里带着编译工具、包管理器、测试文件,安全风险上升
  • 同一份代码,本地能构建,CI 上却慢得离谱

这篇文章我会从**“为什么镜像会变胖、为什么缓存总失效、怎么把构建产物和运行环境拆开”**这几个角度,带你完整走一遍 Docker 优化实战。内容面向中级开发者,默认你已经会写基础 Dockerfile,但还没有系统整理过优化方法。


背景与问题

先看一个非常常见的 Node.js 项目 Dockerfile 写法:

FROM node:20

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

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

它的问题几乎是“教科书级”的:

  1. 基础镜像偏大
    node:20 往往比你想象得重,里面带了很多运行时并不需要的内容。

  2. 构建依赖和运行依赖混在一起
    比如 TypeScript、打包工具、测试框架,这些只在构建阶段需要,运行时不该带进去。

  3. 缓存利用差
    COPY . . 放在前面,一旦代码任意文件变化,后面的 npm install 就会重新执行。

  4. 攻击面扩大
    镜像里保留了 shell、编译链、包管理器,容器被利用时可操作空间更大。

  5. 不利于排查和协作
    构建步骤耦合在一起,新同事一改 Dockerfile,经常会把缓存链打断。

换句话说,很多镜像“胖”,并不只是因为基础镜像大,而是构建阶段和运行阶段没有分离,再叠加缓存层设计不合理


前置知识与环境准备

建议你具备以下环境:

  • Docker 24+
  • 已启用 BuildKit
  • 一个可运行的 Node.js 示例项目
  • 理解基础命令:docker builddocker rundocker images

建议先开启 BuildKit,这对缓存优化非常关键:

export DOCKER_BUILDKIT=1

如果你在 Docker Desktop 中使用,一般默认已启用。


核心原理

这一部分不讲大而泛的概念,只抓住 4 个对实战最关键的点。

1. 多阶段构建:把“生产车间”和“展示橱窗”分开

多阶段构建的核心思想是:

  • 前一个阶段负责下载依赖、编译、打包
  • 最后一个阶段只保留运行所需的最小内容

你可以理解成:
前面的阶段像厨房,后面的阶段像上桌成品。厨房里锅碗瓢盆很多,但端上桌时只需要菜。

flowchart LR
    A[源码] --> B[builder 构建阶段]
    B --> C[安装依赖]
    C --> D[编译/打包]
    D --> E[runner 运行阶段]
    E --> F[仅复制运行产物]
    F --> G[更小更安全的镜像]

2. Docker 缓存:一层变了,后面可能全失效

Dockerfile 每一条指令通常都会形成一层。
缓存命中依赖两个关键因素:

  • 指令本身是否变化
  • 该指令依赖的文件内容是否变化

因此这两句顺序差异很大:

COPY . .
RUN npm install

vs

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

后者的好处是:
如果你只改了业务代码,但 package.json 没变,那么 npm install 这一层还可以直接命中缓存。

3. 镜像瘦身:不是一味追求最小,而是“最少必要”

镜像瘦身常见手段有:

  • 选更小的基础镜像
  • 只复制构建产物
  • 不把 .git、测试数据、文档带进去
  • 减少无意义层数
  • 清理包管理缓存
  • 使用 .dockerignore

但注意边界:
最小不一定最好。例如 Alpine 镜像很小,但某些原生依赖、glibc 兼容场景会更麻烦。中级开发者最容易踩的坑,就是一上来就盲目“全量 Alpine 化”。

4. 安全加固:运行镜像应该尽量“无工具、低权限、少暴露”

一个好的运行时镜像,通常具备这些特征:

  • 非 root 用户运行
  • 不包含编译工具链
  • 只暴露必要端口
  • 基础镜像来源可信
  • 依赖版本可控
  • 尽量减少 shell 与包管理器可用性

用一个例子先建立全局视图

下面用一个典型 Node.js/TypeScript 服务做演示。目标是:

  • 支持构建缓存
  • 使用多阶段构建
  • 只把编译产物和生产依赖放进最终镜像
  • 用非 root 用户运行
sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker BuildKit
    participant Builder as builder阶段
    participant Runner as runner阶段

    Dev->>Docker: docker build
    Docker->>Builder: 复制 package.json / lockfile
    Builder->>Builder: 安装依赖(可缓存)
    Docker->>Builder: 复制源码
    Builder->>Builder: npm run build
    Builder->>Builder: npm prune --omit=dev
    Builder->>Runner: 复制 dist 与生产依赖
    Runner->>Dev: 输出精简运行镜像

实战代码(可运行)

示例项目结构

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

示例应用代码

src/index.js

const http = require("http");

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

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(
    JSON.stringify({
      ok: true,
      message: "hello docker multi-stage",
      time: new Date().toISOString()
    })
  );
});

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage demo",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {}
}

第一步:先看一个“能跑但不优”的版本

FROM node:20

WORKDIR /app
COPY . .
RUN npm install --production

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

构建命令:

docker build -t demo-app:naive .

运行命令:

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

验证:

curl http://localhost:3000

这版可以运行,但优化空间很大。


改造成多阶段构建版本

下面是更推荐的写法。

Dockerfile(多阶段构建 + 缓存友好 + 非 root)

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS base
WORKDIR /app

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

FROM base AS runner
ENV NODE_ENV=production

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

USER node

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

这份 Dockerfile 做了几件关键事情:

  1. base 阶段统一基础环境
    避免重复写 WORKDIR 等配置。

  2. deps 阶段只处理依赖
    并且先复制 package*.json,最大化缓存命中。

  3. RUN --mount=type=cache
    这是 BuildKit 提供的缓存挂载能力。即便镜像层重新构建,也能复用 npm 下载缓存,明显加速。

  4. runner 阶段不再执行安装命令
    直接从 deps 拿生产依赖,减少不确定性。

  5. 使用 USER node
    避免默认 root 运行。

构建:

docker build -t demo-app:optimized .

运行:

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

如果你的项目需要“编译”,该怎么写

现实里更常见的是前端 SSR、NestJS、TypeScript API、Go/Java 等需要构建产物的项目。下面给一个更贴近生产的 Node + TypeScript 写法。

项目结构示意

ts-app/
├── src/
│   └── main.ts
├── dist/
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile

Dockerfile(构建阶段 + 运行阶段分离)

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS builder
WORKDIR /app

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

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

FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

USER node

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

这版的重点是:

  • npm ci 在 builder 阶段安装完整依赖,用于编译
  • 编译完成后执行 npm prune --omit=dev
  • 最终 runner 镜像只拿:
    • dist
    • 生产依赖 node_modules
    • 必要元信息 package*.json

这就是典型的多阶段构建价值:
构建工具还留在 builder,runner 里没有 TypeScript、测试框架、源码编辑辅助包。


.dockerignore:经常被低估,但收益很稳

很多构建慢,并不是 Dockerfile 本身有多差,而是构建上下文太大

建议至少加上:

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

为什么它重要?

当你执行:

docker build .

Docker 会先把当前目录作为“构建上下文”发送给 daemon。
如果你把 .gitnode_modules、测试报告、日志文件都打进去,构建一开始就慢了,而且还可能导致缓存误判。

我自己就踩过这个坑:项目明明没几行代码,但 .git 历史很大,结果每次 docker build 都像在搬家。


逐步验证清单

你可以按这个顺序验证优化是否生效。

1. 检查镜像大小

docker images | grep demo-app

对比 naiveoptimized 版本的镜像体积。

2. 检查缓存命中

第一次构建:

docker build -t demo-app:optimized .

第二次在不修改 package.json 的情况下构建:

docker build -t demo-app:optimized .

你应该能看到依赖安装层大概率命中缓存。

3. 修改业务代码再构建

只改 src/index.js,再执行构建。
观察是否只有后半段步骤重新执行。

4. 检查运行用户

进入容器:

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

如果基础镜像里没有 sh,你可以改成更适合的调试方式,或者临时用调试镜像。
检查当前用户:

id

应该不是 root。


常见坑与排查

这一部分我尽量写得“接地气”一点,因为很多问题不是不会写,而是明明照着写了,结果还是没生效

坑 1:COPY . . 太早,缓存直接废掉

错误示例:

COPY . .
RUN npm ci

问题:
任意源码变动都会导致依赖安装层失效。

正确思路:

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

坑 2:npm installnpm ci 混用,结果不稳定

如果你在 CI 或生产构建中追求一致性,优先用:

npm ci

原因:

  • 严格依赖 lockfile
  • 安装结果更可预测
  • 一般比 npm install 更适合自动化场景

坑 3:Alpine 镜像虽然小,但原生依赖可能出问题

例如某些 Node 模块依赖 glibc 或原生编译环境,Alpine 的 musl 体系可能引发兼容问题。
如果你:

  • 使用了 sharpbcrypt、数据库驱动等原生模块
  • 运行环境依赖特定系统库

那我更建议先从 *-slim 开始,而不是一上来就 Alpine。

坑 4:多阶段构建后,运行时报“找不到模块”

典型原因:

  • 只复制了 dist,没复制生产依赖
  • npm prune --omit=dev 在错误阶段执行
  • 最终启动命令路径不对

排查顺序建议:

  1. 进入最终镜像检查文件结构
  2. node_modules 是否存在
  3. package.jsonmain 或启动脚本是否匹配
  4. 看构建产物目录是否真的生成了

坑 5:BuildKit 缓存挂载没生效

你写了:

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

但发现没提速,可能原因有:

  • 没启用 BuildKit
  • CI 环境每次都是全新 runner,缓存没持久化
  • npm 缓存目录写错
  • 权限或用户切换影响缓存路径

可以先明确启用:

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

坑 6:镜像变小了,但构建反而更慢

这类情况并不少见。常见原因:

  • 阶段拆太碎,导致维护复杂度增加
  • 每次都从远程下载依赖,没有外部缓存支持
  • 使用过度精简镜像,安装依赖时补库成本更高
  • CI 没配置 registry cache / layer cache

所以要记住一点:
瘦身目标不是绝对最小,而是综合体积、速度、稳定性和维护成本。


安全/性能最佳实践

这一部分给你一组“实战可落地”的建议,不追求教条,强调边界条件。

1. 优先选择可信且合适的基础镜像

建议顺序通常是:

  • 官方镜像
  • 指定大版本甚至小版本
  • 根据兼容性决定 slim 还是 alpine

例如:

FROM node:20-bookworm-slim

如果你非常重视供应链可追溯性,可以进一步固定 digest。

2. 运行时镜像不要包含编译工具链

最终镜像中尽量不要保留:

  • gcc
  • make
  • git
  • curl(视场景)
  • 包管理器缓存
  • 测试工具

这些都应该留在 builder 阶段。

3. 使用非 root 用户运行

USER node

如果是自定义用户:

RUN addgroup --system app && adduser --system --ingroup app app
USER app

注意:切换用户后,要确认应用目录权限正确。

4. 减少层数,但别为了“少一层”牺牲可维护性

有些文章喜欢把所有命令揉成一长串:

RUN apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*

这没错,但不要走极端。
如果你的 Dockerfile 可读性已经非常差,新人根本不敢改,长期成本会更高。

5. 清理系统包缓存

如果你安装了系统依赖,记得清理:

RUN apt-get update && apt-get install -y --no-install-recommends \
    dumb-init \
    && rm -rf /var/lib/apt/lists/*

6. 尽量只复制必要文件

不要偷懒总写:

COPY . .

更推荐显式复制:

COPY package*.json ./
COPY src ./src
COPY tsconfig.json ./

这不仅减小上下文,还能提升缓存命中率。

7. 在 CI 中配合远程缓存

如果你的团队构建频繁,强烈建议把缓存策略做进 CI。
例如使用 Buildx 的缓存导入导出,把缓存存入 registry。

示例:

docker buildx build \
  --cache-from=type=registry,ref=your-registry/demo-app:buildcache \
  --cache-to=type=registry,ref=your-registry/demo-app:buildcache,mode=max \
  -t your-registry/demo-app:latest \
  .

这类配置对多人协作和云端 runner 特别有帮助,不然每次构建都像“失忆重来”。

8. 增加基础镜像与依赖扫描

镜像瘦身不等于安全,安全也不等于零漏洞。
建议至少做:

  • 基础镜像定期升级
  • 依赖漏洞扫描
  • 镜像扫描
  • 最小权限运行

可以在 CI 中接入扫描工具,形成发布门禁。


一个简化的优化决策图

如果你不确定该从哪里下手,可以按下面这个顺序做。

flowchart TD
    A[镜像过大或构建过慢] --> B{是否已使用多阶段构建?}
    B -- 否 --> C[先拆分 builder / runner]
    B -- 是 --> D{依赖安装是否频繁重跑?}
    D -- 是 --> E[调整 COPY 顺序并使用 lockfile]
    D -- 否 --> F{构建上下文是否过大?}
    F -- 是 --> G[完善 .dockerignore]
    F -- 否 --> H{是否保留了编译工具和dev依赖?}
    H -- 是 --> I[仅复制运行产物与生产依赖]
    H -- 否 --> J[进一步做非root、扫描与远程缓存]

推荐的目录与 Dockerfile 组织方式

当项目稍微复杂一点时,我建议你这样组织:

  • 一个主 Dockerfile 负责生产构建
  • 如有本地开发需求,单独维护 Dockerfile.dev
  • 把和构建强相关的脚本写入 package.json scripts
  • 不要把业务逻辑塞进 Dockerfile

例如:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/main.js",
    "test": "jest"
  }
}

这样 Dockerfile 更像“编排构建流程”,而不是承担业务构建细节。


总结

如果你只记住一件事,那就是:

多阶段构建的本质,不只是“把镜像做小”,而是把构建职责和运行职责彻底分开。

落地时,优先做这 5 件事:

  1. 拆分 builder 和 runner
  2. COPY package*.json 放在前面
  3. 使用 npm ci 与 lockfile
  4. 补齐 .dockerignore
  5. 最终镜像使用非 root 运行

如果团队已经进入 CI/CD 阶段,再继续做:

  • BuildKit 缓存挂载
  • 远程缓存
  • 基础镜像升级策略
  • 镜像与依赖扫描

最后给一个实用判断标准:
如果你的优化方案让镜像更小、构建更快、排查更清晰,而且新同事 10 分钟内能看懂,那它大概率就是好方案。
反过来,如果你为了“极致瘦身”把 Dockerfile 写成谜语,后面维护的人通常会先把你“优化”掉。

希望这篇文章能帮你把 Docker 构建从“能用”推进到“好用、快用、稳用”。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》