Dodici build

Un runner CI che builda immagini container senza root

Un runner Actions self-hosted su un'istanza Ryzen, che builda immagini OCI rootless con Buildah e le spinge a un registry privato sulla stessa macchina.

Cosa si costruisce

Un runner CI collegato alla propria forge, che fa il checkout di un repository, costruisce un'immagine container senza demone e senza root, e invia il risultato a un registro privato che gira sulla stessa istanza. Nessun socket privilegiato viene montato da nessuna parte, e nulla nella pipeline viene eseguito con uid zero.

La maggior parte delle configurazioni CI self-hosted risolve la creazione di immagini montando il demone container dell'host nel job. Funziona e consegna a ogni pipeline, inclusa quella contro cui qualcuno ha aperto una pull request, il controllo completo della macchina. Buildah in modalità rootless fa lo stesso lavoro senza tutto questo, e su core dedicati non è più lento.

Prima di iniziare

  • Un R-8. Le build di immagini sono la cosa più legata al single-core che la maggior parte dei team esegue, e Zen 5 è il più veloce per core che vendiamo. Quattrocento gigabyte di NVMe contengono molti layer.
  • Una forge che già esegui e che parli il protocollo Actions, e il permesso di creare un token di registrazione del runner al suo interno.
  • Hostname: ci.example.com per il runner e registry.example.com per il registro.

1. Un utente non privilegiato con un intervallo di namespace

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

Quei due intervalli sono ciò che rende possibili i container rootless: l'account runner possiede sessantacinquemila id subordinati, quindi un processo che crede di essere root all'interno di un container viene mappato a un id non privilegiato all'esterno. Il lingering mantiene viva la sessione utente, così le unità systemd sotto quell'account sopravvivono al logout.

Node è installato perché la maggior parte delle azioni riutilizzabili sono JavaScript e il runner le esegue sull'host in questa configurazione. Scoprirlo al passo del checkout è una deviazione comune di dieci minuti.

2. Storage rootless sul 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}}"

Quell'ultimo comando dovrebbe rispondere overlay true. Un driver vfs invece significa che fuse-overlayfs manca, e vfs copia ogni layer per intero a ogni build, trasformando una pipeline di novanta secondi in una di sei minuti.

3. Un registro privato

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

Associa il registro al loopback e lascia che nginx gestisca tutto ciò che guarda verso l'esterno, autenticazione inclusa. In /etc/docker/registry/config.yml:

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

Il registro stesso non porta credenziali perché non riceve mai una richiesta che non sia già passata attraverso il proxy. Un posto per controllare una password è meglio di due posti che possono essere in disaccordo.

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 rimuove il limite di upload. Lascia l'impostazione predefinita di nginx e ogni layer sopra un megabyte fallisce con un 413 circa a due terzi di un push, il che è un pomeriggio memorabile.

systemctl enable --now docker-registry nginx

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

Modifica il file generato in modo che i job vengano eseguiti sull'host piuttosto che all'interno di un container, perché Buildah fornisce già l'isolamento e il nesting dei due non aggiunge altro che complessità:

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

Capacità due su otto core è deliberata: le build sono in gran parte seriali, e due job concorrenti con quattro core ciascuno finiscono prima di quattro job che litigano sulla stessa cache. Registrati contro la tua forge con il token che ha generato:

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

Poi un'unità, eseguita come account non privilegiato:

[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

Sostituisci il reale uid dell'account runner in XDG_RUNTIME_DIR; id -u runner lo stampa. Podman rootless ha bisogno che quella directory esista, il che è garantito dall'impostazione linger del primo passo.

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

5. Un workflow che costruisce e invia

Nel repository, in .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 abilita la cache dei layer, che è la differenza tra ricostruire le dipendenze a ogni commit e ricostruirle quando cambiano.

Verifica

Il runner dovrebbe apparire online nell'elenco dei runner della forge entro pochi secondi dall'avvio dell'unità. Poi fai un push di un commit e osserva il job dalla macchina:

journalctl -fu act-runner

Quando finisce, conferma che l'immagine sia realmente arrivata piuttosto che limitarti a riportare il successo:

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

Vuoi il digest, l'elenco dei layer ed entrambi i tag. Ora dimostra che gira, su una macchina diversa se ne hai una a portata di mano:

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

Infine, l'affermazione su cui si basa tutta questa build. Mentre un job è in esecuzione, guarda a chi appartengono i processi:

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

Ogni processo appartiene a runner, e il controllo di sicurezza risponde true. Nulla nella pipeline detiene root, il che significa che uno script di build compromesso ottiene un account non privilegiato e un namespace, non le tue chiavi del registro e il tuo hypervisor.

Dopo

Lo storage del registro cresce senza limiti a meno che qualcosa non rimuova i vecchi tag, quindi esegui registry garbage-collect con un timer settimanale una volta che hai una regola di conservazione in cui credi. Se le build diventano il collo di bottiglia piuttosto che i test, la pagina di confronto mostra come appare il prossimo livello di dimensioni; più core aiutano molto meno di quanto la maggior parte si aspetti, e più veloci aiutano molto di più.

Pronto quando lo sei

Scegli una città. Scegli una dimensione. Paga in criptovaluta.

Nessun modulo su chi sei, nessuna attesa per l'approvazione di una persona, nessuna chiamata per verificare nulla. La fattura viene saldata e le credenziali arrivano nella tua casella di posta.