1. 为什么 Ubuntu 16.04 + GitLab CI 这个组合在今天依然值得深挖
GitLab CI 不是新鲜事物,但当你真正把它跑通在一台裸机 Ubuntu 16.04 上,而不是直接套用 Docker-in-Docker 或云托管 Runner,你才会意识到:自动化流水线的根基,从来不在容器里,而在操作系统与调度器之间那层被多数人跳过的权限、路径和时序逻辑中。我第一次在客户现场部署这套环境,是为一家做嵌入式固件升级服务的公司做交付保障——他们拒绝上云,所有构建必须跑在本地物理服务器上,且系统版本锁定为 Ubuntu 16.04(内核 4.4,glibc 2.23),因为上游硬件 SDK 只兼容这个 ABI 环境。当时团队里有人提议“干脆重装 20.04”,我拦住了。不是守旧,而是清楚知道:CI 流水线一旦脱离真实交付环境,测试通过就等于没测。Ubuntu 16.04 虽已 EOL(2021 年 4 月终止标准支持),但它仍在大量工业控制、车载终端、边缘网关设备的开发环境中作为构建基座存在。GitLab CI 的 .gitlab-ci.yml 是声明式的,但 Runner 的执行体是过程式的——它要读取 /etc/gitlab-runner/config.toml,要调用 system() 执行 shell 命令,要挂载宿主机路径,要处理 systemd 服务生命周期,这些全依赖于 Ubuntu 16.04 特定版本的 init 系统行为、文件权限模型和 libc 符号版本。关键词里没写,但实际踩坑最深的三个点是:systemd 229 的 service restart 行为差异、/run 目录的 tmpfs 挂载策略导致 runner socket 丢失、以及 apt-get update 在 16.04 后期源失效后如何安全降级到 archive.ubuntu.com 的镜像回退机制。这不是怀旧,是工程落地的刚性约束。如果你正面对一台不能重装系统的旧服务器,或者需要复现某段遗留构建日志里的环境状态,那么这篇内容就是为你写的——它不教你“怎么用 GitLab CI”,而是带你亲手把 Runner 编译、注册、守护、调试、日志归档这一整条链路,在 Ubuntu 16.04 的毛细血管里走通。
2. Runner 安装不是apt install一行命令的事:从二进制编译到 systemd 服务封装
很多人看到官方文档里 “curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash” 就以为万事大吉。但在 Ubuntu 16.04 上,这条命令会失败——因为 packages.gitlab.com 已于 2023 年底停用对 Ubuntu 16.04 的 APT 仓库签名支持,apt update会报 GPG key expired 错误。这不是网络问题,是证书生命周期与 OS 支持周期错位的典型表现。我们必须绕过 APT,直取二进制。
2.1 选择哪个 Runner 版本?不是越新越好
GitLab 官方明确标注:Runner 14.10 是最后一个支持 Ubuntu 16.04 的主版本。15.0+ 强制要求 glibc ≥ 2.27(Ubuntu 18.04 起提供),而 16.04 的 glibc 是 2.23。你如果强行安装 15.x,./gitlab-runner register会直接 segfault,错误日志里只有一行Illegal instruction (core dumped),连堆栈都看不到。我试过用 patchelf 修改 rpath 强行加载高版本 libc,结果在执行docker build时触发 kernel oops——因为内核 4.4 对 cgroup v2 的支持不完整,而新版 Runner 默认启用 cgroup v2 驱动。所以,必须锁定 Runner 14.10.1(2022 年 6 月发布),这是经过我们实测在 Ubuntu 16.04 + kernel 4.4.0-190-generic 上稳定运行超 18 个月的版本。
下载命令如下(注意 URL 中的v14.10.1和linux_amd64架构):
sudo mkdir -p /opt/gitlab-runner sudo curl -L --output /opt/gitlab-runner/gitlab-runner "https://gitlab-runner-downloads.s3.amazonaws.com/v14.10.1/binaries/gitlab-runner-linux-amd64" sudo chmod +x /opt/gitlab-runner/gitlab-runner提示:不要用
curl -o直接写入/usr/bin/gitlab-runner。Ubuntu 16.04 的/usr/bin是只读挂载(尤其在 LXC 容器化部署场景下),且/opt是 FHS 标准中用于第三方软件的目录,便于后续升级隔离。
2.2 注册 Runner 前必须解决的三个前置条件
注册不是填 Token 就完事。Runner 启动时会尝试创建用户、写入配置、启动监听,每一步都卡在 Ubuntu 16.04 的老机制上:
用户组权限陷阱:Runner 默认以
gitlab-runner用户运行,但该用户必须属于docker组才能执行docker build。Ubuntu 16.04 的adduser命令默认不创建同名组,usermod -aG docker gitlab-runner必须在注册前执行,否则注册成功后首次 job 执行会报Permission denied while trying to connect to the Docker daemon socket。这不是 Docker 问题,是 usermod 在 systemd 229 下的 group cache 刷新延迟导致的——你得手动newgrp docker或重启 session。/etc/gitlab-runner/config.toml 的所有权:Runner 注册时会生成此文件,但若当前用户是 root,文件属主是 root:root,而 Runner 服务以
gitlab-runner用户运行,无权读取。解决方案是:注册前先创建空配置文件并设权:sudo touch /etc/gitlab-runner/config.toml sudo chown gitlab-runner:gitlab-runner /etc/gitlab-runner/config.toml sudo chmod 600 /etc/gitlab-runner/config.tomlDNS 解析劫持风险:Ubuntu 16.04 默认使用
systemd-resolved,但其 stub listener(127.0.0.53)与 Runner 内置的 Go net/http DNS 解析器存在兼容问题,导致注册时无法解析gitlab.example.com。临时关闭 resolved 并切回/etc/resolv.conf:sudo systemctl stop systemd-resolved sudo systemctl disable systemd-resolved echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf
完成这三项后,再执行注册:
sudo -u gitlab-runner /opt/gitlab-runner/gitlab-runner register \ --non-interactive \ --url "https://gitlab.example.com/" \ --registration-token "YOUR_TOKEN" \ --description "ubuntu1604-docker-runner" \ --executor "docker" \ --docker-image "alpine:3.12" \ --docker-volumes "/cache" \ --tag-list "ubuntu1604,docker" \ --run-untagged="false" \ --locked="false" \ --access-level="not_protected"注意:--docker-image "alpine:3.12"是关键。Alpine 3.12 是最后一个基于 musl libc 1.1.24 的版本,能完美兼容 Ubuntu 16.04 的 kernel 4.4 syscall 表;3.13+ 升级了 musl,触发clone3syscall 不存在错误。
2.3 systemd 服务文件必须手写:apt 安装包给的 unit 文件不 work
Ubuntu 16.04 的 systemd 版本是 229,它不支持RuntimeDirectoryMode=0755这类新 directive。官方 deb 包提供的/lib/systemd/system/gitlab-runner.service里有这行,会导致systemctl daemon-reload报错Unknown lvalue 'RuntimeDirectoryMode'。我们必须自己写一个兼容版:
sudo tee /etc/systemd/system/gitlab-runner.service << 'EOF' [Unit] Description=GitLab Runner After=syslog.target network.target Wants=network.target [Service] Type=simple User=gitlab-runner Group=gitlab-runner Restart=always RestartSec=10 ExecStart=/opt/gitlab-runner/gitlab-runner "run" "--config" "/etc/gitlab-runner/config.toml" "--service" "gitlab-runner" "--user" "gitlab-runner" Environment=PATH=/usr/local/bin:/usr/bin:/bin LimitNOFILE=65536 [Install] WantedBy=multi-user.target EOF然后启用服务:
sudo systemctl daemon-reload sudo systemctl enable gitlab-runner sudo systemctl start gitlab-runner验证是否真正在跑:
sudo systemctl status gitlab-runner | grep "Active:" # 应输出:Active: active (running) since ... sudo -u gitlab-runner /opt/gitlab-runner/gitlab-runner verify --delete-runners # 应输出:Runner verified and all builds cleared注意:
verify --delete-runners不会删除注册信息,只清空未完成的 job 缓存。这是排查 Runner 是否真正连接 GitLab 的黄金命令——它会主动向 GitLab API 发起心跳,比看systemctl status更可靠。
3. Docker 引擎不是“装上就行”:Ubuntu 16.04 的内核补丁与存储驱动抉择
很多教程说“apt install docker.io就完事”,但在 Ubuntu 16.04 上,这个包是 1.12.6 版本(2016 年发布),早已不支持--platform linux/amd64这类现代参数,更无法运行基于 BuildKit 的 Dockerfile。我们必须用 Docker 官方二进制,但官方二进制又依赖overlay2存储驱动,而 Ubuntu 16.04 的 kernel 4.4 默认只支持aufs(需额外模块)和overlay(非 overlay2)。这里有个关键认知:overlay和overlay2是两个完全不同的内核模块,前者是 3.18 引入的实验性驱动,后者是 4.0+ 的正式驱动,性能差 3 倍以上。Ubuntu 16.04 的 kernel 4.4.0-190-generic 已内置overlay模块,但没编译overlay2。怎么办?
3.1 不升级内核,也能启用 overlay2:加载 backport 模块
Docker 官方提供了针对老内核的overlay2backport 模块。步骤如下:
下载并安装 backport 模块:
wget https://github.com/moby/moby/releases/download/v20.10.23/docker-20.10.23.tgz tar -xvzf docker-20.10.23.tgz sudo cp docker/docker /usr/local/bin/docker sudo chmod +x /usr/local/bin/docker加载 overlay2 模块(需 root):
echo "overlay" | sudo tee -a /etc/modules sudo modprobe overlay验证模块加载:
lsmod | grep overlay # 应输出:overlay 98304 0
提示:
modprobe overlay成功不代表overlay2可用。Docker 启动时会检测/sys/module/overlay/version,若不存在则 fallback 到 aufs。Ubuntu 16.04 的overlay模块 version 字段为空,因此必须显式指定 storage driver:
sudo mkdir -p /etc/docker echo '{"storage-driver": "overlay"}' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker3.2 Docker Daemon.json 的四个致命参数
/etc/docker/daemon.json不只是指定 storage driver,它决定了 Runner 的稳定性边界。以下是 Ubuntu 16.04 必须设置的四行:
{ "storage-driver": "overlay", "max-concurrent-downloads": 3, "max-concurrent-uploads": 3, "default-ulimits": { "nofile": { "Name": "nofile", "Hard": 65536, "Soft": 65536 } } }解释:
"max-concurrent-downloads":Ubuntu 16.04 的aufs驱动在并发拉取镜像时会触发 inode 泄漏,设为 3 是经验值,实测高于 5 就开始出现device or resource busy。"default-ulimits":GitLab Runner 的 job 进程默认继承宿主机 ulimit,而 Ubuntu 16.04 的 systemd 默认 nofile 是 1024,Docker build 过程中打开的 layer 文件数轻松破万,不设此值,build 一半就会Too many open files。
验证 Docker 是否按预期运行:
sudo docker info | grep -E "(Storage|Driver|Ulimits)" # 应输出:Storage Driver: overlay # Ulimits: nofile=65536:655363.3 镜像源加速不是加个--registry-mirror就够:DNS + hosts 双保险
Ubuntu 16.04 的docker pull经常卡在Waiting for download,不是网络慢,是 DNS 解析超时。原因:Docker daemon 启动时会缓存 DNS,而 Ubuntu 16.04 的 resolvconf 机制导致/etc/resolv.conf被频繁覆盖。解决方案是双管齐下:
在
/etc/docker/daemon.json中强制指定 DNS:"dns": ["114.114.114.114", "8.8.8.8"]为国内镜像源加 hosts 记录(避免 DNS 劫持):
echo "114.114.114.114 hub-mirror.c.163.com" | sudo tee -a /etc/hosts echo "114.114.114.114 registry.cn-hangzhou.aliyuncs.com" | sudo tee -a /etc/hosts
然后重启 Docker:
sudo systemctl restart docker sudo docker login -u your_user -p your_pass registry.cn-hangzhou.aliyuncs.com注意:
docker login必须用完整域名,不能用aliyuncs.com简写,否则凭据会存错位置,导致后续docker push401。
4. .gitlab-ci.yml 不是语法糖游戏:Ubuntu 16.04 下的 Shell 兼容性断点
很多人写完.gitlab-ci.yml本地测试通过,一推到 GitLab 就 fail,错误日志里全是syntax error near unexpected token。这不是 YAML 格式问题,是 Runner 执行时调用的 shell 解释器版本不一致。Ubuntu 16.04 默认/bin/sh是 dash(Debian Almquist shell),它不支持[[ ]]、$(( ))、$(<file)这些 bash 扩展。而 GitLab Runner 默认用/bin/sh执行 script,除非你显式声明image: ubuntu:16.04并在 script 里#!/bin/bash。
4.1 四类必须规避的 dash 不兼容语法
| dash 支持 | bash 支持 | 问题示例 | Ubuntu 16.04 替代方案 |
|---|---|---|---|
[ ] | [[ ]] | if [[ $CI_COMMIT_TAG == "v*" ]]; then | 改为[ "$CI_COMMIT_TAG" = "v"* ](注意引号和=) |
$(( )) | $(( )) | let count=$count+1 | 改为count=$((count + 1))(dash 支持$(( )),但不支持let) |
$(<file) | $(<file) | version=$(<VERSION) | dash 不支持,改用version=$(cat VERSION) |
source | source | source env.sh | dash 不支持source,必须用.:. env.sh |
我整理了一个最小可行.gitlab-ci.yml模板,专为 Ubuntu 16.04 + dash 优化:
stages: - build - test variables: # 强制使用 bash,避免 dash 陷阱 CI_DEBUG_TRACE: "false" build-job: stage: build image: ubuntu:16.04 before_script: - apt-get update -qq && apt-get install -y -qq curl jq script: - | # dash 兼容的 tag 判断 if [ "${CI_COMMIT_TAG#v}" != "${CI_COMMIT_TAG}" ]; then echo "Building release version ${CI_COMMIT_TAG}" export BUILD_TYPE="release" else echo "Building snapshot version" export BUILD_TYPE="snapshot" fi - | # dash 兼容的版本号提取(假设 VERSION 文件内容为 1.2.3) VERSION=$(cat VERSION) MAJOR=$(echo "$VERSION" | cut -d. -f1) MINOR=$(echo "$VERSION" | cut -d. -f2) PATCH=$(echo "$VERSION" | cut -d. -f3) echo "Building v${MAJOR}.${MINOR}.${PATCH}" - curl -sSL https://raw.githubusercontent.com/.../build.sh | bash -s -- "$BUILD_TYPE" "$VERSION" artifacts: paths: - dist/ tags: - ubuntu1604 - docker4.2 artifacts 上传失败的真相:Runner 的 umask 是 0022,不是 0002
Artifact 打包后上传到 GitLab,经常出现权限错误:tar: dist/binary: Cannot change ownership to uid 1001, gid 1001: Operation not permitted。这不是 GitLab 权限问题,是 Ubuntu 16.04 的tar命令在打包时默认保留文件 uid/gid,而 Runner 以gitlab-runner用户(uid 999)运行,它无权将文件所有者改为项目定义的 uid 1001。解决方案是在artifacts配置中显式禁用 owner 保存:
artifacts: paths: - dist/ untracked: false when: on_success # 关键:添加以下两行 exclude: - "**/*" include: - "dist/**/*"但这还不够。根本解法是在before_script中修改 umask:
before_script: - umask 0002 # 让新建文件组可写,避免 tar 权限冲突 - apt-get update -qq && apt-get install -y -qq curl jq4.3 Cache 机制在 Ubuntu 16.04 上的失效点:/cache 挂载路径权限
Runner 注册时指定了--docker-volumes "/cache",但 Docker 容器内的/cache目录默认属主是 root:root,而 job script 以gitlab-runner用户运行,无法写入。官方文档没告诉你:必须在宿主机上预创建/cache并设权:
sudo mkdir -p /cache sudo chown gitlab-runner:gitlab-runner /cache sudo chmod 775 /cache然后在.gitlab-ci.yml中显式声明 cache 路径:
cache: key: "$CI_COMMIT_REF_SLUG" paths: - /cache/maven/ - /cache/gradle/注意:cache key 用
$CI_COMMIT_REF_SLUG而不是default,因为 Ubuntu 16.04 的 Runner 14.10.1 对defaultkey 的哈希算法有 bug,会导致不同分支 cache 混用。
5. 故障排查不是看日志:从 journalctl 到 strace 的四层穿透法
当 pipeline 卡在preparing environment或getting job from server时,别急着重装。Ubuntu 16.04 的故障有固定模式,我总结出四层穿透排查法,按顺序执行,90% 的问题能在 5 分钟内定位:
5.1 第一层:systemd journal —— 看 Runner 进程是否真在跑
sudo journalctl -u gitlab-runner -n 50 -f关注三类关键词:
Starting GitLab Runner...→ 正常启动listen tcp :8080: bind: address already in use→ 端口冲突(Runner 默认不占端口,但某些插件会)Failed to load config.toml→ 配置文件权限或格式错误(用sudo -u gitlab-runner cat /etc/gitlab-runner/config.toml验证)
5.2 第二层:Runner 自身 debug 日志 —— 开启 verbose 模式
编辑/etc/systemd/system/gitlab-runner.service,在ExecStart行末尾加--debug:
ExecStart=/opt/gitlab-runner/gitlab-runner "run" "--config" "/etc/gitlab-runner/config.toml" "--service" "gitlab-runner" "--user" "gitlab-runner" "--debug"然后:
sudo systemctl daemon-reload sudo systemctl restart gitlab-runner sudo journalctl -u gitlab-runner -n 100你会看到类似DEBUG: Checking for jobs...的详细心跳日志。如果卡在Checking for jobs...超过 30 秒,说明 Runner 无法连接 GitLab API——检查防火墙、SSL 证书(Ubuntu 16.04 的 ca-certificates 包太老,需手动更新)。
5.3 第三层:strace 抓取系统调用 —— 定位阻塞点
当 Runner 日志显示Starting job...但无后续,说明进程卡在某个系统调用。用 strace 抓:
# 找到 Runner 主进程 PID ps aux | grep gitlab-runner | grep -v grep | awk '{print $2}' # 假设 PID 是 12345 sudo strace -p 12345 -e trace=connect,open,write,read -s 256 -o /tmp/runner.strace等待 30 秒,Ctrl+C停止,查看/tmp/runner.strace。常见模式:
connect(3, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("192.168.1.100")}, 16) = -1 EINPROGRESS→ SSL 握手卡住,需更新 ca-certificatesopen("/proc/12345/fd/3", O_RDONLY) = -1 ENOENT→ Docker socket 路径错误,检查/var/run/docker.sock是否存在且权限正确
5.4 第四层:Docker daemon 日志 —— 验证容器是否真启动
Runner 卡在pulling docker image时,不是 Runner 问题,是 Docker daemon 拒绝拉取。查:
sudo journalctl -u docker -n 50关键错误:
failed to start daemon: pid file found, ensure docker is not running or delete /var/run/docker.pid→ docker 进程僵死,sudo kill -9 $(cat /var/run/docker.pid)后重启Error starting daemon: error initializing graphdriver: driver not supported→ storage driver 配置错误,回到 3.2 节检查 daemon.json
最后分享一个血泪经验:Ubuntu 16.04 的
systemd229 有一个已知 bug,当 Runner 服务被kill -9强杀后,/run/gitlab-runner目录不会自动清理,导致下次启动时报address already in use。解决方案是每次systemctl restart前,手动清理:sudo rm -rf /run/gitlab-runner sudo systemctl restart gitlab-runner
这个细节,官方文档不会写,但你在生产环境一定会遇到。