> ## Content Index
> Fetch the complete content index at: https://isoziyuan.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Docker 镜像瘦身与构建加速实战：多阶段构建、BuildKit 缓存与 CI 复用
- URL: https://isoziyuan.com/p/100102/
- Published: 2026-08-27T06:11:41.000Z
- Updated: 2026-08-27T06:11:40.000Z
- Author: Isoziyuan
- Tags: Docker, Dockerfile, BuildKit, 性能优化

很多项目的 Dockerfile 一开始只有几行：复制代码、安装依赖、执行构建。它确实能运行，但随着项目增长，通常会出现以下问题：

- 一个普通 Node.js 服务镜像动辄数百 MB，甚至超过 1 GB；
- 修改一行源代码，却重新下载全部依赖；
- 本地二次构建很快，到了 CI 环境又从零开始；
- 为了编译原生依赖安装了 `gcc`、`make`，最终运行镜像也携带了这些工具；
- `.env`、`.git`、测试报告甚至本地 `node_modules` 被发送进构建上下文；
- 为了访问私有依赖，把 Token 写进 `ARG` 或 Dockerfile，留下安全隐患；
- 换成 Alpine 后体积看似下降，却出现原生模块、字体、时区或 `glibc` 兼容问题。

这篇教程以一个“TypeScript 编译为 `dist/`、使用 npm 管理依赖的 Node.js 服务”为例，完整演示如何通过多阶段构建、BuildKit 缓存挂载和远程缓存优化镜像体积及构建速度。

虽然示例使用 Node.js，但其中的分层原则同样适用于 Go、Java、Python、Rust 和前端静态站点。

## 一、先理解优化目标

Docker 镜像优化并不等于单纯选择最小的基础镜像。真正需要同时关注的是以下四点。

### 1\. 缩小最终运行镜像

运行镜像通常只需要：

- 运行时；
- 生产依赖；
- 编译产物；
- 必要的配置和系统动态库。

它通常不需要：

- TypeScript 源码；
- 单元测试；
- 开发依赖；
- 编译器和构建工具；
- npm 缓存；
- Git 历史；
- CI 配置。

### 2\. 提高 Docker 层缓存命中率

Docker 构建缓存以指令和相关文件内容为依据。下面这种顺序会让缓存非常脆弱：

```dockerfile
COPY . .
RUN npm install

```

任意源代码发生变化，`COPY . .` 对应的层都会变化，于是后面的依赖安装也必须重新执行。

更合理的顺序是：

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

COPY . .
RUN npm run build

```

只要依赖清单没有变化，修改业务源码就不会让依赖安装层失效。

### 3\. 让依赖下载缓存脱离普通镜像层

普通层缓存失效后，`npm ci` 仍可能需要重新执行。但使用 BuildKit 缓存挂载后，npm 已下载的软件包可以继续复用：

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

```

需要注意：

- `npm ci` 这条指令仍会运行；
- `/root/.npm` 中的下载缓存可以复用；
- 缓存目录不会被写入最终镜像；
- 这与直接把 npm 缓存复制进镜像完全不同。

### 4\. 让 CI 也能复用缓存

开发者电脑上的本地缓存不会自动出现在新的 CI Runner 中。要解决这一问题，需要把 BuildKit 缓存导出到：

- 容器镜像仓库；
- CI 平台提供的缓存后端；
- 本地持久化目录；
- 其他 BuildKit 支持的缓存存储。

本文使用通用性较强的镜像仓库缓存作为主要方案。

---

## 二、准备工作

### 1\. 环境要求

建议准备：

- 较新的 Docker Engine 或 Docker Desktop；
- Docker Buildx；
- 项目中存在 `package-lock.json`；
- 项目能够通过 `npm run build` 生成 `dist/`；
- 应用可以通过 `node dist/server.js` 启动。

检查 Buildx：

```bash
docker buildx version
docker buildx ls

```

如果还没有可用的 Buildx Builder，可以创建一个：

```bash
docker buildx create \
  --name app-builder \
  --driver docker-container \
  --use

docker buildx inspect --bootstrap

```

`docker-container` 驱动通常更适合使用多平台构建和完整的外部缓存导出功能。

### 2\. 示例项目约定

示例假设 `package.json` 至少包含类似脚本：

