Artículos en profundidad sobre la tecnología que da forma al futuro.

Spinlocks de eBPF y el arte de depurar el kernel

Cómo aparecen los bugs de spinlocks de eBPF en el kernel de Linux, por qué cuesta encontrarlos y qué revelan sobre el modelo de seguridad de eBPF.

Un candado brillante girando y chispeando dentro de un paisaje congelado de placa de circuito

Un bug de spinlock en eBPF no avisa con un stack trace ni con un mensaje de error. Avisa con una máquina que deja de responder. Sin logs, sin advertencias, sin degradación elegante: solo un núcleo de CPU girando para siempre sobre un lock que nunca se liberará. Si tienes suerte, el watchdog se dispara y obtienes un kernel panic con una traza. Si no, la máquina simplemente se congela y tienes que reiniciarla a la fuerza.

eBPF se supone que es seguro. Se ejecuta en el kernel, pero pasa por un verificador que demuestra que los programas terminan, no acceden a memoria inválida y no corrompen el estado del kernel. Entonces, ¿cómo se cuelan los bugs de spinlocks? Porque el verificador revisa programas individuales, pero los bugs de spinlocks surgen de las interacciones entre programas, mapas y las decisiones de planificación del kernel, cosas que ningún verificador estático puede predecir del todo.

Para qué sirven los spinlocks de eBPF

Los programas eBPF se ejecutan en contexto de kernel, a menudo en varias CPUs a la vez. Cuando dos programas eBPF necesitan actualizar el mismo valor de un mapa —un contador, una estructura de datos, una máquina de estados— necesitan sincronización. Para eso eBPF ofrece bpf_spin_lock y bpf_spin_unlock.

Un spinlock es el lock más simple que existe: intentas adquirirlo y, si está tomado, giras en un bucle cerrado hasta que se libere. Sin dormir, sin ceder el control, sin colas. Esto encaja con eBPF porque sus programas se ejecutan en contextos donde dormir está prohibido: manejadores de interrupciones, softirqs, secciones con preempción deshabilitada. Un mutex que pusiera a dormir al llamador tumbaría el kernel. Un spinlock simplemente quema ciclos de CPU hasta que el lock queda libre.

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;
}

Parece sencillo. Y para casos simples como este —tomar el lock, actualizar un valor, liberarlo— funciona bien. Los problemas empiezan cuando los patrones de locking se complican, o cuando la interacción entre los programas eBPF y los propios locks del kernel crea dependencias inesperadas.

Cómo se manifiestan los bugs de spinlocks

El bug clásico de spinlocks es el deadlock: el programa A tiene el lock 1 y quiere el lock 2, mientras el programa B tiene el lock 2 y quiere el lock 1. Ambos giran para siempre esperándose mutuamente. En contexto de kernel, eso significa que esos núcleos de CPU están perdidos: nunca volverán a ejecutar nada más.

El verificador de eBPF evita algunos de estos casos. Rechaza programas que mantienen más de un spinlock a la vez, lo que elimina el deadlock AB-BA clásico. Pero no puede prevenir todos los escenarios de deadlock, porque algunos surgen de la relación entre los spinlocks de eBPF y los locks propios del kernel.

Piensa en este escenario: un programa eBPF adjunto a un tracepoint toma un spinlock de un mapa. Mientras el lock está tomado, salta una interrupción de hardware en la misma CPU. El manejador de la interrupción ejecuta otro programa eBPF que intenta tomar el mismo spinlock. Deadlock: la interrupción no puede volver hasta conseguir el lock, pero el lock no se puede liberar hasta que el programa interrumpido continúe, y eso no ocurre hasta que la interrupción retorne.

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

Los spinlocks del propio kernel resuelven esto deshabilitando las interrupciones mientras el spinlock está tomado (spin_lock_irqsave). Los spinlocks de eBPF hacen algo parecido: bpf_spin_lock deshabilita la preempción y, en kernels 5.1 o posteriores, deshabilita las softirqs. Pero no todos los contextos de interrupción quedan cubiertos, y no todas las versiones del kernel lo manejan de forma idéntica, y ahí es donde viven los bugs más sutiles.

El problema de depuración

Depurar problemas de spinlocks en eBPF es difícil por motivos concretos.

La reproducción no es determinista. Los bugs de spinlocks dependen del tiempo: qué CPU ejecuta qué programa, cuándo saltan las interrupciones, cuánto dura la sección crítica. Una prueba que pasa 999 veces puede bloquearse en la número 1000. No puedes reproducirlos de forma fiable en desarrollo porque tu máquina tiene una topología de CPU, frecuencia de interrupciones y patrones de carga distintos a los de producción.

El fallo destruye la evidencia. Cuando ocurre un deadlock de spinlock, las CPUs afectadas dejan de ejecutar cualquier cosa, incluida la infraestructura de logs y monitorización que te diría qué pasó. Si el deadlock está en una sola CPU, la máquina quizá siga arrastrándose. Si se bloquean varias CPUs (o si la CPU bloqueada retiene recursos que otras necesitan), toda la máquina se cuelga.

Las herramientas tradicionales no ayudan. No puedes adjuntar un depurador al kernel para inspeccionar el estado bloqueado (bueno, con KGDB sí, pero necesitas tener una conexión serie configurada antes del deadlock). dmesg no sirve de nada porque nadie escribe en él. La única información fiable suele venir de la salida del detector de lockups, si se dispara antes de que la máquina muera del todo.

Estrategias que de verdad funcionan

A pesar de estos desafíos, la gente sí encuentra y corrige bugs de spinlocks en eBPF. Así se hace.

Análisis del orden de locks. Antes de ejecutar nada, analiza el comportamiento de locking de cada programa eBPF. ¿Qué mapas accede cada uno? ¿Qué locks toma? ¿Pueden dos programas que tocan el mismo lock ejecutarse en la misma CPU (mismo tracepoint, o tracepoint frente a interrupción)? Es un análisis manual y tedioso, pero detecta los patrones de deadlock más comunes antes de que ocurran.

Monitorización del tiempo de retención de locks. Instrumenta tus programas eBPF para medir cuánto tiempo se mantienen los spinlocks. Un spinlock retenido más de unos pocos microsegundos es una señal de alarma: amplía la ventana para deadlocks por interrupción y hace que otras CPUs desperdicien ciclos girando. Las secciones críticas cortas son más rápidas y más seguras.

// 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;
}

Estructuras de datos por CPU. El mejor bug de spinlock es el que no puede ocurrir. Si cada CPU trabaja con su propia copia de los datos y agregas después, no hay contención y no hacen falta locks. Los BPF_MAP_TYPE_PERCPU_HASH y BPF_MAP_TYPE_PERCPU_ARRAY de eBPF están diseñados justo para esto. Contadores, histogramas y acumuladores casi nunca necesitan spinlocks de estado compartido.

Operaciones atómicas en lugar de locks. Para operaciones simples —incrementar un contador, comparar e intercambiar un valor— __sync_fetch_and_add y otras atómicas son más rápidas y libres de deadlocks. No necesitas un spinlock para incrementar un contador. Reserva los spinlocks para operaciones que realmente necesiten actualizar varios campos de forma atómica.

Qué revela esto sobre el modelo de seguridad de eBPF

La historia de seguridad de eBPF es impresionante, pero tiene matices. El verificador garantiza que los programas individuales son seguros: terminan, no acceden a memoria inválida y no corrompen el estado del kernel. Pero programas «seguros» pueden componerse en sistemas inseguros cuando sus interacciones crean dependencias temporales que el verificador no puede analizar.

Esta es una limitación fundamental del análisis estático. El verificador ve cada programa aislado. No sabe qué otros programas están cargados, qué mapas comparten ni en qué contextos del kernel se ejecutan. Un programa que toma un spinlock es «seguro»: lo adquiere y lo libera correctamente. Pero si es seguro junto con todos los demás programas eBPF cargados depende de condiciones de ejecución.

La comunidad del kernel lo está abordando poco a poco. Parches recientes han añadido restricciones sobre qué tipos de programas eBPF pueden usar spinlocks, cuánto pueden durar las secciones críticas y qué operaciones están prohibidas mientras un spinlock está tomado. Cada restricción estrecha la ventana para los bugs, pero también limita lo que pueden hacer los programas eBPF.

Reglas prácticas para el locking en eBPF

Después de depurar suficientes problemas de spinlocks, aparecen patrones para evitarlos.

  1. Prefiere mapas por CPU. Si tus datos pueden particionarse por CPU y agregarse en user space, hazlo. Sin locks, sin contención, sin deadlocks. Esto cubre el 80% de los casos de uso: contadores, logs de eventos, histogramas.
  2. Prefiere atómicas a spinlocks. Si necesitas un contador compartido o un compare-and-swap, usa operaciones atómicas. No usan locks y no pueden entrar en deadlock.
  3. Si tienes que usar spinlocks, mantén las secciones críticas diminutas. Incrementa un contador, actualiza una marca de tiempo, intercambia un puntero. Nada más. Nunca llames a funciones auxiliares (helpers) mientras tienes un lock tomado: no sabes qué locking hacen por dentro.
  4. Nunca mantengas el mismo lock desde dos tipos de programas eBPF. Si un programa kprobe y un programa de tracepoint bloquean el mismo valor de mapa, estás a una interrupción de un deadlock. Usa mapas separados o datos por CPU.
  5. Prueba bajo carga. Los bugs de spinlocks dependen del tiempo. Aparecen bajo contención: alta utilización de CPU, interrupciones frecuentes, muchos programas eBPF ejecutándose a la vez. Prueba con carga realista de producción, no en máquinas de desarrollo en reposo.

eBPF ha cambiado de forma fundamental cómo interactuamos con el kernel de Linux, dando a los programas de user space acceso seguro a observabilidad y redes a nivel de kernel. Pero la «seguridad» tiene límites. El verificador hace que eBPF sea mucho más seguro que escribir módulos de kernel en crudo, de forma drástica. Pero no lo hace seguro como lo es la programación en user space. Entender dónde están esos límites, sobre todo en torno al estado compartido y al locking, es lo que separa los programas eBPF que funcionan en producción de los que solo funcionan en pruebas.