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

《Docker 镜像体积优化实战:多阶段构建、层缓存与构建提速方案》

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

Docker 镜像体积优化实战:多阶段构建、层缓存与构建提速方案

做容器化项目时,很多团队一开始只关注“能跑起来”,等 CI 变慢、镜像仓库膨胀、部署时间拉长,才发现镜像体积和构建速度已经成了隐性成本。

我自己刚开始用 Docker 时,也写过那种“一个 Dockerfile 装天下”的版本:构建工具、源码、测试依赖、临时文件全都打进镜像里,最后一个简单服务能做出 1GB+ 的镜像。后来回头看,问题并不复杂,核心就三件事:

  1. 把不该进运行镜像的东西剥离掉
  2. 让 Docker 尽可能复用已有层缓存
  3. 减少无效拷贝和重复下载

这篇文章我会从“为什么慢、为什么大”讲起,然后带你一步步把一个常见 Node.js 服务镜像优化到更轻、更快、更适合生产环境。


背景与问题

先看一个很典型的 Dockerfile:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

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

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

  • COPY . . 太早,任何源码变动都会让依赖层缓存失效
  • 构建依赖和运行依赖混在一起
  • 源码、测试文件、Git 元数据可能全被打进镜像
  • 没有使用多阶段构建,最终镜像携带完整编译环境
  • 依赖安装策略不稳定,容易导致 CI 时间波动

实际影响通常有这些:

  • 镜像体积大:拉取慢、分发慢、磁盘占用高
  • 构建时间长:CI/CD pipeline 更慢
  • 缓存命中率低:改一行代码就重新安装全部依赖
  • 安全面扩大:运行镜像里包含不必要工具链和包管理器

如果你的团队已经遇到下面任意一个现象,就值得系统优化:

  • docker build 一次要几分钟甚至十几分钟
  • 镜像大小几百 MB 到几个 GB
  • 同样代码在 CI 上频繁重新下载依赖
  • 生产镜像里居然还能执行 gccmake 之类命令

前置知识与环境准备

本文示例基于以下环境:

  • Docker 20.10+
  • 建议开启 BuildKit
  • 一个简单的 Node.js/TypeScript 服务

先开启 BuildKit(本地 shell 临时生效):

export DOCKER_BUILDKIT=1

也可以在构建时显式指定:

DOCKER_BUILDKIT=1 docker build -t demo-app .

示例项目结构如下:

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

示例 package.json

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

示例 src/server.ts

import express from "express";

const app = express();
const port = process.env.PORT || 3000;

app.get("/", (_, res) => {
  res.json({ ok: true, message: "hello docker optimize" });
});

app.listen(port, () => {
  console.log(`server listening on ${port}`);
});

核心原理

镜像优化不是玄学,理解下面三个原理就够了。

1. Docker 镜像是分层的

Dockerfile 中大多数指令都会形成镜像层。构建时,Docker 会尝试复用之前已有的层。

例如:

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

如果你只改了 src/server.ts,理论上:

  • COPY package*.json ./ 不变
  • RUN npm ci 这一层可复用缓存
  • 只有后面的源码复制和构建重新执行

但如果你上来就是 COPY . .,那么只要项目里任意文件变化,后面所有层都可能失效。

2. 多阶段构建的本质是“构建环境”和“运行环境”分离

构建阶段需要:

  • 编译器
  • devDependencies
  • 构建脚本
  • 源码

运行阶段通常只需要:

  • 编译产物
  • 生产依赖
  • 最小运行时

把这两者拆开,最终镜像自然会瘦很多。

3. 缓存优化的核心是“稳定输入放前面,频繁变化放后面”

一般规律:

  • 依赖清单变化频率低,应该尽量前置
  • 源码变化频率高,应该后置
  • 大体积无关文件要通过 .dockerignore 排除

先看整体优化思路

下面这张图可以先建立全局认知:

flowchart TD
    A[源码目录] --> B[构建阶段 builder]
    B --> C[安装依赖 npm ci]
    C --> D[编译源码 npm run build]
    D --> E[产出 dist]
    E --> F[运行阶段 runner]
    F --> G[仅复制 dist 与生产依赖]
    G --> H[轻量运行镜像]

再看缓存命中的关键路径:

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 层缓存
    Dev->>Docker: 发送 package.json / lock 文件
    Docker->>Cache: 查询依赖安装层
    Cache-->>Docker: 命中则复用 npm ci 结果
    Dev->>Docker: 发送 src 源码
    Docker->>Docker: 仅重跑构建层
    Docker-->>Dev: 输出新镜像

实战代码(可运行)

下面我们分三步优化。


第一步:加上 .dockerignore

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

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

为什么重要?