```json
{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

```

目录结构类似：

```text
.
├── src/
├── package.json
├── package-lock.json
├── tsconfig.json
├── Dockerfile
└── .dockerignore

```

如果你的项目是 Next.js、Nuxt、NestJS、Vite SSR 或其他框架，需要按照实际产物调整：

- 构建输出目录；
- 启动文件；
- 是否需要复制静态资源；
- 是否需要生产依赖；
- 是否有框架专用的独立输出模式。

不要机械地把本文的 `dist/server.js` 套到所有项目上。

---

## 三、问题 Dockerfile：为什么又慢又大

很多项目最初使用下面这样的 Dockerfile：

```dockerfile
FROM node:24-bookworm

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

```

它存在几个明显问题：

1. `COPY . .` 在安装依赖之前执行，业务代码变化会导致依赖层失效；
2. 使用 `npm install`，不如 `npm ci` 适合基于锁文件的可重复构建；
3. 开发依赖保留在最终镜像中；
4. 源代码、测试文件和构建配置全部保留；
5. 完整 Debian 基础镜像比 `slim` 版本包含更多非必要组件；
6. 如果构建时安装了编译工具，它们也会进入运行镜像；
7. 没有使用 BuildKit 缓存挂载；
8. 默认以 root 用户运行应用；
9. 如果没有 `.dockerignore`，构建上下文可能非常大。

先保留这个版本用于对照：

```bash
time docker buildx build \
  --load \
  --progress=plain \
  -f Dockerfile.before \
  -t demo-app:before \
  .

```

这里使用 `--load`，是因为 `docker-container` 驱动默认不会自动把结果加载到本地 Docker 镜像列表中。

---

## 四、第一步：编写 `.dockerignore`

在优化 Dockerfile 之前，应先控制构建上下文。

创建 `.dockerignore`：

```dockerignore
# 本地依赖与构建产物
node_modules
dist
coverage

# Git 与编辑器文件
.git
.gitignore
.vscode
.idea

# 日志
*.log
npm-debug.log*

# 本地环境变量，避免敏感配置进入构建上下文
.env
.env.*
!.env.example

# 测试及缓存目录，请按项目实际情况调整
.nyc_output
.cache
.tmp

# 本地 Buildx 缓存
.buildx-cache
.buildx-cache-*

# 编排和说明文件通常不需要进入镜像
compose*.yml
docker-compose*.yml
README*

```

如果构建过程确实依赖 README、测试文件或某个配置目录，就不能直接忽略它们。

检查构建日志中的上下文大小：

```bash
docker buildx build \
  --load \
  --progress=plain \
  -t demo-app:context-test \
  .

```

日志中会出现类似 `transferring context` 的信息。项目根目录下如果有几百 MB 的本地依赖，而 `.dockerignore` 又没有排除它们，每次构建都会浪费时间传输上下文。

> `.dockerignore` 不只是体积优化手段，也是安全边界。未被 `COPY` 的文件不一定会出现在最终镜像中，但它们仍可能被发送给构建器。

---

## 五、第二步：使用多阶段构建拆分依赖、编译与运行环境

下面是一份可直接修改使用的优化版 Dockerfile。

```dockerfile
# syntax=docker/dockerfile:1

# 使用参数集中管理基础镜像版本。
# 正式生产环境还可以进一步固定到经过验证的 sha256 摘要。
ARG NODE_IMAGE=node:24-bookworm-slim

# ------------------------------------------------------------
# 阶段一：安装完整依赖，包括构建所需的 devDependencies
# ------------------------------------------------------------
FROM ${NODE_IMAGE} AS deps

WORKDIR /app

# 先只复制依赖清单。
# 业务源码变化时，只要锁文件未变化，这一层就能继续命中缓存。
COPY package.json package-lock.json ./

# npm 下载缓存保存在 BuildKit 缓存中，不写入镜像层。
# sharing=locked 可减少并行构建同时写缓存时的冲突。
RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci

# ------------------------------------------------------------
# 阶段二：执行项目编译
# ------------------------------------------------------------
FROM ${NODE_IMAGE} AS build

WORKDIR /app

# 复用上一阶段安装好的完整依赖。
COPY --from=deps /app/node_modules ./node_modules

# 此时再复制源码，避免源码改动影响依赖安装缓存。
COPY . .

RUN npm run build

# ------------------------------------------------------------
# 阶段三：只安装生产依赖
# ------------------------------------------------------------
FROM ${NODE_IMAGE} AS prod-deps

WORKDIR /app

COPY package.json package-lock.json ./

# --omit=dev 排除开发依赖。
RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --omit=dev

# ------------------------------------------------------------
# 阶段四：最终运行镜像
# ------------------------------------------------------------
FROM ${NODE_IMAGE} AS runtime

WORKDIR /app

ENV NODE_ENV=production

# 只复制生产依赖、依赖描述文件和编译产物。
# --chown 确保非 root 用户能够正常访问文件。
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json package-lock.json ./
COPY --from=build --chown=node:node /app/dist ./dist

# 如果项目还需要 public、views、prisma 等运行时文件，
# 应继续使用 COPY --from=build 有选择地复制。
#
# 例如：
# COPY --from=build --chown=node:node /app/public ./public

USER node

EXPOSE 3000

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

```

