GoSuda

Cilium 이야기: 작은 코드 변경이 만들어낸 놀라운 네트워크 안정성 향상

By iwanhae views ...

Introducción

Hace poco eché un vistazo al PR de un antiguo compañero de trabajo sobre el proyecto Cilium.

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

(Sin contar el código de prueba) la modificación en sí es pequeña, pues consiste en añadir un bloque if. Sin embargo, el impacto de esta corrección es enorme, y personalmente me resulta interesante que una idea tan simple pueda contribuir de forma muy notable a la estabilidad de un sistema. Por ello, quiero explicar esta historia de manera que incluso quienes no tienen conocimientos especializados en redes puedan comprender este caso.

Conocimientos previos

Si hay un artículo tan indispensable para las personas modernas como el smartphone, quizás sea el router Wi-Fi. El router Wi-Fi se comunica con los dispositivos mediante el estándar de comunicación Wi-Fi y cumple la función de compartir la IP pública que posee para que varios dispositivos puedan utilizarla. Aquí surge una cuestión técnica clave: ¿cómo realiza esa "compartición"?

La tecnología utilizada en este caso es la Network Address Translation (NAT). NAT es una técnica que, dado que la comunicación TCP o UDP se compone de la combinación de información de IP y puerto, asigna la comunicación interna del tipo IP privada:Puerto a un IP pública:Puerto que no se esté utilizando actualmente, de modo que sea posible comunicarse con el exterior.

NAT

Cuando un dispositivo interno intenta conectarse a Internet, el dispositivo NAT convierte la combinación de dirección IP privada y número de puerto de ese dispositivo en su propia dirección IP pública y en un número de puerto libre elegido de forma arbitraria. Esta información de conversión se registra en un lugar del dispositivo NAT denominado tabla NAT.

Por ejemplo, supongamos que un smartphone de la casa (IP privada: a.a.a.a, puerto: 50000) intenta conectarse 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 tal cual al servidor web (c.c.c.c), la respuesta nunca llegaría al smartphone, que tiene una IP privada (a.a.a.a). Por eso, el router primero localiza un puerto libre que no participe 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 la nueva entrada en la tabla NAT, el router modifica la dirección IP y el número de puerto de origen del paquete TCP recibido del smartphone por su propia IP pública (b.b.b.b) y el puerto recién asignado (60000), y lo envía al servidor web.

1# Paquete TCP enviado por el router, router => servidor web
2# Realizar 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 forma.

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    |

Al recibir este paquete de respuesta, el router busca en la tabla NAT la IP privada original (a.a.a.a) y el puerto original (50000) que corresponden a la IP de destino (b.b.b.b) y al número de puerto (60000), y cambia el destino del paquete por el smartphone.

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

Gracias a este proceso, el smartphone tiene la sensación de estar comunicándose directamente con el servidor web utilizando su propia IP pública. Gracias a NAT, varios dispositivos internos pueden utilizar Internet al mismo tiempo con una única IP pública.

Kubernetes

Kubernetes tiene una estructura de red de las más sofisticadas y complejas entre las tecnologías surgidas recientemente. Además, por supuesto, el NAT que mencionamos antes también se utiliza en diversos ámbitos. Los dos casos más representativos son los siguientes.

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

Los pods dentro de un clúster de Kubernetes reciben, por lo general, una IP privada que solo permite la comunicación dentro de la red del clúster. Por lo tanto, para que un pod se comunique con Internet, es necesario aplicar NAT al tráfico que sale del clúster. En este caso, el 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 llega primero al nodo al que pertenece el pod. El nodo cambia la dirección IP de origen del paquete (la IP privada del pod) por su propia IP pública, modifica también el puerto de origen de forma adecuada y reenvía el paquete al exterior. Este proceso es similar al proceso NAT que se explicó antes con el router Wi-Fi.

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

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      |

y, tras 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       |

realiza un SNAT como el siguiente y envía el paquete al exterior.

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

A partir de aquí, el proceso sigue los mismos pasos que en el caso del router del smartphone.

Cuando un cliente externo se comunica con un Pod a través de NodePort

Una de las formas de exponer un servicio de Kubernetes al exterior es utilizar 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 ese puerto a los pods que pertenecen al servicio. Los usuarios externos pueden acceder al servicio mediante la IP del nodo del clúster y el NodePort.

