Docker BuildKit 缓存失效的 5 个真实场景:GitHub Actions CI/CD 提速避坑指南

Docker BuildKit 缓存失效场景

Docker BuildKit 缓存失效的 5 个真实场景:GitHub Actions CI/CD 提速避坑指南

明明什么都没改,GitHub Actions 的 Docker 镜像构建每次都从零开始。层缓存失效是 CI 慢的根因,但 BuildKit 在 GitHub Actions 环境里的失效模式和本地完全不同——认证 token 过期、per-step tmpfs、多平台缓存隔离、系统时间戳、.dockerignore 遗漏,每个都是独立坑。

本文覆盖 5 个我实际踩过的场景,每个都有可复现的命令和验证方法。

问题场景一:cache-from 用了私有镜像,认证 token 过期导致全量回退

场景一:私有镜像的 cache-from 认证 token 过期。GitHub Packages 或其他私有仓库需要认证才能拉取镜像层,cache-from 配置里用了需要 token 的镜像地址时,如果 token 失效,BuildKit 会静默退化为无缓存构建——CI 日志里完全看不到报错。

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build with cache
        uses: docker/build-push-action@v5
        with:
          cache-from: type=registry,ref=ghcr.io/your-org/your-image:latest
          cache-to: type=registry,mode=max,ref=ghcr.io/your-org/your-image:latest
          push: true

问题在于 GITHUB_TOKEN 的有效期是 24 小时(可配置,最长 90 天,但需要额外申请)。超过有效期后,docker/build-push-action 会静默退化为无缓存构建,因为拉取 cache-from 镜像层时认证失败,BuildKit 选择跳过缓存而不是报错。

验证方法——在 CI 日志里搜索 cache not foundauthentication required

# 本地模拟验证(用已过期的 token 测试)
docker buildx build \
  --cache-from ghcr.io/your-org/your-image:latest \
  --push false 2>&1 | grep -i "cache\|auth"
# 输出如果包含 "cache not found" 或 "retrying" 说明缓存拉取失败

解法:改用 GitHub OIDC 认证,或在 workflow 里每天重新登录:

# 使用 OIDC 方式(推荐)
- name: Login to GHCR via OIDC
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
    # 如果 token 过期严重,改用_PAT 或配置 token 有效期

问题场景二:GitHub Actions 的临时文件系统导致 cache 目录权限变更

GitHub Actions 的 runner 在每次 job 开始时会创建一个新的临时文件系统(overlay fs)。如果你在同一个 job 里先 docker build 再做其他操作,然后第二次构建,BuildKit 的缓存目录(默认在 /root/.buildcache/root/.cache/buildkit)可能会因为 runner 环境的临时性被部分清理。

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build first time
        run: |
          docker buildx create --use
          docker buildx build \
            --cache-from type=local,src=/tmp/docker-cache \
            --cache-to type=local,dest=/tmp/docker-cache-new \
            --tag myapp:${{ github.sha }} .

      - name: Build second time (uses cache)
        run: |
          # 注意:/tmp 在 GitHub Actions 里是 per-step 的,
          # 上一步的 /tmp/docker-cache-new 在这一步不可见!
          docker buildx build \
            --cache-from type=local,src=/tmp/docker-cache \
            --tag myapp:${{ github.sha }} .

GitHub Actions 的 /tmp 是 per-step 的,不是 per-job 的。所以 cache-from type=local,src=/tmp/docker-cache 在不同 step 之间完全不共享。

验证方法——在两个 step 里分别检查缓存目录:

- name: Check cache in step 1
  run: |
    echo "Step 1 tmp: $(ls -la /tmp)"
    docker buildx build --cache-from type=local,src=/tmp/my-cache ...

- name: Check cache in step 2
  run: |
    echo "Step 2 tmp: $(ls -la /tmp)"  # 会看到不同的 /tmp 内容
    # /tmp/my-cache 在这里不存在了

解法:使用 GitHub Actions 提供的 actions/cache action 来持久化缓存目录,或直接用 registry 缓存:

- name: Build and push with registry cache
  uses: docker/build-push-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/your-org/your-image:buildcache
    cache-to: type=registry,ref=ghcr.io/your-org/your-image:buildcache,mode=max
    tags: ghcr.io/your-org/your-image:${{ github.sha }}
    push: true

问题场景三:多平台构建导致缓存完全不共享

如果你在 CI 里同时构建 linux/amd64linux/arm64 两个平台,BuildKit 的 layer 缓存是严格平台相关的。amd64 的缓存对 arm64 完全无效,反之亦然。

# 这个配置会产生"明明用了缓存但还是很慢"的问题
- name: Build multi-platform
  uses: docker/build-push-action@v5
  with:
    platforms: linux/amd64,linux/arm64
    cache-from: ghcr.io/your-org/your-image:buildcache
    cache-to: ghcr.io/your-org/your-image:buildcache,mode=max
    push: true

原因:不同 CPU 架构的二进制文件不同,BuildKit 对每个平台独立计算 layer hash。arm64 的 RUN apt-get install -y python3 产生的 layer 和 amd64 的完全不同。

验证方法——检查 BuildKit 的缓存分析输出:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from ghcr.io/your-org/your-image:buildcache \
  --push false \
  2>&1 | grep -E "CACHED|layer|platform"
# 你会看到 amd64 和 arm64 分别处理,但彼此的缓存不共享

正确的做法是每个平台用独立的 cache tag:

# 分别构建,避免缓存污染和跨平台失效
- name: Build amd64
  uses: docker/build-push-action@v5
  with:
    platform: linux/amd64
    cache-from: type=registry,ref=ghcr.io/your-org/your-image:buildcache-amd64
    cache-to: type=registry,ref=ghcr.io/your-org/your-image:buildcache-amd64,mode=max
    tags: ghcr.io/your-org/your-image:latest-amd64
    push: true

- name: Build arm64
  uses: docker/build-push-action@v5
  with:
    platform: linux/arm64
    cache-from: type=registry,ref=ghcr.io/your-org/your-image:buildcache-arm64
    cache-to: type=registry,ref=ghcr.io/your-org/your-image:buildcache-arm64,mode=max
    tags: ghcr.io/your-org/your-image:latest-arm64
    push: true

问题场景四:Dockerfile 里 system time 不一致导致 layer hash 全量失效

这是我在实际项目里踩过的坑。当 GitHub Actions runner 的系统时间发生微小变化(例如由于 NTP 校准),Dockerfile 里的 RUN 指令即使内容完全相同,每次构建产生的 layer hash 也会不同,导致缓存完全失效。

# 每次 CI 运行时间不同,导致这行 RUN 的时间戳变化
RUN date && pip install -r requirements.txt
# 正确做法:不包含 date 或使用固定时间
RUN pip install -r requirements.txt

还有一个不易察觉的坑:某些工具会在 RUN 时写入当前时间导致 layer hash 变化:

# 这些指令每次运行都会改变 layer hash
RUN echo "Built at $(date)" > /app/version.txt
RUN npm version from-git  # npm 会写入当前 git tag 作为版本
RUN python -c "import datetime; print(datetime.datetime.now())"

# 正确做法:将动态内容移到运行时
CMD ["python", "-c", "import datetime; print(datetime.datetime.now())"]

验证方法——连续两次构建,观察 layer 数量:

# 第一次构建
docker buildx build --no-cache --push false -t test:1 .
# 观察输出中的 "CACHED" 行数量

# 第二次完全相同的构建
docker buildx build --no-cache --push false -t test:2 .
# 对比两次构建的 layer 数量和 hash
docker buildx imagetools inspect test:1 | jq '.manifests[0].digest'
docker buildx imagetools inspect test:2 | jq '.manifests[0].digest'
# 如果 hash 不同,说明有动态内容污染了缓存

解法:使用 BuildKit 的 BUILDKIT_INLINE_CACHE 和排除时间相关的文件:

# 在 .dockerignore 里排除
echo "version.txt" >> .dockerignore
echo ".npmrc" >> .dockerignore

# 或者在 BuildKit 配置里强制稳定 layer hash
docker buildx build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from ghcr.io/your-org/your-image:buildcache \
  --push true

问题场景五:依赖层的 .dockerignore 配置错误导致缓存污染

.dockerignore 的作用不仅是为了减小构建上下文大小,它还直接影响 BuildKit 的缓存判断。当 .dockerignore 漏掉了不应该进入镜像的文件,但这些文件的变化又没有被正确感知时,缓存会悄悄失效。

# 错误的 .dockerignore —— 漏掉了会触发依赖重新安装的文件
# 实际项目里可能漏掉:
# - .env(包含依赖版本)
# - package-lock.json(npm)
# - requirements.lock(pip)

# 正确的做法:明确列出要排除的,而不是要包含的
*
!src
!requirements.txt
!pyproject.toml
!Dockerfile
# 排除所有本地开发文件
.env
.git
__pycache__
*.pyc
.venv
node_modules

实战案例:项目里 requirements.txt 没变,但 .dockerignore 漏掉了 .venv/。本地开发者在 .venv 里多装了包,这个变化会被计入构建上下文哈希,导致 BuildKit 认为源码变了,强制重装依赖。

验证方法——用 docker buildx build --progress=plain 观察缓存命中情况:

docker buildx build \
  --cache-from ghcr.io/your-org/your-image:buildcache \
  --progress=plain \
  --push false 2>&1 | grep -E "CACHED|#\d+"
# CACHED 表示命中,#1 #2 表示新增层
# 如果每次 #3(COPY requirements.txt)都重新执行,说明上下文检测有问题

现在你可以做什么

第一步:检查你的 CI 日志里有没有 cache not foundretrying 的字样,有的话先解决认证问题。

第二步:如果你在用 type=local 的 cache-from,确认 GitHub Actions job 内缓存持久化用的是 actions/cache 而不是直接挂载 /tmp。

第三步:多平台构建场景下,给每个平台分配独立的 cache tag,避免跨平台污染。

第四步:搜索你的 Dockerfile 里有没有 $(date)npm versiongit describe 等会在构建时产生变化的命令,把它们移到运行时执行。

第五步:审查 .dockerignore,确保它排除了所有不需要进入镜像的文件,尤其是 .venv/、__pycache__/、node_modules/ 这些大目录。

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享