GoSuda

La historia de Cilium: Una pequeña modificación de código que generó una asombrosa mejora en la estabilidad de la red

By iwanhae
views ...

Introducción

Hace poco, vi un PR sobre el proyecto Cilium de un antiguo compañero de trabajo.

bpf:nat: Restore ORG NAT entry if it's not found

(Excluyendo el código de prueba) la cantidad de modificaciones es pequeña, apenas la adición de un bloque de sentencia if. Sin embargo, el impacto de esta modificación es enorme y, personalmente, me resultó interesante cómo una idea simple puede contribuir significativamente a la estabilidad del sistema. Por ello, intentaré explicar este caso de manera que sea fácilmente comprensible incluso para personas sin conocimientos especializados en redes.

Conocimientos previos

Si hay algo tan esencial para la vida moderna como el teléfono inteligente, probablemente sea el router Wi-Fi. Un router Wi-Fi se comunica con los dispositivos a través del estándar de comunicación Wi-Fi y comparte su dirección IP pública para que puedan utilizarla múltiples dispositivos. La peculiaridad técnica que surge aquí es: ¿cómo se realiza esta "compartición"?

La tecnología utilizada para esto es Network Address Translation (NAT). NAT es una tecnología que permite la comunicación externa al mapear la comunicación interna, compuesta por IP privada:Puerto, a un IP pública:Puerto que no esté en uso, dado que la comunicación TCP o UDP se basa en la combinación de direcciones IP y puertos.

NAT

Cuando un dispositivo interno intenta acceder a Internet externo, el equipo NAT transforma la combinación de la dirección IP privada y el número de puerto de dicho dispositivo en su propia dirección IP pública y un número de puerto arbitrario no utilizado. Esta información de transformación se registra en una tabla NAT dentro del equipo NAT.

Por ejemplo, supongamos que un teléfono inteligente en casa (IP privada: a.a.a.a, puerto: 50000) intenta conectarse a un servidor web (IP pública: c.c.c.c, puerto: 80).

