Des articles approfondis sur les technologies qui façonnent l'avenir.

Spinlocks eBPF et l'art du débogage noyau

Comment les bugs de spinlock eBPF apparaissent dans le noyau Linux, pourquoi ils sont si difficiles à trouver et ce qu'ils révèlent du modèle de sécurité d'eBPF.

Un cadenas lumineux qui tourne et crépite au milieu d'un paysage de circuits imprimés gelé

Un bug de spinlock dans eBPF ne se signale ni par une trace de pile ni par un message d'erreur. Il se manifeste par une machine qui ne répond plus. Pas de logs, pas d'avertissements, pas de dégradation progressive : juste un cœur de processeur qui tourne indéfiniment sur un verrou qui ne sera jamais libéré. Si vous avez de la chance, le watchdog se déclenche et vous obtenez un kernel panic avec une trace. Sinon, la machine se fige et vous devez la redémarrer à la main.

eBPF est censé être sûr. Il s'exécute dans le noyau, mais passe par un vérificateur qui prouve que les programmes se terminent, n'accèdent pas à une mémoire invalide et ne corrompent pas l'état du noyau. Alors comment les bugs de spinlock passent-ils à travers les mailles ? Parce que le vérificateur analyse les programmes un par un, alors que les bugs de spinlock naissent des interactions entre programmes, maps et décisions d'ordonnancement du noyau, des éléments qu'aucun vérificateur statique ne peut prédire entièrement.

À quoi servent les spinlocks eBPF

Les programmes eBPF s'exécutent en contexte noyau, souvent sur plusieurs CPU en même temps. Quand deux programmes eBPF doivent mettre à jour la même valeur dans une map (un compteur, une structure de données, une machine à états), il faut les synchroniser. eBPF fournit pour cela bpf_spin_lock et bpf_spin_unlock.

Un spinlock est le verrou le plus simple qui soit : on tente de l'acquérir et, s'il est déjà pris, on tourne dans une boucle serrée jusqu'à sa libération. Pas de mise en sommeil, pas de cession du processeur, pas de file d'attente. C'est adapté à eBPF, car les programmes eBPF s'exécutent dans des contextes où le sommeil est interdit : gestionnaires d'interruptions, softirqs, sections à préemption désactivée. Un mutex qui endormirait l'appelant ferait planter le noyau. Un spinlock se contente de brûler des cycles CPU jusqu'à ce que le verrou soit disponible.

struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}

Ça semble simple. Et pour des cas simples comme celui-ci (verrouiller, modifier une valeur, déverrouiller), ça fonctionne très bien. Les problèmes commencent quand les schémas de verrouillage se complexifient, ou quand les interactions entre programmes eBPF et le verrouillage propre au noyau créent des dépendances inattendues.

Comment les bugs de spinlock se manifestent

Le bug classique de spinlock est l'interblocage : le programme A détient le verrou 1 et tente d'acquérir le verrou 2, pendant que le programme B détient le verrou 2 et tente d'acquérir le verrou 1. Les deux tournent indéfiniment, chacun attendant l'autre. En contexte noyau, cela signifie que ces cœurs CPU sont perdus : ils ne exécuteront plus jamais rien d'autre.

Le vérificateur eBPF empêche certains de ces cas. Il rejette les programmes qui détiennent plusieurs spinlocks simultanément, ce qui élimine l'interblocage AB-BA classique. Mais il ne peut pas bloquer tous les scénarios, car certains naissent de la relation entre les spinlocks eBPF et le verrouillage propre au noyau.

Prenons ce scénario : un programme eBPF attaché à un tracepoint acquiert un spinlock sur une map. Pendant que le verrou est détenu, une interruption matérielle survient sur le même CPU. Le gestionnaire d'interruption exécute un autre programme eBPF qui tente d'acquérir le même spinlock. Interblocage : l'interruption ne peut pas se terminer tant qu'elle n'a pas le verrou, et le verrou ne peut pas être libéré tant que le programme interrompu ne reprend pas, ce qui ne peut pas arriver avant la fin de l'interruption.

Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck

Le noyau gère ce cas avec ses propres spinlocks en désactivant les interruptions pendant leur détention (spin_lock_irqsave). Les spinlocks eBPF font de même : bpf_spin_lock désactive la préemption et, à partir du noyau 5.1, désactive aussi les softirqs. Mais tous les contextes d'interruption ne sont pas couverts, et toutes les versions du noyau ne traitent pas ce point de la même façon. C'est là que se cachent les bugs les plus vicieux.

Le problème du débogage

Déboguer les problèmes de spinlock dans eBPF est difficile, et pour des raisons précises.

La reproduction est non déterministe. Les bugs de spinlock dépendent du timing : quel CPU exécute quel programme, quand les interruptions surviennent, combien de temps dure la section critique. Un test qui passe 999 fois peut se bloquer à la millième. Impossible de les reproduire de façon fiable en développement, car votre machine de développement n'a ni la même topologie CPU, ni la même fréquence d'interruptions, ni les mêmes profils de charge que la production.

