Aller au contenu
CI/CD & Automatisation medium

Runners éphémères GitHub Actions

16 min de lecture

Les runners éphémères sont créés à la demande et détruits après chaque job. Ce pattern élimine les risques de persistence de données entre jobs et garantit un environnement propre à chaque exécution.

  • Comprendre pourquoi un runner jetable est plus sûr qu'un runner persistant
  • Activer le mode --ephemeral du runner GitHub
  • Mettre en place l'auto-scaling avec ARC (Kubernetes) ou des VM cloud
  • Conteneuriser un runner éphémère avec Docker
  • Gérer le pool et compenser l'absence de cache local

Ces trois problèmes n'ont pas la même gravité, et le troisième contient les deux autres. Un runner persistant conserve son disque et sa mémoire d'un job à l'autre : un job malveillant peut donc y déposer un binaire, modifier le PATH de l'utilisateur de service ou installer un hook Git qui s'exécutera dans le job du projet voisin. La latéralisation entre équipes partageant le même pool est le scénario réel derrière ce pattern.

  1. Données résiduelles : fichiers, caches, credentials d'un job précédent
  2. Drift de configuration : l'environnement change au fil du temps
  3. Surface d'attaque : un job malveillant peut compromettre les suivants

Le gain se résume à une propriété : l'état ne survit pas au job. Cela ferme d'un coup toute la classe d'attaques décrite ci-dessus, mais cela vous prive aussi du cache local et des outils préinstallés que vous aviez peut-être accumulés à la main sur vos runners. Ce n'est donc pas une amélioration gratuite : c'est un déplacement du travail vers l'image ou vers un cache distant.

  1. Isolation parfaite : comme les runners GitHub-hosted
  2. Reproductibilité : même environnement à chaque exécution
  3. Sécurité : pas de persistence entre jobs

Le runner GitHub supporte nativement le mode éphémère :

Fenêtre de terminal
./config.sh --url https://github.com/OWNER/REPO \
--token TOKEN \
--ephemeral

Comportement :

  • Le runner accepte un seul job
  • Après exécution, il se désenregistre automatiquement
  • La VM/container peut être détruite

Créer un runner à la main pour chaque job n'est pas tenable. L'auto-scaling provisionne et détruit les runners automatiquement selon la charge.

Attention à un point de vocabulaire qui prête à confusion : ARC comporte deux modes, et un seul est supporté par GitHub. Le mode actuel repose sur les runner scale sets, installés par les charts Helm gha-runner-scale-set-controller et gha-runner-scale-set du dépôt actions/actions-runner-controller ; leurs runners sont éphémères par conception, il n'y a rien à activer. Le mode historique, celui des ressources RunnerDeployment et HorizontalRunnerAutoscaler en actions.summerwind.dev, est désormais qualifié de legacy par le projet et n'est plus maintenu que par la communauté.

Installation du mode supporté, avec un jeu de runners nommé arc-runner-set :

Fenêtre de terminal
helm install arc \
--namespace arc-systems --create-namespace \
--version 0.14.2 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
helm install arc-runner-set \
--namespace arc-runners --create-namespace \
--version 0.14.2 \
--set githubConfigUrl="https://github.com/OWNER/REPO" \
--set githubConfigSecret.github_token="$GITHUB_TOKEN" \
--set minRunners=0 --set maxRunners=10 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Le nom du jeu de runners devient directement la valeur de runs-on dans vos workflows. Vérifiez le déploiement avec kubectl get pods -n arc-systems, puis kubectl get autoscalingrunnerset -n arc-runners.

Le manifeste ci-dessous illustre le mode legacy, que vous rencontrerez encore sur des clusters existants. Ne le déployez pas sur une nouvelle installation : il est conservé ici pour que vous sachiez le reconnaître, et le champ ephemeral: true y est indispensable puisque ce mode ne l'active pas seul.

# runner-deployment.yaml (mode legacy, community-maintained)
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: ephemeral-runners
spec:
replicas: 1
template:
spec:
ephemeral: true
repository: owner/repo
labels:
- self-hosted
- linux
- ephemeral
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: ephemeral-runners-autoscaler
spec:
scaleTargetRef:
name: ephemeral-runners
minReplicas: 0
maxReplicas: 10
metrics:
- type: TotalNumberOfQueuedAndInProgressWorkflowRuns
repositoryNames:
- owner/repo

