改进我的自托管 Actions Runner 设置
摘要
作者通过用 systemd-nspawn 容器替换 Docker 来改进其自托管的 Gitea Actions Runner 设置,以提高安全性,并详细说明了配置和权衡。
<p><a href="https://lobste.rs/s/zcchf1/improving_my_self_hosted_actions_runner">评论</a></p>
查看缓存全文
缓存时间: 2026/05/23 18:48
# "改进我的自托管 Actions Runner 搭建"
来源:https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/
一段时间以来,我一直自托一个runner(https://gitea.com/gitea/runner)来与Gitea Action(https://docs.gitea.com/usage/actions/)集成。但有一件事一直让我担忧:在软件供应链安全日益重要的时代,我觉得需要提升这套部署的安全性。我最终找到的方案虽然还不完美,但相比之前已经有了很大改进。这篇文章将从头梳理我之前的做法、为什么及选择替换它、如何实现,以及还有哪些地方可以继续改善。
## 最初,是 act# (https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/#in-the-beginning-there-was-act)
act runner(https://nektosact.com/)很久以前就诞生了,旨在提供在本地运行 GitHub Actions 的能力。这既允许快速反馈循环——开发者无需任何成本就能在本地模拟 GHA,同时也充当了本地任务运行器。对于 Gitea 这类项目来说,这似乎非常契合:在当今时代,一个没有集成 CI/CD 管线的 git 平台几乎无法用于“正经工作”。
大约三周前,这个分支变得更加显著。"act_runner" 简化为 "runner",semver 跃升至 v1.0.0,开发速度似乎显著加快——恐怕我们都知道原因,但希望这不会演变成软件的腐烂。无论如何,这与我的担忧息息相关。
我得承认我并不是一个称职的系统管理员。一段时间以来,我一直在系统上直接运行 runner——老派的裸金属方式。不过我不是傻瓜,runner 已经正确配置了专属用户、目录和权限。更重要的是,我只用它运行自己的管线,所以我有合理的信心认为这样做没问题。
但每天我都能读到关于 shai-hulud、GitHub token 被盗、蠕虫传播、CI/CD 管线被攻破的消息。虽然我只用 Go 写软件,而该生态目前相对安全,但这没有理由让我放松。相反,在问题发生之前就改善情况才是正确的时机。
## 下一步?不是 Docker# (https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/#whats-next-not-docker)
目前关于运行 runner 的建议是在 Docker 容器中运行任务。但我并不完全是 Docker 的粉丝;虽然它确实有其用途,让许多任务变得轻松,但它也引入了一些我想避免的权衡。
诚然,Docker 自诞生以来已经有了长足进步,自从它开始利用 **cgroups** 并支持 **rootless** 模式后,大部分安全顾虑已经消失。但它仍然没有提供合理的默认配置,且 rootless 模式仍有其问题。因此,如果我要花一些时间来正确配置 Docker(我已经做过了),我宁可用这些时间学习新东西。所以,让我们利用现代 Linux 所提供的设施,而不必求助于 Docker。
如果说“我不喜欢 Docker”还没得罪够人,那么我接下来要惹更多人不快了。是的,我求助于 `systemd`。更具体地说,我使用了三个 systemd 组件,它们让这件事变得(相对)容易、(非常)安全,以及(不那么)有趣:`systemd-nspawn` 用于利用 **内核命名空间**、**cgroups** 和 **seccomp**;`systemd-networkd` 用于轻松创建私有虚拟链路;以及 `systemd-resolved` 用于解决我系统中的一个问题。
## 容器解决方案# (https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/#the-container-solution)
systemd-nspawn 的手册页(https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html)对其定义和功能有非常贴切的描述,我在此直接引用:
> systemd-nspawn 可用于在轻量级命名空间容器中运行命令或操作系统。它在许多方面类似于 chroot(1),但更强大,因为它虚拟化了文件系统层次结构、进程树、各种 IPC 子系统以及主机和域名。
现在明确一点:这里没有任何 systemd 独有的东西。从某种意义上说,nspawn 就像 Docker,因为它重新打包并围绕非常强大的技术创建了抽象层。好处在于它与 systemd 生态系统的其余部分拥有一流的集成,我们将充分利用这一点。
不过总的来说,我想要一个容器,能够运行一个具有以下特征的进程:
- 无能力(no capabilities)
- 无法访问主机文件(单独且只读的根文件系统)
- 无法访问主机进程(独立的 PID 命名空间)
- 无法访问主机网络服务(独立的网络命名空间)
- 无新特权(no new privileges)
- 受限的系统调用集合
这可以在不到 10 行的配置文件中实现:
``
[Exec]
# 禁用用户命名空间——避免与绑定挂载的 UID 映射问题。
PrivateUsers=no
# 阻止 setuid/setgid 二进制文件(sudo, ping 等)
NoNewPrivileges=yes
# Seccomp 过滤器:只允许列出的系统调用组
SystemCallFilter=@system-service @process @basic-io @file-system @network-io @signal @ipc @mount
[Network]
# 具有 veth 对和自动 NAT 的私有网络命名空间
VirtualEthernet=yes
[Files]
# 根文件系统不可变——所有写入必须通过绑定挂载
ReadOnly=yes
# 可写绑定挂载(在重建根文件系统后仍然存在)
Bind=/var/lib/gitea-runner-container:/var/lib/gitea-runner
Bind=/var/cache/gitea-runner-container:/var/cache/runner
``
## 网络难题# (https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/#the-networking-headaches)
现在,网络部分总是最有趣的,不是吗?既然我不想让生活变得太轻松,有一件事我不愿放弃:让容器使用我的私有 DNS 解析器。如果你不全力以赴,自托管还有什么意义?而且别忘了,我们仍然需要一个独立的网络命名空间——如果容器被攻破,仅仅共享主机的网络会很危险。
幸好,`systemd-networkd` 和 `systemd-resolved` 前来救场。通过在宿主机和容器上都运行 `networkd`,配置变得非常简单,特别是考虑到我想要一个相对静态的配置来设置 DNS 解析。在我们的案例中,`resolved` 是一个极其简单的存根解析器,在宿主机上指向一个只能通过 Wireguard 隧道访问的权威 DNS 解析器。
第一步是屏蔽通用的容器网络配置,只需执行 `ln -sf /dev/null /etc/systemd/network/80-container-ve.network`。之后,通过编辑 `/etc/systemd/network/90-ve-gitea-runner.network` 来配置我们容器的接口:
``
[Match]
Name=ve-gitea-runner
[Network]
Address=172.30.0.1/28
DHCPServer=yes
IPMasquerade=both
[DHCPServer]
EmitDNS=yes
DNS=172.30.0.1
``
这里重要的不是容器最终获得的地址,而是网关的 IP:我们将把 DNS 解析指向那里。因此,在 **宿主机** 的 `resolved` 配置中,我们需要添加一个存根,以确保 resolved 会响应来自容器的查询:
``
[Resolve]
DNSStubListenerExtra=udp:172.30.0.1:53
``
而在 **容器** 的 `resolved` 中,我们需要将其指向宿主机:
``
[Resolve]
DNS=172.30.0.1
Domains=~.
DNSStubListenerExtra=udp:[::1]:53
``
添加最后一行是因为容器中的某些进程期望 IPv6 解析;这样可以让 `resolved` 也将这些查询转发给宿主机。
就是这样!我想你现在明白我为什么还额外利用了 systemd 的那些好处了。在宿主机和容器上都启用适当的服务后,一切按预期运行。除了一件事:如果你有防火墙,还需要配置它。我使用的 iptables,但同样的规则配置 nftables 应该也不难:
``
-A INPUT -i ve-+ -p udp --dport 67 -j ACCEPT
-A INPUT -i ve-+ -p udp --dport 53 -j ACCEPT
-A FORWARD -i ve-+ -o eth0 -j ACCEPT
-A FORWARD -i eth0 -o ve-+ -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
``
## 总结与一些考虑# (https://excipio.tech/blog/improving-my-self-hosted-actions-runner-setup/#wrapping-it-up-and-some-considerations)
现在,配置好所有这些后,剩下的就是从容器内向 Gitea 平台注册 runner,然后开始构建!事实上,如果你正在阅读这篇文章,说明这次迁移成功了,我现在正在使用容器化的 CI/CD 管线!万岁!
当然,我把一切说得如此美好平静,但这仍然存在一些缺点,即:
- 你的缓存和工作空间仍然可能被恶意依赖污染
- 更新容器意味着必须停止容器,禁用 ReadOnly 模式,执行更新命令,启用 ReadOnly,然后再次启动容器
- 这不是一个真正的虚拟机,因此仍然存在一些方法可以串联利用漏洞,理论上能够实现容器逃逸、权限提升等
虽然我认为这些都不是致命问题,但它们确实留下了改进空间。鉴于 runner 已经支持临时 runner(https://docs.gitea.com/usage/actions/act-runner#ephemeral-runners),将其与 `systemd-nspawn` 集成肯定是一个可以做的事情,并能极大地帮助提升系统的安全性。也许有一天 :-)
相似文章
用于 Gleam 单仓库的 GitHub Actions
一位开发者分享了他们在 Gleam 单仓库中测试 BEAM 与 JavaScript 两套运行时的 GitHub Actions 配置,采用矩阵策略并严格执行格式检查。
@DeRonin_: 任何使用或学习智能体系统的人都应该读一读这个。我在每个新智能体项目前执行的安装顺序:1.…
一条分享智能体项目结构化安装顺序的推文:使用 direnv 配合密码管理器保障凭证安全,使用 litellm 或 portkey 作为模型代理管理成本和回退,使用 uv + git 在评估通过时提交以确保可复现性,使用 mitmproxy 实现 LLM 调用的全面可观测性。重点介绍了常见故障模式和安全漏洞。
自托管的开发沙箱与预览URL(Docker、Go、无K8s)
sandboxed 是一个开源引擎,能将单个 Linux 机器转变为一系列隔离的开发沙箱,配备编码代理和实时预览 URL,支持自托管且易于安装。
在树莓派上托管网站
技术教程,介绍如何在树莓派上自托管网站,涵盖端口转发、DNS 配置、使用 Caddy 作为反向代理、PM2 进行 Node.js 进程管理,以及使用 GitHub Actions 实现 CI/CD 自动化。
博客在 Ubuntu 16.04 上运行了 10 年,我将其迁移到了 FreeBSD
作者将其博客从一个使用了 10 年的 Ubuntu 16.04 VPS 迁移到了更具成本效益的 FreeBSD VPS,详细介绍了迁移动机、设置过程以及使用 Bastille 的 FreeBSD Jails 入门。