### 为什么要分成四个阶段

#### `deps` 阶段

安装所有依赖，包括 TypeScript、打包器、测试编译插件等开发依赖，供编译使用。

#### `build` 阶段

复制源码并执行编译。源码变更一般只会使这一阶段及其后续阶段失效。

#### `prod-deps` 阶段

单独安装生产依赖，避免把 `devDependencies` 带入最终镜像。

不能简单地认为：

```bash
npm prune --omit=dev

```

在所有项目中都一定比重新执行 `npm ci --omit=dev` 更可靠。单独安装生产依赖通常更清晰，也更容易保证最终内容符合锁文件。

#### `runtime` 阶段

最终镜像只接收运行时需要的内容。前面阶段中的源码、编译器、npm 下载缓存等都不会自动进入这个阶段。

这也是多阶段构建最核心的价值：**构建环境可以复杂，运行环境必须克制。**

---

## 六、第三步：构建并验证缓存效果

### 1\. 第一次构建

```bash
time docker buildx build \
  --load \
  --progress=plain \
  -t demo-app:optimized \
  .

```

第一次构建需要下载基础镜像和 npm 依赖，通常不会特别快。

### 2\. 不修改代码，再构建一次

```bash
time docker buildx build \
  --load \
  --progress=plain \
  -t demo-app:optimized \
  .

```

日志中多数步骤应该显示 `CACHED`。

### 3\. 只修改业务源码

例如修改：

```text
src/server.ts

```

然后重新构建：

```bash
time docker buildx build \
  --load \
  --progress=plain \
  -t demo-app:optimized \
  .

```

正常情况下：

- `COPY package.json package-lock.json` 命中缓存；
- `npm ci` 命中普通层缓存；
- `COPY . .` 及 `npm run build` 重新执行；
- 生产依赖阶段继续命中缓存。

### 4\. 修改锁文件后测试

安装一个依赖：

```bash
npm install some-package

```

再次构建时，依赖安装层会失效并重新执行。不过由于 `/root/.npm` 使用了 BuildKit 缓存挂载，已经下载过的软件包仍有机会被复用，从而减少网络下载。

---

## 七、第四步：检查镜像体积与分层

### 1\. 比较镜像大小

```bash
docker image ls demo-app

```

也可以查看原始字节数：

```bash
docker image inspect demo-app:before \
  --format '{{.Size}} bytes'

docker image inspect demo-app:optimized \
  --format '{{.Size}} bytes'

```

不要直接套用网上“从 1 GB 降到 100 MB”之类的数据。实际大小取决于：

- 基础镜像；
- 生产依赖数量；
- 是否包含浏览器、字体或机器学习模型；
- 是否存在原生模块；
- 编译产物大小；
- CPU 架构；
- 本地显示的是解压后层大小，还是仓库传输时的压缩大小。

建议记录自己的真实结果：

| 项目        | 优化前  | 优化后  |
| --------- | ---- | ---- |
| 镜像大小      | 实测填写 | 实测填写 |
| 首次构建耗时    | 实测填写 | 实测填写 |
| 未改代码二次构建  | 实测填写 | 实测填写 |
| 只改业务代码后构建 | 实测填写 | 实测填写 |

### 2\. 查看每一层的大小

