Cerramos el módulo con la lección que arregla el agujero que arrastramos desde el módulo 3: cualquier pod del clúster puede conectarse a postgres-reservas:5432. Un pod comprometido, una dependencia maliciosa en una imagen o un despliegue equivocado en rutas-norte-dev tienen hoy acceso directo a la base de datos con el nombre, el DNI, el teléfono y el correo de todos los clientes de Rutas Norte. Las NetworkPolicies son el cortafuegos nativo de Kubernetes, y en esta lección construiremos el modelo de aislamiento de la plataforma paso a paso: denegar todo, abrir el DNS —el fallo que tumba el clúster entero y que casi todo el mundo comete una vez—, y autorizar una por una únicamente las conversaciones que la plataforma necesita, verificando cada regla con pods efímeros.

Contenido

  1. Demostrar el problema: la red es plana
  2. El requisito imprescindible: un CNI que las implemente
  3. Anatomía de una NetworkPolicy
  4. El modelo aditivo: solo se permite, nunca se deniega
  5. from/to: podSelector, namespaceSelector e ipBlock
  6. El error más caro: Y frente a O
  7. Paso 1: denegar todo en rutas-norte-pro
  8. Paso 2: permitir DNS (o romperlo todo)
  9. Paso 3: las conversaciones de la plataforma
  10. Paso 4: el Ingress y la pasarela de pagos externa
  11. Verificación sistemática
  12. Limitaciones reales

  1. Demostrar el problema: la red es plana

Antes de arreglar nada, veámoslo. Lanzamos en producción un pod que no tiene ninguna relación con la plataforma: sin etiquetas de Rutas Norte, sin ServiceAccount dedicada, sin nada.

kubectl run intruso --rm -it --restart=Never -n rutas-norte-pro \
  --image=nicolaka/netshoot -- bash

# Y desde dentro del pod intruso, todo responde:
nc -zv postgres-reservas 5432                  # la base de datos con los datos personales
nc -zv redis-cache 6379                        # la cache
nc -zv postgres-reservas.rutas-norte-dev 5432  # OTRO entorno: los namespaces no aislan
curl -s -o /dev/null https://pagos.proveedorexterno.example/   # y salida a internet

Todo funciona. Es la regla 2 del modelo de red de 04-01: todo pod puede hablar con todo pod sin NAT. No es un fallo: es el comportamiento especificado. Y sus implicaciones son serias:

Escenario Consecuencia hoy
Se explota una vulnerabilidad en tienda-web (nginx expuesto a internet) Desde ese pod se llega directamente a la base de datos
Una dependencia de worker-notificaciones resulta maliciosa Puede exfiltrar datos a cualquier destino de internet
Una prueba mal configurada en dev Puede escribir en postgres-reservas de producción

Ya hemos puesto capas —identidad mínima con ServiceAccounts (03-06) y cuotas por entorno (03-04)—; falta la capa de red.

  1. El requisito imprescindible: un CNI que las implemente

Aquí está el peligro silencioso de esta lección. Kubernetes define el objeto NetworkPolicy, pero no lo aplica. Quien lo aplica es el plugin CNI, y si el plugin no lo implementa el objeto se crea sin ningún error ni aviso, kubectl get networkpolicy lo lista con normalidad, kubectl describe muestra las reglas perfectamente… y el tráfico sigue pasando exactamente igual. Tendrás una política de seguridad que no protege nada y una falsa sensación de estar cubierto. Como vimos en 04-01, Flannel no implementa NetworkPolicy, y es el CNI por defecto de muchísimas instalaciones de laboratorio, incluida la de minikube.

Comprobar qué CNI tienes y activar Calico

kubectl get pods -n kube-system -o name | grep -Ei "flannel|calico|cilium|weave"
# kube-flannel-ds-h4k2p   -> Flannel: las politicas NO van a hacer nada

# Perfil nuevo con Calico (el CNI no se cambia en caliente con seguridad)
minikube start -p rutas-norte --cni=calico \
  --addons=ingress,metrics-server,storage-provisioner