En este caso, NAT desempeña un papel importante, y en particular, se producen simultáneamente DNAT (Destination NAT) y SNAT (Source NAT). Cuando el tráfico externo llega al NodePort de un nodo concreto, la red de Kubernetes debe reenviar finalmente ese tráfico al pod que proporciona el servicio. En este proceso, primero se produce el DNAT, que cambia la IP de destino y el número de puerto del paquete por la IP y el puerto del pod.

Por ejemplo, supongamos que un usuario externo al clúster (203.0.113.10, puerto: 30000) accede al servicio a través del NodePort (30001) de un nodo del clúster de Kubernetes (192.168.1.5). Asumamos que este servicio apunta internamente a un pod con la IP 10.0.2.15 y el 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)

Aquí, el nodo de Kubernetes tiene tanto la IP 192.168.1.5, accesible desde el exterior, como la IP 10.0.1.1, válida dentro de la red interna de Kubernetes. (Según el tipo de CNI que se utilice, las políticas relacionadas con esto pueden variar, pero en este artículo la explicación se basa en Cilium).

Cuando la solicitud del usuario externo llega al nodo, este debe reenviarla al pod que la va a procesar. En ese momento, el nodo aplica la siguiente regla de DNAT, que cambia la 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    |

Lo importante aquí es que, cuando el pod envía la respuesta a esta solicitud, la IP de origen es su propia IP (10.0.2.15) y la IP de destino es la IP del usuario externo que hizo la solicitud (203.0.113.10). En tal caso, el usuario externo recibiría una respuesta proveniente de una IP que nunca solicitó y que no existe para él, y descartaría ese paquete sin más. Por ello, cuando el pod envía el paquete de respuesta al exterior, el nodo de Kubernetes realiza adicionalmente un SNAT para cambiar la IP de origen del paquete por la 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 y 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ó este paquete responde al nodo que originalmente recibió la solicitud de NodePort, y el nodo aplica en sentido inverso el mismo proceso de DNAT y SNAT para devolver la información al usuario externo. Durante este proceso, cada nodo almacena 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            |

Desarrollo

En general, en Linux estos procesos de NAT se gestionan y ejecutan a través de iptables, mediante un subsistema llamado conntrack. De hecho, otros proyectos CNI como flannel o calico utilizan este mecanismo para resolver problemas como el descrito. El problema es que Cilium, al usar una tecnología llamada eBPF, prescinde por completo de 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 que necesita en un contexto de Kubernetes de entre las tareas que realizaba la pila de red tradicional de Linux, que aparece en la figura anterior. Así, en lo que respecta al proceso SNAT mencionado antes, Cilium gestiona directamente su tabla SNAT en forma de LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabla SNAT de Cilium
2# ¡Ejemplo para facilitar la explicación. La definición real está en: 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 | protocolo, conntrack, etc. metadatos adicionales
4----------------------------------------------
5|            |          |         |          |

Además, como se trata de una tabla hash, para una búsqueda rápida se utiliza una clave, y la clave es la combinación de src ip, src port, dst ip y dst port (ver clave).

Identificación del problema

Fenómeno 1: búsqueda

El problema surge de la siguiente manera: para verificar si un paquete que pasa por eBPF necesita un proceso SNAT o DNAT, es necesario consultar la tabla hash mencionada. Sin embargo, como vimos antes, en el proceso SNAT existen dos tipos de paquetes: 1. el paquete que sale de la red interna hacia el exterior y 2. el paquete de respuesta que llega del exterior a la red interna. Ambos paquetes requieren una conversión NAT, pero tienen la particularidad de que los valores de src ip y port y de dst ip y port se intercambian.

Por tanto, para realizar búsquedas rápidas, sería necesario añadir una entrada adicional a la tabla hash con la clave en la que src y dst están invertidos, o bien consultar dos veces la misma tabla hash para todos los paquetes, incluso aquellos que no tienen nada que ver con SNAT. Por supuesto, para lograr un mejor rendimiento, Cilium adoptó el método de insertar dos veces los mismos datos, bajo el nombre RevSNAT.

Fenómeno 2: LRU

