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

《Docker 多阶段构建与镜像瘦身实战:从构建缓存到安全加固的完整优化方案》

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

Docker 多阶段构建与镜像瘦身实战:从构建缓存到安全加固的完整优化方案

Docker 用久了,很多团队都会碰到一个很现实的问题:镜像能跑,但越来越大;构建能过,但越来越慢;上线能发,但安全风险也越来越高。

我第一次系统整理这类问题,是在一个 Node.js 服务里。最开始一个镜像只有两三百 MB,后来加了编译依赖、调试工具、私有包、构建脚本,最后镜像接近 1GB。CI 构建慢、推送慢、拉取慢,容器启动也不轻快。更麻烦的是,镜像里还混进了 .git、测试文件、包管理缓存,甚至一度把 token 打进了层里。

这篇文章就从**“为什么镜像会越来越臃肿”**开始,带你一步一步做一次完整优化:

  • 多阶段构建拆分“构建环境”和“运行环境”
  • 构建缓存减少重复安装依赖
  • .dockerignore 和基础镜像选择做镜像瘦身
  • 用非 root 用户、最小权限和敏感信息隔离做安全加固
  • 最后给出一套可运行的实战代码和排查思路

这不是概念扫盲,而是偏落地的一套方案。


前置知识与环境准备

建议你已经具备以下基础:

  • 知道 Dockerfile 的基本指令:FROMCOPYRUNCMD
  • 知道镜像、容器、层(layer)的基本关系
  • 本机已安装:
    • Docker 24+
    • 推荐启用 BuildKit

启用 BuildKit(临时方式):

export DOCKER_BUILDKIT=1

或者直接在构建命令前使用:

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

背景与问题

先看一个很常见、但问题很多的 Dockerfile。

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

它的问题几乎是“新手经典全家桶”:

  1. 基础镜像太大

    • node:18 通常比 slim 或 alpine 版本大很多。
  2. 缓存利用差

    • COPY . . 放得太早,任何代码变动都会让 npm install 失效,依赖重新安装。
  3. 构建工具混入运行镜像

    • TypeScript 编译、打包工具、开发依赖都被带到生产镜像里。
  4. 上下文过大

    • 如果没有 .dockerignorenode_modules、日志、测试文件、.git 都可能被打包进构建上下文。
  5. 安全性差

    • 默认 root 运行,攻击面大。
    • 如果在构建时写入 token,可能永久留在镜像层历史里。

你会看到几个直接后果:

  • CI/CD 构建越来越慢
  • 镜像仓库存储成本升高
  • 生产环境拉镜像慢
  • 安全扫描漏洞越来越多
  • 线上容器调试复杂,责任边界不清晰

核心原理

要把这个问题解决干净,先把几个核心原理捋顺。

1. Docker 镜像是分层的

每条会生成层的指令,都会形成一层。只要某一层内容变化,后续层的缓存都会失效。

flowchart TD
    A[FROM node:18] --> B[WORKDIR /app]
    B --> C[COPY package*.json]
    C --> D[RUN npm ci]
    D --> E[COPY src/]
    E --> F[RUN npm run build]
    F --> G[CMD node dist/index.js]

这里最关键的点是:

  • COPY package*.jsonRUN npm ci 要尽量放前面
  • 业务代码 COPY src/ 放后面
  • 这样代码改动时,不必每次都重新安装依赖

2. 多阶段构建的本质:把“构建”与“运行”分开

构建阶段需要编译器、构建工具、开发依赖; 运行阶段通常只需要编译产物和少量运行时依赖。

flowchart LR
    A[builder 阶段<br/>安装依赖/编译打包] --> B[生成 dist 或二进制]
    B --> C[runner 阶段<br/>仅复制运行所需文件]
    C --> D[最终生产镜像]

多阶段构建的价值很直接:

  • 最终镜像更小
  • 构建工具不进入生产环境
  • 攻击面更小
  • 镜像职责更清晰

3. 构建缓存优化,不只是“顺序对了”这么简单

BuildKit 支持更细粒度的缓存,比如缓存 npm/pip/apt 下载目录,避免每次重新拉包。

也就是说,优化有两层:

  • Docker 层缓存:通过 Dockerfile 顺序优化
  • 包管理器缓存:通过 BuildKit --mount=type=cache

这两者叠加,效果会非常明显。


4. 镜像瘦身不只是为了“省空间”

很多人第一次优化镜像,只盯着体积。但实际上镜像瘦身还带来:

  • 更快的构建、推送、拉取
  • 更低的漏洞暴露面
  • 更少的维护复杂度
  • 更稳定的发布流程

所以它本质上是性能优化 + 安全治理 + 工程规范化


环境准备:一个可运行的 Node.js 示例项目

下面我用一个最常见的 TypeScript Node.js 服务做演示。目录如下:

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

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

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "private": true,
  "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.0.0",
    "typescript": "^5.5.4"
  }
}

tsconfig.json

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