kubectl get pods -n kube-system -l k8s-app=calico-node

Alternativa: --cni=cilium, que además da políticas L7 y observabilidad con Hubble. Pero nunca confíes en el nombre del plugin: verifica con una prueba real (ejercicio 1). La idea es crear un pod diana en un namespace de usar y tirar, comprobar que responde, aplicar un deny-all de ingress y comprobar que deja de responder; si sigue respondiendo, tu CNI ignora las políticas. Hazlo siempre en un clúster nuevo, antes de escribir una sola política de verdad.

  1. Anatomía de una NetworkPolicy

apiVersion: networking.k8s.io/v1     # v1 estable; no uses extensions/v1beta1
kind: NetworkPolicy
metadata:
  name: ejemplo
  namespace: rutas-norte-pro         # SIEMPRE tiene namespace
spec:
  podSelector:                       # 1. A QUE PODS se aplica (en su namespace)
    matchLabels: { app: postgres-reservas }
  policyTypes: [Ingress, Egress]     # 2. QUE DIRECCIONES regula
  ingress:                           # 3. Reglas de entrada
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }
      ports:
        - { protocol: TCP, port: 5432 }
  egress:                            # 4. Reglas de salida
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
      ports:
        - { protocol: UDP, port: 53 }
Campo Significado Detalle crítico
podSelector Los pods protegidos por esta política {} (vacío) significa todos los pods del namespace
policyTypes Qué direcciones regula Si omites Egress, la salida no se restringe
ingress[].from / egress[].to Orígenes permitidos para entrar / destinos permitidos para salir Listas; cada elemento es un "O"
ports Puertos y protocolos permitidos Si se omite, todos los puertos

Tres avisos sobre policyTypes, que es donde más se falla: si lo omites, Kubernetes lo infiere (incluye Ingress siempre y Egress solo si hay bloque egress), así que escríbelo explícitamente; policyTypes: [Ingress] con ingress: [] significa "denegar toda la entrada", distinto de no tener el campo; y policyTypes: [Ingress, Egress] sin reglas es el aislamiento total. Y el punto clave: una NetworkPolicy tiene namespace y su podSelector solo mira dentro del suyo. Para proteger los tres entornos hacen falta las políticas en los tres.

  1. El modelo aditivo: solo se permite, nunca se deniega

El modelo mental que hay que fijar antes de escribir nada:

flowchart LR
    A["Alguna NetworkPolicy selecciona<br/>a este pod en esta direccion?"] -->|NO| B["TODO PERMITIDO<br/>(el pod no esta aislado)"]
    A -->|SI| C["Pod AISLADO:<br/>por defecto se deniega todo"]
    C --> D["Alguna de esas politicas<br/>permite este trafico?"]
    D -->|"SI (basta UNA)"| E["PERMITIDO"]
    D -->|NO| F["DENEGADO"]

De ahí salen cuatro reglas de oro: sin políticas, todo pasa (el estado de Rutas Norte hasta ahora); en cuanto UNA política selecciona a un pod en una dirección, ese pod queda aislado en esa dirección y solo pasa lo explícitamente permitido; las políticas son aditivas y no existe "denegar" —no hay campo deny, la unión de todas las que seleccionan al pod define lo permitido y basta con que una lo autorice—; y no hay prioridades ni orden, ni order ni "la última gana".

Consecuencia práctica: no puedes hacer una excepción restrictiva. Si una política permite a todo rutas-norte-pro llegar a postgres-reservas, no puedes añadir otra que diga "menos este pod": hay que eliminar el permiso amplio y enumerar los permitidos. Otro detalle que confunde: ingress y egress se evalúan por separado y en ambos extremos, así que para que api-reservas hable con postgres-reservas hacen falta dos autorizaciones —el egress del emisor y el ingress del receptor—, y si falta cualquiera no hay conexión; es la causa número uno de "he permitido el tráfico y sigue sin funcionar". Por último, las políticas actúan sobre conexiones y los CNI que las implementan siguen el estado, así que la respuesta vuelve sin necesitar una regla de vuelta.

  1. from/to: podSelector, namespaceSelector e ipBlock