Le mode de défaillance détruit les preuves. Quand un interblocage de spinlock se produit, les CPU concernés cessent d'exécuter quoi que ce soit, y compris l'infrastructure de logs et de monitoring qui permettrait de comprendre ce qui s'est passé. Si l'interblocage touche un seul CPU, la machine peut continuer à tourner tant bien que mal. Si plusieurs CPU se bloquent (ou si le CPU bloqué détient des ressources dont d'autres ont besoin), la machine entière se fige.

Les outils de débogage classiques ne suffisent pas. Vous ne pouvez pas attacher un débogueur au noyau pour inspecter l'état bloqué (si, avec KGDB, mais il faut une connexion série configurée avant le blocage). dmesg ne sert à rien, car plus rien n'y écrit. Les seules informations fiables proviennent de la sortie du détecteur de blocage (lockup detector), à condition qu'il se déclenche avant la mort complète de la machine.

Les stratégies qui fonctionnent vraiment

Malgré ces difficultés, on finit par trouver et corriger les bugs de spinlock eBPF. Voici comment.

Analyse de l'ordre des verrous. Avant de lancer quoi que ce soit, analysez le comportement de verrouillage de chaque programme eBPF. Quelles maps chaque programme accède-t-il ? Quels verrous acquiert-il ? Deux programmes qui touchent le même verrou peuvent-ils s'exécuter sur le même CPU (même tracepoint, ou tracepoint contre interruption) ? C'est une analyse manuelle fastidieuse, mais elle attrape les schémas d'interblocage les plus courants avant qu'ils ne surviennent.

Surveillance de la durée de détention des verrous. Instrumentez vos programmes eBPF pour mesurer le temps pendant lequel les spinlocks sont détenus. Un spinlock tenu plus de quelques microsecondes est un signal d'alarme : il élargit la fenêtre des interblocages liés aux interruptions et fait perdre du temps aux autres CPU, qui tournent à vide. Des sections critiques courtes sont à la fois plus rapides et plus sûres.

// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) {  // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}

Structures de données par CPU. Le meilleur bug de spinlock est celui qui ne peut pas arriver. Si chaque CPU travaille sur sa propre copie des données et que vous agrégez ensuite, il n'y a ni contention ni verrou nécessaire. BPF_MAP_TYPE_PERCPU_HASH et BPF_MAP_TYPE_PERCPU_ARRAY sont conçus exactement pour cela. Les compteurs, histogrammes et accumulateurs ont presque toujours besoin de spinlocks partagés.

Opérations atomiques plutôt que verrous. Pour les opérations simples, comme incrémenter un compteur ou comparer-et-échanger une valeur, __sync_fetch_and_add et les autres atomiques sont plus rapides et exempts d'interblocage. Vous n'avez pas besoin d'un spinlock pour incrémenter un compteur. Réservez les spinlocks aux opérations qui exigent réellement la mise à jour atomique de plusieurs champs.

Ce que cela révèle du modèle de sécurité d'eBPF

La sécurité d'eBPF est impressionnante, mais elle est nuancée. Le vérificateur garantit que chaque programme pris isolément est sûr : il se termine, n'accède pas à une mémoire invalide et ne corrompt pas l'état du noyau. Mais des programmes « sûrs » peuvent former un système dangereux lorsque leurs interactions créent des dépendances temporelles que le vérificateur ne peut pas analyser.

C'est une limite fondamentale de l'analyse statique. Le vérificateur examine chaque programme isolément. Il ignore quels autres programmes sont chargés, quelles maps ils partagent ou dans quels contextes noyau ils s'exécutent. Un programme qui acquiert un spinlock est « sûr » : il l'acquiert et le libère correctement. Mais savoir s'il reste sûr en combinaison avec tous les autres programmes eBPF chargés dépend des conditions d'exécution.

La communauté du noyau traite le problème progressivement. Des correctifs récents ont restreint les types de programmes eBPF autorisés à utiliser des spinlocks, la durée des sections critiques et les opérations interdites pendant la détention d'un verrou. Chaque restriction réduit la fenêtre de bugs, mais limite aussi ce que les programmes eBPF peuvent faire.

Règles pratiques pour le verrouillage eBPF

Après avoir déboguйé suffisamment de problèmes de spinlock, des schémas se dégagent pour les éviter.

  1. Privilégiez les maps par CPU. Si vos données peuvent être partitionnées par CPU puis agrégées en espace utilisateur, faites-le. Pas de verrou, pas de contention, pas d'interblocage. Cela couvre 80 % des cas d'usage : compteurs, journaux d'événements, histogrammes.
  2. Privilégiez les atomiques aux spinlocks. Si vous avez besoin d'un compteur partagé ou d'un compare-and-swap, utilisez les opérations atomiques. Elles sont sans verrou et ne peuvent pas provoquer d'interblocage.
  3. Si vous devez utiliser des spinlocks, gardez les sections critiques minuscules. Incrémenter un compteur, mettre à jour un horodatage, échanger un pointeur. Rien de plus. N'appelez jamais de fonctions helper pendant la détention d'un verrou : vous ne savez pas quel verrouillage elles effectuent en interne.
  4. Ne détenez jamais le même verrou depuis deux types de programmes eBPF. Si un programme kprobe et un programme tracepoint verrouillent la même valeur de map, une seule interruption vous sépare d'un interblocage. Utilisez des maps distinctes ou des données par CPU.
  5. Testez sous charge. Les bugs de spinlock dépendent du timing. Ils apparaissent sous contention : forte utilisation CPU, interruptions fréquentes, nombreux programmes eBPF en parallèle. Testez avec une charge de production réaliste, pas sur des machines de développement au repos.

eBPF a profondément changé la façon dont nous interagissons avec le noyau Linux, en donnant aux programmes en espace utilisateur un accès sûr à l'observabilité et au réseau au niveau noyau. Mais « sûr » a ses limites. Le vérificateur rend eBPF bien plus sûr que l'écriture de modules noyau bruts, et de loin. Il ne le rend pas sûr au sens où la programmation en espace utilisateur l'est. Comprendre où se situent ces limites, surtout autour de l'état partagé et du verrouillage, c'est ce qui distingue les programmes eBPF qui tiennent en production de ceux qui ne fonctionnent qu'en test.