GitHub Actions Self-Hosted Runner 优化——Golang 相关内网缓存

在之前的文章 在 Kubernetes 上运行 GitHub Actions Self-hosted Runner关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究 中,我们知道已经可以通过各种手段让 Self Hosted Runner 在我们内部设施上跑起来,加上一个设计合理的专线+路由表,基本已经可以流畅地接受+处理 GitHub 上使用了 self-hosted 的 CI 任务了,由于语法和 GitHub Actions 官方语法一致,基本使用者都会有类似「太顺滑了,几乎没有任何的体感差异」,「有效减少了高峰用官方 Runner 排队的问题」,「成功获得了 ARM64 环境」,「性能和 RAM 直接翻倍」等等好评。

但是使用一个非海外的基础设施我们很快就会看到一些地理位置上的缺陷,比如…

为什么 actions/setup-go@v2 可以跑这么久?

那..这..用的国内三大运营商,这不是很正常么?(虽然这个包本身并不大,才 120M 左右)

GOPROXY 优化

为了优化 Golang 做 go mod tidy 等操作,在 Runner 的镜像中已经显式地指定好了 GOPROXY ,Dockerfile 类似:

ENV GOPROXY "http://goproxy.nova.moe,https://proxy.golang.org,direct"

这样在用户使用 Golang 程序的时候就可以直接走内部 GOPROXY 来加速了,但是这样依然不够,因为要给 Runner 安装 Go ,还需要使用 actions/setup-go@v2 来安装。

这个时候,有些小机灵鬼就会说了:「那你把 Go 打在 Image 里面不就好了么?」

确实可以,但是这样对于多版本管理是很不利的,难道你像下图一样维护一堆类似 n0vad3v/github-runner:go1130n0vad3v/github-runner:go1160 的镜像,然后手动控制这些镜像的 Container 数量和 Tag,然后让用户去用类 Jenkins 的语法,去手动指定 runs-on: [self-hosted,X64,go1130]

所以为了解决这个问题,我们还是得让用户自己去用一个 Step 来安装 Go,毕竟环境的模块化组装(以及 Matrix 的使用)是 GitHub Actions 的一大优势,不然一堆 if-else 和 Jenkins 有啥区别,更何况现在 Runner 安装了一次 Go 之后就会缓存下来(除非你启动的时候指定了 --ephemeral),在下一次遇到同版本的时候会直接使用缓存。

actions/setup-go 优化

在 Runner 上安装 Golang,大家一般会使用 actions/setup-go@v2,用法也很简单,如下:

- uses: actions/setup-go@v2
  with:
    go-version: '1.16'

为了了解这个 Action 是如何工作的,在不看代码,只看代码结构的角度,我们从 https://github.com/actions/setup-go/blob/main/__tests__/data/versions-manifest.json 文件中可以发现它 ”背后的数据地址“ 类似:

https://github.com/actions/go-versions/releases/download/1.12.17-20200616.21/go-1.12.17-darwin-x64.tar.gz

反推得到实际的数据仓库为: https://github.com/actions/go-versions/https://github.com/actions/go-versions/blob/main/versions-manifest.json , 数据格式类似如下:

[
  {
    "version": "1.17.5",
    "stable": true,
    "release_url": "https://github.com/actions/go-versions/releases/tag/1.17.5-1559554870",
    "files": [
      {
        "filename": "go-1.17.5-darwin-x64.tar.gz",
        "arch": "x64",
        "platform": "darwin",
        "download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-darwin-x64.tar.gz"
      },
      {
        "filename": "go-1.17.5-linux-x64.tar.gz",
        "arch": "x64",
        "platform": "linux",
        "download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-linux-x64.tar.gz"
      },
      {
        "filename": "go-1.17.5-win32-x64.zip",
        "arch": "x64",
        "platform": "win32",
        "download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-win32-x64.zip"
      }
    ]
  },
]

后来发现其实 README 中有写:It will first check the local cache for a version match. If version is not found locally, It will pull it from main branch of go-versions