因为 docker build 时,Docker 会先把构建上下文打包发送给 daemon。上下文越大,构建越慢。而且一旦把无关文件带进去,还会影响缓存。

我见过有人把本地 node_modules.git 一起发进去,构建上下文几百 MB 起步,CI 直接拖垮。


第二步:把 Dockerfile 改造成“缓存友好”版本

先给一个比原版更合理的单阶段版本:

FROM node:18-slim

WORKDIR /app

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

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

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

这个版本已经比最开始好很多:

  • 依赖文件单独复制,便于缓存
  • 源码后复制,减少 npm ci 失效概率
  • 基础镜像从 node:18 换成 node:18-slim

但它仍然有问题:

  • devDependencies 还在最终镜像里
  • TypeScript 编译环境也留在运行镜像
  • 不够“生产化”

所以我们继续。


第三步:使用多阶段构建

下面是更适合生产环境的版本。

# syntax=docker/dockerfile:1.6

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

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

FROM node:18-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

这个 Dockerfile 做了四件事:

  1. deps 阶段安装完整依赖,供构建使用
  2. builder 阶段只负责编译
  3. prod-deps 阶段只安装生产依赖
  4. runner 阶段只保留运行所需文件

这样最终镜像中没有:

  • TypeScript 源码
  • devDependencies
  • 编译中间产物
  • 不必要工具链

构建与运行

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

验证:

curl http://localhost:3000

输出示例:

{"ok":true,"message":"hello docker optimize"}

进一步提速:BuildKit 缓存挂载

如果你在 CI 中频繁构建,单靠层缓存还不够。因为有些时候层虽然失效了,但包下载缓存仍然可以复用。

比如 npm,可以这样写:

# syntax=docker/dockerfile:1.6

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

作用是:

  • 即使 npm ci 这一层因为 lock 文件变化需要重跑
  • Docker 仍可复用 /root/.npm 下载缓存
  • 大量减少重复下载时间

如果是 apt 安装,也可以使用类似方式:

RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y curl

不过注意:缓存挂载依赖 BuildKit,不是所有旧环境都默认启用。


优化前后对比思路

不同项目结果不一样,但通常能看到类似收益:

优化项优化前优化后
镜像体积700MB+150MB~250MB
首次构建时间较慢略有改善
增量构建时间很慢明显缩短
CI 依赖下载经常重复命中缓存后更快
运行镜像内容构建环境全带上仅保留运行所需

如果你想自己量化,可以执行:

docker images | grep demo-app
docker history demo-app:optimized

查看每层大小:

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

常见坑与排查

这部分很关键,因为很多人“照抄了 Dockerfile,结果还是慢”。

坑 1:.dockerignore 没配好

现象:

  • 构建上下文很大
  • 明明只改了代码,缓存还是频繁失效

排查:

docker build --progress=plain -t demo-app .

注意日志里 transferring context 的大小。如果几十 MB、几百 MB,就要怀疑上下文过大。

常见漏项:

  • node_modules
  • .git
  • dist
  • 测试报告
  • 本地 IDE 文件

坑 2:COPY . . 放太早

现象:

  • 改一个注释,也会重新执行 npm ci
  • 增量构建几乎没有加速

错误写法:

COPY . .
RUN npm ci

正确思路:

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

坑 3:没有 lock 文件,依赖层不稳定

如果没有 package-lock.json,你每次构建安装出来的依赖版本可能不同,缓存也更难稳定命中。

建议:

npm ci

而不是:

npm install

原因:

  • npm ci 更适合 CI/CD
  • 安装结果更可预测
  • 更容易复现问题

坑 4:Alpine 不是所有场景都更好

很多文章喜欢一句话:换 Alpine,镜像立刻变小。这话不算错,但不完整。

Alpine 的问题在于:

  • 使用 musl,某些 Node 原生模块兼容性不如 Debian/Ubuntu 系
  • 编译依赖复杂时,排错成本更高
  • 某些包安装并不一定更快

如果你的服务依赖原生扩展,我更建议优先试:

  • node:18-slim
  • python:3.x-slim
  • openjdk:*-slim

也就是说,先追求稳定,再追求极限瘦身


坑 5:多阶段构建后,运行时报“文件不存在”

常见原因:

  • 编译产物目录写错,比如以为是 build/,实际是 dist/
  • 没复制 package.json
  • 运行命令路径不一致

排查方式:

先进入镜像检查:

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

然后查看目录:

ls -R /app

如果是 dist 没生成,多半是 builder 阶段构建失败或者 TS 配置有问题。


坑 6:缓存没有命中,其实是基础镜像变了

如果你写的是:

FROM node:latest

那么某一天官方镜像更新后,缓存链条可能整体变化。

