十二个构建

一个无需 root 即可构建容器镜像的 CI 运行器

在 Ryzen 实例上自托管的 Actions 运行器,使用 Buildah 以 rootless 方式构建 OCI 镜像,并推送到同一台机器上的私有仓库。

构建内容

一个连接到您自有 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:5000

Registry 本身不携带凭据,因为它从不接收未经代理处理的请求。一个检查密码的地方比两个可能不一致的地方更好。

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 nginx

4. 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-runner

5. 构建并推送的工作流

在仓库中,位于 .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。如果构建成为瓶颈而不是测试,对比页面 显示了升级一档后的样子;更多核心的帮助远不如人们预期的那么大,而更快核心的帮助则大得多。

随时恭候

选择城市,选择大小,用加密货币支付。

无需填写关于您的身份信息的表格,无需等待人工审批,无需电话验证。账单结清后,凭证将发送到您的邮箱。