GitHub Actions 连不上你的 VPN:自建 runner 还是隧道
托管 runner 是你网络之外的一台新 VM。要么把 runner 放进那个网络,要么从 job 里建隧道并等一条真正可用的路由。
你的部署步骤卡在一个内网主机名上,而同一条命令在你笔记本上却能秒回,两台机器上的客户端构建完全相同。这个 job 运行在 GitHub 托管的 runner 上:一台为该 job 在 GitHub 云端新建的虚拟机,没有通往你私有地址空间的路由,没有内网区域的 resolver 记录,也没有私有端点会接受的证书。满足这个约束的只有两种设计,而且事后无法在不重建 workflow 的情况下切换。请在第一行 YAML 之前就选好,因为事后再迁移的代价高于第一次尝试。
选择发生在两种做法之间:把 runner 放在网络已经终止的地方,或者建一条只存在于一个 job 生命周期内的隧道。两者都支撑了多年的生产部署,失败方式却不同。
边界究竟在哪里
装在笔记本上的东西止于笔记本,job 期间安装的东西随 runner 一起消失。没有持久的网络命名空间能让客户端在两次运行之间保持挂接,因此每次运行都从同样的不可达状态开始。Azure 为 Microsoft 托管的代理记录过这条边界:指向私有网络的 VPN 和 ExpressRoute 链路不受支持;GitHub 托管的 runner 承载的是同一类限制,而不是另一套机制。请把它当作产品属性,而不是需要绕开的缺陷。
没有任何仓库设置能放宽它。job 的标签决定哪个池接手工作,托管标签意味着整个运行期间都是一台在你边界之外的机器。
因此私有可达性必须来自你自己拥有的东西。把 runner 放在网络已经终止的地方,或者建一条只活一个 job 的隧道。本文余下部分讲的是如何主动为其中之一付费,而不是在生产环境里发现账单。
按运行频率决定,而不是按熟悉程度
当部署一天落地多次、当 job 触及不止一个私有端口,或当平台团队已经负责打补丁和凭证轮换时,就在 VPC 或办公网内使用自建 runner。那台机器上的 job 像防火墙后任何主机一样解析和路由。网络工作只在安装时做一次,而不是每次运行都做。
当访问是偶发的、你也不希望有一个长期驻留的 agent 握着进入网络的凭证时,就在 job 内部建隧道。job 在几分钟内成为自己的网络客户端,什么都不留下,这往往就是全部意义。
选隧道之前先估算它的成本。每次运行都要为 profile 落地、客户端启动、就绪等待和拆除付费。一条能起来却从未被验证的隧道会产生半途失败的部署,并把私有状态留成没人计划过的样子。之后的重新尝试很少干净,第二次失败通常被归咎于客户端版本。
把 profile 写成让该步骤无法中断
把 profile 和凭证留在仓库之外,在需要它们的步骤里从 secret 落地。顺序决定 job 能否活下来:在 sudo 下创建的文件属于 root,非特权的步骤 shell 就无法再写入。
- name: Bring up OpenVPN
run: |
umask 077
profile="$RUNNER_TEMP/client.ovpn"
printf '%s\n' "$VPN_PROFILE" > "$profile"
: > "$RUNNER_TEMP/openvpn.log"
printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
sudo openvpn --daemon openvpn-job \
--config "$profile" \
--auth-user-pass "$RUNNER_TEMP/credentials.txt" \
--writepid "$RUNNER_TEMP/openvpn.pid" \
--log "$RUNNER_TEMP/openvpn.log"这个代码块里的两个细节都是为后续步骤服务的。有了 umask 077,profile 以 0600 模式落地且属于 runner 用户,sudo 仍然可读,客户端把日志写进 runner 已经拥有的文件,因此就绪循环无需第二次 sudo 就能读日志。--writepid 记录拆除时要针对的 PID。
用客户端证书认证的 profile 需要以同样方式写入密钥,static-challenge profile 需要匹配的认证模式。推送 DNS 选项的 profile 在 Ubuntu 和 Debian 上需要 openvpn-systemd-resolved,因为仅靠推送在基础镜像上什么都不会改变。
就绪是探针,不是暂停
固定 sleep 一直撑到集中器变慢的那一刻,然后部署步骤在路由存在之前就跑了。失败随后在部署工具里表现为对私有主机的 timeout,这对隧道本身什么也没说明。
- name: Await private endpoint
run: |
for attempt in $(seq 1 30); do
if getent hosts private.example.internal > /dev/null && \
timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
echo "endpoint answered after $attempt attempts"
exit 0
fi
sleep 2
done
grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
exit 1循环观察两件事,因为任一件都可能是坏掉的那一半。getent hosts 证明被推送的 resolver 能为该名字作答,socket 打开证明部署将要使用的端口上有人在监听;只有路由的 profile 需要直接写字面地址并删掉 getent 那一行。
循环超时后,日志是区分三种不同故障的唯一依据:证书被拒、认证模式错误,以及从未到达的路由。在 tail 之前过滤能让这段输出短到在 job 仍在失败时读得完。读第一条错误而不是最后一行,因为无法认证的客户端会不断重复,直到循环放弃。ip -4 route show dev tun0 回答更简单的问题:路由到底有没有被推送,而在同一个循环里它只占一行。
拆除,以及制品的问题
用一个由 if: always() 保护、只执行 sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" 的步骤来收尾。在共享的自建 runner 上,绝不按进程名杀进程:另一个 job 可能拥有同二进制、参数相近的客户端。故障就落在后跑的那个 job 上,而它的日志指向一个什么都没做错的 workflow。
保护之所以重要,是因为失败的部署仍会留下一条活着的隧道。没有 always(),清理步骤恰好在最需要它的时候被跳过,下一次运行会撞上一个已经连着的客户端。
采纳任一设计之前还剩一个问题:构建阶段真的需要私有路由吗?已经坐在网络里的部署代理可以拉取经过批准的制品,这就让普通构建远离私有路径。持有隧道的 job 越少,陈旧路由能藏身的地方就越少。