Saltar al contenido
Alexander Zelada

Ruta 02 · Lección 4 de 4

Kubernetes Network Policy con Cilium y Hubble: identidades y qué se bloquea

Cómo escribir Network Policy con Cilium, por qué usa identidades y no IPs, y cómo ver con Hubble qué tráfico se bloquea y por qué.

Alexander Zelada

Alexander Zelada · 11 min de lectura

Ruta 02 · Redes por dentro

En esta lección vas a ver dos cosas de Cilium que van muy de la mano: las Network Policy, que deciden qué Pod puede hablar con cuál, y Hubble, que te enseña en vivo lo que pasa en la red, incluido lo que se bloquea. Las vemos juntas porque, cuando aplicas una política y algo deja de funcionar, lo primero que necesitas saber es qué se está bloqueando, y eso es justo lo que te enseña Hubble.

Antes, por si acabas de llegar: Cilium es lo que se encarga de la red de los Pods en tu clúster de Kubernetes, y lo hace con eBPF, dentro del kernel. Si no lo conoces, lo explico en la lección de Cilium. Empezamos por el problema.

Dentro del clúster todo está abierto

Empezamos por algo que mucha gente no sabe. En Kubernetes, por defecto, cualquier Pod puede hablar con cualquier otro Pod del clúster, da igual el namespace en el que esté. O sea, si alguien consigue entrar en un Pod, desde ahí puede llegar al backend, a la base de datos y a todo lo demás.

Aquí es normal pensar que para eso ya tienes el firewall. Pero fíjate en lo que ve tu firewall. En muchos clústeres, por ejemplo en AKS con red overlay, el tráfico que sale del clúster lleva la IP del nodo, no la del Pod. Así que tu firewall solo sabe que algo viene de ese nodo. No sabe si es el frontend o cualquier otro Pod que corre al lado.

Y hay otra cosa: el tráfico entre Pods ni siquiera pasa por el firewall. Cuando el frontend llama al backend, el paquete va de un nodo a otro dentro del clúster, y el firewall no lo ve nunca.

Para controlar ese tráfico de dentro están las Network Policy. Y fíjate en cómo se escriben: no con IPs, sino con etiquetas. Tú dices «los Pods con la etiqueta frontend pueden hablar con los Pods con la etiqueta backend», y da igual cuántos Pods haya o qué IP tengan en ese momento.

Quién aplica la Network Policy

Hay una cosa que se confunde mucho. Cuando aplicas una Network Policy, Kubernetes solo la guarda. Quien la aplica de verdad, paquete a paquete y en cada nodo, es el plugin de red del clúster, lo que se llama el CNI. En nuestro caso, Cilium.

Si tu CNI no sabe aplicar políticas, la política se guarda igual, pero no bloquea nada. Y nadie te avisa.

Tampoco la confundas con Gatekeeper o con Azure Policy. Esas herramientas revisan lo que despliegas antes de que entre en el clúster; por ejemplo, pueden obligarte a que cada namespace tenga su política. Pero no ven el tráfico. El paquete entre dos Pods lo bloquea el CNI.

Con Cilium puedes escribir dos tipos de política. Una es la NetworkPolicy normal de Kubernetes, que Cilium también entiende. La otra es la CiliumNetworkPolicy, que es la suya y permite más cosas: filtrar por el método HTTP, dejar salir a un Pod solo hacia ciertos dominios, como api.stripe.com, o escribir reglas que deniegan.

Aquí uso la de Cilium, porque luego le vamos a añadir una regla de HTTP. Pero antes de escribir la primera regla, vamos a ver cómo funciona Hubble, que es lo que vamos a usar para comprobar qué pasa.

Cómo ve Hubble lo que pasa en la red

¿De dónde saca Hubble la información de lo que pasa en la red? Todo empieza en cada nodo, en los programas eBPF. eBPF te permite cargar programas dentro del kernel de Linux sin tener que modificarlo. Si quieres profundizar, tienes la lección de eBPF.

Los mismos programas eBPF que aplican la política generan eventos sobre el tráfico, tanto el que pasa como el que se descarta. Por defecto Cilium los agrega, así que no ves uno por cada paquete, sino los relevantes de cada conexión. Ese evento llega al agente de Cilium del nodo, que va guardando los últimos en un buffer circular. Cuando el buffer se llena, los eventos nuevos pisan a los más viejos. Así Hubble nunca frena el tráfico.

Dónde está el buffer de Hubble

El buffer circular está en el agente de Cilium, no en el kernel. Su tamaño se configura con el valor hubble.eventBufferCapacity.

Pero cada agente solo ve su propio nodo. Para no tener que preguntarle a cada uno por separado está Hubble Relay, que se conecta a todos los nodos y lo junta todo en una sola vista.

Y para consultar todo eso tienes el comando hubble en la terminal y una interfaz web. Los dos le piden la información a Relay.

Hubble frente a Prometheus

