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

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

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

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

很多团队在刚开始用 Docker 时,往往先解决“能跑起来”,然后才发现两个现实问题:

  1. 镜像越来越大,一个 Node.js 服务轻轻松松几百 MB;
  2. 构建越来越慢,代码改一行,依赖又重装一遍;
  3. 安全风险越来越多,镜像里带着编译器、包管理器、root 用户,漏洞扫描一片红。

我自己第一次给线上服务做镜像瘦身时,原本觉得“几十 MB 没什么”,结果 CI/CD 一天构建几十次,拉取镜像、推送镜像、跨环境分发的时间被不断放大,最后才意识到:镜像体积、构建速度和安全性,其实是同一个工程问题的三个面

这篇文章我们不讲空泛原则,而是围绕一个可运行示例,把这几个目标一起落地:

  • 多阶段构建 拆分“构建环境”和“运行环境”
  • 用合理的 Dockerfile 设计提高 缓存命中率
  • 用更小的基础镜像和复制策略完成 镜像瘦身
  • 用非 root、只带运行时依赖等方式做 安全加固

背景与问题

先看一个常见但不太理想的 Dockerfile。以一个 TypeScript Node.js 服务为例:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

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

它的问题很典型:

  • COPY . . 太早,任何源码改动都会让 npm install 缓存失效
  • 构建环境和运行环境混在一起,最终镜像里保留了:
    • 源码
    • devDependencies
    • 编译工具链
  • 默认 root 用户运行,安全面较大
  • 没有 .dockerignore,可能把 .git、日志、测试文件都打进镜像

结果通常是:

  • 镜像大
  • 构建慢
  • 漏洞多
  • 交付链路成本高

一个更实际的目标

中级开发者做镜像优化时,不要只盯着“体积最小”,而应该追求这三个平衡:

  • 构建快
  • 运行稳
  • 安全边界清晰

前置知识与环境准备

建议你本地准备:

  • Docker 20.10+
  • Docker BuildKit(建议开启)
  • 一个 Node.js 示例项目
  • 可选:docker buildxdocker image inspectdocker history

开启 BuildKit:

export DOCKER_BUILDKIT=1

如果你在 Windows PowerShell 中:

$env:DOCKER_BUILDKIT=1

核心原理

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

多阶段构建的本质,是把一个镜像构建过程拆成多个阶段:

  • builder 阶段:安装编译依赖、执行构建
  • runner 阶段:只保留运行所需文件

这样最终镜像不会把中间阶段的“工具链垃圾”带进去。

flowchart LR
    A[源代码] --> B[Builder 阶段]
    B --> C[安装依赖]
    C --> D[编译产物 dist]
    D --> E[Runner 阶段]
    E --> F[仅复制运行所需文件]
    F --> G[更小更安全的生产镜像]

2. 缓存命中率为什么影响构建速度

Docker 构建按层执行。某一层输入变了,这一层及其后续层缓存都会失效。

比如下面两种写法差异很大:

不推荐:

COPY . .
RUN npm ci

只要任何文件变动,npm ci 就会重新执行。

推荐:

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

这样只有依赖声明变化时,才会重新安装依赖。普通业务代码变动时,可以复用缓存。

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

很多人会先想到 alpine,这当然有帮助,但不够完整。真正有效的瘦身通常来自四件事:

  1. 少装依赖
  2. 少复制文件
  3. 只保留运行产物
  4. 减少层浪费

4. 安全加固的底线思路

容器不是天然安全,它只是隔离手段。镜像层面的基本加固建议至少包括:

  • 不用 root 运行
  • 运行镜像不带编译器和包管理器
  • 使用明确版本标签,避免漂移
  • 控制复制内容,减少敏感文件进入镜像
  • 定期扫描漏洞

示例项目结构

我们先准备一个最小可运行示例。

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "Docker multi-stage build demo",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.10.1",
    "typescript": "^5.7.2"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true
  },
  "include": ["src/**/*"]
}

src/index.ts

import express from "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://localhost:${port}`);
});

.dockerignore

这个文件非常关键,很多人会漏掉。

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

实战代码(可运行)

下面从“普通写法”升级到“生产可用写法”。

第一步:一个相对合理的多阶段 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-bookworm-slim AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build
RUN npm prune --omit=dev

FROM node:18-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/index.js"]

这个版本做了什么

  • builder 阶段完成依赖安装与 TypeScript 编译
  • npm prune --omit=dev 删除开发依赖
  • runner 阶段只复制:
    • package*.json
    • 生产依赖后的 node_modules
    • 构建产物 dist
  • 使用 node 非 root 用户运行

构建并运行

docker build -t demo-app:multi-stage .
docker run --rm -p 3000:3000 demo-app:multi-stage

验证:

curl http://localhost:3000

