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.comper il runner eregistry.example.comper 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 runnerQuei 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.comAssocia 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:5000Il 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 nginx4. 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.yamlModifica 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/cacheCapacità 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:hostPoi 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.targetSostituisci 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-runner5. 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-runnerQuando 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/appVuoi 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 --versionInfine, 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ù.