Hay exactamente tres formas de identificar el otro extremo.

podSelector: pods del MISMO namespace

  ingress:
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }   # solo pods de ESTE namespace

Un podSelector dentro de from/to no puede alcanzar otros namespaces; para eso está el siguiente.

namespaceSelector: todos los pods de ciertos namespaces

  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }

Kubernetes añade a cada namespace la etiqueta kubernetes.io/metadata.name con su nombre, lo que evita etiquetarlos a mano; aun así conviene poner etiquetas propias y estables (kubectl label namespace rutas-norte-pro entorno=pro).

ipBlock: rangos CIDR, para lo que está fuera del clúster

  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0            # todo internet...
            except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]  # ...menos redes privadas
      ports:
        - { protocol: TCP, port: 443 }

Dos advertencias: no sirve para pods —aunque técnicamente una IP de pod esté en un CIDR, la implementación lo trata como tráfico externo y no es fiable; para pods, selectores— y except solo resta del cidr de ese mismo bloque, no es una denegación global. El patrón 0.0.0.0/0 con except de las redes privadas es el estándar para decir "puede salir a internet, pero no puede pivotar hacia la red interna de la empresa".

  1. El error más caro: Y frente a O

Este apartado es el que más incidentes de seguridad reales evita. Comparemos dos fragmentos que se diferencian en un guion:

# VERSION A -- OJO: esto es un O (union)
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }
        - namespaceSelector:              # <-- guion propio: OTRO elemento de la lista
            matchLabels: { entorno: pro }

# VERSION B -- esto es un Y (interseccion)
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }
          namespaceSelector:              # <-- SIN guion: mismo elemento
            matchLabels: { entorno: pro }
Versión A (dos guiones) Versión B (un guion)
Semántica podSelector O namespaceSelector podSelector Y namespaceSelector
Quién entra Pods app=api-reservas de este namespace, más TODOS los pods de cualquier namespace con entorno=pro Solo los pods app=api-reservas que estén en un namespace entorno=pro
Riesgo Enorme: cualquier pod de producción entra a la base de datos Correcto

La versión A abre la base de datos a namespaces enteros. Es un fallo que se cuela con facilidad en una revisión de código porque el YAML parece decir lo contrario, y el clúster no da ningún aviso: la política se aplica y funciona, solo que permite mucho más de lo previsto. Regla mnemotécnica: cada guion de la lista from es un "O"; los campos dentro de un mismo elemento son un "Y". La misma distinción aplica a los elementos de ingress (lista de reglas unidas por O) y a ports dentro de una regla.

  1. Paso 1: denegar todo en rutas-norte-pro

Empezamos por el cimiento: cerrar todo, y después abrir lo justo, aunque durante unos minutos la plataforma quede rota.

# k8s/entornos/pro/np-00-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
  namespace: rutas-norte-pro
  labels: { app.kubernetes.io/part-of: rutas-norte, entorno: pro }
spec:
  podSelector: {}                # {} = TODOS los pods del namespace
  policyTypes: [Ingress, Egress]
  # sin bloques ingress ni egress = no se permite nada
kubectl apply -f k8s/entornos/pro/np-00-deny-all.yaml

kubectl exec -n rutas-norte-pro deploy/api-reservas -- \
  timeout 5 nc -zv postgres-reservas 5432 || echo "DENEGADO (esperado)"

La plataforma está completamente rota, y es lo que queríamos: a partir de aquí cada regla será una decisión consciente y documentada. Es el mínimo privilegio aplicado a la red. Nota importante: kubectl exec, kubectl logs y las sondas de salud del kubelet (07-01) no se ven afectados, porque vienen del nodo y no de otro pod; por eso puedes seguir depurando con un clúster totalmente cerrado.

  1. Paso 2: permitir DNS (o romperlo todo)

Este es el fallo clásico de las NetworkPolicies, y merece apartado propio porque su síntoma despista muchísimo. Con deny-all activo ningún pod puede hablar con CoreDNS, y en los logs de api-reservas se ve esto:

Error: getaddrinfo EAI_AGAIN postgres-reservas
Error: getaddrinfo EAI_AGAIN pagos.proveedorexterno.example

El síntoma no es "conexión rechazada": es un fallo de resolución de nombres tras varios segundos de espera, el fallo tipo 1 de 04-03. La confusión típica es investigar CoreDNS, que está perfectamente sano, en lugar de mirar la política de egress recién aplicada. Agravante: un deny-all de egress en kube-system deja sin DNS a todo el clúster; nunca apliques políticas amplias en los namespaces del sistema.

# k8s/entornos/pro/np-01-allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: rutas-norte-pro }
spec:
  podSelector: {}                    # todos los pods del namespace necesitan DNS
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:               # SIN guion: Y logico -> CoreDNS DENTRO de kube-system
            matchLabels: { k8s-app: kube-dns }
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }   # imprescindible: respuestas grandes, NodeLocal DNSCache

Tres detalles que hay que respetar:

  1. podSelector: {}: el DNS lo necesitan todos los pods, sin excepción. Es la primera regla que se escribe siempre.
  2. UDP y TCP en el 53. Se olvida el TCP constantemente. El DNS cae a TCP cuando la respuesta no cabe en un paquete UDP, y en un Service headless con muchos pods eso pasa: el síntoma es demoledor, funciona casi siempre y falla de forma aparentemente aleatoria.
  3. namespaceSelector + podSelector sin guion, para que sea la intersección: CoreDNS dentro de kube-system. Con guion abrirías todo kube-system. Verifica además la etiqueta real de esos pods (kubectl get pods -n kube-system --show-labels | grep dns); en algunas distribuciones es k8s-app: coredns.
kubectl apply -f k8s/entornos/pro/np-01-allow-dns.yaml
kubectl exec -n rutas-norte-pro deploy/api-reservas -- nslookup postgres-reservas
# resuelve correctamente
kubectl exec -n rutas-norte-pro deploy/api-reservas -- timeout 5 nc -zv postgres-reservas 5432
# sigue DENEGADO: resolver no es conectar ([04-03](04-03-dns-interno-y-descubrimiento-de-servicios))

  1. Paso 3: las conversaciones de la plataforma

Enumeramos, una a una, las conexiones legítimas de Rutas Norte:

Origen Destino Puerto Motivo
api-reservas postgres-reservas 5432 Consultar disponibilidad y crear reservas
worker-notificaciones postgres-reservas 5432 Leer los datos de la reserva para el correo
api-reservas redis-cache 6379 Caché de disponibilidad de plazas
Controlador de Ingress tienda-web 8080 Tráfico público de la tienda
Controlador de Ingress api-reservas 3000 Tráfico público de la API
api-reservas Pasarela de pagos 443 Cobrar los billetes
Todos CoreDNS 53 Ya autorizado

Nada más: tienda-web no habla con la base de datos (sirve una SPA estática), redis-cache no inicia conexiones y postgres-reservas no sale a ningún sitio.

La base de datos: solo dos clientes

# k8s/entornos/pro/np-02-postgres.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: postgres-reservas-ingress, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchLabels: { app: postgres-reservas }   # solo app y entorno en los selectores
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: { matchLabels: { app: api-reservas } }           # guion 1
        - podSelector: { matchLabels: { app: worker-notificaciones } }  # guion 2 (O)
      ports:
        - { protocol: TCP, port: 5432 }
---
# k8s/entornos/pro/np-03-api-reservas-egress.yaml -- y ahora el lado del EMISOR,
# que es la mitad que se olvida: sin este egress, la conexion tampoco se establece
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-reservas-egress, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchLabels: { app: api-reservas }
  policyTypes: [Egress]
  egress:
    - to: [ { podSelector: { matchLabels: { app: postgres-reservas } } } ]
      ports: [ { protocol: TCP, port: 5432 } ]
    - to: [ { podSelector: { matchLabels: { app: redis-cache } } } ]
      ports: [ { protocol: TCP, port: 6379 } ]

