一、先把问题说清楚:多阶段构建和缓存到底是个啥

很多朋友用 Docker 的时候习惯了那一套 docker build 缓存逻辑,换到 Podman 之后发现“哎,怎么每次重新构建都跟第一次一样慢”?尤其用了多阶段构建,明明第一个阶段只是拉个依赖、编译一下,什么都没变,第二次构建却又老老实实重新跑了一遍。这背后不是 Podman 故意跟你作对,而是我们对它的缓存机制理解还停留在“应该和 Docker 一样”的错觉上。

多阶段构建的好处不用多讲:一个阶段负责编译,一个阶段负责出最小运行镜像。问题在于,如果我们不刻意安排每一层的指令顺序,或者没搞清楚 Podman 默认的缓存键是什么,那么中间层缓存很难被命中。今天咱就用大白话,把这件事掰开揉碎了说清楚,顺便给出一套能直接抄的优化方案。

二、先搞清楚 Podman 的缓存到底靠什么认人

2.1 不是靠“长得像”,是靠“每一层指令都一模一样”

Podman 在构建镜像时,会把 Dockerfile 里的每一条指令当成一层。每一层在生成之前,它会先算一个“指纹”。这个指纹由当前的指令内容、基础镜像信息、以及上一层的结果决定。只要有一丁点不一样,缓存就失效。

举个例子,你第一条指令是 FROM node:20,Podman 会去本地找有没有这个镜像。有的话,这一层就算命中了。接下来如果有一条指令是 RUN npm install,那它就会看这一整条指令的字符串内容,包括里面的空格、换行、注释(其实注释不算进入层指令)……只有和之前完全一致,才会尝试复用之前生成的中间层。

这里有个关键:Podman 和 Docker 一样,它并不关心你这条 RUN npm install 执行的时候,网上的 npm 包有没有更新。它只看指令文本和基础镜像指纹。所以,如果你什么都没改,理论上肯定能命中。但为什么实际没命中呢?往下看。

2.2 中间层缓存被“无意中”弄脏的常见姿势

  • 基础镜像用的不是固定标签,比如写 FROM node:latest
  • 构建上下文里有文件变化,而你在很靠前的位置就用了 COPY . .
  • RUN 命令里使用了 --no-cache 或者 Podman 构建时全局加了 --no-cache
  • 两条指令顺序乱排,让容易变的东西跑到了不容易变的东西前面。

2.3 中间层缓存和最终镜像缓存是两码事

这里特别要强调一下:Podman 的“中间层缓存”是构建过程中产生的临时层。如果命中了,构建会非常快;如果没命中,就重新执行。而最终镜像缓存是 podman pull 时从 registry 拉下来的。两者不一样。我们今天重点讲前者,也就是怎么让中间层缓存尽量命中。

三、直接上干货:一个典型的多阶段构建,以及它为什么缓存失效

先看一个最常见的 Node.js 项目,技术栈是 Node.js (npm)。我们写一个多阶段构建 Dockerfile:


# ==============================================
# 阶段一:编译 / 安装依赖
# ==============================================
FROM node:20-alpine AS builder

# 设置工作目录
WORKDIR /app

# 先把 package.json 和 package-lock.json 拷进来
# 注意:这里没有复制源码,目的就是为了利用缓存
COPY package*.json ./

# 安装依赖
# 只要 package 文件没变,这一层就会命中缓存
RUN npm ci

# 再复制源码
COPY . .

# 执行构建
RUN npm run build

# ==============================================
# 阶段二:生产环境镜像
# ==============================================
FROM node:20-alpine AS runner

WORKDIR /app

# 只是把编译结果和必要的生产依赖拷过来
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

# 启动服务
CMD ["node", "dist/index.js"]

