WebP Cloud Services Blog

混合部署 GitHub Actions Runner:Multi Arch 镜像构建速度飙升 10 倍!

· Nova Kwok

This article is also available in English, at Hybrid Deployment of GitHub Actions Runner: Multi-Arch Image Building Speed Soars 10x!.

在 WebP Cloud Services,我们所有的组件都是容器化部署的,代码托管和 CI/CD 都是在 GitHub 和 GitHub Actions 上面完成,这套比较 “现代” 的工作流让我们的工作量节省了很多时间和成本。

容器化部署的好处就是我们可以专注于每个环境的功能,而非花大量的时间用来处理不同机器上的环境问题。

既然是容器部署,就不得不提到镜像的托管,由于我们和 GitHub 深度整合,所以我们直接使用了 GHCR 来托管镜像(而非使用 ECR,GCR 等容易让我们破产的服务)。

前情提要

从上文中可以知道我们所有的服务都是容器化部署,举个例子,我们用于提供用户 API 的服务名字叫做 webppt ,如果大家有关注过我们的 API 文档 的话也会发现我们的 API 地址和主流的 API 服务商命名不一样,不是 https://api.webp.se 而是 https://webppt.webp.se ,背后的原因就是因为这个服务名字被称为 webppt

  • 当然,这其中有一段很有意思的故事,我们决定在之后分享整个 WebP Cloud Services 的故事的时候来详细和大家聊聊~

我们在 GitHub 上的 Org 名称是 webp-pt (请不要 Follow 这个 Org,这个 Org 下不会有任何的公开仓库的),那么 webppt 组件的镜像名也自然而然的成为了 ghcr.io/webp-pt/webppt

在我们探索并发现 ARM64 的优点(见文章: 「Hetzner CAX 系列 ARM64 服务器性能简评以及 WebP Cloud Services 在其上的实践」)之前,我们的基础设施在 AMD64 架构的独立服务器上,我们的工作流如下:

  • 所有的代码流程都会走 PR 的形式用来 Review,这里 GitHub Actions 会跑一遍所有的 CI 测试和镜像的 trivy 扫描(用于检测是否有明显的漏洞)
  • 代码合并到了 master (是的,我们不喜欢 main 分支)分支之后会由 GitHub Actions 构建一个名为 ghcr.io/webp-pt/webppt:latest 的镜像
  • 当我们决定发布一个版本到线上了之后,我们会给某一个 commit 打一个 tag,比如叫 31,那这个时候 GitHub Actions 会构建一个名为 ghcr.io/webp-pt/webppt:31 的镜像

对应的 GitHub Actions Step 也非常直观,大概长下面这个样子:

- name: Login to GitHub Container Registry
  uses: docker/login-action@v2
  with:
    registry: ghcr.io
    username: ${{ github.repository_owner }}
    password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push latest images
  if: github.ref_name == 'master'
  uses: docker/build-push-action@v4
  with:
    context: .
    platforms: linux/amd64
    push: true
    tags: |
      ghcr.io/${{ github.event.repository.full_name }}:latest      

- name: Build and push tagged images
  if: startsWith(github.ref, 'refs/tags/')
  uses: docker/build-push-action@v3
  with:
    context: .
    platforms: linux/amd64
    push: true
    tags: |
      ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}      

这个时候我们的镜像构建速度是这样的:

当我们发现 Hetzner 的 ARM64 机器性价比很高并迁移到 ARM64 的机器了之后,由于我们的组件(除了前端部分)基本都是 Golang 编写的,所以要让 GitHub Actions 构建一下 ARM64 的镜像也无非就是在上面的的:

platforms: linux/amd64

改成:

platforms: linux/amd64, linux/arm64

就完成了,大家都很开心在 GitHub Actions 上面来完成整个工作流非常方便,甚至 GHCR 的私有仓库似乎都没有容量限制,我们构建了那么多不同版本的镜像之后 Storage 使用一直是 0,我们可以一直免费地使用 GHCR。