Hors Kubernetes, le principe reste le même mais vous portez vous-même le cycle de vie : une VM démarre, s'enregistre, exécute un job, puis se supprime. Les deux scripts qui suivent illustrent ces deux moitiés. Le point sensible n'est pas le scaling mais le jeton d'enregistrement : il est éphémère (une heure) et s'obtient par l'API, ce qui évite de figer un secret dans l'image.

Script de scaling avec AWS :

launch-ephemeral-runner.sh
#!/bin/bash
# Créer une VM
INSTANCE_ID=$(aws ec2 run-instances \
--image-id ami-xxxxx \
--instance-type t3.medium \
--user-data file://runner-init.sh \
--query 'Instances[0].InstanceId' \
--output text)
echo "Launched: $INSTANCE_ID"

Script d'initialisation :

#!/bin/bash
# runner-init.sh (user-data)
set -euo pipefail
# Télécharger le runner et vérifier son empreinte avant extraction.
# La somme SHA-256 est publiée par le projet dans les notes de la release.
RUNNER_VERSION=2.336.0
RUNNER_SHA256=04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d
cd /opt
curl -fsSL -o actions-runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
echo "${RUNNER_SHA256} actions-runner.tar.gz" | sha256sum --check -
tar xzf actions-runner.tar.gz
# Obtenir un token d'enregistrement (via API GitHub)
TOKEN=$(curl -fsS -X POST \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/OWNER/REPO/actions/runners/registration-token \
| jq -r '.token')
# Configurer en mode éphémère
./config.sh --url https://github.com/OWNER/REPO \
--token $TOKEN \
--ephemeral \
--unattended \
--labels ephemeral,linux
# Exécuter (va s'arrêter après un job)
./run.sh
# Auto-destruction
aws ec2 terminate-instances --instance-ids $(curl -s http://169.254.169.254/latest/meta-data/instance-id)

Sans Kubernetes, Docker suffit à obtenir des runners jetables : un conteneur par job, détruit à la fin de l'exécution.

L'image ci-dessous embarque le binaire du runner mais aucun secret : le jeton arrive à l'exécution par variable d'environnement, sinon il resterait lisible dans une couche de l'image par quiconque peut la tirer. Notez l'épinglage de l'image de base par digest : un tag comme 24.04 est mutable et le registre peut republier autre chose derrière, ce qui ruinerait la reproductibilité recherchée avec des runners jetables.

Dockerfile.runner
# Image de base épinglée par digest : un tag comme 24.04 est mutable.
# Relevez le digest courant avec `docker manifest inspect ubuntu:24.04`.
FROM ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
RUN apt-get update && apt-get install -y \
curl jq git \
&& rm -rf /var/lib/apt/lists/*
# Télécharger le runner, vérifier son empreinte, puis extraire.
# La somme SHA-256 est publiée dans les notes de la release actions/runner.
ARG RUNNER_VERSION=2.336.0
ARG RUNNER_SHA256=04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d
RUN set -eux; \
curl -fsSL -o /tmp/runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"; \
echo "${RUNNER_SHA256} /tmp/runner.tar.gz" | sha256sum --check -; \
mkdir /runner; \
tar xzf /tmp/runner.tar.gz -C /runner; \
rm /tmp/runner.tar.gz
WORKDIR /runner
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh
#!/bin/bash
./config.sh --url $REPO_URL \
--token $RUNNER_TOKEN \
--ephemeral \
--unattended \
--labels docker,ephemeral
./run.sh
# Le container s'arrête après un job

Ce fichier sert à lancer plusieurs conteneurs d'un coup sur une même machine. La ligne qui compte est restart: "no" : sans elle, Docker relancerait le conteneur dès qu'il s'arrête, et vous obtiendriez un runner qui se réenregistre en boucle, exactement l'inverse du comportement recherché. Chaque conteneur doit recevoir son propre jeton d'enregistrement, un jeton ne valant que pour un enregistrement.

docker-compose.yml
services:
runner:
build:
context: .
dockerfile: Dockerfile.runner
environment:
- REPO_URL=https://github.com/owner/repo
- RUNNER_TOKEN=${RUNNER_TOKEN}
restart: "no" # Ne pas redémarrer (éphémère)
deploy:
replicas: 3

Un pool de runners éphémères se pilote : trouver l'équilibre entre réactivité (éviter les cold starts) et coût (ne pas garder trop de runners inactifs).

Pour éviter les cold starts, maintenez un pool minimum :

# Kubernetes HPA
spec:
minReplicas: 2 # Toujours 2 runners prêts
maxReplicas: 20 # Scale jusqu'à 20 si nécessaire

Sans supervision, un pool éphémère échoue silencieusement : les jobs restent en file et personne ne le voit avant qu'un développeur ne s'en plaigne. La métrique à surveiller en premier n'est pas le nombre de runners actifs mais l'attente en file, car c'est elle qui indique que le plafond de scaling est atteint ou que l'enregistrement échoue.

# Prometheus metrics avec ARC
- job_name: 'actions-runner-controller'
static_configs:
- targets: ['actions-runner-controller-metrics:8080']

Métriques utiles :

  • actions_runner_controller_running_runners : runners actifs
  • actions_runner_controller_pending_runners : en attente de job
  • actions_runner_controller_registered_runners : total enregistrés

Côté workflow, rien ne change ou presque : vous ciblez vos runners par leurs labels dans runs-on. Le détail qui compte est la séparation en deux jobs, build puis deploy : chacun obtient un runner neuf, donc le job de déploiement ne peut hériter d'aucun fichier ni d'aucune variable laissés par le build. C'est le bénéfice de sécurité recherché, et c'est aussi pourquoi tout artefact à transmettre entre les deux doit passer explicitement par actions/upload-artifact.

name: Build with Ephemeral Runner
on:
push:
branches: [main]
# Aucun droit par défaut : chaque job demande le minimum
permissions: {}
# Annule l'exécution précédente encore en cours sur la même branche :
# inutile de mobiliser des runners pour un commit déjà remplacé.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Compiler l'application
runs-on: [self-hosted, ephemeral, linux]
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build
run: |
npm ci
npm run build
# Pas besoin de cleanup : le runner sera détruit
deploy:
name: Déployer en production
needs: build
runs-on: [self-hosted, ephemeral, linux]
timeout-minutes: 10
permissions:
contents: read
# Nouveau runner frais pour le déploiement
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./deploy.sh

Le modèle éphémère a un prix : pas de cache local, des cold starts, un coût d'infrastructure variable. Voici les points à anticiper.

Ces ordres de grandeur ne sont pas des mesures de votre plateforme : traitez-les comme un classement, pas comme des valeurs cibles. Le rapport entre les lignes est ce qui compte, et il explique le choix par défaut : le conteneur l'emporte tant que le job n'exige pas une isolation au niveau du noyau, cas dans lequel la VM redevient nécessaire malgré son démarrage cinq à dix fois plus lent.

MéthodeCold start
Container Docker5-15s
VM cloud30-90s
Kubernetes pod10-30s

Avec des runners éphémères, le cache local n'existe pas. Options :

  1. actions/cache : cache sur GitHub Storage
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
  1. Registry de cache : images Docker avec dépendances pré-installées
container:
image: myregistry.com/node-with-deps:latest
  1. Volume partagé : NFS ou EFS monté sur les runners

Le coût d'un pool éphémère dépend surtout du grain de facturation de votre fournisseur. Facturé à la seconde, détruire une VM après chaque job coûte à peu près la même chose qu'un pool permanent bien dimensionné. Facturé à l'heure entamée, chaque job de trois minutes vous coûte une heure, et le calcul s'inverse complètement.

  • Plus de VMs créées/détruites = plus de coût si facturation à l'heure
  • Optimiser avec des instances spot/preemptible
  • Équilibrer entre pool minimum et coût

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Un runner éphémère accepte un seul job puis se désenregistre, l'isolation des runners hosted, sur votre infra.
  • Le mode natif s'active avec ./config.sh --ephemeral : pas d'outil tiers requis.
  • En Kubernetes, Actions Runner Controller gère création, scaling et destruction automatiquement.
  • Sans cache local, compensez avec actions/cache, des images pré-garnies ou un volume partagé.
  • Gardez un pool minimum chaud pour absorber les cold starts sans exploser le coût.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn