☰
Ubuntu 18.04上以Docker容器化部署最新GitHub Actions Runner
2026/10/1 8:52:10 网站建设 项目流程

接手一台2018年买的旧服务器,系统是Ubuntu 18.04,因为内部业务还依赖一堆老库,没法说升级就升级。但CI这边老板又要求用最新的GitHub Actions Runner,结果我直接在release页下载2.323.0版二进制,执行./run.sh就给我甩了个version 'GLIBC_2.28' not found。那一刻我意识到,Ubuntu 18.04的glibc还是2.27,而新版Runner早就把最低依赖抬到2.28以上了。这不是改个权限、装个依赖就能糊弄过去的事。

这篇文章我把这次完整的踩坑和最终落地过程写出来。核心思路就是:不要硬在宿主机上跟GLIBC搏斗,用Docker跑一个新版Runner容器,把宿主机隔离在外。这个方法我在多台Ubuntu 18.04机器上验证过,稳定跑了一百多个job,Workflow里包括编译、部署、跑容器操作全都没问题。如果你也是老系统、新Runner、不想重装系统,那这篇应该能直接给你抄作业。

1. 新版Runner在Ubuntu 18.04上失败的原因

1.1 GLIBC版本是最大拦路虎

Runner本质上是预编译的二进制程序,使用了很多动态链接库,其中最核心的就是glibc。Ubuntu 18.04自带的是GNU C Library 2.27,而GitHub官方从某个版本开始,Runner的Linux x64版本要求glibc至少是2.28及以上。因为GitHub那边的构建环境基本都是Ubuntu 22.04/24.04,用的glibc版本是很新的。

你可以自己验证一下当前系统的glibc版本,命令是这样的:

ldd --version

我这台机器输出明显是ldd (Ubuntu GLIBC 2.27-3ubuntu1.6) 2.27。然后你再看看Runner二进制依赖的glibc符号:

objdump -T ./bin/Runner.Listener | grep GLIBC_ | awk '{print $NF}' | sort -u

我这边能看到GLIBC_2.28、GLIBC_2.29、GLIBC_2.34这些符号。这就意味着,这个二进制一启动,动态链接器根本找不到对应版本的符号,直接报错退出。这不是什么“缺少某个so”的问题,而是版本级的不兼容。

1.2 除了GLIBC,还有哪些暗雷

如果你以为只用老办法,从网上下个新glibc丢进去就行,那就太天真了。Runner不仅仅依赖glibc,它为了执行JavaScript action,内部还打包了一个Node.js运行时;为了处理workflow的命令,还依赖一堆操作系统库。比如libicu,Runner在解析字符串和国际化时依赖它,不同发行版的libicu版本和soname也不一样。你强行从Ubuntu 22.04拷贝libicu.so过来,宿主机18.04基本跑不起来,因为libicu又依赖高版本glibc。

还有libssl相关的问题。新版Runner依赖OpenSSL 3.x的符号,但Ubuntu 18.04自带的是OpenSSL 1.1.x。你装不上,也替换不了,因为系统一堆基础组件都绑定在旧OpenSSL上。你只要敢动它,apt、curl、ssh全都会出问题。

所以总结一下,硬怼的路线是死路。唯一不碰宿主机底层库的方案,就是容器化。

2. 部署方案选型:硬修GLIBC还是容器隔离

2.1 为什么我不建议直接升级宿主机GLIBC

在Linux折腾过的人可能第一反应是“升级glibc”。但我必须认真说:这件事在Ubuntu 18.04上,特别是生产环境里,风险极高。glibc是系统几乎所有程序都依赖的底层库,直接源码编译安装新版glibc,很容易把/usr/bin/ls、bash、apt全干废。我有一次在测试机上试过,装完以后连apt remove都提示段错误,最后只能进救援模式恢复。

就算你用LD_LIBRARY_PATH指向一个新目录、只让Runner进程用新glibc,也容易出现各种版本错乱。因为Runner会fork子进程,子进程同样会继承LD_LIBRARY_PATH,然后里面很多工具还是老系统自带的,结果git、tar、bash启动时反而因为加载了不匹配的glibc崩溃。这属于“看似聪明实则埋雷”的路线,不建议碰。

2.2 Docker容器方案的适配逻辑

容器方案的核心逻辑很简单:在新操作系统里运行Runner,比如Ubuntu 22.04或Debian 12,这些系统自带glibc 2.35以上,Runner要什么有什么。宿主机只负责跑Docker,不关心Runner底下的那些库是否存在。

