⚠️ 法律与用途声明:本文讨论 GitHub Actions self-hosted runner 与 Docker 容器化 CI 的架构设计,基于开源工具链(GitHub Actions / Docker / Conan 2),用于技术学习与工程研究。所有企业名、组织名、域名、token 均已脱敏。读者须遵守所在地区法律法规。

一、为什么 runner 不能在宿主机上直接构建
最朴素的 self-hosted runner 用法是:把 runner 装在一台 Linux 服务器上,job 直接在宿主机里跑 conan install、cmake、make。能用,但很快会撞墙:
- 环境污染:今天这个 job 装了 GCC 13,明天那个 job 要 GCC 11,宿主机上的编译器版本被反复覆盖,最后谁也说不清"这台机器现在到底是什么环境"。
- 构建不可复现:同一个 commit,今天构建过、明天失败——因为宿主机上某个依赖被另一个 job 改了。CI 的核心价值"可复现"直接归零。
- 清理噩梦:交叉编译要装一堆
arm-none-eabi-*工具、Conan cache 越攒越大、临时文件到处都是,宿主机逐渐变成垃圾场。 - 并发冲突:多个 job 同时跑,抢同一个 Conan cache 目录、同一个临时路径,互相踩踏。
这些问题的根因是同一个:调度(决定跑什么 job)和执行(真正跑构建)搅在了宿主机这一层。runner 既当指挥又当施工队,宿主机既是调度节点又是构建环境,职责混在一起,必然互相污染。
解法很清晰:让 runner 只做调度,把执行交给一次性的 Docker 容器。宿主机永远保持干净。

二、self-hosted runner 的接入:把"调度员"装好
接入一个组织级 self-hosted runner,本质上是在服务器上跑通 GitHub 官方的 actions-runner,并把它注册成 systemd 服务。核心步骤(已脱敏):
- 创建专用用户(不共用个人账号):
useradd --create-home --shell /bin/bash ghrunner - 下载官方 runner 到独立目录:
/opt/actions-runner - 注册到组织(用一次性注册 token):
sudo -u ghrunner /opt/actions-runner/config.sh --url https://github.com/<企业组织> --token <TOKEN> --name <runner名> --labels <标签> --unattended --replace - 装成系统服务并开机自启:
./svc.sh install ghrunner && systemctl enable --now <service>
几个关键设计值得说:
专用用户隔离。runner 跑在 ghrunner 用户下,不共用任何开发者的账号。这样 runner 的 .conan2、工作目录、权限边界都和开发环境物理隔离。构建用的是 Docker,但 runner 服务本身的身份是独立的,便于审计和回收。
标签即调度契约。注册时打上标签 self-hosted,linux,x64,<企业>,docker。workflow 里写 runs-on: [self-hosted, linux, x64, <企业>, docker],GitHub 据此把 job 路由到这台 runner。标签是平台和 CI 之间的调度接口——加一台新 runner 只要打对标签,workflow 不用改。
Docker 使用权限。runner 要能拉起 Docker 容器执行构建,所以 usermod -aG docker ghrunner 把它加进 docker 组。注意这一步给的是"调用 Docker 守护进程"的权限,不是 root;真正的构建发生在容器里,宿主机文件系统仍然受保护。
三、调度与执行分离的工程实现:job 跑在 container 里
分离的核心机制是 GitHub Actions 的 container: 字段——job 不在宿主机跑,而是在一个指定镜像的容器里跑:
jobs:
linux-self-hosted:
runs-on: [self-hosted, linux, x64, <企业>, docker]
strategy:
fail-fast: false
matrix:
image: [ubuntu:22.04, ubuntu:24.04]
container:
image: ${{ matrix.image }}
steps:
- uses: actions/checkout@v4
- name: Install build tools
run: apt-get update && apt-get install -y python3 cmake ninja-build g++ git
- name: Build with Conan
run: conan install . --build=missing && cmake --build build
这一段配置藏着分离的全部秘密:
"runner 只负责拉起容器"。GitHub runner 收到 job 后,启动一个 ubuntu:22.04(或 24.04)容器,把 job 的所有 step 放进容器执行。构建里装的 GCC、Conan cache、临时文件,全部在容器内,job 结束容器销毁,宿主机连一个字节都没被动过。
矩阵即多环境。matrix.image 让同一个 job 在 ubuntu:22.04 和 24.04 两个环境里各跑一遍,验证兼容性。这在"宿主机直接构建"模式下几乎不可能(你不可能让一台机器同时是 22.04 和 24.04),而容器让它成了声明式的一行配置。
可复现回来了。每次构建都从一个干净的官方镜像开始,依赖从私有 Conan 仓库(Artifactory)拉取,环境完全确定。今天和明天、这台 runner 和那台 runner,构建结果一致。
这就是"调度与执行分离"的工程落点:runner 是调度员(决定跑哪个 job、在哪个容器跑),Docker 是施工队(真正编译),宿主机是机房,提供算力和 Docker 守护进程但不碰构建。三者职责清晰,互不污染。
四、代理注入:让容器里的工具能上网
嵌入式 CI 有个绕不开的问题:构建过程要访问外网(拉 conancenter 包、装 apt 依赖),但企业内网往往要走代理。而容器是隔离环境,宿主机的代理设置不会自动传进去。
这套方案的解法是两层代理注入:
第一层:runner 服务环境注入(host 侧)。安装脚本为 runner 的 systemd 服务生成一个 drop-in 文件 /etc/systemd/system/<service>.d/proxy.conf:
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:20171"
Environment="HTTPS_PROXY=http://127.0.0.1:20171"
Environment="NO_PROXY=localhost,127.0.0.1,<企业域名>,.<企业域>"
这样 runner 进程及其拉起的容器默认继承这组代理变量。关键在 NO_PROXY——私有 Conan 仓库的域名必须走直连,否则访问自己的制品库还要绕一圈代理,又慢又容易断。
第二层:容器内显式注入。container 型 job 里,workflow 会再次显式传入同一组代理变量,确保容器内 apt、pip、conan 都能正确走代理访问外网,访问内网制品库时又走直连。
这套"host drop-in + 容器显式"的双层注入,解决了"外网要代理、内网要直连"的矛盾,而且对算法仓库透明——开发者写 workflow 时不用操心代理,模板已经处理好。