Quizá estés pensando que esto ya lo tienes con Prometheus, o con Alloy. Pues no es lo mismo. Prometheus te da métricas agregadas: cuántas peticiones por segundo, cuánta latencia, cuántos errores. Hubble te da cada conexión: quién habló con quién, y si pasó o se descartó.

Cuando aplicas una política y algo se rompe, Prometheus te dice que suben los errores. Hubble te dice qué paquete se descartó y por qué regla. Además, Hubble exporta sus propias métricas, que puedes recoger con Prometheus. Así que se complementan.

Activar Hubble

Hubble se activa con cilium hubble enable --ui, que despliega Hubble Relay y la interfaz web. Estos son los comandos para activarlo, abrir el acceso y comprobar su estado:

cilium hubble enable --ui
cilium hubble port-forward &
hubble status

Sin política, todo pasa

Para las pruebas tengo tres Pods: un frontend, un backend y otro Pod que va a hacer de intruso.

En una terminal dejo Hubble mirando todo el tráfico que va hacia el backend:

hubble observe --to-pod default/backend --follow

Y en otra llamo al backend, primero desde el frontend y luego desde el intruso:

kubectl exec frontend -- curl -s backend:5678
kubectl exec intruso -- curl -s backend:5678

Las dos peticiones llegan, y Hubble las marca como FORWARDED. Sin ninguna política, en Kubernetes cualquier Pod puede hablar con cualquier otro.

La primera política: al backend solo entra el frontend

Vamos a escribir la primera regla. Lo que quiero es que al backend solo pueda entrar el frontend, y solo por el puerto 5678.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: backend-solo-frontend
spec:
  endpointSelector:
    matchLabels:
      app: backend
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: frontend
    toPorts:
    - ports:
      - port: "5678"
        protocol: TCP

Con endpointSelector le dices a qué Pods protege la regla. En este caso, a los que tienen la etiqueta backend. Y con fromEndpoints le dices quién puede entrar: aquí, los Pods con la etiqueta frontend.

Fíjate en que no aparece ni una IP. Todo son etiquetas.

Aplico la política y repito la prueba:

kubectl apply -f backend-solo-frontend.yaml
kubectl exec frontend -- curl -s backend:5678
kubectl exec intruso -- curl -s --connect-timeout 3 backend:5678

El frontend sigue entrando sin problema, pero el intruso se queda colgado.

¿Y por qué se queda colgado, en vez de darte un error? Porque Cilium tira el paquete sin avisar, así que al intruso nunca le llega ninguna respuesta.

Esto, normalmente, es lo que te hace perder una tarde depurando. Pero si miras la terminal de Hubble, ahí lo tienes: DROPPED, Policy denied, con el origen, el destino y el puerto. Y lo has visto sin entrar al contenedor y sin lanzar un tcpdump.

Si solo quieres ver lo que se descarta, filtras por veredicto:

hubble observe --verdict DROPPED --follow

Por dentro: las identidades de Cilium

Seguramente te has fijado en que Hubble te enseña frontend y backend, y no IPs. ¿Cómo lo hace? Con lo que Cilium llama identidades.

Una identidad es simplemente un número, y Cilium lo calcula a partir de las etiquetas del Pod. Si dos Pods tienen las mismas etiquetas, tienen la misma identidad, da igual en qué nodo estén y qué IP tengan.

Vamos a verlo en el clúster:

kubectl -n kube-system exec ds/cilium -- cilium-dbg endpoint list

Aquí tienes cada Pod con su identidad y sus etiquetas. El frontend tiene un número y el backend tiene otro. En versiones anteriores de Cilium, este comando era cilium endpoint list.

Ahora escalo el backend a 3 réplicas:

kubectl scale deploy/backend --replicas=3
kubectl -n kube-system exec ds/cilium -- cilium-dbg endpoint list

Las 3 réplicas tienen el mismo número: 3 IPs distintas, pero una sola identidad.

Por qué no usar directamente las IPs

¿Y por qué no usar directamente las IPs? Porque en Kubernetes las IPs de los Pods cambian todo el rato. Si un Pod muere, vuelve con otra IP. Si haces un despliegue, cambian todas. Y si escalas a 10 réplicas, tienes 10 IPs nuevas.

Otros plugins de red, como Calico en su modo con iptables, traducen tus etiquetas a listas de IPs en cada nodo. Así que cada vez que un Pod nace o muere, hay que reescribir esas listas en todos los nodos. En un clúster grande eso es mucho trabajo, y mientras se actualizan hay un rato en el que las listas no coinciden con lo que de verdad está corriendo.

Cilium lo resuelve de otra manera: un Pod nuevo con las mismas etiquetas recibe el mismo número de identidad, así que las reglas no hay que tocarlas.

Cómo sabe el nodo de destino la identidad del origen

Cuando un paquete llega a otro nodo, ¿cómo sabe ese nodo qué identidad tiene el Pod que lo envió? Depende de cómo viajen los paquetes entre los nodos, y Cilium tiene dos maneras de hacerlo.

