Ubuntu 18.04上以Docker容器化部署最新GitHub Actions Runner
接手一台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机器上验证过稳定跑了一百多个jobWorkflow里包括编译、部署、跑容器操作全都没问题。如果你也是老系统、新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的命令还依赖一堆操作系统库。比如libicuRunner在解析字符串和国际化时依赖它不同发行版的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仓库或组织下生成的tokensystemd服务保证Runner容器开机自启、挂掉自动拉起3. 实战在Ubuntu 18.04上以Docker方式部署最新Runner3.1 宿主机安装DockerUbuntu 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 [archamd64] 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: cant initialize iptables table nat这种。一般是内核模块没加载执行sudo modprobe iptable_nat再重启Docker就好了。3.2 构建Runner容器镜像接下来我们需要自己做一个Runner镜像。有人可能去用社区现成的catthehacker/runner但那个镜像非常大而且更新节奏跟GitHub官方不一定同步。我更推荐基于官方Ubuntu镜像自己构建几分钟就能搞定而且完全可控。创建一个目录比如~/github-runner-image在里面放一个DockerfileFROM ubuntu:22.04 ENV DEBIAN_FRONTENDnoninteractive # 安装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_VERSION2.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_URLhttps://github.com/your-org \ -e RUNNER_TOKEN你的注册Token \ -e RUNNER_NAMEold-server-runner \ -e RUNNER_LABELSself-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/workRunner执行Job时的工作目录。如果容器重建工作目录里的临时文件可以保留。不过在真实使用中每次Job基本都在干净环境跑这里只是起到一个缓冲作用。/opt/runner/configRunner注册后生成的.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] DescriptionGitHub Actions Runner Container Requiresdocker.service Afterdocker.service [Service] Typesimple ExecStartPre-/usr/bin/docker rm -f github-runner ExecStart/usr/bin/docker run --rm \ --name github-runner \ -e RUNNER_URLhttps://github.com/your-org \ -e RUNNER_TOKEN你的注册Token \ -e RUNNER_NAMEold-server-runner \ -e RUNNER_LABELSself-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 Restartalways RestartSec10 [Install] WantedBymulti-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 helloRunner容器本身是从镜像跑起来的里面没有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时有几个参数要特别留心--workRunner默认工作目录是_work。如果多个Runner共用一个宿主机必须给每个Runner独立的work目录否则不同Job同时运行时会在同一个目录里写代码互相踩踏CI随机失败。--labels给Runner打标签。通过runs-on可以指定标签比如self-hosted、linux、x64。我一般会加一个自定义标签ubuntu18这样以后有专门针对旧系统环境的Workflow时可以直接用runs-on: [self-hosted, ubuntu18]。--nameRunner名称会显示在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/.runnerWorkflow中调用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,x64Runner容器自动重启后报“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_URLhttps://github.com/your-org RUNNER_TOKENxxxxx RUNNER_NAMEold-server-runner RUNNER_LABELSself-hosted,linux,x64,ubuntu18 HTTP_PROXYhttp://proxy.internal:8080 HTTPS_PROXYhttp://proxy.internal:8080 NO_PROXYlocalhost,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。