五、混合 CI:Linux 自托管 + Win/Mac 云端,成本与能力的平衡
不是所有平台都值得自托管。这套方案做了一个清晰的取舍:
- Linux 构建自托管:嵌入式交叉编译的主战场在 Linux(工具链、Docker、Conan 生态都在 Linux 上最成熟),且构建密集、对网络延迟敏感(要访问内网 Artifactory),自托管收益最高。
- Windows / macOS 构建用 GitHub 托管 runner:这两类构建是低频需求(比如验证 MSVC / Apple-Clang 兼容性),维护专用 Win/Mac 宿主机成本高、利用率低,直接用 GitHub 云端配额更划算。
实现上,workflow 里把 Win/Mac 的 job 限定为手动触发(workflow_dispatch),日常 push 只触发 Linux 自托管构建:
windows-cloud:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- run: Write-Host "Windows build steps here"
这是个典型的"按收益分配资源"的架构判断:把重资产(自托管 Linux runner)投在高频主战场,把低频需求外包给云端弹性资源。既保证了主链路的速度和稳定性,又控制了总体成本。

六、架构哲学:调度面与执行面解耦
回头看,self-hosted runner 这一篇落地的核心哲学只有一条:调度面与执行面解耦。
- 调度面(GitHub Actions + runner)关心的是"什么时候、跑什么 job、路由到哪台机器"——它是逻辑层,应该是无状态、可水平扩展的。
- 执行面(Docker 容器)关心的是"在这个确定的环境里把代码编译出来"——它是物理层,应该是隔离、可复现、用完即弃的。
把两者分开后,好处自然涌现:宿主机干净、构建可复现、环境可矩阵化、清理自动化、并发不冲突。而如果搅在一起(runner 直接在宿主机构建),这些问题会以"偶发构建失败""环境漂移""磁盘爆满"的形式反复折磨你。
这条哲学不止适用于 CI。它其实是平台工程的通用原则:调度(编排)和执行(落地产物)永远要分两层。Kubernetes 这么做(控制平面 vs 节点)、CI 系统这么做(调度器 vs 执行器)、这套嵌入式 DevOps 平台也这么做。
下一篇,我们走进执行面:Docker 三层构建模型——纯净镜像、模板缓存、即时容器,是怎么让交叉编译做到可复现、可缓存、可销毁的。

参考链接:
[1] GitHub Actions 自托管 Runner 文档 https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners
[2] 在容器中运行 job(container 字段) https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container
[3] GitHub Actions 矩阵策略 https://docs.github.com/en/actions/how-to/write-workflows/choosing-what-your-workflow-does/run-variations-of-tasks-using-a-matrix
[4] systemd drop-in(服务环境变量覆盖) https://systemd.io/
[5] Docker 官方文档 https://docs.docker.com/
[6] Conan 2 远程仓库配置 https://docs.conan.io/2/reference/commands/remote.html