预期输出:

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

第二步:进一步优化缓存与构建速度

如果你已经在 CI 中频繁构建,接下来建议用 BuildKit 的缓存挂载。

# syntax=docker/dockerfile:1.4

FROM node:18-bookworm-slim AS deps

WORKDIR /app

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

FROM node:18-bookworm-slim AS builder

WORKDIR /app

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

RUN npm run build
RUN npm prune --omit=dev

FROM node:18-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/index.js"]

为什么这版更快

  • deps 阶段专门处理依赖
  • 只要 package-lock.json 不变,这一层就能最大概率命中缓存
  • BuildKit 会把 npm 缓存持久化,减少重复下载
sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 构建缓存
    participant Registry as npm Registry

    Dev->>Docker: docker build
    Docker->>Cache: 检查 package-lock 对应层
    alt 依赖未变化
        Cache-->>Docker: 命中缓存
    else 依赖变化
        Docker->>Registry: 下载依赖
        Registry-->>Docker: 返回依赖包
        Docker->>Cache: 写入缓存
    end
    Docker->>Docker: 编译源码
    Docker-->>Dev: 输出镜像

第三步:更进一步的瘦身思路

对于 Node.js 应用,常见选择有:

  • node:bookworm-slim
  • node:alpine
  • distroless(更安全、更小,但调试不方便)

什么时候选 slim

适合大多数中级团队,原因很实际:

  • 兼容性更稳
  • 原生模块踩坑更少
  • 出问题时更容易排查

什么时候考虑 alpine

适合对体积更敏感、依赖较简单的场景,但要注意:

  • musl libc 与 glibc 差异
  • 原生依赖编译/运行兼容问题
  • 某些 Node 模块在 Alpine 下更容易踩坑

distroless 方案示例

如果你追求更强的最小化和更少攻击面,可以考虑 distroless:

FROM node:18-bookworm-slim AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev

FROM gcr.io/distroless/nodejs18-debian12

WORKDIR /app
ENV NODE_ENV=production

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

EXPOSE 3000

CMD ["dist/index.js"]

这个方案的优点是镜像更干净,但缺点也很明显:

  • 没有 shell
  • 线上排障难度更高
  • 需要团队有更成熟的观测与日志体系

逐步验证清单

实践时我建议不要“一把梭”,而是每改一步都验证。

1. 验证镜像体积

docker images | grep demo-app

2. 查看镜像层历史

docker history demo-app:multi-stage

3. 检查容器运行用户

docker run --rm demo-app:multi-stage id

如果输出不是 uid=0(root),说明非 root 生效。

4. 检查运行文件是否过多

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

如果是 distroless 镜像,这一步不适用。普通 slim 镜像可以用来人工检查是否仍然带了源码、测试文件等。

5. 比较构建耗时

第一次和第二次分别执行:

time docker build -t demo-app:multi-stage .

如果缓存策略合理,第二次构建应明显更快。


常见坑与排查

这一节非常重要,因为多阶段构建的思路并不难,难的是“为什么它在我项目里不工作”。

坑 1:COPY . . 把缓存打爆

现象

明明只改了一个业务文件,结果 npm ci 又重新执行。

原因

你把全部上下文过早复制进镜像,任何文件变化都会影响后续层。

解决

先复制依赖描述文件,再安装依赖,最后复制业务代码。

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

坑 2:生产镜像里仍然有 devDependencies

现象

镜像体积不小,漏洞扫描还扫出一堆构建期依赖问题。

原因

你在运行阶段直接复制了完整 node_modules,但没有裁掉开发依赖。

解决

使用:

RUN npm prune --omit=dev

或者在单独生产依赖阶段中执行:

RUN npm ci --omit=dev

但要注意:如果构建需要 TypeScript、webpack 这类工具,那它们通常是开发依赖,构建阶段必须先装全依赖


坑 3:alpine 下原生模块异常

现象

本地构建成功,容器启动时报错,常见于 sharpbcryptcanvas 等原生模块。

原因

Alpine 使用 musl libc,与很多预编译二进制或动态库依赖存在兼容差异。

解决

  • 优先改用 bookworm-slim
  • 必要时在 Alpine 中补齐编译依赖
  • 对原生模块项目,不要为了几十 MB 盲目换底座

这是我比较想强调的一点:“更小”不一定“更省事”


坑 4:非 root 用户导致权限错误

现象

容器启动时报权限问题,比如无法写日志目录、缓存目录。

原因

复制文件后所有权不匹配,或者应用写入了没有权限的路径。

解决

必要时使用 --chown

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

如果应用必须写文件,尽量写到明确可控目录,并提前设置权限。


坑 5:.dockerignore 缺失导致构建上下文过大

现象

docker build 还没开始执行命令,就在“Sending build context…”阶段耗时很久。

