NetworkPolicies en Kubernetes: por qué el tráfico este-oeste sigue abierto por defecto
Un clúster de Kubernetes recién instalado, sin ninguna NetworkPolicy aplicada, no tiene ninguna restricción de red entre pods. Cualquier pod puede abrir una conexión TCP o UDP a cualquier otro pod del clúster, sin importar en qué namespace esté cada uno, sin pasar por ningún control de identidad ni de autorización a nivel de red. Un pod en el namespace frontend puede conectarse directamente a la base de datos que corre en el namespace data, sin que nada en la capa de red lo impida. Esto no es un descuido de configuración: es el comportamiento que el modelo de red de Kubernetes define como punto de partida.
El motivo es que Kubernetes especifica el modelo de red —cada pod tiene su propia IP, todos los pods pueden hablar entre sí sin NAT— pero no especifica ninguna restricción sobre quién puede hablar con quién. Esa restricción es responsabilidad explícita del recurso NetworkPolicy, y una NetworkPolicy solo tiene efecto si el plugin de red (CNI) del clúster sabe interpretarla. Algunos CNI muy usados en instalaciones simples, como Flannel en su configuración por defecto, no aplican NetworkPolicies en absoluto: el recurso se puede crear sin error, pero no bloquea nada. Antes de diseñar cualquier política, lo primero que confirmamos es si el CNI del clúster —Calico, Cilium, o el plugin de red administrado del proveedor cloud— soporta y aplica NetworkPolicies, porque escribir políticas que el plugin ignora en silencio es peor que no tener ninguna: da la falsa sensación de que el tráfico está controlado.
Qué significa "este-oeste" aquí
Tráfico este-oeste es el tráfico entre servicios dentro del clúster, para distinguirlo del tráfico norte-sur que entra o sale del clúster hacia el exterior. La mayoría de los esfuerzos de seguridad de red en una organización se concentran en el borde: firewalls, WAF, reglas de ingreso. El tráfico este-oeste casi nunca recibe la misma atención, y es exactamente el que un atacante que ya comprometió un pod —por una dependencia vulnerable, una imagen con una librería desactualizada, un contenedor con permisos de más— va a usar para moverse lateralmente hacia servicios con datos más sensibles. Si todos los pods pueden hablar con todos, comprometer el servicio más expuesto y menos crítico del clúster equivale, en la práctica, a tener acceso de red a todo lo demás.
Construir una política de denegación por defecto
El punto de partida razonable no es escribir una política para cada servicio, sino invertir el modelo: denegar todo el tráfico este-oeste por defecto, y abrir explícitamente solo las conexiones que cada servicio necesita.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: produccion
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Un podSelector vacío selecciona todos los pods del namespace. Sin ninguna regla de ingress ni egress definida, y con ambos tipos de política activos, el efecto es que ningún pod de ese namespace puede iniciar ni recibir ninguna conexión, salvo lo que otras NetworkPolicies permitan explícitamente. Las NetworkPolicies son aditivas: si existe al menos una política que selecciona un pod y permite un tráfico específico, ese tráfico queda permitido, sin importar cuántas políticas de denegación también existan.
Lo que se rompió la primera vez
La primera vez que aplicamos una política de este tipo en un namespace con tráfico real, el síntoma no fue una conexión rechazada con un error claro. Fue peor: empezaron a aparecer timeouts de DNS en pods que, hasta ese momento, funcionaban sin problema. Cualquier llamada que dependiera de resolver un nombre —ya fuera un servicio interno por su nombre corto, o un dominio externo como el de un proveedor de pagos— se quedaba colgada durante varios segundos antes de fallar.
La causa es que la política de denegación por defecto bloquea egress sin distinción, y resolver un nombre DNS es, en sí mismo, una conexión de red: un paquete UDP (o TCP, si la respuesta no cabe en un datagrama) hacia CoreDNS, que normalmente corre como un Deployment en el namespace kube-system. Al no existir ninguna regla que permitiera explícitamente ese tráfico, cada pod del namespace quedó sin forma de resolver ningún nombre, interno o externo. El clúster seguía funcionando desde el punto de vista del control plane, pero cualquier aplicación dentro de ese namespace que no tuviera ya una conexión abierta y una IP cacheada dejó de poder comunicarse con nada fuera de sí misma.
La corrección es una política adicional, aditiva a la de denegación, que permite explícitamente el tráfico de egress hacia CoreDNS:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: produccion
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Esta regla tiene que existir antes de activar cualquier denegación por defecto en un namespace con tráfico activo, no después. Aplicar primero el deny y recién después el allow de DNS significa, en la práctica, aceptar una ventana de interrupción total mientras se corrige.
Otros flujos que suelen quedar fuera sin que se note de inmediato
El DNS es el primero que se rompe porque afecta a todo, pero no es el único tráfico que una política de denegación por defecto corta sin avisar. El scraping de métricas de Prometheus, cuando el servidor corre en un namespace de observabilidad distinto al de la aplicación, necesita una regla de ingress explícita que lo permita desde ese namespace hacia el puerto de métricas de cada pod. Los webhooks de admisión y los sidecars de un service mesh que dependen de llamadas entre namespaces tienen el mismo problema. Y el tráfico hacia el propio API server de Kubernetes —necesario para cualquier controlador o aplicación que use el cliente de Kubernetes para leer o modificar recursos del clúster— también queda bloqueado si egress está denegado por defecto sin una excepción hacia el rango de IP del API server.
Ninguno de estos casos produce un error inmediato y explícito al aplicar la política. El error aparece minutos o hasta horas después, cuando algo que dependía de ese tráfico necesita usarlo: un pod se reinicia y no logra resolver el nombre de la base de datos, o Prometheus marca el servicio como objetivo caído sin que el servicio en sí tenga ningún problema real.
Qué revisamos antes de aplicar una política de denegación por defecto
Antes de aplicar un default-deny-all en un namespace con tráfico activo, confirmamos primero que el CNI del clúster aplica NetworkPolicies de verdad y no solo las acepta sin efecto; escribimos y aplicamos la regla de egress hacia CoreDNS en el mismo cambio que el deny, nunca como un paso posterior; listamos explícitamente cada dependencia cruzada de namespace que el servicio tiene —scraping de métricas, webhooks, llamadas al API server, sidecars de malla de servicios— y escribimos la regla de allow correspondiente antes de activar el deny; y aplicamos el cambio primero en un namespace de staging con tráfico sintético que ejercite esos mismos flujos, porque un namespace de producción sin ninguna NetworkPolicy previa no da ninguna señal de advertencia hasta que la política ya está activa y algo deja de responder.