GoSuda

La Historia de Cilium: Mejoras Sorprendentes en la Estabilidad de la Red Gracias a Pequeños Cambios en el Código

By iwanhae
views ...

Introducción

Hace poco, revisé una Pull Request del proyecto Cilium de un antiguo compañero de trabajo.

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

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

Conocimientos Previos

Si hay un elemento tan crucial para las personas modernas como el smartphone, 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 múltiples dispositivos puedan utilizarla. La particularidad técnica que surge aquí es: ¿cómo se realiza este "compartir"?

La tecnología utilizada aquí es la Network Address Translation (NAT). NAT es una técnica que, dado que la comunicación TCP o UDP se compone de una combinación de direcciones IP y puertos, permite que la comunicación interna, que se realiza con IP privada:Puerto, se mapee a una IP pública:Puerto no utilizada actualmente para realizar la comunicación externa.

NAT

Cuando un dispositivo interno intenta acceder a Internet externo, el equipo NAT convierte la combinación de dirección IP privada y 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 traducción se registra en una tabla NAT dentro del equipo NAT.

Por ejemplo, supongamos que un smartphone (IP privada: a.a.a.a, puerto: 50000) en una casa intenta acceder a un servidor web (IP pública: c.c.c.c, puerto: 80).

1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Servidor Web (c.c.c.c:80)

Cuando el router recibe la solicitud del smartphone, verá un paquete TCP como el siguiente:

1# Paquete TCP recibido por el router, smartphone => 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 regresaría al smartphone (a.a.a.a), que tiene 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 cambia la dirección IP de origen y el número de puerto del paquete TCP recibido del smartphone a su propia dirección IP pública (b.b.b.b) y al 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# Realiza 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 de origen (b.b.b.b) y el número de puerto (60000) correspondientes a la dirección IP privada original (a.a.a.a) y el número de puerto (50000), y cambia el destino del paquete al smartphone.

1# Paquete TCP enviado por el router, router => smartphone
2# Realiza DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

A través de este proceso, el smartphone siente como si estuviera comunicándose directamente con el servidor web utilizando su propia dirección IP pública. Gracias a NAT, múltiples dispositivos internos pueden usar Internet simultáneamente con una sola 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, el NAT mencionado anteriormente se utiliza en diversas áreas. A continuación, se presentan dos casos representativos:

Cuando un Pod realiza comunicación con el exterior del clúster desde su interior

Los Pods dentro de un clúster de Kubernetes generalmente reciben direcciones IP privadas que solo pueden comunicarse 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 donde se ejecuta el Pod (cada servidor del clúster). Cuando un Pod envía un paquete hacia el exterior, este paquete se transmite 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) a su propia dirección IP pública y modifica adecuadamente el puerto de origen 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á la siguiente información:

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       |

Y luego realizará SNAT de la siguiente manera antes de enviar el paquete al exterior.

1# Paquete TCP enviado por el nodo, Nodo => Servidor API
2# Realiza SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Después de esto, el proceso sigue el mismo curso que en el caso del router y el smartphone.

Cuando se comunica con un Pod desde el exterior del clúster a través de un NodePort

Una de las formas de exponer un servicio en Kubernetes al exterior es utilizando un servicio 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 llega a un NodePort de un nodo específico desde el exterior, 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 (192.168.1.5) en un clúster de Kubernetes. Este servicio, internamente, apunta a un Pod con dirección IP 10.0.2.15 y puerto 8080.

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

Aquí, en el caso del 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. (Aunque las políticas relacionadas con esto varían según el tipo de CNI utilizado, en este artículo la explicación se basa en Cilium).

Cuando la solicitud del usuario externo llega al nodo, el nodo debe reenviarla 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     |

Un punto crucial aquí es que cuando el Pod envía una respuesta a esta solicitud, la dirección IP de origen será su propia dirección IP (10.0.2.15) y la dirección IP de destino será la dirección IP del usuario externo que envió la solicitud (203.0.113.10). En tal caso, el usuario externo recibiría una respuesta de una dirección IP inexistente que nunca solicitó y simplemente descartaría 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 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 recibe el paquete responderá al nodo que inicialmente recibió la solicitud a través del 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            |

Cuerpo Principal

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

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

Como resultado, Cilium adoptó el enfoque de implementar directamente solo las funciones necesarias en el contexto de Kubernetes de las tareas que la pila de red tradicional de Linux 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 tabla hash, 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 clave.

