Aller au contenu
Sylvain Joffraud l4w
Retour

Runners GitHub : `_temp` en tmpfs, `IOWeight` inutile

Un runner GitHub self-hosted qui ralentit la prod, c’est mon cas depuis que la CI tourne sur le même serveur que Nextcloud, la messagerie et Coolify : le goulot n’est pas le CPU mais le disque, deux HDD 7 200 tr/min en RAID1 avec une pression I/O d’environ 45 % au repos. Mon premier réflexe, baisser la priorité disque du runner avec IOWeight= dans systemd, ne sert à rien : l’ordonnanceur du noyau est mq-deadline, qui ignore ces poids (il faut BFQ). Ce qui marche : sortir les fichiers temporaires des jobs du disque, avec _work/_temp en tmpfs, monté côté hôte.

Ce billet prolonge le passage des runners de Docker à systemd et la mise en place des runners self-hosted : même utilisateur actions-runner, même binaire officiel, ici en version 2.337.0.

Un runner = un job à la fois

Le point de départ n’était pas le disque, mais la file d’attente. Un runner GitHub Actions n’exécute qu’un job à la fois : sur un dépôt dont la CI lance plusieurs jobs, ils s’enchaînent au lieu de tourner ensemble. La parade est simple : un deuxième runner pour le même dépôt, dans un dossier séparé (/home/deploy/actions-runner-2), avec le même label. Aucun workflow à modifier, GitHub répartit les jobs.

Mais deux jobs en parallèle, ce sont plus d’I/O simultanées sur des disques déjà occupés. Avant d’ajouter le deuxième runner, il fallait donc décider qui a la priorité : la prod, évidemment.

Fausse piste : IOWeight= dans le drop-in systemd

Le réflexe systemd : un drop-in qui baisse le poids CPU et disque du service runner. CPUWeight= et Nice= jouent sur le CPU, IOWeight= (cgroup v2) ou IOSchedulingClass= / IOSchedulingPriority= (ioprio) sur le disque.

Le problème, c’est que les priorités I/O sont appliquées par l’ordonnanceur disque, pas par systemd. Seul BFQ (Budget Fair Queueing) tient compte des poids cgroup et des niveaux ioprio. Avec mq-deadline, la directive est acceptée sans un avertissement, et le noyau l’ignore. Pour vérifier :

cat /sys/block/sda/queue/scheduler   # l'ordonnanceur actif est entre crochets
cat /proc/pressure/io                # pression disque (PSI), à suivre pendant une CI chargée

Sur ce serveur, c’est mq-deadline. Je n’ai pas basculé en BFQ : le changement concerne tout le disque, donc toute la prod.

Sur un disque piloté par mq-deadline, IOWeight= et IOSchedulingClass= ne protègent rien : systemd les accepte, le noyau les ignore.

Deuxième trou dans la raquette : même avec BFQ, les priorités du service ne couvriraient qu’une partie du job. Le Postgres d’un bloc services: et les docker build sont lancés par dockerd, dans des cgroups gérés par Docker, hors de celui du runner.

Pourquoi _temp en tmpfs et pas TemporaryFileSystem= ?

Si on ne peut pas prioriser les écritures, on peut en supprimer une partie. Le dossier _work/_temp reçoit les fichiers temporaires de chaque job : scripts des étapes run:, fichiers intermédiaires des actions. Il est vidé entre deux jobs : rien à perdre à le mettre en RAM.

systemd propose TemporaryFileSystem=, mais ce tmpfs vit dans l’espace de montage privé du service. Quand un job utilise services: ou container:, c’est dockerd qui monte _temp dans les conteneurs, et dockerd ne voit pas ce montage : il monterait le dossier vide resté sur disque. Le tmpfs doit donc être monté côté hôte, via /etc/fstab :

tmpfs /home/deploy/actions-runner/_work/_temp tmpfs size=4G,uid=1002,gid=1002,mode=0755,nodev,nosuid,nofail 0 0

uid/gid donnent le dossier à l’utilisateur actions-runner, nofail évite qu’un montage raté bloque le démarrage du serveur. En contrepartie, le service runner doit refuser de démarrer sans son tmpfs, sinon il écrirait de nouveau sur le disque sans que je m’en aperçoive.

Le drop-in final

# /etc/systemd/system/actions.runner.my-org-app.srv-a-app.service.d/priorities.conf
# IOSchedulingClass/Priority : effet réel seulement avec l'ordonnanceur BFQ (actuellement mq-deadline)
[Unit]
RequiresMountsFor=/home/deploy/actions-runner/_work/_temp

[Service]
CPUWeight=20
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

RequiresMountsFor= fait dépendre le service du montage. CPUWeight=20 (100 par défaut) et Nice=10 laissent la main à la prod côté CPU. J’ai laissé les lignes ioprio, avec un commentaire qui dit qu’elles sont inopérantes : elles serviront le jour où le disque passera en BFQ.

Sur le runner existant, le script d’installation vérifie d’abord qu’aucun processus Runner.Worker ne tourne (un job en cours), arrête le service, vide _temp, monte le tmpfs, pose le drop-in et relance. Le deuxième runner reçoit le même traitement avant son premier démarrage.

Lessons learned

Questions fréquentes

Pourquoi <code>IOWeight=</code> ne change rien sur mon service systemd ?
IOWeight= et les niveaux ioprio (IOSchedulingClass=, IOSchedulingPriority=) ne sont appliqués que par l’ordonnanceur disque BFQ. Avec mq-deadline ou none, systemd accepte la directive sans erreur, mais le noyau l’ignore : aucune priorité n’est réellement appliquée au service. L’ordonnanceur actif est celui entre crochets dans /sys/block/sda/queue/scheduler.
Comment faire tourner deux jobs en parallèle sur un runner GitHub self-hosted ?
Un runner GitHub Actions n’exécute qu’un job à la fois. Pour paralléliser, on enregistre un deuxième runner sur la même machine, dans un dossier séparé, avec son propre service systemd et le même label. GitHub répartit alors les jobs entre les deux runners sans aucune modification des workflows.
Pourquoi ne pas utiliser <code>TemporaryFileSystem=</code> pour le dossier <code>_temp</code> du runner ?
TemporaryFileSystem= crée un tmpfs dans l’espace de montage privé du service. Les conteneurs d’un job (services:, container:) sont lancés par dockerd, qui ne voit pas ce montage : il monterait le dossier vide sur disque. Un tmpfs déclaré dans /etc/fstab, monté côté hôte, est vu à l’identique par le runner et par dockerd.
Les priorités systemd du runner s’appliquent-elles aux conteneurs Docker du job ?
Non. Le Postgres d’un bloc services: ou un docker build sont créés par dockerd et placés dans des cgroups gérés par Docker, hors du cgroup du service runner. CPUWeight=, Nice= ou IOWeight= posés dans le drop-in du runner ne les concernent donc pas.

Partager cet article :

Article précédent
Svelte 5 `$state` et IndexedDB : `DataCloneError`
Article suivant
Montant d’API en euros ou en centimes : un virement ×100