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.comvoor de runner enregistry.example.comvoor 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 runnerDie 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.comBind 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:5000Het 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 nginx4. 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.yamlBewerk 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/cacheCapaciteit 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:hostDan 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.targetSubstitueer 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-runner5. 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-runnerAls 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/appJe 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 --versionTot 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.