Docker引擎本身对Ubuntu 18.04的支持还是不错的。Docker 20.10及早期版本可以直接在18.04上安装运行,只需要内核版本不要太老(一般4.15以上就够了)。而18.04默认内核就是4.15,跑Docker完全OK。

这里要明确一下,我们不是在宿主机上直接运行Runner,而是在一个容器里运行Runner。容器内是完整的用户态环境,Runner在这个环境里做检测、拉代码、执行命令,全部正常。如果Workflow里面还需要跑Docker命令,我们再通过挂载宿主机Docker套接字的方式来解决。整体架构就是宿主机Docker作为底层运行时,Runner容器作为一个特殊的“CI客户端”。

基于这个设计,我们需要准备几样东西:

  • 宿主机:Ubuntu 18.04 + Docker
  • 一个Runner镜像:建议自己构建,轻量可控
  • Runner注册信息:GitHub仓库或组织下生成的token
  • systemd服务:保证Runner容器开机自启、挂掉自动拉起

3. 实战:在Ubuntu 18.04上以Docker方式部署最新Runner

3.1 宿主机安装Docker

Ubuntu 18.04上安装Docker,不要用系统自带源里的docker.io,因为版本太老,问题多。直接用Docker官方源装docker-ce。我这个服务器当时是从Docker 20.10开始跑的,直到现在升级到24.0也能正常用。

安装过程如下:

# 更新系统包索引 sudo apt update # 安装依赖,让apt能走https sudo apt install -y apt-transport-https ca-certificates curl software-properties-common # 添加Docker官方GPG密钥 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - # 添加Docker稳定版源 sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" # 再次更新并安装 sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io # 把当前用户加入docker组,免去反复sudo sudo usermod -aG docker $USER

注意这里用的是bionic,因为Ubuntu 18.04的codename就是bionic。安装完成后验证一下:

sudo systemctl enable docker sudo systemctl start docker docker version

在旧系统上装新版Docker,偶尔会遇到iptables版本错误,比如报iptables v1.6.1: can't initialize iptables table nat这种。一般是内核模块没加载,执行sudo modprobe iptable_nat,再重启Docker就好了。

3.2 构建Runner容器镜像

接下来我们需要自己做一个Runner镜像。有人可能去用社区现成的catthehacker/runner,但那个镜像非常大,而且更新节奏跟GitHub官方不一定同步。我更推荐基于官方Ubuntu镜像自己构建,几分钟就能搞定,而且完全可控。

创建一个目录,比如~/github-runner-image,在里面放一个Dockerfile:

FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive # 安装Runner需要的系统依赖 RUN apt update && apt install -y \ curl \ git \ jq \ wget \ tar \ gzip \ zip \ unzip \ bash \ sudo \ ca-certificates \ libicu70 \ libssl3 \ nodejs \ npm \ && rm -rf /var/lib/apt/lists/* # 创建runner用户 RUN useradd -m -d /home/runner -s /bin/bash runner # 创建Runner目录 RUN mkdir -p /actions-runner && chown runner:runner /actions-runner # 切换到runner用户,下载Runner二进制 USER runner WORKDIR /actions-runner # 以2.323.0为例,你可以去GitHub Releases拿最新版本号 ARG RUNNER_VERSION=2.323.0 RUN curl -o actions-runner.tar.gz -L \ https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \ && tar xzf ./actions-runner.tar.gz \ && rm ./actions-runner.tar.gz # 返回root,设置entrypoint USER root COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]

这里有个细节,libicu70和libssl3在Ubuntu 22.04的源里都有,Runner运行时需要它们。如果你用Debian 12,那对应包名可能是libicu72和libssl3,自己调整一下就好。

3.3 注册Runner并持久化配置

我们还需要一个entrypoint脚本,负责在容器启动时完成Runner的注册。一个可行的entrypoint.sh:

#!/bin/bash set -e if [ -f /actions-runner/.runner ]; then echo "检测到已有Runner配置,跳过注册" else echo "开始注册Runner..." ./config.sh --url "${RUNNER_URL}" --token "${RUNNER_TOKEN}" \ --name "${RUNNER_NAME:-$(hostname)}" \ --work "/home/runner/_work" \ --labels "${RUNNER_LABELS:-self-hosted,linux,x64}" \ --unattended --replace fi exec ./run.sh "$@"

为什么要持久化.runner配置文件?因为Runner注册时会生成一个唯一标识,还会和GitHub建立某种“绑定关系”。如果你每次容器重建都重新注册,不仅慢,而且会在GitHub那边留下很多离线的runner记录,看着很乱。所以我建议把容器里的/actions-runner/.runner以及_work目录用volume映射到宿主机。

构建镜像并运行容器的完整命令:

# 构建镜像 docker build -t my-github-runner:2.323.0 . # 创建工作目录和数据目录 mkdir -p /opt/runner/work /opt/runner/config # 运行容器(注册只执行一次) docker run -d \ --name github-runner \ --restart always \ -e RUNNER_URL="https://github.com/your-org" \ -e RUNNER_TOKEN="你的注册Token" \ -e RUNNER_NAME="old-server-runner" \ -e RUNNER_LABELS="self-hosted,linux,x64,ubuntu18" \ -v /opt/runner/work:/home/runner/_work \ -v /opt/runner/config:/actions-runner/.runner \ -v /var/run/docker.sock:/var/run/docker.sock \ my-github-runner:2.323.0

这里解释一下几个挂载点的作用:

  • /opt/runner/work:Runner执行Job时的工作目录。如果容器重建,工作目录里的临时文件可以保留。不过在真实使用中,每次Job基本都在干净环境跑,这里只是起到一个缓冲作用。
  • /opt/runner/config:Runner注册后生成的.runner配置文件所在目录。这个必须持久化,不然第二次启动时会重复注册。
  • /var/run/docker.sock:挂载宿主机Docker套接字。这样可以让你在Workflow里调用docker run时,实际跑在宿主机Docker上,而不是在Runner容器里套一个Docker容器。

3.4 用systemd守护容器进程

虽然docker run --restart always已经能保证Docker守护进程启动后自动拉起容器,但如果你希望在系统刚开机时就稳定拉起Docker和Runner容器,建议还是写一个systemd服务。这样日志处理、依赖关系、故障排查都方便很多。

在/etc/systemd/system/github-runner.service里写入:

[Unit] Description=GitHub Actions Runner Container Requires=docker.service After=docker.service [Service] Type=simple ExecStartPre=-/usr/bin/docker rm -f github-runner ExecStart=/usr/bin/docker run --rm \ --name github-runner \ -e RUNNER_URL="https://github.com/your-org" \ -e RUNNER_TOKEN="你的注册Token" \ -e RUNNER_NAME="old-server-runner" \ -e RUNNER_LABELS="self-hosted,linux,x64,ubuntu18" \ -v /opt/runner/work:/home/runner/_work \ -v /opt/runner/config:/actions-runner/.runner \ -v /var/run/docker.sock:/var/run/docker.sock \ my-github-runner:2.323.0 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

注意这里没有-d参数,因为systemd需要前台进程。docker run --rm配合ExecStartPre里的docker rm -f,可以保证每次重启都是全新容器,但数据卷映射的配置和工作目录都保留。

重新加载并启用服务:

sudo systemctl daemon-reload sudo systemctl enable github-runner sudo systemctl start github-runner

以后想看日志就用journalctl -u github-runner -f,比翻Docker日志还直观。

4. 容器内Runner的权限、工作目录与Docker套娃问题

4.1 让Runner以非root身份运行

我一再强调,Runner容器内不要用root跑。GitHub官方文档明确说过,Runner容器内的所有操作默认执行用户是runner用户,这样Workflow中执行的命令不具备系统最高权限,能减少误操作和安全隐患。

我们构建镜像时已经创建了runner用户,并且Dockerfile最后用USER runner切换过。但注意,我在最后的ENTRYPOINT之前又切换回了USER root。这是为了什么?

因为entrypoint.sh里可能要调用config.sh,而如果你以runner用户运行容器,但挂载了/var/run/docker.sock给runner用户,有时候会因为socket文件权限问题无法访问。Docker socket默认是root:docker,容器内的runner用户不一定在docker组里。所以我在entrypoint里先以root执行,在脚本中通过sudo -u runner或者su runner来运行Runner进程,同时给Runner用户补充docker组的权限。

这里我提供一个更完善的entrypoint思路:

#!/bin/bash set -e # 确保挂载目录属主正确 chown -R runner:runner /actions-runner chown -R runner:runner /home/runner/_work # 判断是否有docker socket,有则把runner加入docker组 if [ -S /var/run/docker.sock ]; then groupadd -f -g 999 docker usermod -aG docker runner fi if [ ! -f /actions-runner/.runner ]; then sudo -u runner -H ./config.sh --url "${RUNNER_URL}" --token "${RUNNER_TOKEN}" \ --name "${RUNNER_NAME:-$(hostname)}" \ --work "/home/runner/_work" \ --labels "${RUNNER_LABELS:-self-hosted,linux,x64}" \ --unattended --replace fi exec sudo -u runner -H ./run.sh "$@"

groupadd -f -g 999 docker是为了让容器内的docker组GID和宿主机一致。不过实际上,当你挂载docker socket后,容器内进程能否访问socket,取决于socket文件对宿主机docker组的权限。如果宿主机docker组的GID是999,你容器内也弄个GID 999的组,再把runner加进去,就能直接访问。不同机器GID可能不同,你灵活处理就行。

4.2 让Workflow能调用Docker容器

这是很多人第一次把Runner容器化之后最容易翻车的地方。你在Workflow里写了:

jobs: test: runs-on: self-hosted steps: - name: Run docker run: docker run --rm alpine echo "hello"

Runner容器本身是从镜像跑起来的,里面没有Docker守护进程。如果你只挂载了docker socket,那么docker命令还是会报“Cannot connect to the Docker daemon”吗?

其实不会,前提是你的Runner容器里装了dockerCLI。上面Dockerfile里我故意没有装docker CLI,因为我想让Workflow里用的docker命令直接调用宿主机。但docker客户端二进制还是要有的。可以在Runner镜像里增加安装docker-cli的步骤:

# 安装Docker CLI RUN apt install -y docker.io-cli || apt install -y docker-ce-cli

在Ubuntu 22.04的官方源里没有docker.io-cli,所以直接用docker-ce-cli吧,或者从Docker官方源装。我个人建议直接复制宿主机上的/usr/bin/docker到镜像里,省事,版本完全一致:

在宿主机上执行:

cp /usr/bin/docker /opt/runner/docker-cli

然后把Dockerfile改成:

COPY docker-cli /usr/bin/docker RUN chmod +x /usr/bin/docker

这样宿主机的docker客户端和守护进程版本匹配,不会有“client version too new”的幺蛾子。

另外,如果你是用了services:容器,也就是Workflow里定义一个服务容器,Runner容器需要动态创建新的Docker网络、启动服务容器。这个能力也必须依赖宿主机的Docker。挂载socket后,Runner会跟宿主机Docker正常通信,问题不大。

4.3 Work目录、标签与Runner名称规划

在注册Runner时,有几个参数要特别留心:

  • --work:Runner默认工作目录是_work。如果多个Runner共用一个宿主机,必须给每个Runner独立的work目录,否则不同Job同时运行时,会在同一个目录里写代码,互相踩踏,CI随机失败。
  • --labels:给Runner打标签。通过runs-on可以指定标签,比如self-hosted、linux、x64。我一般会加一个自定义标签ubuntu18,这样以后有专门针对旧系统环境的Workflow时,可以直接用runs-on: [self-hosted, ubuntu18]。
  • --name:Runner名称会显示在GitHub仓库的Runner列表里。如果是多台机器,建议加上机器名,方便定位。

持久化.runner配置时要注意:这个配置文件里保存了Runner的注册信息和自动更新策略。自动更新这块,容器里跑Runner时,默认可能无法自动更新自身二进制,因为容器文件系统不是持久的,而且更新后重启容器会回到原始镜像。所以我在entrypoint里直接取消了自动更新相关配置,或者通过config.sh的参数禁用自动更新。Runner支持在.runner配置里设置"disableUpdate"字段。为了省心,也可以在config命令后手动修改:

jq '.disableUpdate = true' /actions-runner/.runner > /tmp/.runner && mv /tmp/.runner /actions-runner/.runner

这样Runner每次启动不会尝试替换自身,避免容器内更新后消失的诡异现象。

5. 常见问题与排查实录

5.1 报错速查表

我在这套方案测试中,遇到了一大堆问题,这里整理成表格方便你对照排查:

现象根本原因解决方法
启动run.sh报GLIBC_2.28 not found宿主机glibc版本太旧改用容器方案,不要在宿主机直接跑Runner
容器启动后Runner反复注册.runner目录没有持久化挂载/opt/runner/config到/actions-runner/.runner
Workflow中调用docker命令失败Runner容器内没有docker CLI把宿主机的/usr/bin/docker复制到镜像内
Workflow中docker命令提示permission deniedRunner用户没有访问docker socket权限在entrypoint中将runner加入宿主机docker GID对应组
容器启动后一直显示“Self-hosted runner is not connected”容器无法访问GitHub服务,或注册token过期检查网络、代理环境变量,重新生成token注册
Job排队但始终不被Runner领取Runner标签与runs-on不匹配检查Runner的labels,比如self-hosted,linux,x64
Runner容器自动重启后报“Runner already exists”注册时没有加--replace在config.sh中加--replace参数
Runner无法拉取代码,报证书错误旧系统证书过期或缺失在容器内更新ca-certificates包
Workflow中启动services容器报network not found宿主机Docker网络创建失败检查宿主机iptables规则,确保网络正常
Runner更新了,但容器内还是旧版本镜像中的Runner版本是固定的重新构建镜像,升级RUNNER_VERSION参数

5.2 几个容易忽略的细节

第一个容易被忽略的是环境变量传递。如果你的CI Job里需要访问一些私有仓库,你可能之前在Runner宿主机上配置了SSH key。现在Runner跑到容器里了,SSH key需要挂载进容器,或者在环境变量和.netrc里带凭证。我是在注册Runner时给RUNNER_URL对应的仓库配置了GITHUB_TOKEN,同时把宿主机~/.ssh挂载到容器内/home/runner/.ssh,保证拉私有子模块时能通过SSH认证。

第二个细节是HOME环境变量。我们在entrypoint里用sudo -u runner -H运行,-H会重置HOME为/home/runner,避免Runner把配置写到root目录下。有些Workflow步骤里会用~定位缓存,如果HOME不对,缓存目录就会错乱。

第三个细节是内存和磁盘。新版Runner镜像虽然精简,但跑起来之后,再加上Job编译、Docker镜像下载,磁盘占用很容易冲到10GB以上。检查一下/opt/runner和/home/runner/_work所在分区是否足够大。我在一台服务器上没注意,结果Job跑到一半报No space left on device,特别尴尬。建议给Runner工作目录单独划分一个分区,或者定期清理_work下不需要的历史Job目录。

第四个细节是系统时区。容器默认时区UTC,而有些构建任务会打时间戳,或者你的业务代码依赖时区。我直接在Dockerfile里加了一行:

RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

如果你在其他时区,换成你那边对应的zone就好。不要等到Job里的时间对不上再改。

还有一个小坑,就是GitHub Runner需要访问pipelines.actions.githubusercontent.com、*.blob.core.windows.net这些地址。如果你的网络环境有出网限制,一定记得提前放通,或者给容器配置HTTP_PROXY/HTTPS_PROXY环境变量。这个不影响容器方案本身,但很多部署到后面发现Runner起不来,其实是网络策略问题,不是依赖问题。

5.3 环境变量注入的完整示例

把环境变量集中放在systemd服务里不太好看,我更习惯用EnvironmentFile来管理。比如在/etc/github-runner.env里:

RUNNER_URL=https://github.com/your-org RUNNER_TOKEN=xxxxx RUNNER_NAME=old-server-runner RUNNER_LABELS=self-hosted,linux,x64,ubuntu18 HTTP_PROXY=http://proxy.internal:8080 HTTPS_PROXY=http://proxy.internal:8080 NO_PROXY=localhost,127.0.0.1,.internal

然后systemd服务里改为:

EnvironmentFile=/etc/github-runner.env

这样改token、改label都不需要动服务文件,重启服务即可生效。而且环境变量文件记得把权限设成600,避免同一个机器上其他用户看到token。

如果要更新Runner版本,也只要改Dockerfile里的RUNNER_VERSION重新构建镜像,systemd服务的镜像tag换一下,重启服务就完事。整个过程不碰宿主机系统库,这才是“终极方案”的底气。

6. 写在踩坑之后的一些个人体会

这套容器化部署方案我前后用了大概一周时间打磨。最初我试着在Ubuntu 18.04上直接下载新版Runner,失败后想过编译老版本Runner,但老版本Runner又因为GitHub服务端协议变化,很多新功能用不了,比如有些新版本的action要求更高Runner版本。后来也试过在宿主机上手动装新glibc到自定义目录,最后被一通依赖问题劝退。

容器方案真正解决了底层系统版本和CI工具链版本之间的不可调和矛盾。它没有去破坏宿主机环境,而是让Runner运行在一个“它所期望”的操作系统里。这其实也是DevOps里很常见的思想:让应用和它的依赖一起打包,而不是在宿主机上灌各种东西。对我这种维护老服务器的人来说,这种隔离带来的安全感是实实在在的。

如果让我给一个直接建议,那就是:先从最小镜像跑起,不要一上来就模仿别人塞一堆开发工具。我后来在镜像里除了基础依赖和Docker CLI,基本什么都不装。所有编译工具链都通过Workflow里自己拉取的action容器来实现。这样镜像小、更新快、占用的资源也少。GitHub官方的Runner本来就是一个领包入队执行任务的代理,保持干净才是正确姿势。

如果你也正在被Ubuntu 18.04的新Runner问题折磨,听我一句,别再跟glibc较劲了。把Docker装好,照着我上面的Dockerfile和systemd配置抄一遍,很快你也能在旧系统上稳定跑最新Runner。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询