在确认了实际下载的包的地址之后我们就可以反推 setup-go 中是如何使用这个地址的了,通过一波 rg,我们在 src/installer.ts 的 143 行发现:

  const releases = await tc.getManifestFromRepo(
    'actions',
    'go-versions',
    auth,
    'main'
  );

所以现在缓存的思路就很清晰了:

  1. 下载 https://github.com/actions/go-versions/blob/main/versions-manifest.json 中的包到内网
  2. Fork + 修改一份 https://github.com/actions/go-versions 仓库,把 download_url 中的内容换为内网地址
  3. Fork + 修改一份 setup-go,把它获取 Manifest 的地址指向 Fork 后的 go-versions 仓库
  4. 在 Runner 中调用 Fork 后的 setup-go

下载包到内网

非常容易,Python 可以这么写,只要指定一下 HOST_URL 为内网下载地址,STORE_PATH 为实际存储地址,GOLANG_VERSION_LIST 中填上想要缓存的 Golang 版本即可,保存为一个 download.py ,运行后等着就好:

import requests
from urllib.parse import urlparse
import os
import json

## ENV
HOST_URL = "http://download.nova.moe/download/github-actions/golang/"
STORE_PATH = "/path/to/download/github-actions/golang/"
GOLANG_VERSION_LIST = ['go-1.16','go-1.17','go-1.13']
## END ENV

def process_each_package(package_filename, package_url):
    package_path = STORE_PATH + package_filename
    if not os.path.isfile(package_path):
        print("Downloading: " + package_url)
        r = requests.get(package_url)
        with open(package_path, 'wb') as f:
            f.write(r.content)
    return package_path

if __name__ == '__main__':
    go_versions_url = 'https://raw.githubusercontent.com/actions/go-versions/main/versions-manifest.json'
    r = requests.get(go_versions_url).json()

    return_list = []
    golang_package_list = []
    for item in r:
        package_url = item['files'][1]['download_url']
        
        for version in GOLANG_VERSION_LIST:
            if version in package_url:
                golang_package_list.append(package_url)
                a = urlparse(package_url)
                package_filename = os.path.basename(a.path)

                process_each_package(package_filename, package_url)
                
                item['files'][1]['download_url'] = HOST_URL + package_filename
                return_list.append(item)

    with open('versions-manifest.json', 'w') as f:
        f.write(json.dumps(return_list, indent=2))

运行结束后所有的 tar.gz 包都会保存到 STORE_PATH 中,同时运行目录下会生成一个下载地址已经替换为内网地址的 versions-manifest.json

修改 go-versions 和 setup-go

Fork 这两个仓库后,将 Fork 后的 go-versions 仓库下的 versions-manifest.json 替换为刚刚已经生成好的版本(这个操作过于简单建议直接用网页修改,避免浪费拉仓库使用的本地带宽)。

由于 setup-go 需要编译,为了省事考虑(反正我们只修改两个变量),直接将 Fork 的 setup-go 中 dist/index.js 的 5037 行

const releases = yield tc.getManifestFromRepo('actions', 'go-versions', auth, "main");

改为 fork 后的地址,比如:

const releases = yield tc.getManifestFromRepo('n0vad3v', 'go-versions-forked', auth, "master");

修改 Runner

在上面的操作完成之后,我们只需要使用 fork 后的 setup-go ,即可使用到内网的下载速度了,用法类似:

- uses: n0vad3v/setup-go-forked@master
  with:
    go-version: '1.16'

看看效果?

快到模糊!

小结

由于缓存 Golang 的包的操作看上去是一个 One shot 的操作,基本没有短时间内持续更新的需求,暂时也就没有考虑自动化之类的事情,在有了内网缓存之后,整体的 Runner 运行效率一下子就提升了起来,使用体验又愉快了不少。

这是关于 GitHub Actions Self-hosted Runner 优化的第一篇文章,后续可能还会有一些相关的有趣的分享,同时我也在考虑把相关的组件(比如 关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究 中提到的那个假 KMS,以及可用的 Runner 的 Dockerfile)开源出来,不过这些都还没想好,有兴趣的同学可以期待一下~