La primera es la política que justifica toda la lección: ningún otro pod del clúster puede ya intentar abrir una conexión a la base de datos con los datos personales. Y recuerda que allow-dns-egress y la segunda se suman: api-reservas sale al DNS, a PostgreSQL y a Redis, y a nada más. El egress de worker-notificaciones es análogo, con PostgreSQL y el correo corporativo.

  1. Paso 4: el Ingress y la pasarela de pagos externa

Desde el controlador de Ingress

El controlador vive en ingress-nginx, así que necesitamos namespaceSelector:

# k8s/entornos/pro/np-04-desde-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-ingress-controller, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchExpressions:
      - { key: app, operator: In, values: ["tienda-web", "api-reservas"] }
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
      ports:
        - { protocol: TCP, port: 8080 }   # targetPort de tienda-web
        - { protocol: TCP, port: 3000 }   # targetPort de api-reservas

Dos detalles: los puertos son los del contenedor (targetPort), no los del Service, porque las políticas ven el tráfico real que llega al pod y kube-proxy ya tradujo el port en el nodo de origen (04-01) —poner el 80 en lugar del 8080 produce un 503 en el Ingress que cuesta mucho relacionar con una política—; y matchExpressions de 02-07 resuelve el "uno u otro" en el podSelector.

Hacia la pasarela de pagos

Está fuera del clúster, en pagos.proveedorexterno.example: los selectores no sirven, hay que usar ipBlock.

# k8s/entornos/pro/np-05-pasarela-pagos.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-reservas-egress-pagos, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchLabels: { app: api-reservas }
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 198.51.100.0/24     # rango publicado por el proveedor de pagos
      ports:
        - { protocol: TCP, port: 443 }

Puntos delicados: un ExternalName no ayuda aquí, porque el Service pasarela-pagos de 04-02 solo crea un CNAME y el tráfico real va a una IP externa que hay que autorizar por ipBlock; las políticas trabajan con IPs, no con nombres, así que si el proveedor cambia de rango la conexión se rompe sin aviso —usa el rango que publica y documenta, no la IP que devuelva un dig hoy—; el DNS ya está permitido por la política del paso 2, sin la cual api-reservas ni podría resolver el nombre; y si no hay rangos estables, la alternativa es un proxy de salida con lista blanca por dominio, o Cilium, con políticas FQDN.

El resultado completo

flowchart LR
    NET["Internet"] --> IC["ingress-nginx"]
    IC -->|":8080"| TW["tienda-web"]
    IC -->|":3000"| API["api-reservas"]
    API -->|":5432"| PG["postgres-reservas"]
    API -->|":6379"| RC["redis-cache"]
    API -->|":443 ipBlock"| PAY["pagos.proveedorexterno.example"]
    WK["worker-notificaciones"] -->|":5432"| PG
    ALL["Todos los pods"] -->|":53 UDP/TCP"| DNS["CoreDNS (kube-system)"]
    X["Cualquier otro pod"] -.->|"DENEGADO"| PG

Todo lo que no aparece en ese diagrama está denegado.

  1. Verificación sistemática

Una política sin verificar es una suposición: hay que comprobar las dos caras.

#!/bin/bash
# verificar-politicas.sh -- comprobacion de rutas-norte-pro
NS=rutas-norte-pro
probar() {   # probar <descripcion> <deploy> <destino> <puerto> <esperado:OK|KO>
  RES=$(kubectl exec -n $NS deploy/$2 -- timeout 5 nc -z $3 $4 2>&1 && echo OK || echo KO)
  [ "$RES" = "$5" ] && echo "  PASA   $1" || echo "  FALLA  $1 (esperado $5)"
}
# Debe FUNCIONAR
probar "api-reservas -> postgres" api-reservas          postgres-reservas 5432 OK
probar "api-reservas -> redis"    api-reservas          redis-cache       6379 OK
probar "worker -> postgres"       worker-notificaciones postgres-reservas 5432 OK
# Debe FALLAR
probar "tienda-web -> postgres"   tienda-web            postgres-reservas 5432 KO
probar "worker -> redis"          worker-notificaciones redis-cache       6379 KO
probar "tienda-web -> redis"      tienda-web            redis-cache       6379 KO