```bash
docker history demo-app:optimized

```

需要更完整的指令信息时：

```bash
docker history --no-trunc demo-app:optimized

```

重点检查：

- 是否存在异常大的 `COPY` 层；
- 是否把整个项目复制进了运行阶段；
- 是否把 npm 缓存复制进镜像；
- 是否在某一层创建大文件、下一层才删除。

Docker 镜像是分层的。下面的操作不能真正消除上一层中的文件：

```dockerfile
RUN download-big-file
RUN rm -f big-file

```

因为大文件已经存在于前一层。应改为同一条指令中下载、使用并删除：

```dockerfile
RUN download-big-file \
    && use-big-file \
    && rm -f big-file

```

更好的做法通常是让大文件只存在于构建阶段，最终阶段根本不复制它。

---

## 八、第五步：运行容器并做冒烟测试

构建完成后，不要只看镜像大小，还要实际运行：

```bash
docker run --rm \
  --init \
  -p 3000:3000 \
  -e PORT=3000 \
  demo-app:optimized

```

另开终端测试：

```bash
curl http://127.0.0.1:3000/

```

应用在容器中应监听：

```text
0.0.0.0

```

而不是只监听：

```text
127.0.0.1

```

否则即使端口映射正确，宿主机也可能无法访问容器中的服务。

`--init` 会为容器添加轻量级 init 进程，帮助转发信号和回收孤儿进程。生产环境也可以在 Compose、Kubernetes 或运行命令中配置对应能力，而不一定要把额外 init 工具安装进镜像。

---

## 九、BuildKit 缓存挂载的正确用法

### 1\. npm 缓存

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

```

缓存的是 npm 下载目录，而不是直接缓存最终的 `node_modules`。

通常不建议把跨构建共享缓存直接挂载到 `/app/node_modules`，因为：

- 可能残留已经从锁文件删除的包；
- 不同 Node.js 版本之间可能不兼容；
- 原生模块可能与 CPU 架构或 libc 不匹配；
- 容易让构建结果依赖缓存历史，降低可重复性。

`npm ci` 根据锁文件创建干净依赖树，再利用 npm 下载缓存，通常更稳妥。

### 2\. apt 缓存

如果 Node.js 原生模块需要 `python3`、`make`、`g++`，应只在依赖构建阶段安装。

示例：

```dockerfile
FROM node:24-bookworm-slim AS native-deps

WORKDIR /app

# Debian 官方容器镜像可能包含自动清理 apt 缓存的配置。
# 如果确实希望 BuildKit 缓存 deb 包，可移除该配置。
RUN rm -f /etc/apt/apt.conf.d/docker-clean

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
    && apt-get install -y --no-install-recommends \
       python3 \
       make \
       g++

COPY package.json package-lock.json ./

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

```

这些编译工具只应该出现在构建阶段，最终 `runtime` 阶段仍从干净的基础镜像开始。

如果某个原生模块在运行时依赖系统动态库，例如图像处理、数据库客户端或字体库，则必须把对应的**运行时库**安装到最终镜像中。只复制 `node_modules` 并不能自动复制其依赖的系统库。

### 3\. 查看和清理 Buildx 缓存

查看缓存占用：

```bash
docker buildx du

```

清理当前 Builder 的未使用缓存：

```bash
docker buildx prune

```

无交互清理：

```bash
docker buildx prune -f

```

不要把定期清理缓存理解为常规加速手段。缓存被删除后，下一次构建自然会变慢。通常只在磁盘压力大或排查异常缓存时清理。

---

## 十、第六步：让 CI 使用镜像仓库远程缓存

本地二次构建快，并不代表 CI 也快。临时 Runner 每次启动时可能没有任何本地缓存。

可以把 BuildKit 缓存导出到镜像仓库中的独立引用。

先登录仓库：

```bash
docker login registry.example.com

```

设置变量：

```bash
IMAGE_REF=registry.example.com/team/demo-app
CACHE_REF=registry.example.com/team/demo-app:buildcache

```

执行构建：

```bash
docker buildx build \
  --platform linux/amd64 \
  --cache-from=type=registry,ref=${CACHE_REF} \
  --cache-to=type=registry,ref=${CACHE_REF},mode=max \
  --tag ${IMAGE_REF}:latest \
  --push \
  .