原因

node_modules.git、测试报告、日志、构建产物全传给 Docker daemon 了。

解决

补齐 .dockerignore,这是最容易做、收益又很高的优化。


安全/性能最佳实践

这一节给出可以直接落地的建议,不求“绝对最优”,但求团队能稳定执行。

1. 固定基础镜像版本,不用模糊标签

不推荐:

FROM node:latest

推荐:

FROM node:18-bookworm-slim

更进一步,可以固定到 digest,不过维护成本会提高。


2. 运行镜像只保留运行时内容

一个生产镜像里,尽量不要有:

  • 源码
  • 测试文件
  • 编译工具链
  • 包管理缓存
  • shell(在极致安全场景下)

核心原则是:能不带就不带

flowchart TD
    A[生产镜像内容审查] --> B{是否运行必需}
    B -->|是| C[保留]
    B -->|否| D[移除]
    D --> E[减少体积]
    D --> F[降低攻击面]
    D --> G[减少漏洞数量]

3. 使用非 root 用户运行

最低限度:

USER node

如果你用的是自定义基础镜像,也可以显式创建用户:

RUN useradd -r -s /sbin/nologin appuser
USER appuser

边界条件是:如果应用需要绑定 1024 以下端口、写某些系统目录,就需要额外设计权限模型,而不是简单回退到 root。


4. 合理设计层顺序,提高缓存复用

推荐顺序通常是:

  1. 工作目录
  2. 复制依赖声明
  3. 安装依赖
  4. 复制源码
  5. 执行构建
  6. 切换到运行阶段

不要把高频变化文件放在前面,否则缓存收益会迅速下降。


5. 借助扫描工具做持续治理

可以接入这些工具:

  • docker scout
  • trivy
  • grype
  • 云厂商镜像扫描能力

例如用 Trivy:

trivy image demo-app:multi-stage

这里要有一个务实认知:漏洞扫描不会让镜像自动安全,但它能帮你发现明显问题,并把风险治理纳入流水线。


6. 控制容器运行时资源与行为

镜像安全只是第一步,运行时同样重要。比如:

  • 限制 CPU/内存
  • 尽量使用只读文件系统
  • 不随意挂载宿主机敏感目录
  • 配合 --cap-drop 最小化能力集

示例:

docker run --rm \
  -p 3000:3000 \
  --read-only \
  --memory="256m" \
  --cpus="1" \
  demo-app:multi-stage

如果应用确实需要写临时目录,需要额外挂载可写目录,不要硬套只读策略。


一个可作为生产起点的 Dockerfile

如果你想要一个“够用、稳妥、便于团队推广”的版本,我建议从下面这个模板开始:

# syntax=docker/dockerfile:1.4

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

FROM node:18-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev

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

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

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

这个版本的特点是:

  • 结构清晰,团队容易理解
  • 构建缓存友好
  • 体积明显优于单阶段
  • 安全性比“默认 root + 全量依赖”好很多
  • 出问题时仍然容易调试

方案取舍:别把优化做成负担

中级开发者经常会遇到一个实际问题:到底要不要一步到位上最极致方案?

我的建议是分层推进:

适合大多数团队的优先级

  1. 先补 .dockerignore
  2. 再改多阶段构建
  3. 调整 COPY 顺序,优化缓存
  4. 运行阶段裁剪 devDependencies
  5. 切换非 root
  6. 最后再评估 alpine / distroless

原因很简单:

  • 前四步收益大、风险低
  • 后两步收益也不错,但兼容性和排障成本更高

换句话说,先拿到 80 分,再冲 95 分,通常比一开始追求 100 分更划算。


总结

Docker 镜像优化不是一个“写几行 Dockerfile 技巧”的小问题,而是贯穿开发、构建、交付、运行与安全治理的工程实践。

如果你只记住三件事,我建议是:

  1. 用多阶段构建,把构建环境和运行环境彻底分开
  2. 围绕缓存设计 Dockerfile 顺序,优先保证构建速度
  3. 生产镜像只保留运行必需内容,并坚持非 root 运行

最后给你一套可执行落地建议:

  • 新项目:直接从多阶段模板起步
  • 老项目:先加 .dockerignore 和依赖分层复制
  • 原生模块项目:优先 bookworm-slim,别急着上 Alpine
  • 高安全场景:评估 distroless,但要先补齐日志、探针和可观测性
  • CI 中:开启 BuildKit,并接入镜像扫描

当你把这些动作做完后,通常会看到三个直接结果:

  • 镜像更小
  • 构建更快
  • 发布更稳,也更安全

这不是“过度优化”,而是容器化应用走向工程化的必经之路。


分享到:

上一篇
《微服务架构中的分布式事务落地实践:基于 Saga 模式的设计与排错指南》
下一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-444》