Identificación del Problema

Fenómeno - 1: Consulta

Esto lleva a un problema: para verificar si un paquete que pasa por eBPF necesita realizar un proceso SNAT o DNAT, se debe consultar la tabla Hash mencionada. Como se ha visto, existen dos tipos de paquetes en el proceso SNAT: 1. Paquetes que salen del interior hacia el exterior, y 2. Paquetes que entran del exterior hacia el interior como respuesta. Estos dos paquetes requieren una transformación NAT y se caracterizan por tener los valores src ip, port y dst ip, port intercambiados.

Por lo tanto, para una búsqueda rápida, es necesario agregar un valor adicional a la tabla Hash con la clave de src y dst invertidos, o realizar la misma consulta dos veces en la 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 RevSNAT.

Fenómeno - 2: LRU

Independientemente del problema anterior, no existen recursos ilimitados en ningún hardware, y especialmente en la lógica a nivel de hardware que requiere un rendimiento rápido, donde no se pueden utilizar estructuras de datos dinámicas, es necesario desalojar los datos existentes cuando los recursos son escasos. 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. Los mismos datos se registran dos veces en una tabla Hash para los paquetes salientes y entrantes.
  2. Dada la lógica LRU, cualquiera de los dos datos puede perderse en cualquier momento.

Si una de las entradas de NAT (en adelante, "entrada") para un paquete saliente o entrante se elimina por LRU, la NAT no se puede realizar correctamente, lo que puede llevar a la pérdida de toda la conexión.

Solución

Aquí es donde entran en juego las PR mencionadas 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, se intentaba buscar en la tabla SNAT utilizando una clave compuesta por src ip, src port, dst ip, dst port. Si la clave no existía, se generaba nueva información NAT según las reglas SNAT y se registraba en la tabla. Si era una nueva conexión, esto conduciría a una comunicación normal, y si la clave se eliminaba accidentalmente por LRU, se realizaría una nueva NAT con un puerto diferente al utilizado en la comunicación existente, lo que provocaría que el receptor rechazara el paquete y la conexión se terminara con un paquete RST.

El enfoque de la PR mencionada es simple:

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

Cuando se observa una comunicación en cualquier dirección, ambas entradas se actualizan, lo que las aleja de ser objetivos de desalojo por la lógica LRU. Esto reduce la posibilidad de que solo una entrada se elimine y se colapse toda la comunicación.

Este es un enfoque muy simple y puede parecer una idea sencilla, pero a través de este enfoque, el problema de la interrupción de la conexión debido al vencimiento prematuro de la información NAT para los paquetes de respuesta se ha resuelto eficazmente, y la estabilidad del sistema ha mejorado significativamente. También es una mejora importante que ha logrado los siguientes resultados en términos de estabilidad de la red.

benchmark

Conclusión

Considero que esta PR es un excelente ejemplo que ilustra cómo el conocimiento fundamental de Ciencias de la Computación sobre el funcionamiento de NAT, combinado con una idea sencilla, puede generar un cambio significativo incluso dentro de sistemas complejos.

Ah, por supuesto, no he presentado directamente ejemplos de sistemas complejos en este artículo. Sin embargo, para comprender correctamente esta PR, le rogué a DeepSeek V3 0324 durante casi 3 horas, incluso añadiendo la palabra Please, y como resultado, obtuve conocimiento adicional sobre Cilium y el siguiente diagrama. 😇

diagram

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

Posdata - 1

Por cierto, existe un método muy eficaz para 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 que se encontró con el mismo problema probablemente aumentó el tamaño de la tabla NAT y se marchó sin dejar un registro, admiro y respeto la pasión de gyutaeb por analizar y comprender a fondo el problema, incluso sin estar directamente relacionado con él, y por contribuir al ecosistema de Cilium con datos objetivos.

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

Posdata - 2

Esta historia, de hecho, no es un tema que se adapte directamente a Gosuda, que se ocupa profesionalmente del lenguaje Go. Sin embargo, dado que el lenguaje Go y el ecosistema de la nube están estrechamente relacionados y los contribuidores de Cilium tienen cierta competencia en el lenguaje Go, he traído un contenido que podría haber publicado en mi blog personal a Gosuda.

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

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

Posdata - 3

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