实战代码:一步步把 Dockerfile 优化到可上线


第 1 步:先写一个“能跑但不够好”的版本

FROM node:20

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

构建并运行:

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

访问:

curl http://localhost:3000

虽然能跑,但这只是起点。


第 2 步:引入 .dockerignore,先解决“无效文件进镜像”

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

.dockerignore

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

这一步的作用:

  • 减少构建上下文大小
  • 避免宿主机 node_modules 污染容器
  • 避免把不必要文件送进 Docker daemon
  • 避免敏感文件误入镜像

我见过不少项目镜像大,不是因为应用本身大,而是因为 .git 目录和测试产物全被送进去了。


第 3 步:优化层顺序,最大化缓存命中

FROM node:20-slim

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

这里已经比前面进步很多了:

  • 依赖安装单独一层
  • 源码变动时,npm ci 层通常还能复用
  • 基础镜像换成 node:20-slim

不过它仍然有问题:开发依赖和构建工具还在生产镜像里。


第 4 步:改成多阶段构建

这是整篇文章最核心的一步。

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

EXPOSE 3000

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

这个版本做了什么?

  • builder 阶段:

    • 安装完整依赖
    • 执行编译
    • 编译完成后移除开发依赖
  • runner 阶段:

    • 只复制运行所需内容
    • 不带源码、TypeScript、编译中间文件

这就是多阶段构建最经典的落地方式。


第 5 步:使用 BuildKit 缓存包管理器目录

如果 CI 经常全新构建,这一步会特别值。

# syntax=docker/dockerfile:1.7

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

EXPOSE 3000

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

构建命令:

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

为什么这一步有效?

  • 即使某些层失效,npm 下载缓存还在
  • 对网络依赖重的 CI 环境提升明显
  • 对 monorepo、依赖多的项目帮助更大

第 6 步:安全加固,避免 root 运行

生产镜像建议不要默认 root。

# syntax=docker/dockerfile:1.7

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

RUN useradd -r -u 1001 -g root appuser && \
    chown -R appuser:root /app

USER appuser

EXPOSE 3000

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

这样做的意义:

  • 即使应用被利用,默认权限也更低
  • 减少对宿主环境和挂载卷的潜在风险

第 7 步:最终推荐版本

下面给一个更接近生产可用的完整版本。

# syntax=docker/dockerfile:1.7

FROM node:20-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 && npm prune --omit=dev

FROM node:20-slim AS runner

WORKDIR /app

ENV NODE_ENV=production \
    PORT=3000

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

RUN useradd -r -u 1001 -g root appuser && \
    chown -R appuser:root /app

USER appuser

EXPOSE 3000

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

构建与运行:

DOCKER_BUILDKIT=1 docker build -t demo-app:prod .
docker run --rm -p 3000:3000 demo-app:prod

验证:

curl http://localhost:3000

逐步验证清单

做优化时,我很建议一边改,一边验证,而不是一口气重写 Dockerfile。

1. 看镜像体积

docker images | grep demo-app

2. 看历史层

docker history demo-app:prod

重点观察:

  • 有没有异常大的层
  • 是否把源码、缓存、构建工具带进最终镜像

3. 进入容器确认文件

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

检查:

ls -la
ls -la dist
ls -la src

如果最终运行镜像里还有 src/、测试目录、构建缓存,那通常说明复制策略有问题。

4. 检查运行用户

id

确认不是 root。

5. 验证缓存是否生效

先构建一次,再只改一行业务代码,再次构建:

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

如果你看到 npm ci 被缓存复用,说明层顺序设计正确。


构建流程全景图

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
    Builder->>Builder: npm ci(使用缓存)
    Builder->>Builder: 复制源码并编译
    Builder->>Builder: 删除 devDependencies
    Docker->>Runner: 复制 dist/node_modules/package.json
    Runner->>Runner: 创建非 root 用户
    Runner-->>Dev: 输出生产镜像

常见坑与排查

这一节很重要。因为多阶段构建“看起来不复杂”,但实际踩坑不少。

1. COPY . . 太早,导致缓存雪崩

现象

哪怕只改了一行业务代码,npm ci 也重新执行。

原因

你在安装依赖之前就把整个项目复制进镜像了。

错误示例

COPY . .
RUN npm ci

正确示例

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

2. 使用 npm install 而不是 npm ci

现象

CI 环境构建结果不稳定,不同时间依赖版本可能漂移。

原因

npm install 可能修改锁文件解析结果,不够确定性。

建议

如果有 package-lock.json,生产构建优先用:

RUN npm ci

3. Alpine 不一定总是最优

很多人上来就说“瘦身就用 alpine”。这句话不总对。

你需要知道的边界:

  • alpine 很小,但使用 musl libc
  • 某些 Node 原生模块、系统依赖、调试场景会更麻烦
  • slim 往往是更稳妥的折中

经验建议

  • 追求极致体积、依赖简单:可以试 alpine
  • 追求兼容性、排障便利:优先 slim

我个人在业务服务里,通常先选 slim,只有明确收益时才切到 alpine


4. 开发依赖删早了,构建失败

现象

TypeScript、Webpack、Vite 等构建工具找不到。

原因

你在编译前就删了 devDependencies

正确顺序

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

不是反过来。


5. 把密钥写进 Dockerfile

错误示例

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && npm ci

这样即使后面删掉 .npmrc,敏感信息仍可能存在于镜像层历史中。

更好的方式

使用 BuildKit secret:

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

构建命令:

DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t demo-app:secret .

这样敏感文件不会进入最终镜像层。


6. 最终镜像里仍然有源码

现象

进入容器后,发现 /app/src 还在。

原因

通常是 runner 阶段又执行了一次 COPY . .

排查方法

检查最终阶段 Dockerfile,确保只复制必要文件:

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

7. 容器启动报权限错误

现象

切成非 root 用户后,应用读写目录失败。

原因

工作目录属主未调整,或程序要写入无权限目录。

处理方式

RUN useradd -r -u 1001 -g root appuser && \
    chown -R appuser:root /app
USER appuser

如果应用必须写临时文件,明确指定可写目录,不要默认写系统目录。


安全/性能最佳实践

这一部分可以当成生产环境检查表。

1. 固定基础镜像版本

不要只写:

FROM node:20-slim

更稳妥的做法是固定更具体的 tag,甚至 digest:

FROM node:20.11.1-slim

原因:

  • 避免基础镜像漂移
  • 便于问题回溯
  • 有利于构建可重复性

2. 运行镜像只放“运行所需文件”

最终镜像尽量只保留:

  • 编译产物
  • 运行依赖
  • 必需配置文件

不要保留:

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

3. 优先使用非 root 用户

这几乎应该成为默认动作,而不是“有空再做”。


4. 善用 .dockerignore

很多镜像瘦身效果,最先来自它。

推荐至少忽略:

node_modules
.git
dist
coverage
.env
*.log

5. 减少层数,但不要为了“少一层”牺牲可维护性

层数不是越少越好。比起强行把所有命令写成一行,更重要的是:

  • 缓存逻辑合理
  • 每一步职责清晰
  • 排障容易

6. 在 CI 中启用远程缓存

如果你的 CI 平台支持 Buildx,可以进一步使用 registry cache。

例如:

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

适用场景:

  • 频繁构建
  • 多分支并行
  • 依赖安装成本高
  • 自托管 runner 缓存不稳定

7. 做镜像漏洞扫描

瘦身不代表一定安全,但通常有助于降低漏洞面。建议在流水线加入扫描步骤,比如:

  • Trivy
  • Grype
  • Docker Scout

示例:

trivy image demo-app:prod

8. 明确区分“构建期变量”和“运行期配置”

  • 构建期:ARG
  • 运行期:ENV 或容器启动注入

不要把环境差异强行打进镜像,尽量让镜像本身保持通用。


方案对比:单阶段 vs 多阶段

维度单阶段构建多阶段构建
编写难度
镜像体积通常较大通常更小
缓存优化空间一般更高
构建工具隔离
安全性一般更好
生产可维护性一般更强

如果只是本地临时验证,单阶段未必不行;但只要进入 CI/CD 和生产环境,多阶段通常都值得。


一套我更推荐的落地思路

如果你打算在团队里推广,不要一上来追求“最极致最复杂”的 Dockerfile。我建议按下面顺序推进:

  1. 先补 .dockerignore
  2. 再调整 COPY 顺序,提升缓存命中
  3. 再改成多阶段构建
  4. 再接入 BuildKit 缓存
  5. 最后做非 root、安全 secret、漏洞扫描

这样好处是:

  • 每一步收益都能被量化
  • 出问题容易回滚
  • 团队更容易接受

很多时候,工程优化的难点不在技术本身,而在于你是否能让优化过程可验证、可维护、可推广


总结

Docker 镜像优化,表面上是在“瘦身”,本质上是在做一件更大的事:把构建过程、运行环境和安全边界重新梳理清楚。

这篇文章里,你可以直接带走的核心结论有 5 个:

  1. 多阶段构建是生产镜像优化的基础动作
  2. COPY package*.json 提前、源码后置,是缓存优化关键
  3. .dockerignore 往往是最容易拿到收益的一步
  4. BuildKit 缓存和 secret 能同时提升性能与安全
  5. 最终镜像要尽量小、尽量少、尽量低权限

如果你现在手上就有一个 Docker 项目,我建议你今天就做这三件事:

  • .dockerignore 补上
  • 把 Dockerfile 改成多阶段
  • docker history 看一遍最终镜像到底装了什么

只要这三步做完,你的镜像质量通常就会明显提升。至于 alpine、远程缓存、漏洞扫描这些,可以在此基础上逐步加。

一句话收尾:先把镜像做“干净”,再去追求“极致小”。 这是我踩坑后觉得最稳的路线。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:提升接口聚合性能与可维护性》