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=etIOSchedulingClass=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
- Un runner GitHub self-hosted n’exécute qu’un job à la fois. Pour paralléliser, on ajoute un deuxième runner avec le même label, sans toucher aux workflows.
IOWeight=et ioprio ne font rien sans l’ordonnanceur BFQ. Vérifier/sys/block/*/queue/scheduleravant de régler des priorités disque.- Les conteneurs d’un job échappent aux priorités du service runner. Postgres de
services:etdocker buildtournent dans les cgroups de Docker, pas dans celui du runner. - Un tmpfs partagé avec Docker se monte côté hôte, pas avec
TemporaryFileSystem=. dockerd ne voit pas l’espace de montage privé d’un service. - Supprimer des écritures vaut mieux que les prioriser.
_tempen tmpfs retire du disque une partie des I/O de chaque job, quel que soit l’ordonnanceur.