构建内容
一个连接到您自有 forge 的 CI runner,它检出仓库,在无守护进程且无 root 的情况下构建容器镜像,并将结果推送到同一实例上运行的私有 registry。没有挂载任何特权套接字,管道中没有进程以 uid zero 运行。
大多数自托管 CI 设置通过将主机容器守护进程挂载到作业中来解决镜像构建问题。这有效,但它将机器完全控制权交给了每个管道,包括有人在其中发起 pull request 的那个。Buildah 以其 rootless 模式可以在不牺牲控制权的情况下完成相同的工作,而且在专用核心上并不慢。
开始之前
- 一台 R-8。镜像构建是大多数团队运行的最受单核限制的任务,而 Zen 5 是我们售卖的每核心性能最强的。四百 GB 的 NVMe 可以存放大量镜像层。
- 一个您已经运行的、支持 Actions 协议的 forge,并拥有在其中创建 runner 注册令牌的权限。
- 主机名:
ci.example.com用于 runner,registry.example.com用于 registry。
1. 拥有子 ID 范围的普通用户
apt update && apt install -y podman buildah skopeo fuse-overlayfs uidmap slirp4netns git nodejs nginx apache2-utils
useradd -m -s /bin/bash runner
echo "runner:200000:65536" >> /etc/subuid
echo "runner:200000:65536" >> /etc/subgid
loginctl enable-linger runner这两个范围是 rootless 容器成为可能的关键:runner 账号拥有六万五千个子 ID,因此容器内认为自己是 root 的进程会被映射到外部的一个普通 ID 上。Lingering 保持用户会话存活,因此该账号下的 systemd 单元在注销后仍然运行。
安装 Node 是因为大多数可复用操作是 JavaScript 的,而 runner 在此配置中在主机上执行它们。在检出步骤发现这一点是常见的十分钟绕路。
2. NVMe 上的 Rootless 存储
sudo -u runner mkdir -p /home/runner/.config/containers
sudo -u runner tee /home/runner/.config/containers/storage.conf <<EOF
[storage]
driver = "overlay"
graphroot = "/home/runner/.local/share/containers/storage"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
sudo -u runner podman info --format "{{.Store.GraphDriverName}} {{.Host.Security.Rootless}}"最后一条命令应回答 overlay true。若是 vfs 驱动,则意味着缺少 fuse-overlayfs,而 vfs 会在每次构建时完整复制每一层,这会导致原本九十秒的管道变成六分钟。
3. 私有 Registry
apt install -y docker-registry
htpasswd -c /etc/docker/registry/htpasswd ci
certbot certonly --standalone -d registry.example.com将 registry 绑定到 loopback,并让 nginx 承担所有对外的职责,包括认证。在 /etc/docker/registry/config.yml 中:
version: 0.1
storage:
filesystem:
rootdirectory: /srv/registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000Registry 本身不携带凭据,因为它从不接收未经代理处理的请求。一个检查密码的地方比两个可能不一致的地方更好。
server {
listen 443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
auth_basic "restricted";
auth_basic_user_file /etc/docker/registry/htpasswd;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900s;
}
}client_max_body_size 0 移除了上传限制。保留 nginx 默认设置,每个超过一兆字节的层在推送约三分之二时都会失败并报 413,这是令人难忘的下午。
systemctl enable --now docker-registry nginx4. Runner
cd /usr/local/bin
wget -O act_runner https://code.forgejo.org/forgejo/runner/releases/download/v6.3.1/forgejo-runner-6.3.1-linux-amd64
chmod +x act_runner
sudo -u runner mkdir -p /home/runner/.runner-cfg
cd /home/runner/.runner-cfg && sudo -u runner /usr/local/bin/act_runner generate-config > config.yaml编辑生成的文件,使作业在主机上运行而不是在容器内,因为 Buildah 已经提供了隔离,而嵌套则只会增加复杂性而没有任何收益:
runner:
capacity: 2
timeout: 1h
labels:
- "debian-13:host"
host:
workdir_parent: /home/runner/work
cache:
enabled: true
dir: /home/runner/cache在八个内核上容量设为二是有意为之:构建大多是串行的,两个并发作业各占四个核比四个作业争抢同一缓存完成得更快。使用 forge 生成的令牌进行注册:
cd /home/runner/.runner-cfg
sudo -u runner /usr/local/bin/act_runner register --no-interactive \
--instance https://forge.example.com --token <registration token> \
--name ci-ams --labels debian-13:host然后创建一个以普通用户身份运行的单元:
[Unit]
Description=Actions runner
After=network-online.target
[Service]
User=runner
WorkingDirectory=/home/runner/.runner-cfg
ExecStart=/usr/local/bin/act_runner daemon --config /home/runner/.runner-cfg/config.yaml
Restart=always
Environment=HOME=/home/runner
Environment=XDG_RUNTIME_DIR=/run/user/3001
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target将 runner 账号的真实 uid 替换到 XDG_RUNTIME_DIR 中;id -u runner 会打印它。Rootless podman 需要该目录存在,这正是第一步中 linger 设置所保证的。
systemctl daemon-reload && systemctl enable --now act-runner5. 构建并推送的工作流
在仓库中,位于 .forgejo/workflows/image.yaml:
on:
push:
branches: [main]
jobs:
image:
runs-on: debian-13
steps:
- uses: actions/checkout@v4
- name: Build
run: |
buildah bud --layers --format oci -t app:${{ github.sha }} .
- name: Push
run: |
buildah login -u ci -p ${{ secrets.REGISTRY_PASSWORD }} registry.example.com
buildah push app:${{ github.sha }} docker://registry.example.com/app:${{ github.sha }}
buildah push app:${{ github.sha }} docker://registry.example.com/app:latest--layers 开启层缓存,这是每次提交都重建依赖与仅在依赖更改时重建之间的区别。
验证
单元启动后几秒内,runner 应出现在 forge 的 runner 列表中,显示为在线。然后推送一个提交,并从机器上观察作业:
journalctl -fu act-runner作业完成后,确认镜像确实到达而不是仅仅报告成功:
skopeo inspect --creds ci:<password> docker://registry.example.com/app:latest | head -20
skopeo list-tags --creds ci:<password> docker://registry.example.com/app您需要摘要、层列表和两个标签。现在证明它能运行,如果您手头有另一台机器,可以在那上面测试:
podman run --rm registry.example.com/app:latest --version最后,验证整个构建所依赖的主张。在作业运行时,查看谁拥有这些进程:
ps -eo user,pid,comm | grep -E "buildah|podman" | head
sudo -u runner podman info --format "{{.Host.Security.Rootless}}"每个进程都属于 runner,安全检查回答 true。管道中没有进程持有 root,这意味着受损的构建脚本只能获得普通账号和命名空间,而非您的 registry 密钥和 hypervisor。
后续
如果不删除旧标签,registry 存储将无限增长,因此一旦您确定了保留规则,请每周定时运行 registry garbage-collect。如果构建成为瓶颈而不是测试,对比页面 显示了升级一档后的样子;更多核心的帮助远不如人们预期的那么大,而更快核心的帮助则大得多。