Y la prueba que da sentido a todo, con el pod intruso del apartado 1:

kubectl run intruso --rm -it --restart=Never -n rutas-norte-pro \
  --image=nicolaka/netshoot -- timeout 5 nc -zv postgres-reservas 5432
# nc: connect to postgres-reservas port 5432 (tcp) timed out: Operation now in progress

Tiempo de espera agotado. El agujero que arrastrábamos desde el módulo 3 está cerrado. Fíjate en el detalle: es tiempo agotado, no "conexión rechazada", porque las NetworkPolicies descartan los paquetes en silencio, sin enviar un RST. Esa firma sirve para diagnosticar:

Síntoma Causa probable
connection refused (inmediato) Hay conectividad; el proceso no escucha en ese puerto
timed out (5-30 s) NetworkPolicy descartando, o un problema de red
EAI_AGAIN / no such host DNS bloqueado o caído
503 en el Ingress Endpoints vacíos, o política bloqueando al controlador

  1. Limitaciones reales

Son imprescindibles y también claramente limitadas. Hay que saber qué no hacen.

Limitación Detalle Qué lo cubre
Operan en L3/L4 Solo IP, puerto y protocolo: no entienden HTTP, rutas, métodos ni cabeceras Cilium (L7) o una malla
Sin identidad criptográfica La "identidad" es una etiqueta: quien pueda crear pods con ella suplanta al componente mTLS con una malla (08-04)
No registran denegaciones No hay log de paquetes descartados; se depura a base de tiempos de espera Hubble (Cilium), flujos de Calico
Sin "denegar" explícito Solo se suma permiso, no caben excepciones restrictivas Políticas propias de Calico (order, Deny)
Sin DNS/FQDN ni cifrado ipBlock con IPs que cambian sin avisar; restringen quién habla, no protegen el contenido FQDN de Cilium, proxy de salida, WireGuard
No aplican a hostNetwork Un pod con red de host escapa al modelo Pod Security Standards (08-03)

Lo que aportarían Cilium o una malla en Rutas Norte: permitir solo GET /disponibilidad y POST /reservas en lugar de "el puerto 3000 entero"; identidad basada en certificados, de modo que crear un pod con app: api-reservas no baste para suplantar al componente; visibilidad de flujos permitidos y denegados, que convierte "esto se queda colgado" en "esta política concreta lo descartó"; y políticas por nombre de dominio para la pasarela de pagos.

Nada de eso quita valor a lo construido: las NetworkPolicies son la capa base y la que exige cualquier auditoría. La estrategia completa se estudia en 08-04.

Errores Comunes y Consejos

  • Escribir políticas con un CNI que las ignora. Se aplican sin error y no protegen nada. Verifica siempre con una prueba real, no con el nombre del plugin.
  • Olvidar el DNS. El primer deny-all de egress rompe la resolución en todo el namespace y el síntoma (EAI_AGAIN) no apunta a la política: la regla de DNS es siempre la primera que se escribe. Y permitir solo UDP en el 53 falla de forma intermitente: autoriza UDP y TCP.
  • El guion de más en from. Convierte un Y en un O y abre namespaces enteros. Reléelo en cada revisión de código.
  • Autorizar solo una dirección (hacen falta el egress del emisor y el ingress del receptor), o usar el puerto del Service en lugar del targetPort: las políticas ven el puerto del contenedor.
  • Aplicar deny-all en kube-system. Puedes dejar sin DNS, sin métricas y sin Ingress a todo el clúster.
  • Esperar un "denegar" explícito (no existe: para restringir hay que quitar el permiso amplio) o confundir timed out con connection refused: el primero huele a política, el segundo a proceso caído.
  • Consejo: aplica las políticas primero en rutas-norte-dev, verifica el guion de comprobación y solo entonces promociona a pre y pro. Un deny-all mal calculado en producción es una caída total.
  • Consejo: nombra los ficheros con prefijo numérico (np-00-deny-all, np-01-allow-dns, ...) para que el orden de lectura refleje el orden de construcción.
  • Consejo: documenta cada política con la conversación de negocio que autoriza. "worker-notificaciones lee la reserva para enviar el correo" envejece mucho mejor que "permite 5432".