1Teléfono inteligente (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Servidor web (c.c.c.c:80)

Cuando el router reciba la solicitud del teléfono inteligente, verá el siguiente paquete TCP.

1# Paquete TCP recibido por el router, teléfono inteligente => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Si este paquete se enviara directamente al servidor web (c.c.c.c), la respuesta no llegaría al teléfono inteligente (a.a.a.a) que posee una dirección IP privada. Por lo tanto, el router primero busca un puerto arbitrario que no esté involucrado en la comunicación actual (por ejemplo, 60000) y lo registra en su tabla NAT interna.

1# Tabla NAT interna del router
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Después de registrar una nueva entrada en la tabla NAT, el router modifica la dirección IP de origen y el número de puerto del paquete TCP recibido del teléfono inteligente por su propia dirección IP pública (b.b.b.b) y el número de puerto recién asignado (60000), y lo envía al servidor web.

1# Paquete TCP enviado por el router, router => servidor web
2# Realización de SNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Ahora, el servidor web (c.c.c.c) reconoce la solicitud como proveniente del puerto 60000 del router (b.b.b.b) y envía el paquete de respuesta al router de la siguiente manera:

1# Paquete TCP recibido por el router, servidor web => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Cuando el router recibe este paquete de respuesta, busca en la tabla NAT la dirección IP privada original (a.a.a.a) y el número de puerto (50000) correspondientes a la dirección IP de destino (b.b.b.b) y el número de puerto (60000), y modifica el destino del paquete para que sea el teléfono inteligente.

1# Paquete TCP enviado por el router, router => teléfono inteligente
2# Realización de DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Mediante este proceso, el teléfono inteligente percibe que se comunica directamente con el servidor web como si tuviera una dirección IP pública. Gracias a NAT, múltiples dispositivos internos pueden usar Internet simultáneamente con una única dirección IP pública.

Kubernetes

Kubernetes posee una de las estructuras de red más sofisticadas y complejas entre las tecnologías recientes. Y, por supuesto, la NAT mencionada anteriormente se utiliza en diversos lugares. Los dos casos representativos son los siguientes:

Cuando un Pod se comunica con el exterior del clúster

Los Pods dentro de un clúster de Kubernetes suelen recibir direcciones IP privadas que solo permiten la comunicación dentro de la red del clúster. Por lo tanto, si un Pod necesita comunicarse con Internet externo, se requiere NAT para el tráfico saliente del clúster. En este caso, NAT se realiza principalmente en el nodo de Kubernetes (cada servidor del clúster) donde se ejecuta el Pod. Cuando un Pod envía un paquete hacia el exterior, dicho paquete se dirige primero al nodo al que pertenece el Pod. El nodo cambia la dirección IP de origen de este paquete (la IP privada del Pod) por su propia dirección IP pública y modifica el puerto de origen adecuadamente antes de enviarlo al exterior. Este proceso es similar al proceso NAT explicado anteriormente para el router Wi-Fi.

Por ejemplo, si un Pod dentro de un clúster de Kubernetes (10.0.1.10, puerto: 40000) se conecta a un servidor API externo (203.0.113.45, puerto: 443), el nodo de Kubernetes recibirá el siguiente paquete del Pod:

1# Paquete TCP recibido por el nodo, Pod => Nodo
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

El nodo registrará el siguiente contenido:

1# Tabla NAT interna del nodo (ejemplo)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Luego, realizará SNAT de la siguiente manera y enviará el paquete al exterior.

1# Paquete TCP enviado por el nodo, nodo => servidor API
2# Realización de SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Después, el proceso sigue el mismo curso que en el caso del router del teléfono inteligente.

Al comunicarse con un Pod desde fuera del clúster a través de NodePort

Una de las formas de exponer servicios al exterior en Kubernetes es mediante el uso de servicios NodePort. Un servicio NodePort abre un puerto específico (NodePort) en todos los nodos del clúster y reenvía el tráfico que llega a este puerto a los Pods asociados al servicio. Los usuarios externos pueden acceder al servicio a través de la dirección IP del nodo del clúster y el NodePort.

En este caso, NAT juega un papel importante, y específicamente, DNAT (Destination NAT) y SNAT (Source NAT) ocurren simultáneamente. Cuando el tráfico externo llega al NodePort de un nodo específico, la red de Kubernetes debe finalmente entregar este tráfico al Pod que proporciona el servicio. En este proceso, primero ocurre DNAT para cambiar la dirección IP de destino y el número de puerto del paquete a la dirección IP y el número de puerto del Pod.

Por ejemplo, supongamos que un usuario externo (203.0.113.10, puerto: 30000) accede a un servicio a través de un NodePort (30001) de un nodo de clúster de Kubernetes (192.168.1.5). Se asume que este servicio apunta internamente a un Pod con dirección IP 10.0.2.15 y puerto 8080.

1Usuario externo (203.0.113.10:30000) ==> Nodo de Kubernetes (externo:192.168.1.5:30001 / interno: 10.0.1.1:42132) ==> Pod de Kubernetes (10.0.2.15:8080)

En este caso, el nodo de Kubernetes tiene tanto la dirección IP 192.168.1.5, accesible desde el exterior, como la dirección IP 10.0.1.1, válida en la red interna de Kubernetes. (La política relacionada con esto varía según el tipo de CNI utilizado, pero en este artículo la explicación se basa en Cilium.)

Cuando la solicitud del usuario externo llega al nodo, el nodo debe reenviar esta solicitud al Pod que la procesará. En este punto, el nodo aplica la siguiente regla DNAT para cambiar la dirección IP de destino y el número de puerto del paquete.

1# Paquete TCP que el nodo está preparando para enviar al Pod
2# Después de aplicar DNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10  | 30000    | 10.0.2.15 | 8080     |

Aquí, un punto importante es que cuando el Pod envía una respuesta a esta solicitud, la dirección IP de origen es su propia dirección IP (10.0.2.15) y la dirección IP de destino es la dirección IP del usuario externo que envió la solicitud (203.0.113.10). En este caso, el usuario externo recibirá una respuesta de una dirección IP inexistente que nunca solicitó y simplemente descartará el paquete. Por lo tanto, el nodo de Kubernetes realiza SNAT adicionalmente cuando el Pod envía paquetes de respuesta al exterior, cambiando la dirección IP de origen del paquete a la dirección IP del nodo (192.168.1.5 o la IP de la red interna 10.0.1.1; en este caso, se procede con 10.0.1.1).

1# Paquete TCP que el nodo está preparando para enviar al Pod
2# Después de aplicar DNAT, SNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1      | 40021    | 10.0.2.15 | 8080     |

Ahora, el Pod que recibió el paquete responderá al nodo que inicialmente recibió la solicitud de NodePort, y el nodo revertirá los mismos procesos de DNAT y SNAT para devolver la información al usuario externo. Durante este proceso, cada nodo almacenará la siguiente información:

1# Tabla DNAT interna del nodo
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Tabla SNAT interna del nodo
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Discusión

Generalmente, en Linux, estos procesos NAT son gestionados y operados por el subsistema conntrack a través de iptables. De hecho, otros proyectos de CNI como flannel o calico utilizan esto para resolver los problemas mencionados. Sin embargo, el problema es que Cilium, al usar la tecnología eBPF, ignora por completo esta pila de red tradicional de Linux. 🤣

https://cilium.io/blog/2021/05/11/cni-benchmark/

Como resultado, Cilium optó por implementar directamente solo las funciones necesarias en el contexto de Kubernetes de las tareas que la pila de red de Linux tradicional solía realizar, como se muestra en la figura anterior. Por lo tanto, para el proceso SNAT mencionado anteriormente, Cilium gestiona directamente la tabla SNAT en forma de LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabla SNAT de Cilium
2# !Ejemplo para una explicación sencilla. La definición real es: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip     | src port | dst ip  | dst port | protocol, conntrack y otros metadatos
4----------------------------------------------
5|            |          |         |          |

Y, como es una Hash Table, para una búsqueda rápida, existe una clave que utiliza la combinación de src ip, src port, dst ip, dst port como valor de la clave.

Identificación del problema

Fenómeno - 1: Consulta

Debido a esto, surge un problema: un paquete que pasa por eBPF debe consultar la tabla Hash para verificar si necesita realizar un proceso SNAT o DNAT. Como se ha visto anteriormente, en el proceso SNAT existen dos tipos de paquetes: 1. paquetes que salen de la red interna hacia la externa, y 2. paquetes de respuesta que entran de la red externa hacia la interna. Estos dos paquetes requieren una transformación en el proceso NAT, y sus valores de src ip, port y dst ip, port se invierten.

Por lo tanto, para una búsqueda rápida, es necesario agregar otro valor a la tabla Hash con la clave de src y dst invertidos, o realizar una búsqueda dos veces en la misma tabla Hash para todos los paquetes, incluso si no están relacionados con SNAT. Naturalmente, Cilium, para un mejor rendimiento, adoptó el método de insertar los mismos datos dos veces bajo el nombre de RevSNAT.

Fenómeno - 2: LRU

Además del problema anterior, no hay recursos infinitos en ningún hardware, y dado que se trata de lógica a nivel de hardware que requiere un rendimiento rápido, no es posible utilizar estructuras de datos dinámicas. Por lo tanto, cuando los recursos son insuficientes, es necesario desalojar los datos existentes. Cilium resolvió esto utilizando LRU Hash Map, una estructura de datos básica proporcionada por Linux.

Fenómeno 1 + Fenómeno 2 = Pérdida de conexión

https://github.com/cilium/cilium/issues/31643

Es decir, para una conexión TCP (o UDP) con SNAT:

  1. La misma información se registra dos veces en una tabla Hash para los paquetes de salida y los de entrada.
  2. Dada la lógica de LRU, cualquiera de las dos entradas puede perderse en cualquier momento.

Si una de las entradas NAT (en adelante, 'entry') para los paquetes salientes o entrantes se elimina por LRU, la NAT no puede realizarse correctamente, lo que puede llevar a una pérdida total de la conexión.

Solución

Aquí es donde entran en juego los siguientes PRs mencionados anteriormente:

bpf:nat: restore a NAT entry if its REV NAT is not found

bpf:nat: Restore ORG NAT entry if it's not found

Anteriormente, cuando un paquete pasaba por eBPF, intentaba realizar una búsqueda en la tabla SNAT creando una clave a partir de la combinación de src ip, src port, dst ip, dst port. Si la clave no existía, se generaba nueva información NAT según la regla SNAT y se registraba en la tabla. Si se trataba de una nueva conexión, esto llevaría a una comunicación normal. Sin embargo, si la clave se eliminaba involuntariamente por LRU, se realizaría un nuevo NAT con un puerto diferente al utilizado en la comunicación existente, lo que provocaría que el receptor del paquete rechazara la recepción y la conexión se terminaría con un paquete RST.

El enfoque adoptado por el PR mencionado es simple:

Si se observa un paquete en cualquier dirección, la entrada para la dirección inversa también debe actualizarse.

Cuando se observa comunicación en cualquier dirección, ambas entradas se actualizan, lo que las aleja de la prioridad de eliminación de la lógica LRU y reduce la posibilidad de que solo una entrada se elimine, lo que llevaría al colapso de toda la comunicación.

Aunque este enfoque puede parecer simple y una idea sencilla, ha permitido resolver eficazmente el problema de la interrupción de la conexión debido a la caducidad prematura de la información NAT para los paquetes de respuesta, mejorando significativamente la estabilidad del sistema. Además, representa una mejora importante que ha logrado los siguientes resultados en términos de estabilidad de la red:

benchmark

Conclusión

Considero que este PR es un excelente ejemplo que ilustra cómo una idea simple puede generar un cambio significativo, incluso dentro de sistemas complejos, partiendo de conocimientos fundamentales de Ciencias de la Computación sobre el funcionamiento de NAT.

Ah, por supuesto, no he presentado directamente un ejemplo de un sistema complejo en este artículo. Sin embargo, para comprender adecuadamente este PR, tuve que suplicarle a DeepSeek V3 0324 durante casi 3 horas, incluso añadiéndole la palabra "Please", y como resultado obtuve +1 conocimiento sobre Cilium y la siguiente imagen. 😇

diagram

Y, al leer los problemas y los PRs, escribo este artículo como una forma de compensación por las ominosas premoniciones de que los problemas podrían haber surgido debido a algo que yo había creado en el pasado.

Posdata - 1

Por cierto, existe una forma muy efectiva de evitar este problema. Dado que la causa fundamental del problema es la falta de espacio en la tabla NAT, basta con aumentar el tamaño de la tabla NAT. :-D

Mientras que alguien podría haber encontrado el mismo problema, no haberlo reportado, haber aumentado el tamaño de la tabla NAT y haberse ido, admiro y respeto la pasión de gyutaeb por analizar, comprender a fondo y contribuir al ecosistema de Cilium con datos objetivos, a pesar de que el problema no estaba directamente relacionado con él.

Esta fue la razón por la que decidí escribir este artículo.

Posdata - 2

Esta historia, en realidad, no es un tema que se ajuste directamente a Gosuda, que se especializa en el lenguaje Go. Sin embargo, dado que el lenguaje Go y el ecosistema de la nube están estrechamente relacionados, y los colaboradores de Cilium tienen cierto dominio del lenguaje Go, decidí traer a Gosuda un contenido que podría haber publicado en mi blog personal.

Dado que uno de los administradores (yo mismo) dio su permiso, supongo que estará bien.

Si considera que no está bien, guárdelo rápidamente en PDF, ya que no se sabe cuándo podría ser eliminado. ;)

Posdata - 3

Para la redacción de este artículo, recibí una gran ayuda de Cline y Llama 4 Maveric. Aunque comencé el análisis con Gemini y le supliqué a DeepSeek, la ayuda real provino de Llama 4. Llama 4 es excelente. Definitivamente deberían probarlo.