假如你第一次构建用了 5 分钟。第二次你只改了源码 src/index.js 里面的一个字符,重新 podman build,你会发现:

  1. FROM node:20-alpine 这层命中。
  2. WORKDIR /app 命中。
  3. COPY package*.json ./ 命中了,因为这两个文件没变。
  4. RUN npm ci 命中了!
  5. COPY . . 没命中,因为源码变了。
  6. RUN npm run build 没命中,因为上一层 COY 结果变了。

这其实是正常且高效的。问题出在,如果你把 COPY . . 放到了 COPY package*.json 前面,那就全盘皆输。再来看一个反面教材:


# ==============================================
# 错误示例:COPY 全部文件放太前面
# 技术栈:Node.js (npm)
# ==============================================
FROM node:20-alpine AS builder

WORKDIR /app

# 直接把整个上下文复制进去,包括源码、配置文件、node_modules 等
# 只要任意一个文件变了,下面所有层缓存全部失效
COPY . .

# 再安装依赖,每次都得重新下载
RUN npm ci

RUN npm run build

这种写法很常见,有些人觉得“省事”,实则每次改一行代码,npm ci 都会重新跑一遍,时间全耗在下载包上。缓存几乎等于形同虚设。

四、让中间层缓存命中的三大核心原则

4.1 原则一:把“不容易变化的东西”放在最前面

最容易变化的文件,比如源码、文档、临时产物,要放在 Dockerfile 后面。最稳定的东西,比如 package-lock.jsonpoetry.lockgo.sum,要尽量靠前。

这里的“稳定”指的是:只要你不主动升级依赖,它就不会变。所以,当你写 COPY package*.json ./ 时,Podman 会计算这个文件的 hash。如果 hash 没变,后续 RUN npm ci 就直接用上次生成的层。

如果你连 package.json 都不常改,那就把 package-lock.json 单独复制,再复制 package.json。总之,越靠前越稳定越好。

4.2 原则二:不要用“可变标签”当基础镜像

假如你的 Dockerfile 写的是:


FROM node:latest

那你今天构建和明天构建,虽然你本地已经拉过 node:latest,但 Podman 可能会去远程检查这个 tag 是否更新了。一旦上游更新了 tag,你的基础镜像指纹就变了,之后每一层缓存全部失效。

更糟糕的是,哪怕你本地已经有这个镜像,Podman 在某些配置下依然会尝试拉取。所以,一定要用固定版本,甚至用摘要(digest)来锁定。

推荐写法:


# 使用固定版本号,避免上游更新导致缓存失效
FROM node:20.18.0-alpine

如果你追求更严格,可以直接用 @sha256:... 这种摘要方式,但可读性差一点。普通项目用具体版本号就够了。

4.3 原则三:单独留一个“依赖层”,把它和代码层彻底分开

多阶段构建的核心,就是要让“依赖安装”这一层成为一个独立且稳定的中间层。很多人抱怨“明明我已经把 package 文件放在源码前面了,为什么改源码时 npm ci 还是重新跑?”

此时你要注意:COPY package*.json ./ 只是复制了两个文件,但 COPY . . 会把整个目录复制进去,包括 package.jsonpackage-lock.json!当这条 COPY . . 执行时,它会覆盖之前复制来的 package.jsonpackage-lock.json。Podman 虽然会合并层,但这些层的文件内容变了,后续指令的上下文中这两个文件的内容仍然以最终层为准。

那为什么 npm ci 还能命中?因为它是在 COPY . . 之前执行的,它的输入是“前一层的结果”,也就是只包含 package 文件的目录。只要这两个文件没变,npm ci 就命中。换句话说,只要你不把 COPY . . 放到 RUN npm ci 前面,依赖层就能安全复用。

所以,原则三的本质是:永远不要让大范围 COPY 出现在依赖安装之前

五、实战示例:完整优化后的多阶段构建(Node.js + npm)

下面给一个比较完善的多阶段构建,技术栈是 Node.js (npm),大家可以直接参考。这个示例包含了注释,方便看懂每一行的用途。


# ==============================================
# 第一阶段:构建依赖和环境
# 技术栈:Node.js (npm)
# ==============================================
FROM node:20.18.0-alpine AS build

# 设置工作目录,后续所有命令都在这个目录下执行
WORKDIR /home/node/app

# 先复制依赖清单文件,注意顺序很重要
# 这里只复制 package.json 和 package-lock.json
# 目的是让下面 RUN npm ci 能尽量命中缓存
COPY package.json package-lock.json ./

# 安装依赖
# npm ci 会严格按照 lock 文件安装,并会删除 node_modules 中多余的内容
# 因为前面只复制了 package 文件,所以只要这两个文件没变,这层就命中
RUN npm ci --no-audit --no-fund

# 复制源码
# 如果源码变了,这一层会失效,但不会影响上面的依赖层
COPY . .

# 执行测试(可选)
# 建议把测试放在构建之前,如果测试失败就不用继续构建了
RUN npm run test

# 构建生产代码,输出到 dist 目录
RUN npm run build

# ==============================================
# 第二阶段:生产运行时镜像
# 技术栈:Node.js (npm)
# ==============================================
FROM node:20.18.0-alpine AS prod

# 创建一个非 root 用户,提升安全性
RUN addgroup -S app && adduser -S app -G app

WORKDIR /home/node/app

# 先复制构建产物和依赖,注意复制顺序也可以优化
COPY --from=build /home/node/app/package*.json ./
COPY --from=build /home/node/app/node_modules ./node_modules
COPY --from=build /home/node/app/dist ./dist

# 切换用户
USER app

# 暴露端口,这里只是示例,实际根据项目情况修改
EXPOSE 3000

# 启动命令
CMD ["node", "dist/index.js"]

这个示例里,RUN npm ci 会尽量被复用。等你改了源码,重新跑 Podman 构建,它会快速跳过依赖安装,只从 COPY . . 开始重跑,至少能省下一大半时间。

六、高级技巧:利用 Podman 内置的 --cache-from 和 BuildKit 兼容模式

6.1 什么是 BuildKit 兼容模式

Podman 默认使用自己的构建器(称为 buildah 的底层实现)。它支持大部分 Dockerfile 指令。同时,Podman 也支持通过环境变量 BUILDKIT_PROGRESS=plain 来让输出更详细,但真正影响缓存的是它是否能够把中间层推送到镜像仓库供别人复用。

注意,Podman 本身没有 --cache-from 这个 Docker 同款参数。不过,Podman 支持一种叫“缓存镜像”的方式,那就是把中间层作为普通镜像推送到 registry,下次构建时直接拉取。做法是:用 podman build --layers --force-rm 加上 --manifest 之类,但实践中最简单的是直接利用本地存储——只要不清理本地,Podman 就会自动复用中间层。所以,你只要别动不动就 podman system prune,缓存一般不会被丢掉。

6.2 如果你确实需要远程共享缓存

可以借用 podman build --cache-from 吗?很遗憾,Podman 官方没有这个参数。但有一个替代方案:把构建阶段中的每一步拆分成独立的镜像,或者使用 Buildah--layers 配合 --cache 选项。对于大多数开发者和个人项目,本地缓存已经足够。

这里补充一个小技巧:如果你用 GitLab CI 或 GitHub Actions 跑 Podman,每次都是全新的机器,本地缓存永远为空。这时候建议改用 docker buildx 或者 kaniko 等方式实现远程缓存。但本文主题是 Podman,所以点到为止。如果非要继续用 Podman,可以手动把中间层导出再导入,但那太复杂,不推荐。

七、再来一个典型场景:Python 项目的多阶段缓存优化

技术栈是 Python (pip),实际项目里非常常见。很多 Python 开发者习惯把所有文件一下子复制进去,然后 pip install,导致缓存永远用不上。下面给一个优化后的例子:


# ==============================================
# 第一阶段:安装依赖并收集编译产物
# 技术栈:Python (pip)
# ==============================================
FROM python:3.11-slim AS builder

