Twaalf builds

Een CI-runner die containerimages bouwt zonder root

Een self-hosted Actions-runner op een Ryzen-instantie, die OCI-images rootless bouwt met Buildah en ze pusht naar een privéregister op dezelfde machine.

Wat dit bouwt

Een CI-runner die aan je eigen forge hangt, een repository uitcheckt, een containerimage bouwt zonder daemon en zonder root, en het resultaat naar een privaat registry op dezelfde instance pusht. Er wordt nergens een privileged socket gemount en niets in de pipeline draait als uid zero.

De meeste self-hosted CI-opstellingen lossen imagebuilding op door de containerdaemon van de host in de job te mounten. Dat werkt, maar het geeft elke pipeline, inclusief die waarvoor iemand een pull request opende, volledige controle over de machine. Buildah in rootless mode doet hetzelfde zonder dat alles, en op dedicated cores is het niet langzamer.

Voordat je begint

  • Een R-8. Imagebuilds zijn het meest single-core-gebonden wat de meeste teams draaien, en Zen 5 is per core het snelste dat we verkopen. Vierhonderd gigabyte aan NVMe biedt plaats aan een groot aantal lagen.
  • Een forge die je al draait en die het Actions-protocol spreekt, en toestemming om er een runnerregistratietoken in aan te maken.
  • Hostnamen: ci.example.com voor de runner en registry.example.com voor het registry.

1. Een unprivileged gebruiker met een namespace-bereik

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

Die twee bereiken maken rootless containers mogelijk: de runner-account bezit vijferenzestigduizend ondergeschikte id's, dus een proces dat binnen een container denkt dat het root is, wordt buiten de container naar een unprivileged id gemapt. Lingering houdt de gebruikerssessie in leven zodat systemd-units onder die account het uitloggen overleven.

Node wordt geïnstalleerd omdat de meeste herbruikbare actions JavaScript zijn en de runner die in deze configuratie op de host uitvoert. Dat bij de checkout-stap ontdekken is een bekend tien minuten durend omweggetje.

2. Rootless opslag op de NVMe

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}}"

Dat laatste commando zou overlay true moeten antwoorden. Een vfs-driver in plaats daarvan betekent dat fuse-overlayfs ontbreekt, en vfs kopieert elke laag in zijn geheel bij elke build, wat een pijplijn van negentig seconden in een van zes minuten verandert.

3. Een privaat registry

apt install -y docker-registry
htpasswd -c /etc/docker/registry/htpasswd ci
certbot certonly --standalone -d registry.example.com

Bind het registry aan loopback en laat nginx alles bezitten wat naar buiten gericht is, inclusief authenticatie. In /etc/docker/registry/config.yml:

version: 0.1
storage:
  filesystem:
    rootdirectory: /srv/registry
  delete:
    enabled: true
http:
  addr: 127.0.0.1:5000

Het registry zelf draagt geen credentials omdat het nooit een verzoek ontvangt dat niet al door de proxy is gegaan. Eén plek om een wachtwoord te controleren is beter dan twee plekken die het oneens kunnen zijn.

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 verwijdert de uploadlimiet. Laat de nginx-standaard staan en elke laag boven een megabyte faalt met een 413 ongeveer twee derde van de weg door een push, wat een memorabele middag oplevert.

systemctl enable --now docker-registry nginx

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

Bewerk het gegenereerde bestand zodat jobs op de host draaien in plaats van in een container, omdat Buildah al voor de isolatie zorgt en het nesten van de twee niets anders toevoegt dan complexiteit:

runner:
  capacity: 2
  timeout: 1h
  labels:
    - "debian-13:host"
host:
  workdir_parent: /home/runner/work
cache:
  enabled: true
  dir: /home/runner/cache

Capaciteit twee op acht cores is bewust: builds zijn grotendeels serieel, en twee gelijktijdige jobs met vier cores elk zijn sneller klaar dan vier jobs die over dezelfde cache vechten. Registreer tegen je forge met het token dat die genereerde:

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

Dan een unit, draaiend als de unprivileged account:

[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

Substitueer de echte uid van de runner-account in XDG_RUNTIME_DIR; id -u runner print die. Rootless podman heeft die map nodig om te bestaan, wat de linger-instelling in stap één garandeert.

systemctl daemon-reload && systemctl enable --now act-runner

5. Een workflow die bouwt en pusht

In de repository, op .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 zet laagcache aan, wat het verschil is tussen je dependencies bij elke commit herbouwen en ze herbouwen wanneer ze veranderen.

Verifieer het

De runner zou binnen enkele seconden na het starten van de unit als online in de runnerslijst van de forge moeten verschijnen. Push dan een commit en bekijk de job vanaf de machine:

journalctl -fu act-runner

Als die klaar is, bevestig dan dat het image echt is aangekomen in plaats van alleen succes te melden:

skopeo inspect --creds ci:<password> docker://registry.example.com/app:latest | head -20
skopeo list-tags --creds ci:<password> docker://registry.example.com/app

Je wilt de digest, de lagenlijst en beide tags. Bewijs nu dat het draait, op een andere machine als je die bij de hand hebt:

podman run --rm registry.example.com/app:latest --version

Tot slot, de bewering waarop deze hele build rust. Terwijl een job draait, kijk wie de processen bezit:

ps -eo user,pid,comm | grep -E "buildah|podman" | head
sudo -u runner podman info --format "{{.Host.Security.Rootless}}"

Elk proces behoort aan runner, en de beveiligingscontrole antwoordt true. Niets in de pipeline heeft root, wat betekent dat een gecompromitteerd buildscript een unprivileged account en een namespace krijgt, niet je registry-sleutels en je hypervisor.

Achteraf

Registryopslag groeit onbeperkt tenzij iets oude tags verwijdert, dus draai registry garbage-collect op een wekelijkse timer zodra je een retentieregel hebt waar je in gelooft. Als builds de bottleneck worden in plaats van de tests, laat de vergelijkinspagina zien hoe het volgende formaat eruitziet; meer cores helpen veel minder dan de meeste mensen verwachten, en snellere veel meer.

Klaar wanneer jij dat bent

Kies een stad. Kies een formaat. Betaal in munt.

Geen formulieren over wie je bent, geen wachten op een mens die je goedkeurt, geen telefoontje om iets te verifiëren. De factuur wordt betaald en de inloggegevens belanden in je inbox.