Además, independientemente del problema anterior, ningún hardware tiene recursos infinitos, y especialmente en la lógica a nivel de hardware, que requiere un rendimiento muy alto, tampoco se pueden utilizar estructuras de datos dinámicas. Por ello, cuando faltan recursos es necesario expulsar datos existentes (eviction). Cilium resolvió esto utilizando LRU Hash Map, una estructura de datos básica que Linux proporciona de forma predeterminada.

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

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

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

  1. En una misma tabla hash, los datos de los paquetes salientes y entrantes están registrados dos veces, y
  2. según la lógica LRU, cualquiera de los dos datos puede perderse en cualquier momento,

si alguna de las entradas (en adelante, entry) de información NAT de los paquetes salientes o entrantes desaparece por LRU, no se puede realizar el NAT correctamente, lo que puede provocar la pérdida de toda la conexión.

Solución

Aquí aparecen los PR 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, se generaba una clave con la combinación de src ip, src port, dst ip y dst port para consultar la tabla SNAT. Si la clave no existía, se generaba una nueva información NAT según las reglas de SNAT y se registraba en la tabla. En el caso de una conexión nueva, esto conducirá a una comunicación normal, pero si la clave se eliminó involuntariamente por LRU, el NAT se realizaría con un puerto distinto al que usaba la comunicación original, y el receptor rechazaría el paquete, de modo que la conexión terminaría con un paquete RST.

El enfoque que adopta el PR mencionado es sencillo:

Cada vez que se observe un paquete en cualquier dirección, también se debe actualizar la entry correspondiente a la dirección inversa.

Cuando se observa una comunicación en cualquier dirección, se actualizan ambas entries, lo que las aleja de la prioridad de expulsión del LRU, y así se reduce la probabilidad de que solo una de ellas se elimine y colapse toda la comunicación.

Aunque es un enfoque muy simple y la idea puede parecer trivial, con este método se resuelve de forma eficaz el problema de que la información NAT de los paquetes de respuesta expire primero y la conexión se corte, lo que mejora significativamente la estabilidad del sistema. Además, en cuanto a la estabilidad de la red, se trata de una mejora importante que logró resultados como los siguientes.

benchmark

Conclusión

Considero que este PR es un excelente ejemplo que muestra, partiendo de los conocimientos básicos de ciencias de la computación sobre cómo funciona NAT, cuántos cambios puede producir una idea simple incluso dentro de un sistema complejo.

Por supuesto, en este artículo no he mostrado directamente un caso de sistema complejo. Sin embargo, para entender bien este PR, le pedí a DeepSeek V3 0324, añadiendo incluso la palabra Please, casi tres horas de ayuda suplicante, y como resultado obtuve +1 de conocimiento sobre Cilium y un diagrama como el siguiente. 😇

diagram

Y, al leer los issues y los PR, sentí el presentimiento desagradable de que algo que yo había creado en el pasado podría haber causado esos problemas; escribo este artículo como una especie de compensación por ese sentimiento.

Posdata - 1

Como nota, este problema tiene un método de evasión muy eficaz. Dado que la causa raíz es la falta de espacio en la tabla NAT, basta con aumentar el tamaño de la tabla NAT. :-D

Admiro y respeto profundamente el entusiasmo de gyutaeb, que, aunque el problema no estaba directamente relacionado con él, analizó y comprendió a fondo la situación, aportó datos objetivos y contribuyó al ecosistema de Cilium, cuando otra persona, ante el mismo problema, habría aumentado el tamaño de la tabla NAT y se habría marchado sin dejar ni un issue.

Esta fue la razón que me llevó a decidirme a escribir este artículo.

Posdata - 2

Esta historia no es, en realidad, un tema que encaje directamente con Gosuda, que se dedica especialmente al lenguaje Go. Sin embargo, el lenguaje Go y el ecosistema cloud están estrechamente relacionados, y como los contribuyentes de Cilium tienen cierto dominio de Go, decidí traer aquí, a Gosuda, un contenido que podría publicar en un blog personal.

Como uno de los administradores (yo mismo) dio su permiso, creo que probablemente no haya problema.

Si consideran que no es apropiado, podría eliminarse en cualquier momento, así que les recomiendo guardarlo cuanto antes en PDF. ;)

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 pedí ayuda a DeepSeek de forma suplicante, en realidad la ayuda decisiva llegó de Llama 4. Llama 4 es muy bueno. Háganme caso y pruébenlo.