混合部署 GitHub Actions Runner:Multi Arch 镜像构建速度飙升 10 倍!
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-Arch 的
http://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-Arch 的
http://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_TO
和 PAT_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
会等arm64
和amd64
运行完成了之后开始运行,把上面两个镜像给缝合起来
那么实际运行效果如何呢?
平均 2 分钟多一点就跑完了,从一开始的 22+ 分钟到现在的 2 分钟左右,差不多提升了 10 倍!
- 当然,这个流程中我们也遇到了一些小问题,比如
ghcr.io/webp-pt/webppt:latest-amd64 is a manifest list
之类的,好在 Nova Kwok 之前有遇到过,并记录在了「为什么镜像可以 pull 下来但是在 manifest inspect 的时候提示 no such manifest?—— Docker Buildx Attestations 检修记」上,只要在docker/build-push-action@v3
里面加上provenance: false
和sbom: false
即可。
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