如果你手里还压着一台跑着 Ubuntu 18.04 的老服务器,想让它接进 GitHub Actions 当自托管 Runner,直接下载最新的 Runner 二进制包解压运行,大概率会撞上一个让人血压升高的报错:GLIBC_2.28 not found。这不是你操作错了,而是新版 Runner 的底层依赖已经超过这个老系统自带的 glibc 版本。
这事的麻烦点在于:Ubuntu 18.04 早已 EOL,官方源里能给的补丁和依赖就那么多了;而 GitHub 那边又在不停往前更新 Runner 的运行时,两边越拉越远。我一开始也试过各种“硬刚”的办法,比如给系统装新库、用环境变量指过去,结果都是这边能跑那边就崩。折腾一圈下来,真正靠谱且敢叫“终极”的做法其实是容器化:宿主机系统再旧无所谓,Runner 跑在一个干净的新版 Ubuntu 容器里,两边各过各的日子。这篇文章就把完整的部署过程、选型理由和踩坑记录都写出来,给同样被困在旧系统上的朋友一条能直接抄的路径。
1. Ubuntu 18.04 其实卡在了哪一环:先从 GLIBC_2.28 这个报错说起
1.1 一条经典报错背后的依赖链
先看现象。把官方 Runner 下载回来之后:
bash复制mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.319.1.tar.gz -L https://github.com/actions/runner/releases/download/v2.319.1/actions-runner-linux-x64-2.319.1.tar.gz
tar xzf actions-runner-linux-x64-2.319.1.tar.gz
sudo ./bin/installdependencies.sh
./config.sh --url https://github.com/yourname/yourrepo --token YOUR_TOKEN
前面的依赖脚本大概率会报一堆 Unable to locate package 之类的错误,因为很多依赖包在 Ubuntu 18.04 的源里已经不存在了。就算你强行把 config 跑过去,真正开始执行 job 时,Runner.Listener 进程会直接起不来。
最常见的报错长这样:
code复制./Runner.Listener: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by ./Runner.Listener)
这里有个背景知识。glibc 是 Linux 下几乎所有动态链接程序的地基,系统里绝大多数命令、服务、语言运行时都构建在它上面。Ubuntu 18.04 自带的 glibc 版本是 2.27,而新版 GitHub Actions Runner 在编译时链接了需要 glibc 2.28 才提供的符号。简单类比:glibc 2.27 是一个按旧图纸建的地基,新版 Runner 是按新图纸盖的房子,把新房子直接放到旧地基上,自然会塌。
1.2 为什么“跑不起来”不止是 glibc 的事
很多人以为只要解决了 glibc 就完事了,实际不止。新版 Runner 已经切换到 .NET 运行时,它除了要求 glibc 版本够新之外,还依赖 OpenSSL、libkrb5、liblttng-ust 等一堆系统库。Ubuntu 18.04 上的这些库版本同样停留在 2018 年,虽然某些库还能勉强满足要求,但整体依赖链是拆东墙补西墙。
如果是在容器里部署,这个问题会简单很多:容器内部跑的是 Ubuntu 22.04 或 24.04 的基础镜像,glibc、OpenSSL、各种运行时从一开始就是新版,Runner 的所有依赖都能被满足。宿主机只需要提供一个能跑 Docker 的 Linux 内核——而 Ubuntu 18.04 的内核版本完全够用。
1.3 为什么不能靠旧版 Runner 凑合
我最早的想法是:既然新版 Runner 跑不动,那我找一个旧版本不就行了?试过之后发现这条路走得非常难受。
一方面,GitHub 官方对自托管 Runner 的版本有硬性淘汰机制。版本过旧的 Runner 在执行 job 时会直接报 This runner version is outdated 并被强制停止注册,你想长期“冻结”在一个旧版本上根本不现实。另一方面,旧版 Runner 对 workflow 语法新特性、新的 action 版本支持也不完整,比如新版 actions/checkout 需要更高版本的 Node.js 运行时,这又回到了系统库版本不够的坑里。
所以当时我就意识到:在 Ubuntu 18.04 上装新版 Runner,不能用“把 Runner 塞进旧系统”的思路,必须换一个思路——把 Runner 放进一个它应该待的新系统里,旧系统只负责给这个容器提供运行环境。这就是方案的转折点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三条可走的路:升级系统、硬打补丁、容器隔离
2.1 升级系统:治本但代价不小
最直接的解法其实是把 Ubuntu 18.04 升级到 20.04 或 22.04,升级完 glibc 版本自然上去了,什么都能跑。
但这个方案的代价往往被低估。我这边的情况是服务器上还在跑一堆内部脚本、定时任务和老的构建工具链,直接做系统大版本升级,光是回归测试就要排很长的周期。do-release-upgrade 过程中还有可能因为内核驱动、软件源配置、第三方 apt 仓库不兼容而中断,一旦出问题整台机器可能起不来。
如果这台机器没有特殊历史包袱,比如是刚装好的系统或者上面跑的东西都很标准,那升级系统就是最干净的路。但如果像很多生产环境一样,机器上积累了各种不可描述的配置和旧软件,升级的隐性成本会远高于你的预期。
2.2 在宿主机硬补 glibc:看着能跑,实则埋雷
网上有不少教程教人从源码编译新版 glibc,或者从其他发行版搬一个高版本库过来,再通过 LD_LIBRARY_PATH 指给 Runner 用。我试着理解了一下原理,也看了别人的踩坑帖,最终没在自己机器上试,因为这是一条风险极高的路。
glibc 是整个系统最底层的库,系统里几乎所有动态链接的程序都在使用它。你如果在一个目录底下放了一个新版本 glibc,然后通过环境变量让 Runner 进程优先加载它,表面上 Runner 能跑起来,但实际上新 glibc 会连带加载新的 ld-linux、新的 NSS 模块,很可能导致系统里其他程序出现莫名其妙的崩溃。更可怕的是,一旦动态链接器版本和系统里其他库不匹配,你可能连 ls、bash 都起不来,只能进救援模式修复。
这条路的风险收益比极低,不建议作为生产方案。
2.3 容器隔离:让 Runner 活在它该活的系统里
容器化方案的核心逻辑很简单:宿主机系统旧不要紧,关键是 Runner 进程看到的系统环境要是新的。 用 Docker 拉一个 Ubuntu 22.04/24.04 的镜像,在镜像里安装、注册、运行最新版 Runner,宿主机只负责提供一个稳定的内核和 Docker 运行时。
这个方案的好处是立竿见影的:
- Runner 的依赖问题全部由镜像解决,宿主机 glibc 是 2.27 还是 3.x 都无所谓;
- 容器出问题随时删掉重建,不影响宿主机环境;
- Runner 自动更新只要容器内目录可写就能正常完成,不需要额外干预。
三条路线对比下来,我最终选择的就是容器化方案。下面从宿主机准备开始,把完整操作一步步写清楚。
| 方案 | 优点 | 缺点/风险 | 推荐度 |
|---|---|---|---|
| 升级系统 | 环境彻底解决,一劳永逸 | 周期长、回归成本高,中途可能中断 | 有充足测试时间可考虑 |
| 宿主机硬补 glibc | 不需要额外组件 | 极易弄崩系统基础库,后患无穷 | 不推荐 |
| 容器隔离 | 干净、隔离、快速重启,宿主机无感 | 需要 Docker 基础,镜像体积偏大 | 推荐 |
3. 宿主机改造:卸掉旧 Runner,装一个能用的 Docker
3.1 先处理历史遗留的旧 Runner 服务
如果你之前在这台机器上装过旧版 Runner,第一步不是直接装 Docker,而是把旧服务停掉、目录清掉。否则后面注册新 Runner 时,GitHub 后台会提示这台机器已经注册过同名 Runner,token 就算是对的也注册不上。
先用 systemctl 查到旧的 Runner systemd 服务名:
bash复制systemctl list-units | grep actions.runner
# 例如 actions.runner.yourname-yourrepo.old-runner.service
然后停掉并禁用:
bash复制sudo systemctl stop actions.runner.yourname-yourrepo.old-runner.service
sudo systemctl disable actions.runner.yourname-yourrepo.old-runner.service
rm -rf /home/runner/actions-runner
同时要去 GitHub 对应仓库的 Settings -> Actions -> Runners 页面,把之前注册的 Runner 移除掉。这一步经常被忽略,但漏掉之后会遇到极其 confusing 的注册失败问题。
3.2 不要用系统自带的 docker.io,要装官方 Docker CE
Ubuntu 18.04 的 apt 源里其实有 docker.io 这个包,直接 apt install docker.io 就能装。但我强烈不建议这么做,因为 18.04 源里的 Docker 版本太老,老版本 Docker 对镜像 manifest 列表(多架构镜像)的支持有问题,后面拉取新版镜像时会直接报错。我后面有一个坑就跟这个有关,这里先按下不表。
正确的做法是添加 Docker 官方 apt 源,安装 Docker CE:
bash复制sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
这里有个小细节:$(lsb_release -cs) 在 Ubuntu 18.04 上会输出 bionic,Docker 官方源至今仍然保留 bionic 的仓库目录,所以这条命令可以正常跑通。
安装完成后,确认 Docker 版本:
bash复制docker version
如果能看到 Client 和 Server 的版本号都大于 20.10,后面基本不会遇到兼容性问题。
3.3 权限与内核准备:docker 组、overlay、HWE 内核
Docker 默认需要 root 权限。为了使用方便,把当前用户加进 docker 组:
bash复制sudo usermod -aG docker $USER
newgrp docker
注意 newgrp docker 只是让当前终端立即生效。如果你用的 SSH 会话,重新登录一次更稳妥。验证方式:
bash复制docker run --rm hello-world
另外建议确认一下内核的 overlay 模块是否可用,因为 Docker 默认存储驱动是 overlay2:
bash复制lsmod | grep overlay
如果没有输出,可以让 Docker 帮你自动加载,一般 Docker 启动时会自动处理,但手动 sudo modprobe overlay 也不多余。
如果你的内核版本比较老(比如默认的 4.15),跑容器时遇到 failed to mount overlay 之类的报错,可以考虑给 Ubuntu 18.04 装 HWE 内核升级到 5.4:
bash复制sudo apt-get install -y linux-generic-hwe-18.04
sudo reboot
HWE 内核能让老系统对容器的新特性支持得更好,这是我后来补上的一个优化点,实测对稳定性提升明显。
4. 容器部署 Runner 的完整操作:从镜像选型到开机自启
4.1 镜像选型:catthehacker 与 myoung34 怎么选
Runner 容器化最常被用到的两个镜像分别是 ghcr.io/catthehacker/ubuntu 和 myoung34/github-runner。两者都能实现“容器内跑 Runner”的目标,但侧重点完全不同。
catthehacker/ubuntu 镜像最大的特点是预装工具极全:Node.js、Python、Go、Java、Docker CLI、git、build-essential 等 CI 构建常见依赖全都提前装好了,适合跑比较复杂的编译构建类 workflow,省去“每个 job 先装一遍工具链”的痛苦。
myoung34/github-runner 则更轻量,只包含 Runner 本体和少量必要工具,适合跑一些简单的脚本任务,镜像体积小、启动快,对磁盘占用敏感的场景更友好。
我的选择是 catthehacker,因为这台 Ubuntu 18.04 老机器主要承担的是各类编译打包任务,预装工具全意味着 workflow 里少很多 apt install 步骤。你可以根据自己的实际 workload 来定,不需要盲目跟随。
4.2 获取注册 Token:网页和 gh CLI 两种方式
Runner 注册需要 token。最直观的方式是网页操作:进入 GitHub 仓库的 Settings -> Actions -> Runners -> New self-hosted runner,页面会显示一个临时 token,复制下来即可。
但网页 token 容易过期,而且每次都要手动进去复制。如果你习惯命令行操作,可以用 gh CLI 获取:
bash复制# 先登录
gh auth login
# 获取注册 token
gh api repos/yourname/yourrepo/actions/runners/registration-token -X POST --jq .token
也可以直接用 curl 加 GitHub Token:
bash复制export GITHUB_TOKEN=your_personal_access_token
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/yourname/yourrepo/actions/runners/registration-token | jq -r .token
这个 token 的有效期是 1 小时,注册完成后立即失效。整个流程里 token 只是用来建立一次信任关系,不是长期凭证,所以不要到处贴,用完就好。
4.3 docker run 命令逐项拆解
拿到 token 后,启动容器的完整命令如下:
bash复制export REPO_URL="https://github.com/yourname/yourrepo"
export RUNNER_TOKEN="上面的token"
docker run -d --restart=always --name gha-runner \
-e RUNNER_NAME="bionic-ci-01" \
-e RUNNER_WORKDIR="/tmp/runner/work" \
-e RUNNER_TOKEN="$RUNNER_TOKEN" \
-e RUNNER_REPOSITORY_URL="$REPO_URL" \
-e RUNNER_LABELS="self-hosted,linux,x64,bionic" \
-e RUNNER_GROUP="default" \
-e TZ="Asia/Shanghai" \
-e LANG="C.UTF-8" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/runner-cache:/tmp/runner/cache \
--network host \
ghcr.io/catthehacker/ubuntu:act-22.04
这里每个参数都值得说清楚:
RUNNER_NAME 是 Runner 显示在 GitHub 后台的名字,建议用能区分机器的名字,比如 bionic-ci-01。RUNNER_WORKDIR 是 Runner 执行 job 时的工作目录,默认放在 /tmp/runner/work 就行,如果不是特殊需要不建议改成宿主机挂载目录,因为后面权限问题大多从这个目录来。
RUNNER_TOKEN 和 RUNNER_REPOSITORY_URL 是注册必需信息。RUNNER_LABELS 很重要,workflow 里的 runs-on 就是靠这些标签找到这台 Runner 的;self-hosted 和 linux 是默认必须保留的,额外的 bionic 可以让你在 workflow 里精确指定跑在老机器上。
RUNNER_GROUP 如果不指定,会注册在默认的 default 组。TZ 和 LANG 是后面避免乱码和时区坑的关键,具体原因在第 6 节展开。
挂载 /var/run/docker.sock 是为了让容器里的 Runner 能调用宿主机 Docker daemon,从而在 workflow 里执行 docker build 等命令。如果你们的 workflow 不需要操作 Docker,这行可以删掉,安全性和隔离性更好。
--network host 是我在这个场景下的推荐做法。它让容器直接使用宿主机网络,Runner 在容器里访问 localhost 就等同于访问宿主机 localhost,方便 workflow 里连接宿主机上跑的数据库、缓存等内部服务。
4.4 验证 Runner 已经上线:日志、后台、测试 Workflow
容器启动后,先看日志:
bash复制docker logs -f gha-runner
正常情况下会看到类似这样的输出:
code复制√ Connected to GitHub
√ Register runner... succeeded
√ Runner settings saved
√ Runner started successfully
然后去 GitHub 仓库的 Settings -> Actions -> Runners 页面,你会看到这台 Runner 的状态变成绿色 Idle。这时候可以推一个最小化测试 workflow 验证端到端链路:
yaml复制name: Test Runner
on: [push]
jobs:
smoke-test:
runs-on: [self-hosted, linux, x64, bionic]
steps:
- uses: actions/checkout@v4
- run: |
whoami
cat /etc/os-release
uname -a
如果一切正常,日志里会打印出容器内的身份信息、Ubuntu 22.04 的发行版信息,以及宿主机的内核版本。看到这里基本可以确认 Runner 已经能正常执行任务了。
4.5 用 systemd 托管容器,宿主机重启也不慌
docker run 的时候我们加了 --restart=always,意味着 Docker daemon 启动时会自动拉起这个容器。但如果你想更精细地控制启动顺序、或者在容器启动前先等待 Docker 就绪,可以用 systemd 来托管。
创建一个服务文件 /etc/systemd/system/gha-runner.service:
ini复制[Unit]
Description=GitHub Actions Runner Container
Requires=docker.service
After=docker.service
[Service]
Restart=always
RestartSec=10
ExecStart=/usr/bin/docker start -a gha-runner
ExecStop=/usr/bin/docker stop gha-runner
[Install]
WantedBy=multi-user.target
然后:
bash复制sudo systemctl daemon-reload
sudo systemctl enable gha-runner
sudo systemctl start gha-runner
这个方案相当于给容器上了双保险:systemd 负责管理容器生命周期,Docker 的 --restart=always 负责兜底。实测下来,宿主机重启后 Runner 能在两分钟以内自动恢复上线,基本不需要人工干预。
5. 容器内执行 Docker Workflow 的权限处理
5.1 不挂 socket,docker build 会直接给你翻脸
如果你在 workflow 里执行过 docker build,一定见过这个报错:
code复制Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
原因很简单:容器里虽然装了 docker CLI,但容器本身没有 Docker daemon。要解决这个问题有两条路,一种是“容器内跑 Docker daemon”的 DinD 模式,镜像大、隔离性强但磁盘开销高;另一种就是我们在 4.3 里做的“共享宿主机 Docker daemon”方式,把宿主机的 /var/run/docker.sock 挂载进容器。
这个做法的好处是:容器里执行 docker build 时,实际构建任务由宿主机 Docker daemon 完成,构建缓存天然共享,不需要每次重新 pull 基础镜像。代价是容器里的 Runner 获得了宿主机 Docker 的完全控制权,如果你的 workflow 内容可控且没有恶意代码,这个风险可以接受。
5.2 socket 挂上之后,还有 uid/gid 的隐形问题
挂载了 socket 之后,还需要注意一个权限匹配问题。Docker socket 文件的所有者是 root,组是 docker 组。容器里如果是以非 root 用户运行 Runner,而这个用户不在 docker 组里,访问 socket 依然会被拒绝。
catthehacker 镜像默认处理得比较好,一般以 root 或者自动创建的用户运行,不会主动遇到这个问题。但如果你自己修改过镜像用户,或者用了其他基础镜像,就需要手动对齐:
bash复制# 在宿主机上查 docker 组的 gid
getent group docker
# 输出类似 docker:x:999:youruser
然后在 docker run 的时候加上:
bash复制--group-add 999
这样容器内的用户就临时加入了 docker 组,可以正常访问 socket。这个细节如果不注意,排查起来非常浪费时间,因为报错信息只说 permission denied,完全不会提示是组权限问题。
5.3 网络模式选择:为什么我建议直接用 host
Runner 容器如果使用默认 bridge 网络,容器有自己独立的网络命名空间,workflow 里访问宿主机上的服务就不能直接用 localhost,必须搞清楚宿主机的 IP 或者用 host.docker.internal 之类的特殊域名。这会让 workflow 的编写和调试变得很别扭。
--network host 直接把容器塞进宿主机的网络命名空间,优点是网络行为跟宿主机进程完全一致,localhost、端口绑定、防火墙规则全部沿用宿主机配置。缺点是把容器和宿主机网络彻底打通,隔离性弱一些。但对于一台跑构建任务的老机器来说,这个取舍完全值得。
如果你的 workflow 不需要访问宿主机内部服务,还是建议用默认 bridge 模式,少一个暴露面。这个决策没有标准答案,按实际场景来。
6. 我实际踩过的三个坑与排查链路
6.1 旧版 Docker 拉镜像失败:manifest 不认识
我第一次在这台机器上部署时,一台服务器上已经装了系统自带的 docker.io,版本是 19.03。运行 docker run 拉取 catthehacker 镜像时,一直报:
code复制Error response from daemon: no matching manifest for linux/amd64 in the manifest list entries
我当时一度以为是镜像本身的问题,后来发现是 Docker 版本太旧,不认识新格式的 multi-arch manifest。GitHub Container Registry 上的镜像基本都带架构清单,旧版 Docker 没法正确解析。
排查链路:先用 docker version 确认 Docker 版本,如果低于 20.10,直接按 3.2 的方式从官方源重新安装 Docker CE。装完之后,同一个镜像名拉取一次就成功了。
这个坑最值得记住的一点是:不要因为图省事装系统源里的 docker.io,版本差距是实打实的兼容性门槛。
6.2 容器里的 locale 与时区:构建日志里最容易被忽略的乱码
有一段时间 Runner 跑 Java 项目的构建任务经常莫名其妙失败,日志里的报错千奇百怪,有的是 UnicodeEncodeError,有的是 Malformed input or input contains unmappable characters。排查了很久,最后发现是容器默认的 locale 不是 UTF-8,导致某些工具在输出中文字符、特殊符号时直接崩溃。
类似的问题还有时区。Runner 在容器里默认 UTC 时间,workflow 里如果依赖系统时间做文件命名、日志记录,生成的时间和国内时间差 8 个小时,排查问题时会多一层干扰。
解决办法就是在启动容器时显式设置环境变量:
bash复制-e TZ="Asia/Shanghai"
-e LANG="C.UTF-8"
-e LC_ALL="C.UTF-8"
这个操作成本几乎为零,但避免的问题非常典型。强烈建议每个 Runner 容器都加上,不要等出了乱码再补救。
6.3 Job 卡在 Preparing environment:workdir 权限排查
有一次 Runner 注册正常、状态正常,但 workflow 一跑起来就卡在 Preparing environment 这一步,日志上也没有具体报错,过几分钟后 job 超时失败。我一度以为 Runner 进程挂了,但看容器日志一切正常。
后来查 GitHub Actions Runner 源码和 issue 才知道,Runner 在启动 job 前会创建 RUNNER_WORKDIR 目录,并在里面生成 _work 目录。如果这个目录的权限不够,Runner 创建目录时会静默失败,然后卡在准备阶段。
我当时的配置是把 RUNNER_WORKDIR 指到了宿主机挂载的一个目录 /home/runner-work,而宿主机的 /home 权限限制了 runner 用户写入。
排查链路:执行 docker exec gha-runner ls -ld /tmp/runner/work 看目录权限,确认后把工作目录改为容器内目录 /tmp/runner/work,问题立刻消失。如果你确实需要把 workdir 挂到宿主机,务必提前在宿主机上 chown 好对应的 uid/gid。
这个坑的通用经验是:Runner 的 workdir 尽量用容器内可写目录,不要轻易改成外部挂载卷。改挂载卷前先想清楚宿主机目录的权限和属主,否则排查成本很高。
现在这台 Ubuntu 18.04 的旧机器已经稳定跑了小半年,期间 GitHub Actions Runner 自动更新过好几个版本,全程没再折腾过宿主机环境。对我个人而言,这套方案最值钱的地方不是“跑通了”,而是把“旧系统跑新软件”这个永恒矛盾的解法沉淀成了一套可复用的套路:不跟系统版本硬刚,用一层容器隔离把问题挡在宿主机之外。如果你手头也有类似的旧机器要接入现代 CI/CD 体系,建议直接上容器方案,省下来的时间干点别的。