```

参数含义：

- `--cache-from`：尝试从远程缓存读取可复用层；
- `--cache-to`：将本次构建缓存写回仓库；
- `mode=max`：尽量导出包括中间阶段在内的缓存；
- `--push`：把最终镜像推送到仓库；
- 缓存引用与正式镜像标签分开管理。

如果只是本地测试，不推送镜像：

```bash
docker buildx build \
  --cache-from=type=registry,ref=${CACHE_REF} \
  --cache-to=type=registry,ref=${CACHE_REF},mode=max \
  --load \
  --tag demo-app:local \
  .

```

不过，此命令仍需要：

- 已登录对应仓库；
- 对缓存引用具有拉取和推送权限；
- 仓库能够保存 BuildKit 导出的缓存制品。

### 多平台构建

如果需要同时发布 AMD64 和 ARM64：

```bash
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from=type=registry,ref=${CACHE_REF} \
  --cache-to=type=registry,ref=${CACHE_REF},mode=max \
  --tag ${IMAGE_REF}:latest \
  --push \
  .

```

多平台结果通常应使用 `--push` 推送到仓库。普通 Docker 本地镜像存储一般不能通过一次 `--load` 接收完整的多平台镜像索引。

---

## 十一、不使用镜像仓库时的本地目录缓存

某些自建 CI 可以持久化工作目录，此时可以使用本地缓存：

```bash
docker buildx build \
  --cache-from=type=local,src=.buildx-cache \
  --cache-to=type=local,dest=.buildx-cache-next,mode=max \
  --load \
  --tag demo-app:local \
  .

```

构建成功后替换缓存目录：

```bash
rm -rf .buildx-cache
mv .buildx-cache-next .buildx-cache

```

不要同时把同一个目录直接作为输入和输出，以免出现缓存布局冲突或无效累积。

同时确保 `.dockerignore` 包含：

```dockerignore
.buildx-cache
.buildx-cache-*

```

否则缓存目录可能被重新发送进 Docker 构建上下文，形成越构建上下文越大的问题。

---

## 十二、私有 npm 仓库：不要把 Token 写进镜像

错误做法：

```dockerfile
ARG NPM_TOKEN
RUN echo "//registry.example.com/:_authToken=${NPM_TOKEN}" > /root/.npmrc \
    && npm ci

```

即使后续删除 `.npmrc`，敏感信息仍可能出现在：

- 镜像历史；
- 构建元数据；
- 某个中间层；
- CI 日志；
- Builder 缓存。

应使用 BuildKit Secret。

Dockerfile 中写成：

```dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci

```

构建时传入本机的 `.npmrc`：

```bash
docker buildx build \
  --secret id=npmrc,src="${HOME}/.npmrc" \
  --load \
  -t demo-app:private \
  .

```

Secret 只在该条 `RUN` 指令执行期间挂载，不会因为普通 `COPY` 自动进入镜像层。

需要特别注意：Secret 内容变化通常不会像普通文件那样自动使构建层失效。如果私有依赖版本发生变化，应同时更新锁文件，或者有针对性地使对应阶段重新执行。

---

## 十三、基础镜像应该怎么选

### 1\. 不要只看标签中的“最小”

常见选择包括：

```dockerfile
FROM node:24-bookworm
FROM node:24-bookworm-slim
FROM node:24-alpine

```

通常可以先尝试 `bookworm-slim`，因为它在镜像体积和兼容性之间较为平衡。

### 2\. Alpine 不一定是最优解

Alpine 使用 musl libc，而很多预编译原生模块以 glibc 环境为主要目标。切换到 Alpine 后可能遇到：

- 原生模块没有对应预编译包，需要现场编译；
- 编译时间增加；
- 某些二进制程序无法直接运行；
- 字体、时区、证书或系统工具需要额外安装；
- 为解决兼容问题安装大量软件，抵消了体积优势。

因此，是否使用 Alpine 应通过实际依赖、构建时间和运行测试决定，而不是只比较空基础镜像大小。

### 3\. 正式环境考虑固定镜像摘要

标签对应内容可能随着上游更新发生变化。需要高可重复性时，可以在验证后固定摘要：

```dockerfile
FROM node:24-bookworm-slim@sha256:实际验证过的摘要