WORKDIR /app

# 先复制依赖清单
# requirements.txt 变化频率低,放前面保证缓存命中
COPY requirements.txt .

# 安装依赖到指定目录
# 这里用 --prefix 把依赖安装到一个自定义目录,方便后续拷贝
RUN pip install --prefix=/deps -r requirements.txt

# 再复制源代码
COPY src ./src

# 如果有编译步骤,比如 py_compile,可以在这里做
RUN python -m compileall src

# ==============================================
# 第二阶段:运行镜像
# 技术栈:Python (pip)
# ==============================================
FROM python:3.11-slim AS runtime

WORKDIR /app

# 复制已安装的依赖
COPY --from=builder /deps /usr/local

# 复制源码
COPY --from=builder /app/src ./src

# 设置环境变量
ENV PYTHONUNBUFFERED=1

CMD ["python", "src/main.py"]

这个例子里,只要 requirements.txt 不变,pip install 就不会重新执行。如果源码变了,只影响后面步骤,缓存效果非常明显。

八、缓存命中的另一大杀手:构建上下文里塞了不必要的内容

8.1 什么是构建上下文

podman build . 中那个点号就是上下文。Podman 会把整个目录打包发送给构建进程,即使你没用 COPY,它也会先扫描一遍。所以,如果你的项目里有个巨大的 node_modules 或者 .git 目录,每次构建上下文传输都很慢,而且 COPY . . 会把这些大文件全部复制进镜像层,导致缓存指纹很容易变。

解决办法是写一个 .dockerignore 文件,把无需进入镜像的部分排除掉。举例如下,技术栈是 通用配置


# 忽略依赖目录
node_modules
venv
__pycache__

# 忽略版本控制和 CI 配置
.git
.gitignore
.github

# 忽略本地环境文件
.env
.env.*

# 忽略日志和临时文件
*.log
*.tmp
.DS_Store

# 忽略测试产物和报告
coverage
dist
build

注意,如果你在 Dockerfile 里 COPY . .,排除这些目录后,构建上下文里就没有它们了,缓存指纹就会更稳定,而且构建速度显著提升。

8.2 用 .dockerignore 优化后的完整示例

我们结合 Node 项目再写一个完整版本,技术栈是 Node.js (npm)


# 技术栈:Node.js (npm)
# 这是 Dockerfile
FROM node:20.18.0-alpine AS build
WORKDIR /app

# 复制依赖文件
COPY package*.json ./

# 安装依赖
RUN npm ci

# 复制源码
COPY . .

# 构建
RUN npm run build

# 运行阶段
FROM node:20.18.0-alpine
WORKDIR /app
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]

然后同目录下的 .dockerignore 内容如下:


# Node 项目忽略文件
node_modules
dist
.git
*.log
.DS_Store

这样一来,当源码没变时,构建上下文几乎没有变化,COPY . . 甚至会命中缓存。

九、哪些情况下中间层缓存本来就不该命中

很多同学有一个误区:认为优化缓存就是“无论我怎么改,都要命中”。实际上,如果代码逻辑变了,那一层编译自然必须重新执行。否则你看到的是假缓存,最后跑起来的还是旧代码。

所以,我们要优化的目标不是“永远命中”,而是“尽量让贵的操作不多跑”。贵操作包括:

  • 下载依赖包(网络请求)
  • 编译 C 扩展
  • 运行耗时测试
  • 生成大型产物

这些操作应该放在源代码变化影响不到的位置。而便宜的操作用来触发代码更新,比如 COPY . .RUN npm run build,它们跑一遍成本低,值得每次重新执行。

十、实用小工具和命令:如何查看缓存命中情况

10.1 用 podman build --progress=plain 看详细日志

默认输出比较简短,加了这个参数后,你会看到每一步是 CACHED 还是执行了。例如:


podman build --progress=plain -t myapp:latest .

日志中如果某一行显示 CACHED,那就说明这一层命中了缓存。如果显示 RUN npm ci,就说明它重新执行了。

10.2 用 podman images --filter dangling=true 查看中间层残留


# 查看所有悬空镜像,也就是中间层缓存
podman images --filter dangling=true

如果你清理了这些悬空镜像,中间层缓存就没了。所以没事别乱删。

10.3 强制绕过缓存的做法

有时候我们确实需要忽略缓存,比如基础镜像更新了、或者想验证构建过程是否可靠。可以用:


# 关闭构建缓存,所有层重新执行
podman build --no-cache -t myapp:latest .

但请记住,这会丢弃所有中间层缓存,一般只在 CI 上做全量构建时才用。

十一、Podman 和 Docker 在缓存上的细微差别

虽然两者都是 OCI 标准,但 Podman 的构建引擎基于 Buildah,它默认不依赖 daemon。这意味着,Podman 构建过程不会和 Docker daemon 抢占资源,但也导致一些 Docker 的缓存特性(比如 --cache-from 拉取远程缓存)在 Podman 上不支持。

另外,Podman 默认会把中间层保存在本地存储中,你可以通过 podman system df 查看存储占用。如果你经常构建,存储会被中间层占很多空间。这其实是缓存带来的“副作用”,不要急着清理。当你需要释放空间时,再考虑 podman image prune -a

十二、常见的坑和注意事项总结

12.1 不要频繁清理 Podman 存储

很多人为了清理磁盘,定期执行:


podman system prune -a

这一下就会把所有的中间层缓存全删掉。下次构建直接回到“地狱模式”。建议只清理悬空镜像(docker images --filter dangling=true 对应的悬空中间层)时,使用:


# 仅清理没有标签且没有被使用的中间层
podman image prune

这样能保留正在使用的构建缓存吗?其实也会删掉某些悬空层。所以,如果你想彻底保留缓存,最好的办法是啥都别动。

12.2 使用 COPY --from 时,别把源镜像搞复杂

多阶段构建里,COPY --from=builder 是把前一阶段的文件复制过来,这一操作本身不会产生新的缓存层问题。但要注意:如果 builder 阶段因为源码变化而重新执行,那 runner 阶段里的 COPY --from=builder 也会跟着失效。这很正常,因为文件内容确实变了。如果你希望 runner 阶段的某些层不受影响,可以把“复制依赖”和“复制编译产物”分开,但一般来说没必要过度优化。

12.3 注意 ARGENV 也会影响缓存

如果你在 Dockerfile 里写了:


ARG BASE_VERSION=20
FROM node:${BASE_VERSION}-alpine

那么修改这个 ARG 的值,会导致基础镜像指纹改变,后续所有缓存失效。同理,在 RUN 之前定义一个 ENV,如果环境变量值变了,也会影响该层以及后续层的缓存。因此,尽量保持 ARGENV 稳定,不要随便变动。

12.4 当使用了 --squash 时,缓存机制不同

--squash 会把所有层压缩成一层,导致中间层的独立性丧失。一般没必要使用这个选项,除非你的镜像大小要求非常严格。用了它,缓存命中率会显著下降。

十三、综合实战:一个带缓存优化的完整构建脚本

我写一个接近真实项目的脚本示例,包含 Dockerfile.dockerignore、以及一条构建命令。技术栈是 Node.js (npm)。这个示例会展示如何组织文件以及如何验证缓存。

首先是项目目录结构,就用文字说明下:


myapp/
├── .dockerignore
├── Dockerfile
├── package.json
├── package-lock.json
└── src/
    └── index.js

Dockerfile 内容如下:


# 技术栈:Node.js (npm)
# 多阶段构建示例,注意依赖层与源码层的分离

# 阶段一:构建器和依赖安装
FROM node:20.18.0-alpine AS build

WORKDIR /myapp

# 复制依赖清单,为了缓存,只复制这两个文件
COPY package*.json ./

# 安装依赖
RUN npm ci --no-audit --no-fund

# 复制源码
COPY . .

# 编译
RUN npm run build

# 阶段二:运行时
FROM node:20.18.0-alpine AS runtime

WORKDIR /myapp

# 从 build 阶段复制必要文件
COPY --from=build /myapp/package*.json ./
COPY --from=build /myapp/node_modules ./node_modules
COPY --from=build /myapp/dist ./dist

# 声明端口
EXPOSE 8080

# 运行
CMD ["node", "dist/index.js"]

.dockerignore 内容如下:


# 忽略依赖和构建产物
node_modules
dist

# 忽略版本控制
.git
.gitignore

# 忽略环境文件
.env
.env.*

# 忽略日志
*.log

构建命令及注释:


# 第一次构建,缓存为空,会完整执行所有步骤
podman build -t myapp:latest .

# 修改 src/index.js 中的代码后重新构建
# 你会发现 npm ci 那行显示 CACHED,速度很快
podman build -t myapp:latest .

这样操作下来,第一次构建可能耗时 3 分钟,第二次可能只需要 10 秒。这就是缓存命中的力量。

十四、另一语言生态参考:Go 项目如何缓存

技术栈是 Go (module),举一个多阶段构建例子,顺便说明依赖层的用法。


# 技术栈:Go (module)
# 先用带 go 编译器的镜像作为 builder
FROM golang:1.22-alpine AS builder

WORKDIR /app

# 先复制 go.mod 和 go.sum
COPY go.mod go.sum ./

# 下载依赖
RUN go mod download

# 复制源码
COPY . .

# 编译静态二进制
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .

# 运行时镜像
FROM alpine:3.20

RUN adduser -D app && chown -R app /app
USER app

WORKDIR /app

# 直接复制二进制文件
COPY --from=builder /app/app .

# 启动
CMD ["./app"]

在 Go 项目中,go mod download 只要 go.modgo.sum 不变,就会命中缓存。源码的 COPY 和 build 放在后面,不影响依赖缓存。

十五、Podman 构建缓存的进阶:使用 --layers--cache

实际上 Podman 本身的 buildah 引擎默认就启用层缓存,不需要额外参数。但你可以通过给 buildah bud 传参来探索更多。下面列出两个相关选项的含义,帮助大家理解:


# --layers 强制使用缓存层(默认开启)
# --no-cache 禁用缓存
# --cache-from 在 buildah 中不可用,但可以通过 --cache 指定本地缓存目录

注意,--cache 是 buildah 的选项,不是 Podman 的。直接使用 podman build --cache /tmp/cache 会报错。我们可以改用环境变量 BUILDAH_LAYERS=true 来确保层缓存启用,但默认就是 true。

所以,普通用户不用太纠结这些参数。最重要的是遵循 Dockerfile 编写规范,让缓存自然生效。

十六、总结:让中间层缓存真正可用的心法

最后回到核心,如果你要在一个新项目里使用 Podman 多阶段构建,并且希望缓存命中率最高,请记住这几条:

第一,基础镜像用固定版本,不追 latest。第二,依赖清单文件先复制,再执行依赖安装。第三,源码复制和构建动作永远放在依赖安装之后。第四,用好 .dockerignore 减少上下文变动。第五,不要轻易清理 Podman 本地存储。第六,查看详细日志确认哪一层 CACHED,哪一层没有。第七,如果使用 CI,考虑其他缓存方案,因为本地缓存在新机器上无效。

多阶段构建本身就是为了让最终镜像小而安全,缓存优化则是让构建过程快而流畅。两者配合起来,你的开发体验会提升一大截。今天给出的示例都是可以直接用的,你只要把项目里的实际文件路径和命令替换进去,就能立刻感受到差别。

希望这篇文章真的帮到你。以后再用 Podman 构建时,如果发现某一步没有命中缓存,回想一下“是不是我把容易变的东西放前面了?”大概率就能找到原因。