ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用Modal Sandbox打造按需自托管GitHub Actions Runner

2026/8/30 20:05:54 拓冰建站 浏览量
用Modal Sandbox打造按需自托管GitHub Actions Runner 最近 Hacker News 上有一个挺有意思的 Show HNGitHub Actions self-hosted runners on Modal Sandboxes。这个思路听起来不复杂就是把 GitHub Actions 的自托管 Runner 放到 Modal 的按需沙箱里跑完一个 Job 就销毁。但如果只是把它当成“另一种部署自托管 Runner 的方式”会错过这件事真正有信息量的地方。先说我的判断这套方案的价值不在“换个地方跑 Runner”而在于把 CI 从“常驻资源”变成了“按需资源”。它用 Modal Sandbox 的秒级启动解决了自托管 Runner 最常见的两座大山——弹性伸缩和基础设施维护。如果你正在 GitHub-hosted Runner 的分钟数和自托管 VM 的常驻成本之间纠结这篇文章应该能帮你理清思路。当然它不是银弹。它有一些明显的边界和坑Runner 注册 Token 的安全边界、构建缓存如何持久化、Sandbox 的网络访问限制、以及对 fork PR 的安全策略。这些我会在文章里一并讲清楚。本文会按“为什么需要这个方案 → Runner 的工作机制 → Modal Sandbox 适合什么 → 完整代码实现 → 运行验证 → 常见问题 → 工程建议”的顺序展开。代码是基于 Modal 官方 SDK 公开 API 编写的最小可运行示例你可以直接照着跑一遍再结合自己的场景改造。1. 为什么我建议你重新考虑“自托管 Runner”先说一个很现实的场景。你的项目在 GitHub 上CI 用的是 GitHub Actions。最开始一切都很美好用的是默认的 GitHub-hosted Runner。但很快你会发现两个问题。第一托管 Runner 的环境是锁死的。系统镜像虽然会更新但你想装一个特殊版本的编译器、一个 GPU 驱动、或者访问公司内网的某个服务托管 Runner 很难办。第二费用和配额。私有仓库的 Actions 有分钟数限制跑得多了要么花钱买分钟数要么排队等免费额度恢复。对于一个小团队来说每分钟都计费压力不小。于是很多人转向自托管 Runner。自托管的意思是你自己提供一台机器在这台机器上安装 GitHub 官方开源的 runner 软件然后这台机器就能代替 GitHub 的托管机器去执行 workflow。但自托管 Runner 也有自己的问题。最典型的就是资源浪费为了随时能接 Job你必须在云上常驻一台 VM。 CI 不是每时每刻都在跑的大部分时间这台机器都是在空转。一个月下来你为该机器付的钱可能比 GitHub Actions 分钟数还贵。另一个问题是维护成本。runner 软件要升级操作系统要打补丁依赖要更新。如果哪天 runner 软件自己在后台升级挂掉了你的 CI 会莫名其妙开始排队排查起来一点也不轻松。还有安全问题。自托管 Runner 执行的是仓库里的代码如果某个来自 fork 的恶意 PR 在你的 Runner 上运行了破坏性命令这台机器就等于裸奔了。GitHub 官方对自托管 Runner 一直有明确的安全警告这对不熟悉 CI 安全的人来说很容易踩坑。所以大家真正需要的是一个“既想有 GitHub 托管 Runner 的零维护又想要自托管 Runner 的定制自由”的中间方案。Modal Sandbox 正好在这个位置上有机会。它的做法很简单Runner 还是自托管的但不再常驻一台 VM而是每次需要执行 Job 时在 Modal 上秒级启动一个隔离的 Sandbox在里面临时把 Runner 注册到 GitHub执行完一个 Job 后 Sandbox 销毁一切归零。这其实就是把“自托管 Runner”和“Serverless 容器”结合在了一起。2. GitHub Actions Runner 的工作机制先搞懂这些再动手在写代码之前有必要先理清 GitHub Actions 自托管 Runner 的几个核心概念。很多人在这一步理解不到位后面配置的时候就会出各种奇怪问题。2.1 Runner 的注册与 Job 分配Runner 不是一个持续运行在 GitHub 服务器上的实体而是运行在你机器上的一个客户端进程。它启动之后会主动和 GitHub 建立一条长连接等待 GitHub 下发 Job。要让 GitHub 认识这个 Runner需要两步在 GitHub 仓库或组织里注册它。注册后Runner 向 GitHub 发起长轮询持续询问“有没有适合我的 Job”。注册时最关键的是 registration token。你可以从 GitHub 的仓库 Settings - Actions - Runners 页面生成也可以通过 GitHub API 获取。这个 token 有效期通常只有 1 小时所以不能提前很久生成。注册完成后runner 软件会在本地生成一个.runner文件保存注册信息。之后每次启动它会用这个文件里的凭据和 GitHub 通信。2.2 Label 是 Runner 和 Job 之间的“接头暗号”每个自托管 Runner 可以配置一组 label。GitHub 官方 Runner 默认自带self-hosted、Linux、X64这些标签你还可以自定义 label。Workflow 里用runs-on指定要什么样的 Runnerruns-on: [self-hosted, modal]GitHub 会找到一个“同时满足所有 label”的在线 Runner。如果不存在Job 就会一直排队直到有符合条件的 Runner 出现。这也是按需启动 Runner 的核心依据Runner 不需要一开始就存在只要 Job 排着队等新的 Runner 上线后它就能被领取并执行。2.3 ephemeral 模式跑完一个 Job 就自动注销GitHub 官方 runner 从 2.273 版本开始支持--ephemeral模式。在这个模式下Runner 注册后只处理一个 Job处理完成后自动注销并退出。这正好是为短生命周期容器场景设计的。没有 ephemeral 模式的话Runner 会一直在线沙箱也就无法自动销毁。2.4 安全边界GitHub 官方为什么一直提醒你GitHub 对自托管 Runner 有一个非常明确的安全建议不要对来自 fork 的未审核 PR 使用自托管 Runner。因为 fork 的代码是在攻击者的控制之下如果它恰好在你的 Runner 上运行可以读取仓库中 workflow 能访问的所有 Secret。了解了这些再回头看 Modal Sandbox 的方案就清晰多了Sandbox 的隔离性只能降低环境被污染的风险但 Runner 注册凭据、Secret 暴露、网络访问这些风险仍然存在需要在设计时单独处理。3. Modal Sandbox 是什么它凭什么适合跑 CIModal 是一个面向 Python 的 Serverless 云平台。你可以把它理解为“云函数的容器版”写一段 Python 函数Modal 会在秒级内启动一个隔离环境去执行用完之后销毁。Modal Sandbox 是 Modal 提供的一种更底层、更灵活的隔离环境。它不要求你按照云函数的模式写代码而是允许你启动一个自定义命令的容器比如在 Sandbox 里跑bash -c ...就像在本地开一个容器一样。它和常驻 VM 最本质的区别是“生命周期”VM 是常驻服务Sandbox 是按需启动、用完即毁的资源。这恰好是自托管 Runner 最需要的模型。为什么它特别适合 Runner 场景有四个原因。第一秒级启动。Runner 注册之前需要等待 Sandbox 初始化如果启动要几分钟CI 的体验会很难受。Modal Sandbox 的冷启动通常很快这是它能作为 Runner 运行环境的基础。第二完全隔离。每次 CI Job 都从一个干净的镜像启动上一次构建留下的脏文件、环境变量、依赖冲突都不会污染下一次构建。这比常驻 VM 上跑自托管 Runner 要安全得多。第三按秒计费。Runner 空闲时不收费只在真正执行 Job 时计费。对于每天只有几次构建的中小型项目成本会远低于常驻 VM。第四可编程调度。你可以通过 Modal 的 Python SDK 在代码里创建 Sandbox这样“启动一个 Runner”就变成了一个可编程的 API 调用后面接 Webhook、接定时任务都很自然。当然它也有明显不足。最突出的是网络问题如果你的 CI 需要访问公司内网、数据库或内部制品库Modal Sandbox 的网络策略可能会成为障碍。这个一定要在选型前确认清楚否则方案落地时会很痛苦。4. 整体架构设计一个 CI Job 的完整生命周期为了便于理解我们先把整个方案拆成组件一个存放了代码的 GitHub 仓库。一份 workflow 文件指定 Job 跑在modal这个 label 的 Runner 上。一个用于启动 Runner 的 Python 脚本部署在 Modal 上。Modal 上的 Secret存放 GitHub 注册 Token 等敏感信息。一个可选的 Modal Volume用于跨 Job 持久化构建缓存。整个流程如下开发者在本地运行modal run传入仓库名。Modal 开始创建一个 SandboxSandbox 内部下载并解压 GitHub Runner 软件。Sandbox 内执行 Runner 的config.sh向 GitHub 注册一个带modallabel 的 Runner。Runner 启动run.sh开始和 GitHub 建立长连接等待 Job。开发者推送代码GitHub 生成新的 workflow Job。GitHub 发现有符合条件的 Runner 在线把 Job 分配给这个 Runner。Runner 在 Sandbox 中执行 Job 的步骤。Job 执行完成Runner 以 ephemeral 模式自动注销退出。Modal Sandbox 销毁本次 CI 的资源全部释放。这个流程里最妙的一点是第 6 步Runner 不需要预先常驻它只需要在 Job 排队期间“恰好”上线即可。而 GitHub 的 Job 排队机制天然支持这种延迟关联所以整个方案不需要任何复杂的调度器。5. 环境准备与前置条件在开始之前你需要确认以下条件已经满足。5.1 GitHub 侧你有一个 GitHub 仓库并且对该仓库有 admin 权限这样才能查看 Runner 注册 Token。仓库是公开仓库或私有仓库都能跑但安全策略需要单独考虑。建议先在一个测试仓库里跑通流程不要直接上生产。5.2 Modal 侧注册一个 Modal 账户。安装 Modal CLIpip install modal modal token newmodal token new会打开浏览器完成认证命令执行成功后会保存本地凭据。在 Modal Dashboard 中创建一个 Secret名字可以叫github-runner-secret里面至少包含一个 keyGH_REGISTRATION_TOKEN你的GitHub注册Token注意注册 Token 有效期只有 1 小时所以这个 Secret 里的值实际上需要每次启动前更新不能一直用同一个值。这一点后面会说。5.3 本地环境安装 Python 3.10 或更高版本。安装 modal 库pip install modal能从本地访问github.com能读取 GitHub API。这里说明一点注册 Token 不能长期存放在 Secret 里因为它会过期。更稳妥的做法是让启动 Runner 的脚本通过 GitHub API 动态获取 Token然后把 Token 作为临时环境变量传给 Sandbox。后面的示例代码会演示这种思路。6. 完整代码实现在 Modal Sandbox 中启动 Runner下面是一个最小可运行示例。代码的核心逻辑是通过 Modal 创建一个 Sandbox在 Sandbox 中下载 GitHub Actions Runner注册为 ephemeral self-hosted Runner然后进入等待 Job 的状态。6.1 项目结构. ├── modal_runner.py └── .github/ └── workflows/ └── demo.yml6.2 启动 Runner 的 Python 脚本文件路径modal_runner.pyimport os import random import string import modal app modal.App(github-actions-on-modal) # 基础镜像包含 runner 运行时需要的系统工具 base_image ( modal.Image.debian_slim(python_version3.12) .apt_install(curl, git, ca-certificates) ) # Runner 版本号请以 GitHub Actions Runner Releases 页面为准 RUNNER_VERSION os.getenv(RUNNER_VERSION, 2.323.0) # 可选把 runner 软件提前打进镜像层减少每次冷启动的时间 runner_image ( modal.Image.debian_slim(python_version3.12) .apt_install(curl, git, ca-certificates) .run_commands( mkdir -p /actions-runner, fcurl -sL -o /actions-runner/runner.tar.gz fhttps://github.com/actions/runner/releases/download/v{RUNNER_VERSION}/actions-runner-linux-x64-{RUNNER_VERSION}.tar.gz, cd /actions-runner tar xzf runner.tar.gz, ) ) app.function( imagerunner_image, secrets[modal.Secret.from_name(github-runner-secret)], timeout60 * 60, ) def start_runner(repo: str): # 从 Modal Secret 中读取 GitHub 注册 Token token os.environ[GH_REGISTRATION_TOKEN] # 生成随机的 Runner 名称避免名字冲突 suffix .join(random.choices(string.ascii_lowercase, k6)) runner_name fmodal-{suffix} # 在 Modal Sandbox 中注册并启动 Runner script ( cd /actions-runner f./config.sh --url https://github.com/{repo} f--token {token} --name {runner_name} --labels modal --ephemeral --unattended ./run.sh ) sb modal.Sandbox.create( bash, -c, script, imagerunner_image, ) # 打印 Sandbox 输出便于查看注册和运行日志 for line in sb.stdout: print(line, end) sb.wait() return sb.returncode这段代码有三个关键点。第一runner_image在镜像构建阶段就把 runner 二进制下载并解压好了。这样做的好处是Sandbox 启动后不需要再下载几十 MB 的包冷启动时间能缩短不少。第二--ephemeral让 Runner 在完成一个 Job 后自动注销并退出这样 Sandbox 就能在 Job 完成后自然结束不会空转。第三--labels modal是 workflow 匹配的关键。如果你的 workflow 里写的是runs-on: [self-hosted, modal]这个 Runner 才会被匹配到。6.3 本地获取注册 Token 的方式上面代码里的GH_REGISTRATION_TOKEN需要你预先放到 Modal Secret 里。获取 Token 最简单的方式是在 GitHub 仓库的 Settings - Actions - Runners 页面点“New self-hosted runner”页面会显示一段包含 Token 的命令复制其中的 token 即可。更好的方式是通过 GitHub API 动态获取适合自动化场景# 需要把 GITHUB_TOKEN 替换为有 admin 权限的 PAT curl -X POST \ -H Authorization: token YOUR_GITHUB_PAT \ -H Accept: application/vnd.githubjson \ https://api.github.com/repos/your-org/your-repo/actions/runners/registration-token返回的 JSON 里会有token字段有效期为 60 分钟。你可以把它写进 Modal Secret或者直接作为环境变量传给start_runner函数。我这里为了演示用了 Modal Secret 的方式。实际工程中我更推荐用“提前动态获取 Token再通过 Modal Secret 或临时环境变量传递给 Sandbox”的方式避免手动复制粘贴。6.4 定义 GitHub Actions Workflow文件路径.github/workflows/demo.ymlname: demo-on-modal on: push: branches: - main jobs: demo: runs-on: [self-hosted, modal] steps: - uses: actions/checkoutv4 - name: 查看系统信息 run: | uname -a cat /etc/os-release - name: 查看可用资源 run: | nproc free -h df -h / | tail -1runs-on里的self-hosted是 GitHub 自托管 Runner 的默认标签modal是我们在config.sh里自定义的标签。如果只想匹配 Modal 上的 Runner就不要在runs-on里省略self-hosted因为所有自托管 Runner 都带有这个隐式标签。7. 启动 Runner 并验证 CI 流程环境准备好之后我们可以完整跑一遍。第一步启动 Modal Runnermodal run modal_runner.py --repo your-org/your-repo这里的your-org/your-repo改成你的仓库地址。运行后你会看到 Modal 创建 Sandbox 的日志以及 runner 注册时的输出。如果看到类似Listening for Jobs的提示说明 Runner 已经成功注册并进入等待状态。第二步推送一个 commit 或手动触发 workflowgit add . git commit -m test modal runner git push origin mainpush 之后GitHub 会在仓库的 Actions 页面创建一个新的 Job。因为此时 Modal Runner 正在线等待Job 会被立即分配给这个 Runner。第三步观察 Sandbox 控制台输出。你会看到 Job 的步骤开始执行包括 checkout 代码、运行命令。执行完成后Runner 会以 ephemeral 模式自动注销Sandbox 退出。如何判断成功三个标志Modal 控制台没有报错Runner 成功注册。GitHub Actions 页面里 Job 不是排队状态而是“in progress”且 Runner 名称以modal-开头。Job 执行完成后Modal 侧进程自动退出返回码为 0。如果失败按照经验第一步应该看 Modal 的日志第二步看 GitHub 的 Actions 日志第三步看 Runner 的注册输出。绝大多数问题都能在这三个地方定位到原因。8. 常见问题与排查思路下面是这套方案里最容易踩的几个坑我用表格整理出来方便你对照排查。问题现象可能原因排查方式解决方案Job 一直在排队没有 Runner 领取Sandbox 没启动成功或 Runner 未注册成功查看 Modal 控制台日志确认是否出现 Listening for Jobs检查注册命令确认 label 是否匹配Job 在排队但 Sandbox 一直空转label 不匹配检查 workflow 里的 runs-on 与 config.sh 的 --labels 是否一致统一 label例如都在 modalconfig.sh 报 Invalid registration tokenToken 过期或对仓库没有 admin 权限检查 GitHub API 返回的 token 有效期重新生成 token启动前再获取Sandbox 启动后立即退出镜像里没有 git 或 runner 二进制执行失败查看 Sandbox 标准输出应该有报错信息在镜像构建阶段安装 git提前下载 runnerRunner 名称冲突注册失败多个 Sandbox 用相同名字注册且没有 --replace检查 Runner 名称是否随机使用随机名称或加 --replace每次构建都要重新下载依赖速度很慢Sandbox 每次销毁本地缓存全部丢失观察日志中依赖安装时长挂载 Modal Volume 到缓存目录持久化缓存Sandbox 能启动但无法访问内网资源Modal Sandbox 的网络策略不允许访问内网确认 CI 是否需要访问内部服务评估改用支持 VPC 的方案或改造 CI 步骤这里面最容易被忽略的是第一个问题Job 排队但 Runner 没注册。因为 GitHub 的排队机制不会告诉你“到底缺哪个 Runner 在线”你只能自己去核对 label。还有一个隐藏问题需要注意如果你的 Modal Sandbox 启动后Sandbox 里的 Runner 注册成功了但 Sandbox 崩溃退出GitHub 端会短暂显示该 Runner 离线过一会儿才会被清理。如果使用随机名称影响不大如果使用固定名称建议加--replace参数避免冲突。9. 工程化最佳实践与安全建议如果这个方案要在真实项目里长期运行有几个工程层面的点一定要提前考虑。9.1 Runner 注册 Token 的动态化注册 Token 只有 1 小时有效期不能长期写死在 Secret 里。工程化的做法是在启动 Runner 的脚本里先用 GitHub API 获取一个新 Token再把它传给 Sandbox。伪代码如下# 在 start_runner 函数中 gh_token os.environ[GITHUB_PAT] resp requests.post( fhttps://api.github.com/repos/{repo}/actions/runners/registration-token, headers{Authorization: ftoken {gh_token}}, ) registration_token resp.json()[token]这样每次启动都拿的是新鲜 Token避免“Token 过期导致注册失败”这一类问题。9.2 不要让 fork PR 也跑在自托管 Runner 上这是 GitHub 官方反复强调过的安全边界。如果你对外开放了一个开源仓库来自 fork 的 PR 会在未审核的代码上触发 workflow。如果这个 workflow 跑在自托管 Runner 上攻击者可以在 runner 环境里做很多事情。建议的做法在 workflow 里判断触发源只对push到受保护分支的事件使用modalRunner对 fork PR 使用 GitHub-hosted Runner或者干脆跳过jobs: demo: if: github.event_name push runs-on: [self-hosted, modal]9.3 把 Runner 软件预装进镜像每次启动 Sandbox 时临时下载 runner 软件会让冷启动慢不少。更优的做法是在 Modal Image 的构建阶段就把 runner 下载并解压好这样 Sandbox 启动后直接执行config.sh和run.sh即可。9.4 用 Volume 持久化缓存ephemeral Runner 的代价是本地缓存会随着 Sandbox 销毁而丢失。对于依赖安装很重的项目这个代价不能接受。解决方案是挂载 Modal Volume并把包管理器的缓存目录指向 Volume 里的目录modal.Volume.from_name(ci-cache, create_if_missingTrue)在 workflow 里设置环境变量env: PIP_CACHE_DIR: /cache/pip然后在 Sandbox 创建时把 Volume 挂载到/cache。这样同一仓库的连续构建可以复用依赖缓存构建速度会有明显提升。9.5 给 Sandbox 设置合理超时如果 runner 注册成功但 Job 一直没来Sandbox 会一直空转产生不必要的费用。建议在创建 Sandbox 时设置一个超时时间比如 10 分钟超过后自动退出sb modal.Sandbox.create( bash, -c, script, imagerunner_image, timeout10 * 60, )这个参数的具体写法以 Modal 文档为准但原则很清楚不要让没有 Job 的 Runner 无限等待。9.6 和 Actions Runner Controller 的定位区分很多了解过 Kubernetes 的读者可能会问这和 GitHub 官方维护的 Actions Runner ControllerARC有什么区别ARC 是在 Kubernetes 集群上管理自托管 Runner 的控制器能根据 Job 数量自动伸缩 Runner。如果你们的团队已经有 Kubernetes 集群ARC 是更标准、更强大的选择。Modal Sandbox 方案的价值在于你不需要运维 Kubernetes 集群也不需要常驻任何 VM只需要一个 Modal 账户和一段 Python 代码。对于中小团队、个人项目、或者不想为 CI 单独养 K8s 集群的场景这种方案明显更轻。10. 适合谁不适合谁聊到最后我想把适用边界说清楚。这套方案最适合以下三类情况第一CI 频率不高但每次构建需要特殊环境。比如你有 GPU 训练任务每次跑一两个小时但一周只跑两三次。为这种任务常驻一台 GPU 机器是不划算的按需启动 Sandbox 就很合适。第二团队不想维护 Kubernetes但又不满意 GitHub 托管 Runner 的限制。你想用自定义镜像、自定义依赖又不想买常驻 VMModal 是一个折中选择。第三需要隔离的构建环境。每个 Job 都从干净镜像启动能避免很多“在我机器上明明是好的”这种问题。不太适合的情况也很明显第一对网络有强依赖的构建。如果 CI 需要频繁访问公司内网、内部制品库、私有数据库Modal Sandbox 的网络能力不一定能满足。第二对成本非常敏感且构建非常频繁的项目。如果每个 commit 都要跑大量 Job按执行时间付费的成本可能比包月 VM 更高。这种场景应该用 ARC Kubernetes或者包月 VM。第三仓库接受大量不可信 fork PR。你需要在 workflow 层面对触发源做严格限制否则自托管 Runner 的安全风险会很大。这套方案本身还处在很早期GitHub 官方并没有把它作为标准用法来支持。它更像是一个基于现有 API 拼出来的“巧劲”。但恰恰是这种巧劲能帮你用很小的成本把 CI 基础设施往前推一步。如果这份代码对你有启发建议先在一个测试仓库里跑通整个流程再考虑是否迁移到生产。重点验证三件事冷启动时间是否可接受、缓存挂载是否生效、以及网络策略是否满足你的 CI 需求。建议收藏备用。