```

不要复制文档中的虚构摘要。应从实际仓库查询：

```bash
docker buildx imagetools inspect node:24-bookworm-slim

```

固定摘要后，基础镜像不会自动获得上游更新，因此还需要建立定期更新、重建和安全验证机制。

---

## 十四、缓存命中率低时的排查顺序

### 问题 1：每次修改源码都重新执行 `npm ci`

检查 Dockerfile 是否写成：

```dockerfile
COPY . .
RUN npm ci

```

应改为：

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

COPY . .

```

同时确认依赖清单没有被脚本自动改写。

### 问题 2：明明没改文件，`COPY . .` 仍然失效

重点检查：

- 是否生成了日志文件；
- 是否生成了覆盖率报告；
- 是否把 `.git` 发送进上下文；
- 是否有工具修改了配置文件；
- 是否存在未忽略的本地缓存；
- CI 是否在工作目录中写入构建编号或时间戳；
- 是否每次都生成不同内容的文件。

使用下面的命令查看详细过程：

```bash
docker buildx build \
  --load \
  --progress=plain \
  -t demo-app:debug \
  .

```

### 问题 3：BuildKit 缓存挂载没有减小镜像

这是正常现象。缓存挂载主要用于加快下载和构建，本身不一定直接改变最终镜像大小。

镜像体积主要由这些措施决定：

- 多阶段构建；
- 只复制运行文件；
- 排除开发依赖；
- 选择合适的基础镜像；
- 避免把缓存、源码和工具链放进运行阶段。

### 问题 4：使用 `USER node` 后出现 `EACCES`

通常是目录权限问题。复制文件时使用：

```dockerfile
COPY --chown=node:node ...

```

如果应用需要写入目录，可以预先创建并修改权限：

```dockerfile
RUN mkdir -p /app/data \
    && chown -R node:node /app/data

```

不要为了省事退回 root 用户运行。

### 问题 5：容器中提示找不到某个模块

可能原因包括：

- 该模块被错误放在 `devDependencies` 中，但运行时仍需要；
- 编译产物动态引用了源码目录；
- 生产依赖阶段未执行安装脚本；
- monorepo 中只复制了根目录锁文件，没有复制 Workspace 所需清单；
- 构建工具把外部依赖保留为运行时依赖。

先在本地执行：

```bash
npm ci --omit=dev
node dist/server.js

```

如果宿主机上也失败，应先修复依赖分类，而不是修改 Docker 缓存。

### 问题 6：出现共享库缺失错误

典型形式：

```text
error while loading shared libraries

```

或者原生模块加载失败。

说明 `node_modules` 中的原生二进制依赖某些系统运行库。应在最终运行阶段安装必要的运行库，而不是把整个编译环境复制进去。

还要确保构建阶段和运行阶段使用兼容的：

- Linux 发行版；
- libc；
- CPU 架构；
- Node.js ABI。

### 问题 7：出现 `exec format error`

常见原因是镜像架构与运行机器不一致，例如在 ARM64 环境构建了 ARM64 镜像，却部署到 AMD64 服务器。

明确指定目标平台：

```bash
docker buildx build \
  --platform linux/amd64 \
  --load \
  -t demo-app:amd64 \
  .

```

如果进行跨架构构建，含原生依赖的项目还应进行真实目标架构测试，不能只保证构建命令成功。

### 问题 8：构建成功但本地看不到镜像

使用 `docker-container` 驱动时，需要显式指定输出：

```bash
docker buildx build --load -t demo-app:local .

```

或推送到仓库：

```bash
docker buildx build --push -t registry.example.com/team/demo-app:latest .

```

### 问题 9：`npm ci` 提示锁文件不一致

`npm ci` 要求 `package.json` 与 `package-lock.json` 一致。

在开发环境重新安装并提交锁文件：

```bash
npm install
git add package.json package-lock.json
git commit -m "更新依赖锁文件"

```

不要在 Dockerfile 中改回 `npm install` 来掩盖锁文件问题。

### 问题 10：想完全重新构建某个阶段

绕过普通层缓存：

```bash
docker buildx build \
  --no-cache \
  --load \
  -t demo-app:clean \
  .

```

如果只想让特定阶段不使用普通层缓存，可以在支持该参数的 Buildx 版本中使用：