Ejercicios

Ejercicio 1: Verificar que el CNI aplica las políticas

Determina qué CNI está instalado y demuestra con una prueba práctica —no con el nombre del plugin— si las NetworkPolicies se aplican. Si no, recrea el perfil con Calico y repite.

Ejercicio 2: Construir el modelo completo de rutas-norte-pro

Aplica en orden deny-all, la regla de DNS y las políticas de los apartados 9 y 10, documentando tras cada paso qué funciona y qué deja de funcionar. Al terminar, ejecuta el guion de verificación y comprueba que el pod intruso ya no llega a la base de datos.

Ejercicio 3: Detectar el error de Y frente a O en una revisión

Te llega esta política para revisar. Identifica el fallo, explica qué permite de más y corrígela.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: redis-solo-api, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchLabels: { app: redis-cache }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }
        - namespaceSelector:
            matchLabels: { entorno: pro }
      ports:
        - { protocol: TCP, port: 6379 }

Soluciones

Ejercicio 1

kubectl get pods -n kube-system -o name | grep -Ei "flannel|calico|cilium|weave"

kubectl create ns prueba-np
kubectl run diana --image=nginx -n prueba-np --labels=app=diana
kubectl expose pod diana --port=80 -n prueba-np
kubectl wait --for=condition=ready pod diana -n prueba-np --timeout=60s
kubectl apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-all-ingress, namespace: prueba-np }
spec: { podSelector: {}, policyTypes: [Ingress] }
EOF
kubectl run p --rm -it --restart=Never -n prueba-np --image=nicolaka/netshoot \
  -- curl -s -o /dev/null --max-time 5 http://diana \
  && echo "EL CNI IGNORA LAS POLITICAS" || echo "EL CNI LAS APLICA (correcto)"

kubectl delete ns prueba-np

Con Flannel el curl responderá (las ignora); con Calico o Cilium agotará el tiempo. Si hay que reconstruir, minikube delete -p rutas-norte y recrear el perfil con --cni=calico.

Ejercicio 2

Paso Funciona tras aplicarlo Sigue sin funcionar
1. deny-all Solo kubectl exec/logs y las sondas Todo el tráfico: DNS, base de datos, caché, Ingress
2. allow-dns-egress Resolución de nombres Todas las conexiones
3. PostgreSQL y Redis api-reservas y worker alcanzan sus dependencias La entrada desde el Ingress
4. Ingress y pagos La plataforma completa, incluidos los cobros Todo lo no autorizado
for f in np-00-deny-all np-01-allow-dns np-02-postgres \
         np-03-api-reservas-egress np-04-desde-ingress np-05-pasarela-pagos; do
  kubectl apply -f k8s/entornos/pro/$f.yaml
  kubectl exec -n rutas-norte-pro deploy/api-reservas -- \
    timeout 5 nc -z postgres-reservas 5432 && echo "$f: postgres OK" || echo "$f: postgres KO"
done

bash verificar-politicas.sh
kubectl run intruso --rm -it --restart=Never -n rutas-norte-pro \
  --image=nicolaka/netshoot -- timeout 5 nc -zv postgres-reservas 5432
# nc: connect to postgres-reservas port 5432 (tcp) timed out

Ejercicio 3

El fallo son los dos guiones en from: el podSelector y el namespaceSelector son elementos distintos de la lista, así que se combinan con O. Lo que permite de más es enorme: cualquier pod, de cualquier namespace etiquetado entorno: pro, puede conectar a redis-cache:6379. La intención era "solo api-reservas" y el resultado es "todo el entorno de producción"; un pod comprometido en cualquier namespace de producción podría leer y envenenar la caché de disponibilidad de plazas.