建议固定版本:

FROM node:18-slim

进一步更稳一点,可以固定 digest,不过维护成本更高。


安全/性能最佳实践

镜像优化不能只看“更小”,还要看生产可用性。

1. 使用非 root 用户运行

更安全的写法:

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

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

RUN useradd -r -s /usr/sbin/nologin appuser && chown -R appuser:appuser /app
USER appuser

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

这样即使应用被利用,攻击面也相对更小。


2. 减少镜像中不必要的软件包

如果你安装系统依赖,尽量避免“顺手装一堆”:

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

重点:

  • --no-install-recommends
  • 清理 apt 索引
  • 不要把调试工具长期留在生产镜像中

3. 合理排序 Dockerfile 指令

一个经验法则:

  1. 基础镜像
  2. 不常变化的系统依赖
  3. 包描述文件
  4. 依赖安装
  5. 源码复制
  6. 构建
  7. 运行配置

这样层缓存通常最友好。


4. 结合 CI 做远程缓存

如果你用 GitHub Actions、GitLab CI 或 Buildx,可以进一步启用远程缓存。思路如下:

flowchart LR
    A[开发者提交代码] --> B[CI 触发 docker buildx]
    B --> C[拉取远程缓存]
    C --> D[执行增量构建]
    D --> E[推送镜像]
    D --> F[回写最新缓存]

这样即使 CI Runner 是临时机器,也能尽可能复用之前构建结果。


5. 定期扫描镜像漏洞

镜像变小不等于一定更安全。建议配合漏洞扫描工具:

  • Trivy
  • Grype
  • Docker Scout

例如:

trivy image demo-app:optimized

如果你的基础镜像长期不更新,小镜像照样可能有高危漏洞。


6. 不要为了极致缓存牺牲可维护性

有些 Dockerfile 会把步骤拆得过碎、写得极度技巧化,团队新人根本看不懂。我的建议是:

  • 先做到结构清晰
  • 再做缓存细节优化
  • 只保留真正有收益的技巧

毕竟构建文件也是代码,后续要维护的。


逐步验证清单

如果你想按教程自己动手,可以按下面清单检查。

检查 1:构建上下文是否变小

docker build --progress=plain -t demo-app .

看日志中的 context 大小是否明显下降。

检查 2:改业务代码后,依赖安装是否命中缓存

修改 src/server.ts 一行内容,再构建:

docker build --progress=plain -t demo-app .

观察 npm ci 对应层是否显示缓存命中。

检查 3:最终镜像是否只包含运行所需内容

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

检查是否还存在源码、测试目录、TypeScript 配置等无关内容。

检查 4:镜像大小是否明显下降

docker images

对比优化前后的镜像体积。

检查 5:服务是否正常启动

docker run --rm -p 3000:3000 demo-app:optimized
curl http://localhost:3000

一个更完整的生产示例

如果你希望直接拿去做模板,可以参考下面这个版本:

# syntax=docker/dockerfile:1.6

FROM node:18-slim AS base
WORKDIR /app

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

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build

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

FROM base AS runner
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN useradd -r -s /usr/sbin/nologin appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

配套 .dockerignore

node_modules
dist
.git
coverage
Dockerfile
README.md
*.log

这个版本兼顾了:

  • 多阶段构建
  • 依赖缓存
  • 生产依赖裁剪
  • 非 root 运行
  • 更好的可读性

总结

Docker 镜像优化最有效的,不是“到处找更小的基础镜像”,而是先把构建链路理顺。

你可以把本文的结论记成这 5 条:

  1. 先写好 .dockerignore,减少无效上下文
  2. 依赖文件先复制,源码后复制,提高层缓存命中率
  3. 用多阶段构建分离编译与运行环境
  4. 优先使用 npm ci 和 lock 文件,保证依赖稳定
  5. 在 CI 中启用 BuildKit 缓存挂载或远程缓存,进一步提速

如果你是中级开发者,落地时我建议按这个顺序推进:

  • 第一步:补 .dockerignore
  • 第二步:调整 Dockerfile 指令顺序
  • 第三步:引入多阶段构建
  • 第四步:启用 BuildKit 缓存
  • 第五步:加上非 root 与漏洞扫描

边界条件也要记住:

  • 不是所有项目都适合 Alpine
  • 不是拆得越细就越快
  • 不是镜像越小就一定越安全
  • 优化目标应结合 CI 时长、仓库流量、部署频率一起看

一句话收尾:**镜像优化的本质,不只是“瘦身”,而是让构建过程更稳定、可预测、可维护。**只要把这个方向抓住,你的 Dockerfile 基本就不会跑偏。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建优化到生产环境安全交付》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑-206》