```bash
docker buildx build \
  --no-cache-filter build \
  --load \
  -t demo-app:rebuild \
  .

```

`--no-cache` 主要针对构建层缓存。缓存挂载是独立机制；若怀疑缓存目录本身异常，可以单独执行 `docker buildx prune`，但这会影响之后的构建速度。

---

## 十五、进一步提升构建速度的原则

### 1\. 把变化频率低的步骤放在前面

推荐顺序：

1. 基础镜像；
2. 系统依赖；
3. 依赖清单；
4. 安装项目依赖；
5. 复制源码；
6. 执行编译；
7. 复制运行产物。

越靠前的层，越应该稳定。

### 2\. 不要滥用缓存破坏参数

下面这种方式会强制后续缓存失效：

```dockerfile
ARG CACHE_BUST
RUN echo "$CACHE_BUST"

```

它适合临时排查，不适合日常构建。缓存问题应通过合理的依赖输入和阶段边界解决。

### 3\. 区分“检查基础镜像更新”和“完全禁用缓存”

构建时增加：

```bash
docker buildx build --pull ...

```

表示检查并拉取更新后的基础镜像，不等于禁用所有构建缓存。

完全绕过层缓存使用：

```bash
docker buildx build --no-cache ...

```

二者用途不同。

### 4\. 不要把运行时配置烘焙进镜像

环境相关配置应尽量在运行容器时注入：

```bash
docker run \
  -e DATABASE_URL="..." \
  -e NODE_ENV=production \
  demo-app:optimized

```

不要为测试、预发布和生产分别把 `.env` 复制进镜像。这不仅会降低镜像复用率，也可能泄露敏感信息。

### 5\. 镜像优化后仍要做功能和安全验证

更小的镜像通常意味着更少的软件包和更小的攻击面，但“体积更小”不等于“没有漏洞”。

优化后至少应验证：

- 应用启动；
- 健康检查接口；
- 文件写入权限；
- 优雅退出；
- 数据库和外部服务连接；
- 原生模块加载；
- 目标 CPU 架构；
- 基础镜像与依赖安全更新。

---

## 十六、最终检查清单

在提交 Dockerfile 前，可以逐项确认：

- \[ \] 已创建 `.dockerignore`；
- \[ \] 没有把 `.git`、`.env`、本地 `node_modules` 发进构建上下文；
- \[ \] 使用锁文件和 `npm ci`；
- \[ \] 依赖清单在业务源码之前复制；
- \[ \] 编译阶段与运行阶段分离；
- \[ \] 最终镜像中没有开发依赖；
- \[ \] 最终镜像中没有编译器和构建工具；
- \[ \] 使用 BuildKit 缓存挂载复用包管理器下载缓存；
- \[ \] CI 配置了持久化或远程缓存；
- \[ \] 私有仓库凭据通过 BuildKit Secret 传入；
- \[ \] 应用不以 root 用户运行；
- \[ \] 已执行容器启动和接口冒烟测试；
- \[ \] 已检查镜像分层和真实体积；
- \[ \] 已验证目标平台及原生依赖兼容性；
- \[ \] 没有为了追求更小体积而盲目切换 Alpine。

## 总结

优化 Docker 镜像体积和构建速度，最有效的方法不是堆叠复杂参数，而是建立清晰的构建边界：

1. 用 `.dockerignore` 缩小构建上下文并阻止敏感文件进入构建器；
2. 先复制锁文件、后复制源码，提高依赖层缓存命中率；
3. 用多阶段构建分离开发依赖、编译产物和运行环境；
4. 用 BuildKit `cache mount` 复用 npm、apt 等下载缓存；
5. 用远程缓存解决临时 CI Runner 每次从零构建的问题；
6. 用 BuildKit Secret 安全传递私有仓库凭据；
7. 最终镜像只保留运行时、生产依赖和必要产物；
8. 通过真实构建耗时、镜像大小和运行测试验证效果。

一份优秀的 Dockerfile 不只是“能构建”，还应该具备缓存稳定、产物精简、凭据安全、结果可重复和运行环境最小化等特征。先完成多阶段构建和依赖分层，再引入 BuildKit 缓存与 CI 远程缓存，通常就能同时解决镜像臃肿和构建缓慢这两个最常见的问题。