但是很快我们就发现了新的问题,那就是——镜像构建速度变得超级慢。

在支持 ARM64 之前我们一个新功能从 PR 合并到推到线上可能只有 5 分钟,现在要等几乎 30 分钟(还可能会失败),体验就变得很差了。

而慢的原因也很简单,从 https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners 我们知道 GitHub Actions 默认的 Runner 机器配置是 2-core CPU (x86_64),7 GB of RAM(对应 Azure 上 Standard_DS2_v2 机型),而上面指定了 ARM64 构建的话其实是使用了 QEMU 模拟 ARM64。

那在这个配置这么弱的机器上同时要构建 AMD64 镜像,还要开 QEMU 模拟 ARM64 环境,速度能快就有鬼了。

所以现在问题和需求已经很明确了:

  • 我们需要同时构建 AMD64 和 ARM64 的镜像
  • 我们需要继续使用整个 GitHub 和 GitHub Actions + GHCR 的工作流,减少在流水线上的心智负担
  • 我们没法接受这个长达 20+ 分钟的构建时间

Self-hosted Runner

于是我们很快就想到了第一个思路,根据 Nova Kwok 之前的经验:「在 GitHub Actions 上使用多 Job 并行构建,提升 Multi-Arch 镜像制作速度」,使用多个并行的 GitHub Actions Runner 来分别构建 ARM64 和 AMD64 的镜像,最后再合并到一起,流程类似这样:

  • Runner 1 构建一个名为 ghcr.io/webp-pt/webppt:31-amd64 的镜像
  • Runner 2 构建一个名为 ghcr.io/webp-pt/webppt:31-arm64 的镜像
  • Runner 3 在 1,2 Runner 运行结束后通过操作 manifest 的方式把上面两个镜像合并到一起,新镜像名字为 ghcr.io/webp-pt/webppt:31
  • 这样我们就获得了一个 Multi-Archhttp://ghcr.io/webp-pt/webppt:31 镜像,在 AMD64 和 ARM64 上使用同一个镜像名可以分别拉到对应 Arch 的镜像。

这个方式在 Nova Kwok 开源的 https://github.com/knatnetwork/github-runner 上正在使用用来构建 Multi-Arch 的 Runner 镜像,但是从实际使用效果来看,我们可以发现即便一个 Runner 只用 QEMU 模拟 ARM64 环境,在 https://github.com/knatnetwork/github-runner 上构建速度还是远不如 AMD64,如下图:

所以这个思路可能也不是那么优雅和极致。

于是我们想到一个新的思路,既然 Nova Kwok 开源的 https://github.com/knatnetwork/github-runner 可以很方便的让我们运行起一个 Runner ,且我们基础设施上有大量的 Hetzner ARM64 资源,那为什么不直接在 ARM64 的机器上原生构建 ARM64 部分的镜像呢,很快我们就设计出了一个流程:

  • Runner 1 (GitHub Actions 官方的 Runner)构建一个名为 ghcr.io/webp-pt/webppt:31-amd64 的镜像
  • Runner 2 (由我们 Self-hosted 的 Runner)构建一个名为 ghcr.io/webp-pt/webppt:31-arm64 的镜像,这里就是原生构建了
  • Runner 3 在 1,2 Runner 运行结束后通过操作 manifest 的方式把上面两个镜像合并到一起,新镜像名字为 ghcr.io/webp-pt/webppt:31
  • 这样我们就获得了一个 Multi-Archhttp://ghcr.io/webp-pt/webppt:31 镜像,在 AMD64 和 ARM64 上使用同一个镜像名可以分别拉到对应 Arch 的镜像。

说干就干!

Spin up runner

要创建一个 Self-hosted Runner 可以非常简单,也可以非常复杂,如果你是在一个复杂的环境中(比如有 K8s,需要弹性扩缩容)的话,可以使用 GitHub 开源的 https://github.com/actions/actions-runner-controller