Por defecto, Cilium usa algo que se llama encapsulación. Coge el paquete que sale del Pod y lo mete dentro de otro paquete, que va de la IP de un nodo a la IP del otro. El protocolo que se usa para eso se llama VXLAN. En la cabecera de ese paquete de fuera hay un campo donde Cilium apunta la identidad del Pod de origen. Cuando el paquete llega, el nodo de destino lee ese campo y ya sabe quién lo manda.

La otra manera se llama enrutamiento nativo. Aquí el paquete no se mete dentro de otro, sino que viaja tal cual, con la IP del Pod. El nodo de destino busca esa IP en un mapa de eBPF. Un mapa es una tabla que Cilium tiene dentro del kernel, y ahí guarda qué identidad tiene cada IP del clúster.

Con una forma o con la otra, el nodo de destino acaba sabiendo la identidad del Pod de origen. Y con ese número comprueba si la política deja pasar ese tráfico a este Pod y a este puerto. Es una sola búsqueda en un mapa, no una lista de reglas que hay que recorrer una a una.

El campo exacto y cómo ver el modo de tu clúster

En modo túnel, la identidad viaja en el campo VNI de la cabecera VXLAN (o Geneve). En enrutamiento nativo se resuelve con el mapa ipcache, que relaciona cada IP con su identidad.

Para saber qué modo usa tu clúster, ejecuta cilium config view | grep routing-mode. En kind, lo normal es tunnel con VXLAN.

Capa 7: filtrar por método HTTP

Ahora vamos a afinar un poco más. Quiero que el frontend pueda leer del backend, pero que no pueda borrar.

Eso ya no se puede decidir por puertos, porque leer y borrar van por el mismo puerto. Se decide por el método HTTP, y Cilium también puede controlar eso. A la regla anterior le añado esto:

    toPorts:
    - ports:
      - port: "5678"
        protocol: TCP
      rules:
        http:
        - method: GET

Pruebo las dos cosas:

kubectl exec frontend -- curl -s -o /dev/null -w "%{http_code}\n" backend:5678/api
kubectl exec frontend -- curl -s -o /dev/null -w "%{http_code}\n" -X DELETE backend:5678/api

El GET devuelve un 200 y el DELETE devuelve un 403.

Y ojo con un detalle que mucha gente cuenta mal: eBPF no lee HTTP. eBPF trabaja con paquetes, o sea, con IPs y puertos, que son las capas 3 y 4. Así que, para mirar el método HTTP, Cilium manda ese tráfico a Envoy, que es un proxy que corre en cada nodo. Envoy lee la petición, y es él quien aplica la regla.

¿Y qué ganas con esto? Que ahora Hubble también ve el HTTP:

hubble observe --to-pod default/backend --protocol http

Aquí tienes cada petición, con su método, su ruta y el código de respuesta.

Hubble solo ve el HTTP del tráfico que pasa por Envoy. Si no tienes una regla de capa 7, en Hubble no vas a ver HTTP. Con el DNS pasa lo mismo: para ver las consultas DNS, necesitas una regla de DNS.

El mapa de Hubble UI

La terminal está muy bien cuando sabes lo que buscas. Pero si llegas a un clúster que no conoces, te viene mejor un mapa:

cilium hubble ui

Eliges el namespace y ahí lo tienes: qué Pod habla con cuál, dibujado automáticamente y sin instalar nada en las aplicaciones. Si lanzas otra vez la petición del intruso, en la lista de flujos aparece el flujo descartado, con su veredicto.

Todo cerrado por defecto: el modo always

Una última cosa, pensando en producción. Por defecto, un Pod que no tiene ninguna política acepta todo. Es lo que vimos al principio de las pruebas.

Pero Cilium tiene un modo que hace justo lo contrario, y se llama always. En ese modo todo está cerrado, y solo pasa lo que tú permites explícitamente.

El problema es que, en ese modo, un Pod nuevo no puede ni preguntarle al DNS del clúster. Así que, si lo activas, lo primero que tienes que escribir es la regla para el DNS. Y para descubrir qué más te falta permitir, lo más rápido es mirar Hubble.

Lo que hemos visto

  • Dentro de un clúster de Kubernetes, por defecto, cualquier Pod puede hablar con cualquier otro, y tu firewall no ve ese tráfico.
  • Las Network Policy se escriben con etiquetas y las aplica el CNI, no Kubernetes. Cilium las aplica con identidades: un número que calcula a partir de las etiquetas del Pod.
  • Hubble recoge los eventos que generan los programas eBPF, y con hubble observe ves qué se descarta y por qué regla, sin entrar al contenedor.
  • Para filtrar por método HTTP, Cilium pasa el tráfico por Envoy, y solo entonces Hubble puede ver las peticiones HTTP.
  • En el modo always todo está cerrado por defecto: lo primero es permitir el DNS, y Hubble te dice qué más falta.