Corrección: unir ambos selectores en un solo elemento (un Y), o simplemente dejar el podSelector, dado que la política ya vive en rutas-norte-pro.

  ingress:
    - from:
        - podSelector:
            matchLabels: { app: api-reservas }
          namespaceSelector:            # SIN guion: interseccion
            matchLabels: { entorno: pro }
      ports:
        - { protocol: TCP, port: 6379 }
kubectl exec -n rutas-norte-pro deploy/api-reservas -- timeout 5 nc -z redis-cache 6379 \
  && echo "api-reservas: OK (debe funcionar)"
kubectl exec -n rutas-norte-pro deploy/tienda-web -- timeout 5 nc -z redis-cache 6379 \
  || echo "tienda-web: DENEGADO (correcto)"

Conclusión

Has cerrado el agujero: empezaste demostrando con un pod intruso que sin NetworkPolicies la red es plana y que cualquier contenedor del clúster llega a la base de datos con los datos personales de los clientes, y has terminado con esa misma prueba agotando el tiempo de espera. En el camino has fijado lo que más importa. Primero, que Kubernetes define el objeto pero no lo aplica: lo hace el CNI, y un plugin como Flannel acepta tus políticas sin una sola advertencia y no filtra nada, por lo que la única garantía aceptable es una prueba práctica en cada clúster. Segundo, el modelo aditivo: mientras ninguna política seleccione a un pod, todo pasa; en cuanto una lo hace, ese pod queda aislado en esa dirección y solo pasa lo explícitamente permitido; las políticas se suman, no hay prioridades y no existe denegar, lo que significa que no caben excepciones restrictivas. Tercero, que hacen falta las dos direcciones: el egress del emisor y el ingress del receptor. Y dominas los tres selectores —podSelector para el mismo namespace, namespaceSelector para cruzarlos e ipBlock con except para lo externo— y sobre todo la diferencia entre Y y O, ese guion de más que convierte "solo api-reservas" en "todo el entorno de producción" sin que el clúster diga nada.

Has construido el modelo completo de rutas-norte-pro en el orden correcto: deny-all primero, aunque rompa la plataforma; DNS después, con UDP y TCP hacia CoreDNS, evitando el fallo clásico cuyo síntoma (EAI_AGAIN) apunta a todas partes menos a la política que acabas de aplicar; y luego cada conversación de negocio autorizada una a una: api-reservas y worker-notificaciones hacia PostgreSQL, api-reservas hacia Redis, el controlador de Ingress hacia la tienda y la API por sus targetPort, y api-reservas hacia la pasarela de pagos por ipBlock. Todo verificado por ambas caras, sabiendo que un timed out huele a política y un connection refused a proceso caído. Y conoces los límites: L3/L4, sin identidad criptográfica, sin registro de denegaciones y sin nombres de dominio, ahí donde entran Cilium o una malla de servicio (08-04).

Con esto termina el módulo 4 y Rutas Norte es por primera vez una plataforma real y accesible. Los clientes entran por https://www.rutasnorte.example, las agencias consumen https://api.rutasnorte.example, los certificados se renuevan solos, el descubrimiento entre componentes va por DNS y la red está segmentada de modo que solo las conversaciones legítimas son posibles.

Queda una pieza que arrastramos desde el módulo 2 con una nota de "PROVISIONAL" en el manifiesto: postgres-reservas no tiene almacenamiento persistente. Su Deployment guarda los datos dentro del contenedor, así que cada reinicio del pod —un rollout, un desalojo, la caída de un nodo— borra todas las reservas y todos los clientes. El módulo 5, Almacenamiento en Kubernetes, resuelve exactamente eso: volúmenes, PersistentVolumes y PersistentVolumeClaims, clases de almacenamiento, aprovisionamiento dinámico con expansión y snapshots, y copias de seguridad y restauración. Es el módulo que convierte a Rutas Norte en una plataforma en la que se puede confiar.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados