Gitea Runner 多架构 .NET 构建优化日志:告别重复下载,实现本地缓存终极方案

记录 Gitea Runner 构建 .NET 多服务(amd64+arm64)的完整优化过程,从踩坑本地文件缓存到最终找到持久化 Buildx 构建器方案,解决基础镜像重复下载、耗流量、构建慢的核心痛点。

作为独立开发者,我们使用 Gitea 搭建了私有代码仓库,并通过 Gitea Runner 实现 .NET 多服务的 CI/CD 自动构建。核心需求很明确:构建 amd64 + arm64 双架构镜像,同时解决 基础镜像重复下载、耗流量、构建速度慢 的痛点——毕竟 .NET SDK 镜像体积不小,每次构建都重新下载,不仅浪费时间,也增加了外网流量成本。

这篇日志记录了我们从初始方案、踩坑试错,到最终找到最优解的完整过程,希望能给有同样需求的开发者提供参考。

一、初始需求与痛点

我们有两个 .NET 微服务,需要通过 Gitea Actions 自动构建双架构镜像(amd64 用于服务器部署,arm64 用于边缘设备),并推送到私有镜像仓库。初始面临的核心问题:

  • 每次构建都会重新下载 .NET SDK、Runtime 基础镜像,耗时久、耗流量;

  • 尝试过本地文件缓存,不仅配置复杂,还存在权限问题和磁盘膨胀风险;

  • 多架构构建(buildx)默认不保留基础镜像,缓存方案难以落地。

二、第一版方案:本地文件缓存(踩坑)

最初想到的解决方案是使用 buildx 的本地文件缓存(type=local),通过映射宿主机目录,将构建缓存持久化,试图避免重复下载基础镜像。

第一版 workflow 核心配置:

name: Build and Push Multiple .NET Services

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:

jobs:
  build-multiple-services:
    runs-on: runner-host
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        project:
          - image_name: service-account
            dockerfile: ./AccountServer/Dockerfile
          - image_name: service-ai
            dockerfile: ./AIServer/Dockerfile

    steps:
      - name: Checkout Code
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract Version
        id: vars
        run: |
          REF_NAME="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}"
          if [ -z "$REF_NAME" ]; then REF_NAME="${GITHUB_REF##*/}"; fi
          if [[ "$REF_NAME" == v* ]]; then
            VERSION="$REF_NAME"
          else
            VERSION="latest"
          fi
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      # 尝试本地文件缓存,解决基础镜像重复下载
      - name: Prepare Cache Directories
        run: |
          mkdir -p /data/build-cache/${{ matrix.project.image_name }}
          mkdir -p /data/build-cache/${{ matrix.project.image_name }}-new

      - name: Build and Push ${{ matrix.project.image_name }}
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ${{ matrix.project.dockerfile }}
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            registry.example.com/group/${{ matrix.project.image_name }}:${{ steps.vars.outputs.version }}
            registry.example.com/group/${{ matrix.project.image_name }}:latest
          # 本地文件缓存配置
          cache-from: type=local,src=/data/build-cache/${{ matrix.project.image_name }}
          cache-to: type=local,dest=/data/build-cache/${{ matrix.project.image_name }}-new,mode=max

      # 清理旧缓存,防止磁盘膨胀
      - name: Move cache to prevent disk bloat
        run: |
          rm -rf /data/build-cache/${{ matrix.project.image_name }}
          mv /data/build-cache/${{ matrix.project.image_name }}-new /data/build-cache/${{ matrix.project.image_name }} || true

踩坑总结

这个方案看似能解决缓存问题,但实际使用中发现多个致命缺陷:

  1. 并行构建冲突:为了避免缓存目录冲突,不得不设置 max-parallel: 1,强制串行构建,降低了构建效率。

  2. 部署迁移麻烦:缓存目录交替删除重命名,防止缓存复写出现问题。runner部署是容器化的,它需要额外挂载一个卷来映射/data,需要配置,清理和迁移都麻烦。

显然,本地文件缓存方案不适合我们的场景,必须寻找更优解。

三、第二版方案:镜像仓库缓存(不适用)

参考 Docker 官方文档,尝试使用 type=registry 缓存,将构建缓存推送到私有镜像仓库,试图实现缓存共享和持久化。

核心修改点:

# 替换原有的本地缓存配置
cache-from: type=registry,ref=registry.example.com/group/${{ matrix.project.image_name }}:buildcache
cache-to: type=registry,ref=registry.example.com/group/${{ matrix.project.image_name }}:buildcache,mode=max

不适用原因

这个方案虽然解决了缓存共享和磁盘膨胀问题,但完全不符合我们的核心需求:

我们的痛点是「避免重复下载基础镜像、节省外网流量」,而将缓存推送到私有镜像仓库,本质上还是需要从网络拉取缓存——无论是拉取基础镜像,还是拉取仓库中的缓存,依然会消耗外网流量,和直接下载基础镜像没有本质区别。

如果私有镜像仓库是内网部署,这个方案可行,但我们的镜像仓库需要外网访问,因此这个方案被放弃。

四、第三版方案:原生 Docker Build(牺牲多架构)

既然 buildx 多架构构建难以缓存基础镜像,我们尝试放弃 buildx,使用原生 docker build,因为原生 Docker 会将基础镜像永久缓存到宿主机,不会重复下载。

核心修改点:

# 替换 buildx 构建步骤
- name: Build & Push ${{ matrix.project.image_name }}
  run: |
    IMAGE="registry.example.com/group/${{ matrix.project.image_name }}"
    VERSION="${{ steps.vars.outputs.version }}"
    
    # 原生 docker build,自动缓存基础镜像
    docker build \
      -t $IMAGE:$VERSION \
      -t $IMAGE:latest \
      -f ${{ matrix.project.dockerfile }} .
    
    # 推送镜像
    docker push $IMAGE:$VERSION
    docker push $IMAGE:latest

优缺点分析

优点:完美解决基础镜像重复下载问题,第一次下载后,后续构建直接复用宿主机缓存,不耗流量、速度极快;配置简单,无权限和磁盘膨胀问题。

缺点:无法构建 arm64 架构——原生 docker build 不支持多架构构建,而我们需要同时构建 amd64 和 arm64 双架构,因此这个方案只能作为过渡,无法满足最终需求。

五、终极方案:持久化 Buildx 构建器(完美解决)

经过多次试错,我们终于找到了解决方案:创建一个长期驻留在宿主机上的 Buildx 构建器,让构建器容器常驻宿主机,其内部缓存会永久保留,既支持多架构构建,又能避免基础镜像重复下载。

这个方案的核心逻辑是:Buildx 构建器本身是一个 Docker 容器,只要这个容器不被删除,其内部的基础镜像、构建中间层缓存就会永久保留,后续构建直接复用,无需重新下载。

最终定稿 workflow

name: Build and Push Multiple .NET Services

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:

jobs:
  build-multiple-services:
    runs-on: runner-host
    
    strategy:
      fail-fast: false # 单个服务构建失败,不影响其他服务
      matrix:
        # 多服务构建矩阵,可按需扩展
        project:
          - image_name: service-account
            dockerfile: ./AccountServer/Dockerfile
          - image_name: service-ai
            dockerfile: ./AIServer/Dockerfile

    steps:
      - name: Checkout Code
        uses: actions/checkout@v3

      # 核心:创建/复用持久化 Buildx 构建器,缓存永久保留
      - name: Set up Persistent Docker Buildx Builder
        run: |
          # 尝试使用已有的构建器,不存在则创建
          docker buildx use custom-builder 2>/dev/null || \
            docker buildx create --name custom-builder --driver docker-container --use --bootstrap

      - name: Login to Docker Registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract Version
        id: vars
        run: |
          REF_NAME="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}"
          if [ -z "$REF_NAME" ]; then REF_NAME="${GITHUB_REF##*/}"; fi
          if [[ "$REF_NAME" == v* ]]; then
            VERSION="$REF_NAME"
          else
            VERSION="latest"
          fi
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      # 多架构构建 + 自动复用缓存
      - name: Build and Push ${{ matrix.project.image_name }}
        uses: docker/build-push-action@v5
        with:
          context: . # .NET 构建必须使用仓库根目录作为上下文
          file: ${{ matrix.project.dockerfile }}
          platforms: linux/amd64,linux/arm64 # 双架构构建
          push: true
          tags: |
            registry.example.com/group/${{ matrix.project.image_name }}:${{ steps.vars.outputs.version }}
            registry.example.com/group/${{ matrix.project.image_name }}:latest

关键优化点解析

  1. 持久化 Buildx 构建器docker buildx create --name custom-builder --driver docker-container 创建一个命名构建器,使用 docker-container 驱动,构建器容器会常驻宿主机,不会每次构建都销毁;

  2. 缓存自动保留:构建器容器内部会自动缓存 .NET SDK、Runtime 等基础镜像,以及构建中间层,后续构建直接复用,无需重新下载;

  3. 无需额外缓存配置:不需要 cache-fromcache-to 配置,Buildx 构建器会自动管理缓存,简洁高效;

  4. 支持多架构 + 并行构建:去掉了 max-parallel: 1,可以并行构建多个服务(根据服务器性能调整),同时完美支持 amd64 + arm64 双架构。

六、可选优化:禁止缓存自动清理(更稳)

为了确保基础镜像缓存永久保留,避免 Buildx 自动清理缓存,可在创建构建器时添加 --buildkitd-flags '--oci-worker-gc=false',禁止自动垃圾回收:

- name: Set up Persistent Docker Buildx Builder
  run: |
    docker buildx use custom-builder 2>/dev/null || \
      docker buildx create --name custom-builder --driver docker-container \
      --use --bootstrap \
      --buildkitd-flags '--oci-worker-gc=false'

七、最终效果与总结

优化后,我们的 CI/CD 构建实现了以下目标:

  • ✅ 双架构构建(amd64 + arm64)正常运行,满足多设备部署需求;

  • ✅ .NET SDK、Runtime 基础镜像只下载一次,后续构建零流量消耗;

  • ✅ 构建速度提升 80% 以上,从每次构建 10+ 分钟缩短到 2 分钟以内;

  • ✅ 无权限问题、无磁盘膨胀风险,无需手动维护缓存;

  • ✅ 配置简洁,可直接复用,支持多服务扩展。

经验总结

  1. 不要盲目使用本地文件缓存或镜像仓库缓存,先明确核心痛点:我们的痛点是「基础镜像重复下载」,而非「项目构建层缓存」;

  2. Buildx 多架构构建的缓存关键的是「持久化构建器」,而非额外的缓存配置;

  3. 原生 Docker Build 和 Buildx 各有优劣,根据是否需要多架构选择合适的方案;

  4. 对于 Gitea Runner + .NET 多架构构建场景,「持久化 Buildx 构建器」是最优解,兼顾简洁性、稳定性和效率。

希望这篇日志能帮助到有同样需求的开发者,少走弯路,快速实现高效、省流量的 CI/CD 构建流程。