Douze constructions

Un runner CI qui construit des images conteneur sans root

Un runner Actions auto-hébergé sur une instance Ryzen, construisant des images OCI sans root avec Buildah et les poussant vers un registre privé sur la même machine.

Ce que cela construit

Un runner CI attaché à votre propre forge, qui récupère un dépôt, construit une image conteneur sans démon et sans root, et pousse le résultat vers un registre privé tournant sur la même instance. Aucun socket privilégié n'est monté nulle part, et rien dans le pipeline ne s'exécute avec l'uid zéro.

La plupart des configurations CI auto-hébergées résolvent la construction d'images en montant le démon conteneur de l'hôte dans le job. Cela fonctionne et cela donne à chaque pipeline, y compris celui qu'une personne a ouvert via une pull request, le contrôle complet de la machine. Buildah en mode rootless fait le même travail sans aucun de ces problèmes, et sur des cœurs dédiés, ce n'est pas plus lent.

Avant de commencer

  • Un R-8. Les builds d'images sont la chose la plus liée au cœur unique que la plupart des équipes exécutent, et Zen 5 est le plus rapide par cœur que nous vendons. Quatre cents gigaoctets de NVMe contiennent un très grand nombre de couches.
  • Une forge que vous exécutez déjà et qui parle le protocole Actions, avec la permission de créer un jeton d'enregistrement de runner.
  • Noms d'hôtes : ci.example.com pour le runner et registry.example.com pour le registre.

1. Un utilisateur non privilégié avec une plage d'espaces de noms

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

Ces deux plages sont ce qui rend les conteneurs rootless possibles : le compte runner possède soixante-cinq mille identifiants subordonnés, donc un processus qui croit être root dans un conteneur est mappé vers un identifiant non privilégié à l'extérieur. Linger maintient la session utilisateur active pour que les unités systemd de ce compte survivent à la déconnexion.

Node est installé parce que la plupart des actions réutilisables sont en JavaScript et que le runner les exécute sur l'hôte dans cette configuration. Découvrir cela à l'étape du checkout est un détour courant de dix minutes.

2. Stockage rootless sur le 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}}"

Cette dernière commande devrait répondre overlay true. Un pilote vfs à la place signifie que fuse-overlayfs est manquant, et que vfs copie chaque couche en entier à chaque build, ce qui transforme un pipeline de quatre-vingt-dix secondes en un pipeline de six minutes.

3. Un registre privé

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

Liez le registre à loopback et laissez nginx posséder tout ce qui est tourné vers l'extérieur, y compris l'authentification. Dans /etc/docker/registry/config.yml :

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

Le registre lui-même ne porte aucune information d'identification car il ne reçoit jamais de requête qui n'est pas déjà passée par le proxy. Un seul endroit pour vérifier un mot de passe vaut mieux que deux endroits qui peuvent ne pas être d'accord.

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 supprime la limite de téléversement. Laissez la valeur par défaut de nginx en place et chaque couche de plus d'un mégaoctet échoue avec un 413 environ aux deux tiers d'un push, ce qui est un après-midi mémorable.

systemctl enable --now docker-registry nginx

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

Modifiez le fichier généré pour que les jobs s'exécutent sur l'hôte plutôt que dans un conteneur, car Buildah fournit déjà l'isolation et l'imbrication n'ajoute que de la complexité :

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

La capacité de deux sur huit cœurs est délibérée : les builds sont largement sérialisés, et deux jobs concurrents avec quatre cœurs chacun se terminent plus tôt que quatre jobs se battant pour le même cache. Enregistrez-vous auprès de votre forge avec le jeton qu'elle a généré :

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

Puis une unité, s'exécutant sous le compte non privilégié :

[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

Remplacez l'uid réel du compte runner dans XDG_RUNTIME_DIR ; id -u runner l'affiche. Podman rootless a besoin que ce répertoire existe, ce que le paramètre linger de l'étape un garantit.

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

5. Un workflow qui construit et pousse

Dans le dépôt, à .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 active la mise en cache des couches, ce qui fait la différence entre reconstruire vos dépendances à chaque commit et les reconstruire lorsqu'elles changent.

Vérifiez-le

Le runner devrait apparaître en ligne dans la liste des runners de la forge quelques secondes après le démarrage de l'unité. Ensuite, poussez un commit et observez le job depuis la machine :

journalctl -fu act-runner

Lorsqu'il se termine, confirmez que l'image est réellement arrivée plutôt que de simplement signaler un succès :

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

Vous voulez le digest, la liste des couches et les deux tags. Maintenant, prouvez qu'elle s'exécute, sur une autre machine si vous en avez une sous la main :

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

Enfin, la revendication sur laquelle repose tout ce build. Pendant qu'un job est en cours, regardez à qui appartiennent les processus :

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

Chaque processus appartient à runner, et la vérification de sécurité répond true. Rien dans le pipeline ne détient root, ce qui signifie qu'un script de build compromis obtient un compte non privilégié et un espace de noms, et non vos clés de registre et votre hyperviseur.

Ensuite

Le stockage du registre croît sans limite à moins que quelque chose ne supprime les anciens tags, donc exécutez registry garbage-collect sur une minuterie hebdomadaire une fois que vous avez une règle de conservation en laquelle vous croyez. Si les builds deviennent le goulot d'étranglement plutôt que les tests, la page de comparaison montre à quoi ressemble la taille supérieure ; plus de cœurs aide beaucoup moins que ce que la plupart des gens attendent, et des cœurs plus rapides aident beaucoup plus.

Prêt quand vous l'êtes

Choisissez une ville. Choisissez une taille. Payez en crypto.

Aucun formulaire sur votre identité, pas d'attente d'approbation humaine, pas d'appel téléphonique pour vérifier quoi que ce soit. La facture est réglée et les identifiants arrivent dans votre boîte mail.