但是我们需要的是让这个事情越简单越好,我们不想折腾 K8s ,不想去搞一堆 controller 出来,于是直接使用 Nova Kwok 开源的 https://github.com/knatnetwork/github-runner ,于是我们挑了个比较闲置的 ARM64 的机器上找个空目录写一个 docker-compose.yml 文件,内容如下:

version: '3'

services:
  runner:
    image: knatnetwork/github-runner:latest
    restart: always
    environment:
      RUNNER_REGISTER_TO: 'webp-pt'
      RUNNER_LABELS: 'docker,webpcloud'
      KMS_SERVER_ADDR: 'http://kms:3000'
      ADDITIONAL_FLAGS: '--ephemeral'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
  
  kms:
    image: knatnetwork/github-runner-kms:latest
    restart: always
    environment:
      PAT_webp-pt: 'ghp_kh4GxxxxxxC'

其中只需要修改 RUNNER_REGISTER_TOPAT_webp-pt 分别表示这个 Runner 需要注册到的 Org 和我自己的 GitHub PAT,然后 docker-compose up -d 启动就行了,很快就可以看到 Runner 注册成功了:

  • 上一次有这么丝滑的体验还是在上一次

Re-write some stuff

下一步,我们只需要改写我们的 GitHub Actions 流水线,调度到对应的 Self-hosted Runner 上就好了,流水线中重要的部分和结构大概是这样的:

name: Build docker images and push
on:
  push:
    branches:
      - 'master'
    tags:
      - "*"
    paths-ignore:
      - '**.md'
      - '*.yml'
jobs:
  amd64:
    runs-on: ubuntu-latest
    steps:

      - name: Build and push tagged images
        if: startsWith(github.ref, 'refs/tags/')
        uses: docker/build-push-action@v3
        with:
          context: .
          platforms: linux/amd64
          push: true
          provenance: false
          sbom: false
          tags: |
      ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}-amd64      

  arm64:
    runs-on: self-hosted
    steps:

      - name: Build and push tagged images
        if: startsWith(github.ref, 'refs/tags/')
        uses: docker/build-push-action@v3
        with:
          context: .
          platforms: linux/arm64
          push: true
          provenance: false
          sbom: false
          tags: |
      ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}-arm64      

  combine-two-images:
    runs-on: ubuntu-latest
    needs:
      - arm64
      - amd64
    steps:
      - name: Combine two tagged images
        if: startsWith(github.ref, 'refs/tags/')
        run: |
          docker manifest create ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }} --amend ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}-amd64 --amend ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}-arm64
          docker manifest push ghcr.io/${{ github.event.repository.full_name }}:${{ steps.imageTag.outputs.tag }}          

其中有三个 Job(独立的运行环境)

  • arm64 运行在 self-hosted 上(也就是我们自己的 Runner)
  • amd64 运行在 GitHub 的 Runner 上
  • combine-two-images 会等 arm64amd64 运行完成了之后开始运行,把上面两个镜像给缝合起来

那么实际运行效果如何呢?

平均 2 分钟多一点就跑完了,从一开始的 22+ 分钟到现在的 2 分钟左右,差不多提升了 10 倍!

WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好,同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。

如你们所见,这次整活我们用混合 Runner 的部署方式让 GitHub Actions 流水线速度提升了 10 倍,让我们的产品部署可以变得更加敏捷。

如果你看完本文后对 Hetzner 的 ARM64 机器感兴趣,可以尝试使用我们的链接来注册 Hetzner 体验: https://hetzner.cloud/?ref=6moYBzkpMb9s (通过我们的链接注册的话你可以在注册成功后直接获得 20EUR 的可用额度,我们也可以获得 10EUR 的奖励,这样也可以支持我们的产品发展。)

如果你觉得我们的服务有意思,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~

References


WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力 ,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好, 同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。

如果你觉得我们的这个服务有意思或者对我们服务感兴趣,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~


Discuss on Hacker News