Hemos cerrado dos dimensiones de la seguridad de Rutas Norte. Con RBAC (08-01) controlamos quién puede hacer qué contra la API. Con los contextos de seguridad (08-02) y las políticas de admisión (08-03) controlamos qué puede hacer un contenedor una vez está corriendo, y conseguimos que el clúster lo haga cumplir por sí solo.

Queda una dimensión entera: qué puede hablar con qué. Y aquí ya hicimos trabajo importante en 04-06, cuando pusimos una NetworkPolicy deny-all en rutas-norte-pro y autorizamos cada conversación una a una. Pero entonces señalamos tres límites incómodos que dejamos aparcados:

  1. Las NetworkPolicies trabajan en L3/L4: entienden de IP y de puertos, no de rutas HTTP ni de métodos. api-reservas puede llegar a postgres-reservas, sí, pero la política no distingue entre una consulta legítima y un volcado completo de la tabla de clientes.
  2. No registran nada. Un intento de conexión denegado es completamente invisible. Si alguien está sondeando la red interna, no nos enteramos.
  3. Dentro del clúster, todo el tráfico viaja sin cifrar una vez pasado el TLS del Ingress (04-05). Quien pueda observar la red del nodo ve las consultas a postgres-reservas en claro, con nombres, DNI y teléfonos incluidos.

Y hay una cuarta cosa de la que apenas hemos hablado: el tráfico saliente. Es la vía natural por la que sale una base de datos de clientes.

Esta lección no repite la sintaxis de NetworkPolicy: la das por sabida y la usamos. Lo que construimos aquí es la estrategia de red completa de una plataforma en producción, por capas.

Advertencia importante. El diseño de la arquitectura de red de una plataforma de producción debe revisarlo un profesional de seguridad, que evaluará el modelo de amenazas concreto. Y como el tráfico de Rutas Norte transporta datos personales de clientes —nombre, DNI, teléfono y correo—, las decisiones sobre cifrado en tránsito, control de salida y registro de flujos deben conocerlas y aprobarlas también el responsable de cumplimiento normativo. El enfoque de esta lección es exclusivamente defensivo: entender los caminos por los que puede fugarse información para cerrarlos y detectarlos.

Contenido

  1. Las capas de defensa en red
  2. Microsegmentación como principio
  3. Control del tráfico saliente
  4. El problema de las IP frente a los nombres de dominio
  5. Cifrado en tránsito dentro del clúster
  6. Qué es una malla de servicio y qué cuesta
  7. Istio, Linkerd y Cilium comparados
  8. Políticas de autorización de nivel 7
  9. Protección del perímetro
  10. Seguridad del plano de control
  11. Registro y visibilidad del tráfico
  12. Confianza cero aplicada a Rutas Norte
  13. Errores comunes y consejos
  14. Ejercicios
  15. Conclusión

  1. Las capas de defensa en red

La seguridad de red no es un mecanismo: es una serie de capas, cada una de las cuales asume que la anterior puede fallar. Este es el modelo completo que vamos a construir.

flowchart TB
    I["Internet"] --> WAF["Capa 1: perímetro<br/>WAF + limitación de peticiones<br/>+ protección DoS"]
    WAF --> ING["Capa 2: Ingress<br/>TLS terminado (04-05)<br/>cabeceras de seguridad"]
    ING --> NP["Capa 3: microsegmentación<br/>NetworkPolicy deny-all + permisos<br/>explícitos (04-06)"]
    NP --> MESH["Capa 4: mTLS + autorización L7<br/>identidad por carga de trabajo<br/>(malla de servicio)"]
    MESH --> APP["Cargas de Rutas Norte"]
    APP --> EGR["Capa 5: control de salida<br/>solo la pasarela de pagos"]
    EGR --> EXT["pagos.proveedorexterno.example"]

    APP -.-> OBS["Capa 6: visibilidad<br/>registro de flujos<br/>(Hubble)"]
    CP["Capa 7: plano de control<br/>apiserver, etcd, kubelet"] -.-> APP

    style NP fill:#d5e8f9,stroke:#36c
    style EGR fill:#f9d5d5,stroke:#c33
Capa Qué mitiga Estado en Rutas Norte
1. Perímetro Ataques desde internet, denegación de servicio, abuso Por construir
2. Ingress con TLS Escucha del tráfico entre cliente y plataforma Hecho (04-05)
3. Microsegmentación Movimiento lateral tras comprometer un pod Hecho (04-06), a reforzar
4. mTLS y autorización L7 Escucha interna, suplantación entre servicios Por decidir
5. Control de salida Exfiltración de datos de clientes Por construir
6. Visibilidad Ceguera ante un incidente Por construir
7. Plano de control Compromiso total del clúster Por revisar

El orden importa: cada capa se justifica por lo que ocurriría si la anterior fallase. Si el WAF no detiene un ataque, el Ingress todavía valida el certificado. Si alguien consigue ejecutar código en tienda-web, la microsegmentación impide que llegue a postgres-reservas. Si consiguiera llegar, el mTLS impediría que se hiciera pasar por api-reservas. Y si todo lo anterior fallase, el control de salida impediría que los datos salieran del clúster.

  1. Microsegmentación como principio

La microsegmentación consiste en tratar cada carga de trabajo como su propio segmento de red, con reglas explícitas de qué puede hablar con qué. Es lo contrario del modelo tradicional de "red interna de confianza detrás de un cortafuegos".

Por qué el modelo del perímetro no sirve en Kubernetes

En una red clásica, el cortafuegos separaba "dentro" de "fuera", y dentro todo el mundo se hablaba. En un clúster de Kubernetes, por defecto, todos los pods pueden hablar con todos los pods de todos los namespaces. Un curl desde tienda-web llega a postgres-reservas sin obstáculo.

Recuerda lo que dijimos en 02-06: un namespace no es una frontera de seguridad por sí solo. Sin NetworkPolicies, rutas-norte-dev y rutas-norte-pro están en la misma red plana.

La consecuencia práctica: si alguien consigue ejecutar código en tienda-web —el componente más expuesto, porque atiende directamente a internet— tiene acceso de red a toda la plataforma. La microsegmentación convierte eso en "tiene acceso de red a api-reservas, y solo al puerto 8080".

Los tres principios

Principio 1: denegar por defecto en los tres entornos.

En 04-06 aplicamos deny-all en rutas-norte-pro. Eso no basta. rutas-norte-dev y rutas-norte-pre también lo necesitan, por dos razones:

  • Si desarrollo y preproducción están abiertos, una política que funciona ahí puede fallar en producción, y el fallo se descubre en el peor momento.
  • Un pod comprometido en rutas-norte-dev sin restricciones de red puede alcanzar rutas-norte-pro, porque la red del clúster es plana.

En 08-03 resolvimos esto elegantemente con una política de Kyverno que genera la deny-all en todo namespace nuevo de Rutas Norte, con synchronize: true para que vuelva si alguien la borra. Es la garantía de que ningún namespace futuro nazca abierto.

Principio 2: políticas por componente, no por namespace.

Es tentador escribir "todo lo de rutas-norte-pro puede hablar con todo lo de rutas-norte-pro". Es un error: reproduce dentro del namespace el mismo modelo plano que queríamos evitar.

Enfoque Qué permite si se compromete tienda-web
Por namespace Acceso a postgres-reservas, redis-cache, api-reservas y todo lo demás
Por componente Solo api-reservas:8080. Nada más

La diferencia es enorme y el coste es escribir seis políticas en lugar de una.

Principio 3: revisión periódica de qué conversaciones siguen siendo necesarias.

Las políticas de red se degradan igual que el RBAC. Se añade un permiso para depurar y se queda. Se retira un componente y su política sobrevive. Se cambia un puerto y se deja el viejo "por si acaso".

La matriz de comunicaciones autorizadas de Rutas Norte, tal como quedó en 04-06, debe caber en una tabla y revisarse cada trimestre:

Origen Destino Puerto Justificación Última revisión
Ingress tienda-web 8080 Tráfico público 2026-07
Ingress api-reservas 8080 API pública 2026-07
tienda-web api-reservas 8080 Consultas y reservas 2026-07
api-reservas postgres-reservas 5432 Datos de reservas y clientes 2026-07
api-reservas redis-cache 6379 Caché de disponibilidad 2026-07
api-reservas Internet (pasarela) 443 Cobros 2026-07
worker-notificaciones postgres-reservas 5432 Lee reservas pendientes 2026-07
worker-notificaciones Internet (SMTP) 587 Envío de correos 2026-07
informes-ocupacion postgres-reservas 5432 Agregados nocturnos 2026-07
Prometheus Todos 9090 Métricas (07-03) 2026-07
Todos CoreDNS 53 Resolución de nombres 2026-07

Cada fila debe tener un porqué vivo. Una pregunta útil en la revisión: si borro esta regla ahora mismo, ¿qué se rompe? Si nadie lo sabe, hay que averiguarlo (en preproducción) o quitarla.

Un guion para detectar políticas huérfanas:

#!/usr/bin/env bash
# k8s/seguridad/politicas-huerfanas.sh
# Detecta NetworkPolicies cuyo podSelector no encaja con ningún pod.
set -euo pipefail
NS="${1:-rutas-norte-pro}"

kubectl get networkpolicy -n "$NS" -o json | jq -r '
  .items[] | select(.spec.podSelector.matchLabels != null)
  | "\(.metadata.name)\t" +
    ([.spec.podSelector.matchLabels | to_entries[] | "\(.key)=\(.value)"] | join(","))' \
| while IFS=$'\t' read -r politica selector; do
    n=$(kubectl get pods -n "$NS" -l "$selector" --no-headers 2>/dev/null | wc -l)
    [[ "$n" -eq 0 ]] && echo "HUÉRFANA: $politica (selector: $selector) no encaja con ningún pod"
  done
HUÉRFANA: permitir-exportador-legado (selector: app=exportador-legado) no encaja con ningún pod

Esa política sobrevivió al componente que protegía. No es peligrosa por sí misma, pero es ruido que dificulta la revisión, y si mañana alguien despliega algo con esa etiqueta hereda permisos que nadie decidió.

  1. Control del tráfico saliente

Aquí está, probablemente, el hueco más grave que queda en Rutas Norte.

Por qué el egreso importa tanto

Piensa en la secuencia de un incidente:

  1. Alguien encuentra un fallo en api-reservas y consigue ejecutar código.
  2. El proceso tiene acceso legítimo a postgres-reservas: puede leer la tabla de clientes con nombres, DNI, teléfonos y correos.
  3. Ahora necesita sacar esos datos del clúster.

El paso 3 es el que convierte un compromiso en una fuga de datos. Sin control de salida, el paso 3 es trivial: una petición HTTPS a cualquier servidor de internet.

Regla que hay que interiorizar: la política de entrada limita quién entra; la política de salida limita qué puede salir. Casi todo el mundo escribe la primera y olvida la segunda. Y la segunda es la que decide si un incidente se queda en "alguien ejecutó código" o escala a "se filtraron los datos personales de 200.000 clientes".

La mayoría de las guías de NetworkPolicy solo hablan de Ingress. Comprueba tus políticas: si policyTypes no incluye Egress, el tráfico de salida está completamente abierto.

La salida que Rutas Norte necesita

Auditemos qué salidas son legítimas:

Componente Destino externo Puerto ¿Imprescindible?
api-reservas pagos.proveedorexterno.example 443 Sí: cobros
worker-notificaciones Servidor SMTP del proveedor 587 Sí: correos de confirmación
tienda-web Ninguno No necesita salir a internet
postgres-reservas Ninguno No
redis-cache Ninguno No
informes-ocupacion Ninguno No

Cuatro de los seis componentes no necesitan salir a internet en absoluto. Eso es lo primero que hay que aprovechar: cerrarles la salida por completo es gratis y elimina la vía de exfiltración para la mayor parte de la plataforma.

La política de salida base

Recordando que en 04-06 dejamos deny-all con policyTypes: [Ingress, Egress], cada componente necesita explícitamente su salida. Lo mínimo universal es DNS:

# k8s/base/red/permitir-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: permitir-dns-salida
  namespace: rutas-norte-pro
spec:
  podSelector: {}                 # todos los pods
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Sin esto, ningún pod resuelve nombres y todo falla de una forma muy confusa: los servicios no se encuentran entre sí aunque las políticas de ingreso estén bien. Es el error más frecuente al activar Egress por primera vez.

La salida a la pasarela de pagos

api-reservas necesita llegar a pagos.proveedorexterno.example. Aquí es donde aparece el problema del apartado siguiente, pero veamos primero la solución con ipBlock:

# k8s/base/red/api-reservas-salida.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-reservas-salida
  namespace: rutas-norte-pro
  annotations:
    seguridad.rutasnorte.example/justificacion: >-
      Salida a la pasarela de pagos externa. Los rangos de IP los publica el
      proveedor en su documentación y se revisan mensualmente mediante el
      trabajo programado "verificar-rangos-pasarela". Última verificación
      documentada en el registro de cambios de red.
spec:
  podSelector:
    matchLabels:
      app: api-reservas
  policyTypes: [Egress]
  egress:
    # 1. DNS (imprescindible)
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    # 2. Base de datos y caché (tráfico interno)
    - to:
        - podSelector:
            matchLabels:
              app: postgres-reservas
      ports:
        - { protocol: TCP, port: 5432 }
    - to:
        - podSelector:
            matchLabels:
              app: redis-cache
      ports:
        - { protocol: TCP, port: 6379 }
    # 3. Pasarela de pagos: SOLO estos rangos, SOLO el 443
    - to:
        - ipBlock:
            cidr: 203.0.113.0/24        # rango publicado por el proveedor
        - ipBlock:
            cidr: 198.51.100.64/26      # rango secundario
      ports:
        - { protocol: TCP, port: 443 }

Lo importante de este manifiesto no es lo que permite, sino lo que no permite: api-reservas no puede conectar con ninguna otra dirección de internet. Si alguien consigue ejecutar código ahí y quiere enviar la tabla de clientes a un servidor propio, la conexión no sale.

Nota sobre ipBlock y las IP privadas: ipBlock se refiere a IP de red, y los pods del clúster también tienen IP. Un ipBlock: 0.0.0.0/0 incluiría todo el tráfico interno del clúster. Cuando se quiere "todo internet menos la red interna", hay que usar except:

    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8        # red de pods y servicios
              - 172.16.0.0/12
              - 192.168.0.0/16
      ports:
        - { protocol: TCP, port: 443 }

Esto es "cualquier destino público en el 443", que es mucho más laxo que la lista de rangos del proveedor y no lo recomendamos para api-reservas. Lo incluimos porque es un patrón que aparece a menudo y conviene entender por qué es peor: permite enviar datos a cualquier servidor de internet que hable HTTPS.

El contenedor embajador

Recuerda de 06-04 que la conexión con la pasarela de pagos pasa por un contenedor embajador dentro del pod de api-reservas. Eso tiene una consecuencia de red importante: como los contenedores de un pod comparten el namespace de red, la política se aplica al pod entero, no al contenedor. El embajador y el contenedor principal tienen exactamente los mismos permisos de red.

El embajador aporta otras ventajas —centraliza los reintentos, la lógica de tiempo de espera y las credenciales de la pasarela— pero no es un límite de seguridad de red. Si quisiéramos que solo el embajador pudiera hablar con la pasarela, tendría que ser un pod aparte con su propia política.

Es un buen ejemplo de una confusión común: el patrón embajador es un patrón de arquitectura, no de aislamiento.

  1. El problema de las IP frente a los nombres de dominio

Aquí llega la limitación estructural de las NetworkPolicies.

Las NetworkPolicies estándar de Kubernetes trabajan con direcciones IP y rangos CIDR. No entienden de nombres de dominio. No existe un campo to: dnsName: pagos.proveedorexterno.example.

Y esto no es un descuido: las políticas se traducen a reglas de cortafuegos en el kernel del nodo, que actúa sobre paquetes IP. Cuando el paquete llega ahí, el nombre ya se resolvió y no queda rastro de él.

Los tres problemas prácticos

Problema 1: las IP cambian. Los servicios en la nube rotan direcciones. La documentación del proveedor de pagos puede publicar rangos hoy y cambiarlos en tres meses. Si tu ipBlock se queda desfasado, los pagos dejan de funcionar y el diagnóstico es especialmente ingrato porque nada en los logs dice "una NetworkPolicy ha bloqueado esto".

Problema 2: un rango es más de lo que quieres. 203.0.113.0/24 son 256 direcciones. Si el proveedor comparte infraestructura, ese rango puede incluir servidores de otros clientes suyos. Has autorizado la salida a 256 destinos para llegar a uno.

Problema 3: el escenario inverso es peor. Si un servicio legítimo comparte IP con un servicio de almacenamiento genérico (algo habitual en las nubes públicas), autorizar el primero autoriza el segundo, y ahí sí hay una vía de exfiltración.

Las tres opciones

Opción Cómo funciona Ventajas Inconvenientes
Pasarela de salida (egress gateway) Todo el tráfico saliente pasa por unos pods concretos con IP fija, que aplican las reglas Punto único de control y registro; IP de origen estable para que el proveedor pueda filtrarla Otra pieza que mantener; punto único de fallo
Proxy con lista de permitidos Un proxy HTTP(S) al que apuntan las aplicaciones, con una lista de dominios autorizados Filtra por nombre de dominio, incluido SNI; registro completo de destinos Las aplicaciones deben configurarse para usarlo; añade latencia
Políticas basadas en DNS (Cilium) El CNI observa las respuestas DNS y programa las reglas con las IP que devuelven Se escribe el nombre directamente; se actualiza solo Requiere Cilium como CNI

Opción A: proxy de salida con lista de permitidos

Es la opción más portátil y la que da mejor visibilidad. Un Squid o un proxy dedicado en un pod, con una lista de dominios:

# k8s/base/red/proxy-salida-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: proxy-salida-config
  namespace: rutas-norte-sistema
data:
  dominios-permitidos.txt: |
    # Pasarela de pagos: cobros de billetes
    pagos.proveedorexterno.example
    # Correo saliente: confirmaciones de reserva
    smtp.proveedorcorreo.example
    # NADA MÁS. Cualquier otro destino se rechaza y se registra.

Las aplicaciones se configuran con HTTPS_PROXY, y la NetworkPolicy solo permite salir hacia el proxy:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-reservas-salida-por-proxy
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      app: api-reservas
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
    # Única salida permitida: el proxy. Ni una IP de internet directamente.
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: rutas-norte-sistema
          podSelector:
            matchLabels:
              app: proxy-salida
      ports:
        - { protocol: TCP, port: 3128 }

Ventaja decisiva: el proxy registra cada destino solicitado, permitido o denegado. Eso resuelve parcialmente el problema de la falta de registro de las NetworkPolicies, al menos para el tráfico saliente, que es el que más importa vigilar.

Inconveniente honesto: una aplicación que ignore las variables HTTPS_PROXY se salta el proxy. Por eso la NetworkPolicy sigue siendo necesaria: impide la salida directa, obligando a pasar por el proxy aunque la aplicación no coopere. Las dos capas juntas funcionan; ninguna por separado.

Opción B: políticas basadas en DNS con Cilium

Si el CNI es Cilium, la CiliumNetworkPolicy permite escribir el nombre directamente:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: api-reservas-salida-dns
  namespace: rutas-norte-pro
spec:
  endpointSelector:
    matchLabels:
      app: api-reservas
  egress:
    # DNS con inspección: Cilium observa las respuestas para programar las reglas
    - toEndpoints:
        - matchLabels:
            io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - { port: "53", protocol: UDP }
          rules:
            dns:
              - matchPattern: "*"
    # Salida por NOMBRE DE DOMINIO, no por IP
    - toFQDNs:
        - matchName: "pagos.proveedorexterno.example"
      toPorts:
        - ports:
            - { port: "443", protocol: TCP }

Cómo funciona: Cilium intercepta las respuestas DNS del pod, ve qué IP devolvió pagos.proveedorexterno.example y programa dinámicamente la regla para esa IP, con el tiempo de vida que indique el registro DNS. Si mañana el proveedor cambia de IP, la política sigue funcionando sin que nadie toque nada.

Es, con diferencia, la solución más elegante. Su coste es la dependencia del CNI: adoptar Cilium es una decisión de plataforma que va mucho más allá de esta política.

Recomendación para Rutas Norte

Un enfoque en dos tiempos:

  1. Ahora: ipBlock con los rangos publicados por el proveedor, más un trabajo programado mensual que verifique que siguen siendo válidos y avise si cambian. Es la solución que funciona con cualquier CNI y no requiere nuevas piezas.
  2. A medio plazo: proxy de salida con lista de permitidos, principalmente por el registro de destinos. Si en algún momento se migra a Cilium (cosa que también aportaría las ventajas del apartado 11), se sustituyen las políticas por toFQDNs.

Y la comprobación que hay que automatizar en cualquier caso:

#!/usr/bin/env bash
# k8s/seguridad/verificar-salida.sh
# Confirma que los componentes que no deben salir a internet, no salen.
set -uo pipefail
NS=rutas-norte-pro

comprobar_bloqueo() {
  local carga="$1" destino="$2"
  local salida
  salida=$(kubectl exec -n "$NS" "$carga" -- \
    timeout 5 wget -q -O- --timeout=4 "$destino" 2>&1 || echo "BLOQUEADO")
  if [[ "$salida" == *"BLOQUEADO"* ]]; then
    echo "OK    $carga no alcanza $destino"
  else
    echo "FALLO $carga SÍ alcanza $destino <-- vía de exfiltración abierta"
  fi
}

comprobar_bloqueo deploy/tienda-web        https://ejemplo-externo.example
comprobar_bloqueo statefulset/postgres-reservas https://ejemplo-externo.example
comprobar_bloqueo deploy/api-reservas      https://ejemplo-externo.example
OK    deploy/tienda-web no alcanza https://ejemplo-externo.example
OK    statefulset/postgres-reservas no alcanza https://ejemplo-externo.example
OK    deploy/api-reservas no alcanza https://ejemplo-externo.example

Esta prueba debe ejecutarse en rutas-norte-pre en cada despliegue. Es la verificación de la capa que impide una fuga de datos personales, y como tal debería estar en el expediente que revisa el responsable de cumplimiento.

  1. Cifrado en tránsito dentro del clúster

En 04-05 pusimos TLS en el Ingress con cert-manager: el tráfico entre el navegador del cliente y la plataforma va cifrado hasta https://www.rutasnorte.example. Perfecto. Pero ¿qué pasa después del Ingress?

Qué queda sin cifrar

flowchart LR
    C["Navegador"] -->|"HTTPS<br/>cifrado (04-05)"| ING["Ingress"]
    ING -->|"HTTP<br/>EN CLARO"| TW["tienda-web"]
    TW -->|"HTTP<br/>EN CLARO"| API["api-reservas"]
    API -->|"protocolo PostgreSQL<br/>EN CLARO"| PG[("postgres-reservas<br/>datos personales")]
    API -->|"RESP<br/>EN CLARO"| R[("redis-cache")]
    style PG fill:#f9d5d5,stroke:#c33

Todo lo que hay a la derecha del Ingress viaja sin cifrar por la red del clúster. Y por ese tramo pasan, en claro:

  • Las consultas SQL con nombres, DNI, teléfonos y correos de clientes.
  • Las respuestas de la API con los datos de las reservas.
  • Las credenciales de la base de datos, en el saludo inicial de cada conexión.

¿Quién puede ver ese tráfico?

Esta es la pregunta que decide si merece la pena el esfuerzo:

Escenario ¿Ve el tráfico interno?
Un pod normal con NetworkPolicies estrictas No (no puede ni conectar)
Un pod con hostNetwork: true : ve todo el tráfico del nodo
Un pod con CAP_NET_RAW y hostNetwork : puede capturar paquetes
Alguien con acceso al nodo (SSH, contenedor privilegiado) Sí, todo
El proveedor de la nube o del centro de datos Depende del contrato y la infraestructura
Un atacante que compromete el CNI

Fíjate en cómo se conectan las lecciones: en 08-02 prohibimos hostNetwork y CAP_NET_RAW, y en 08-03 hicimos que el clúster lo rechace. Ese trabajo es lo que hace que el tráfico interno sin cifrar sea un riesgo aceptable en muchos escenarios. El cifrado interno es la defensa para cuando esa capa falla, o cuando la normativa lo exige explícitamente.

Las tres opciones de cifrado interno

Opción Qué cifra Coste Identidad
TLS en la aplicación Solo lo que la aplicación implemente Cambios de código en cada componente Certificados gestionados a mano
Cifrado del CNI (WireGuard, IPsec) Todo el tráfico entre nodos Bajo: una opción de configuración Por nodo, no por carga
mTLS con malla de servicio Todo el tráfico entre pods de la malla Alto: una capa de infraestructura completa Por carga de trabajo

Opción 1: TLS a nivel de aplicación

Es lo que ya hacemos con la pasarela de pagos (el embajador habla HTTPS). Para el tráfico interno significaría configurar PostgreSQL con ssl = on, generar certificados, distribuirlos y configurar cada cliente.

# Fragmento: PostgreSQL con TLS obligatorio
env:
  - name: POSTGRES_INITDB_ARGS
    value: "--auth-host=scram-sha-256"
volumeMounts:
  - name: certificados-tls
    mountPath: /var/lib/postgresql/certs
    readOnly: true

Ventaja: cifra el tramo que más importa (los datos personales) con poco despliegue. Inconveniente: hay que hacerlo componente a componente, gestionar la rotación de certificados y confiar en que cada cliente verifique realmente el certificado (muchos clientes de base de datos, por defecto, no lo hacen).

Para Rutas Norte, cifrar específicamente la conexión con postgres-reservas es una medida de alto valor y coste moderado, y probablemente el primer paso a dar.

Opción 2: cifrado a nivel de CNI

Varios CNI pueden cifrar automáticamente todo el tráfico entre nodos con WireGuard o IPsec.

# Cilium con cifrado WireGuard (valores de Helm)
encryption:
  enabled: true
  type: wireguard
  nodeEncryption: true
# Calico con WireGuard
kubectl patch felixconfiguration default --type=merge \
  -p '{"spec":{"wireguardEnabled":true}}'
A favor En contra
Esfuerzo Mínimo: una opción
Cobertura Todo el tráfico entre nodos, sin excepciones Solo entre nodos: dos pods en el mismo nodo no se cifran
Rendimiento WireGuard es muy eficiente Algo de CPU; con IPsec, más
Identidad Autentica nodos, no cargas de trabajo

Ese último punto es la limitación esencial: el cifrado del CNI protege contra quien observe la red entre nodos, pero no aporta identidad por servicio. tienda-web y api-reservas en el mismo nodo se hablan igual de en claro, y ninguno puede demostrar criptográficamente quién es.

Aun así, la relación valor/esfuerzo es excelente. Si el CNI lo soporta, actívalo.

Opción 3: mTLS con una malla de servicio

Es el siguiente apartado, porque da mucho más que cifrado.

  1. Qué es una malla de servicio y qué cuesta

Una malla de servicio (service mesh) es una capa de infraestructura que se ocupa de la comunicación entre servicios, sacándola del código de las aplicaciones.

Cómo funciona

En el modelo clásico, se inyecta un proxy sidecar (habitualmente Envoy) en cada pod. Todo el tráfico que entra y sale del pod pasa por ese proxy, que es quien aplica el cifrado, la autorización y la recogida de métricas. La aplicación no se entera: sigue haciendo http://api-reservas:8080.

flowchart LR
    subgraph P1["Pod tienda-web"]
        A1["nginx"] <--> S1["proxy sidecar"]
    end
    subgraph P2["Pod api-reservas"]
        S2["proxy sidecar"] <--> A2["Node.js"]
    end
    S1 <-->|"mTLS<br/>cifrado + identidad<br/>+ autorización L7"| S2
    CP["Plano de control<br/>emite certificados<br/>distribuye políticas"] -.-> S1
    CP -.-> S2

Qué te da

Capacidad Qué resuelve ¿Se puede tener sin malla?
Identidad criptográfica por carga Cada servicio tiene un certificado que demuestra quién es Muy difícil a mano
mTLS automático Cifrado y autenticación mutua sin tocar código Difícil, componente a componente
Autorización L7 "Solo tienda-web puede hacer POST /reservas" No con NetworkPolicy
Reintentos y tiempos de espera Resiliencia sin código Sí, en cada aplicación
Cortacircuitos Aislar un servicio degradado Sí, con bibliotecas
Despliegues canary por porcentaje Enviar el 5 % del tráfico a la versión nueva Parcialmente (11-04)
Observabilidad de la comunicación Métricas de latencia y errores de cada llamada Parcialmente (07-03)

La identidad por carga de trabajo es la joya de la corona y merece que la entendamos bien. Sin malla, cuando postgres-reservas recibe una conexión desde una IP, lo único que sabe es que viene de esa IP. Con la NetworkPolicy sabemos que esa IP corresponde a un pod con la etiqueta app: api-reservas... pero las etiquetas las pone quien crea el pod, y una IP se puede suplantar.

Con mTLS, postgres-reservas recibe un certificado que dice spiffe://cluster.local/ns/rutas-norte-pro/sa/api-reservas, firmado por la autoridad certificadora del clúster, que nadie más puede falsificar. Eso sí es identidad. Y encaja perfectamente con las ServiceAccounts de 03-06.

Qué te cuesta

Esta es la parte que las presentaciones comerciales pasan por encima:

Coste Detalle
Complejidad operativa Una pieza de infraestructura crítica más que actualizar, vigilar y depurar
Recursos Un sidecar por pod: entre 50 y 100 MiB de memoria y algo de CPU. Con 50 pods, son varios GiB
Latencia Entre 1 y 5 ms por salto. Con varios saltos encadenados, se nota
Curva de aprendizaje Conceptos nuevos: VirtualService, DestinationRule, AuthorizationPolicy, PeerAuthentication
Depuración más difícil Un error 503 puede venir de la aplicación o del proxy. Hay que aprender a leer los logs de Envoy
Acoplamiento en las actualizaciones Actualizar la malla suele implicar reiniciar todos los pods
Casos raros Trabajos que terminan y el sidecar sigue vivo; protocolos que el proxy no entiende bien

Ese último punto tiene un ejemplo directo en Rutas Norte: el CronJob informes-ocupacion termina su trabajo, pero el sidecar sigue corriendo y el Job nunca se marca como completado. Hay soluciones (contenedores sidecar nativos desde 1.29, o llamar al punto de terminación del proxy), pero es exactamente el tipo de fricción imprevista que aparece.

¿Necesita Rutas Norte una malla de servicio?

Vamos a responder con honestidad, porque es una decisión cara.

Argumentos a favor:

  • Transporta datos personales entre servicios y el cifrado interno es exigible en un análisis de riesgo serio.
  • La autorización L7 permitiría reglas como "solo tienda-web puede hacer POST /reservas", que NetworkPolicy no puede expresar.
  • La identidad criptográfica elimina la suplantación de servicios.

Argumentos en contra:

  • Son seis componentes. Una malla brilla con decenas o cientos de microservicios y comunicaciones complejas.
  • El grafo de llamadas es casi lineal: tienda-webapi-reservaspostgres-reservas/redis-cache. No hay una topología que justifique una capa de encaminamiento.
  • El equipo de plataforma es pequeño. Añadir una malla mal operada puede empeorar la seguridad (certificados caducados, políticas mal entendidas, actualizaciones aplazadas).
  • Buena parte del beneficio se consigue más barato: cifrado del CNI para el tránsito, TLS en PostgreSQL para lo crítico, y NetworkPolicies estrictas para la segmentación.

Decisión para Rutas Norte: no, todavía no. El plan alternativo:

  1. Activar el cifrado WireGuard del CNI (una opción de configuración).
  2. Configurar TLS en postgres-reservas, que es donde están los datos personales.
  3. Mantener las NetworkPolicies estrictas con control de salida.
  4. Revisar la decisión cuando se cumpla alguno de estos criterios objetivos: más de quince servicios, comunicación entre varios equipos que no se coordinan, un requisito normativo explícito de mTLS, o la necesidad real de despliegues canary por porcentaje de tráfico.

Y una recomendación general que va más allá de este curso:

Una malla de servicio es una respuesta excelente a un problema que hay que tener primero. Adoptarla "porque es lo moderno", sin la complejidad que la justifica, añade riesgo operativo sin añadir seguridad neta. Si la operas mal, es peor que no tenerla.

Dicho esto, hay que conocerla, porque el día que la plataforma crezca será la respuesta correcta. Vamos con las opciones.

  1. Istio, Linkerd y Cilium comparados

Istio (sidecar) Istio (ambient) Linkerd Cilium Service Mesh
Arquitectura Sidecar Envoy por pod Agente por nodo (ztunnel) + waypoint L7 opcional Sidecar propio (linkerd2-proxy, en Rust) eBPF en el kernel + Envoy solo para L7
Memoria por pod 50-100 MiB Casi cero (sin sidecar) 10-20 MiB Casi cero para L4
Latencia añadida 2-5 ms 1-3 ms <1 ms Mínima
Complejidad Alta Media Baja Media-alta
mTLS automático
Autorización L7 Muy completa Sí (con waypoint) Básica
Encaminamiento avanzado El más completo Completo Suficiente Bueno
Multiclúster Muy maduro
Requiere un CNI concreto No No No Sí: Cilium
Comunidad y ecosistema El mayor Creciente Sólida Creciente
Curva de aprendizaje Pronunciada Media Suave Media
Gobierno CNCF (graduado) CNCF CNCF (graduado) CNCF (graduado)

Criterios de elección

Istio en modo sidecar si necesitas el conjunto de funciones más completo: encaminamiento sofisticado, multiclúster complejo, integración con pasarelas de API, extensiones con WebAssembly. Es la opción con más capacidad y también la que más cuesta operar. Necesitas gente dedicada.

Istio en modo ambient es la evolución que ataca el principal reproche al modelo sidecar: el coste por pod. En lugar de un proxy en cada pod, hay un agente por nodo (ztunnel) que da mTLS y L4, y solo se despliega un proxy L7 (waypoint) donde de verdad hace falta autorización de nivel 7. Reduce muchísimo el consumo y elimina el problema de los Jobs que no terminan. Si hoy tuviéramos que elegir Istio, sería este modo.

Linkerd si quieres mTLS y observabilidad con la mínima complejidad. Su proxy en Rust es notablemente ligero y su filosofía es hacer pocas cosas y hacerlas bien. Para una plataforma como Rutas Norte, si algún día hiciera falta una malla, esta sería la primera candidata: cubre lo que necesitamos (mTLS, identidad, autorización básica) sin la superficie de Istio.

Cilium Service Mesh si ya usas Cilium como CNI. Al integrar la malla en el CNI evitas una capa completa: el mTLS y las políticas L4 se aplican en eBPF, dentro del kernel, sin proxy. Solo se despliega Envoy para las políticas L7. Es la opción más eficiente, y además trae las políticas por nombre de dominio del apartado 4 y la visibilidad de Hubble del apartado 11. La contrapartida es que ata la decisión de la malla a la del CNI.

  1. Políticas de autorización de nivel 7

Este es el hueco concreto que NetworkPolicy no puede llenar, y conviene verlo con un ejemplo real.

El límite de L3/L4

Nuestra política actual dice: tienda-web puede conectar con api-reservas en el puerto 8080. Eso es todo lo que puede expresar. En cuanto la conexión está abierta, tienda-web puede hacer:

Petición ¿Debería poder? ¿Lo impide la NetworkPolicy?
GET /disponibilidad
POST /reservas
GET /admin/clientes No No
DELETE /reservas/12345 No No
GET /metrics Discutible No

Una política L7 sí puede distinguir. Veamos cómo se expresaría con Istio, tomando el ejemplo del enunciado: solo api-reservas puede hacer POST /reservas.

# Ejemplo de política L7 con Istio (solo si se adoptara una malla)
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: api-reservas-autorizacion
  namespace: rutas-norte-pro
spec:
  selector:
    matchLabels:
      app: api-reservas
  action: ALLOW
  rules:
    # Regla 1: tienda-web puede consultar disponibilidad y crear reservas
    - from:
        - source:
            # IDENTIDAD CRIPTOGRÁFICA, no una etiqueta ni una IP
            principals:
              - "cluster.local/ns/rutas-norte-pro/sa/tienda-web"
      to:
        - operation:
            methods: ["GET"]
            paths: ["/disponibilidad", "/lineas", "/salud", "/preparado"]
        - operation:
            methods: ["POST"]
            paths: ["/reservas"]

    # Regla 2: solo el worker de notificaciones consulta reservas pendientes
    - from:
        - source:
            principals:
              - "cluster.local/ns/rutas-norte-pro/sa/worker-notificaciones"
      to:
        - operation:
            methods: ["GET"]
            paths: ["/reservas/pendientes"]

    # Regla 3: Prometheus solo puede leer las métricas
    - from:
        - source:
            principals:
              - "cluster.local/ns/monitorizacion/sa/prometheus"
      to:
        - operation:
            methods: ["GET"]
            paths: ["/metrics"]

Con action: ALLOW, todo lo que no encaje con ninguna regla se deniega. Es el mismo principio de denegación por defecto de las NetworkPolicies, ahora aplicado a métodos y rutas HTTP.

Observa la línea clave:

principals:
  - "cluster.local/ns/rutas-norte-pro/sa/tienda-web"

Eso no es una etiqueta ni una IP: es la identidad SPIFFE que la malla verifica criptográficamente en el saludo mTLS. Un pod no puede reclamar esa identidad sin tener la clave privada correspondiente. Y fíjate en que se basa en la ServiceAccount, cerrando el círculo con 03-06 y 08-01: la ServiceAccount pasa de ser solo la identidad ante la API a ser también la identidad ante los demás servicios.

La comparación completa

NetworkPolicy (04-06) AuthorizationPolicy (malla)
Nivel L3/L4: IP y puerto L7: método, ruta, cabeceras
Identidad Etiquetas del pod (suplantables) Certificado criptográfico
Cifrado No Sí (mTLS)
Registro de denegaciones No
Requiere instalar Nada (con CNI compatible) Una malla completa
Coste operativo Bajo Alto

Las dos son complementarias, no alternativas. La NetworkPolicy es la primera línea y la más barata; la política L7 es la afinación. Si tuvieras que quedarte con una, quédate con la NetworkPolicy: sin ella, la política L7 protege un servicio al que cualquiera puede seguir conectándose por otras vías.

La alternativa sin malla

Sin malla, la autorización L7 la hace la propia aplicación. api-reservas puede validar en su código quién le llama:

# La aplicación valida un token de servicio inyectado como Secret
env:
  - name: TOKEN_SERVICIO_ESPERADO
    valueFrom:
      secretKeyRef:
        name: tokens-internos
        key: tienda-web

Funciona y para seis servicios es perfectamente razonable, pero tiene inconvenientes reales: hay que implementarlo en cada servicio, rotar los tokens a mano, y si alguien lee el Secret (RBAC, 08-01) puede suplantar al servicio. El mTLS de la malla resuelve las tres cosas automáticamente. Es exactamente el tipo de coste que va creciendo con el número de servicios y que, llegado un punto, justifica la malla.

  1. Protección del perímetro

Todo lo anterior protege el interior. Ahora, la puerta de entrada.

Exposición mínima: por qué no hay NodePort en producción

En 04-02 vimos los tipos de Service. Repasemos su implicación de seguridad:

Tipo Exposición ¿En producción?
ClusterIP Solo dentro del clúster : por defecto para todo
NodePort Un puerto (30000-32767) en todos los nodos No
LoadBalancer Un balanceador del proveedor Solo para el Ingress
Ingress HTTP/HTTPS por nombre y ruta : única entrada

Por qué NodePort no vale en producción:

  • Abre el puerto en todos los nodos, incluidos los que no ejecutan el pod.
  • Se salta el Ingress, y con él el TLS (04-05), el WAF y la limitación de peticiones.
  • El puerto está en un rango alto y poco convencional, difícil de proteger con un cortafuegos convencional.
  • No tiene nombre: cualquiera que alcance la IP del nodo llega al servicio.

Y recuerda de 08-02 que hostPort es todavía peor, porque ni siquiera hay un Service delante.

La regla de Rutas Norte: un único punto de entrada, el balanceador del Ingress. Todo lo demás es ClusterIP.

# Auditoría: ¿hay algún servicio expuesto que no debería?
kubectl get svc -A -o json | jq -r '
  .items[] | select(.spec.type == "NodePort" or .spec.type == "LoadBalancer")
  | "\(.spec.type)\t\(.metadata.namespace)/\(.metadata.name)\t" +
    ([.spec.ports[] | "\(.port)->\(.nodePort // "-")"] | join(","))'
LoadBalancer	ingress-nginx/ingress-nginx-controller	80->31023,443->30987

Un único LoadBalancer, el del Ingress. Cualquier otra línea en esa salida es una pregunta que hay que responder.

WAF y limitación de peticiones

Un cortafuegos de aplicación web (WAF) inspecciona las peticiones HTTP y bloquea patrones de ataque conocidos: inyección SQL, ejecución de scripts entre sitios, recorrido de directorios. Con ingress-nginx se puede activar ModSecurity con el conjunto de reglas OWASP:

# k8s/base/ingress/configmap-waf.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  enable-modsecurity: "true"
  enable-owasp-modsecurity-crs: "true"
  modsecurity-snippet: |
    SecRuleEngine On
    SecRequestBodyAccess On
    SecRequestBodyLimit 5242880
    SecAuditEngine RelevantOnly
    SecAuditLogParts ABIJDEFHZ
    # Nivel de paranoia 1: pocos falsos positivos. Subir con cuidado.
    SecAction "id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=1"

Advertencia seria sobre los WAF: un WAF mal ajustado bloquea peticiones legítimas. Si tienda-web deja de poder crear reservas porque una regla del WAF considera sospechoso un nombre con apóstrofe, has creado una incidencia. El procedimiento correcto es idéntico al del PSA en 08-03:

  1. SecRuleEngine DetectionOnly: registra pero no bloquea.
  2. Analizar los falsos positivos durante semanas de tráfico real.
  3. Ajustar las reglas problemáticas.
  4. Solo entonces, SecRuleEngine On.

Y la limitación de peticiones, por anotación en el Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rutas-norte
  namespace: rutas-norte-pro
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-produccion   # de 04-05
    # Limitación de peticiones
    nginx.ingress.kubernetes.io/limit-rps: "20"
    nginx.ingress.kubernetes.io/limit-connections: "10"
    nginx.ingress.kubernetes.io/limit-burst-multiplier: "3"
    # Tamaño máximo del cuerpo: evita agotar memoria
    nginx.ingress.kubernetes.io/proxy-body-size: "2m"
    # Tiempos de espera: evita conexiones que se quedan colgadas
    nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
    # Cabeceras de seguridad
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "X-Content-Type-Options: nosniff";
      more_set_headers "X-Frame-Options: DENY";
      more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
      more_set_headers "Permissions-Policy: geolocation=(), microphone=(), camera=()";
      more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains";
spec:
  ingressClassName: nginx
  tls:
    - hosts: [www.rutasnorte.example]
      secretName: rutasnorte-tls
  rules:
    - host: www.rutasnorte.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: tienda-web
                port: { number: 80 }
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: api-reservas
                port: { number: 8080 }

Las cabeceras, una a una:

Cabecera Qué mitiga
X-Content-Type-Options: nosniff Que el navegador adivine el tipo de contenido y ejecute algo como script
X-Frame-Options: DENY Que la página se incruste en un marco de otro sitio (clickjacking)
Referrer-Policy Que se filtren URL internas al navegar a sitios externos
Permissions-Policy Acceso no solicitado a cámara, micrófono o ubicación
Strict-Transport-Security Que un cliente vuelva a conectar por HTTP sin cifrar

Un detalle sobre HSTS: max-age=31536000 son 365 días, y los navegadores lo recuerdan. Si algún día tu TLS deja de funcionar, los clientes que ya visitaron el sitio no podrán entrar por HTTP como alternativa. Es lo deseable desde el punto de vista de seguridad, pero hay que ser consciente antes de activarlo.

Protección contra denegación de servicio

Nivel del ataque Dónde se mitiga
Volumétrico (saturar el ancho de banda) Fuera del clúster: proveedor de nube o CDN
Agotamiento de conexiones Balanceador y limit-connections
Peticiones costosas limit-rps, y en la aplicación: paginación y tiempos de espera
Agotamiento de recursos internos resources.limits (03-04) y autoescalado (módulo 9)

Un punto importante que se olvida: el autoescalado no es una defensa contra la denegación de servicio, es una forma de pagarla. Si api-reservas escala a 50 réplicas ante un ataque, has convertido una caída en una factura. El límite superior del HPA (09-01) es también un control de seguridad y de coste.

  1. Seguridad del plano de control

Todo lo anterior protege las cargas. El plano de control es el objetivo de mayor valor: quien lo controla, controla todo el clúster.

Acceso al apiserver

Control Cómo
No exponerlo a internet Punto final privado, o lista de IP permitidas
Autenticación fuerte OIDC con segundo factor, nunca certificados compartidos
Deshabilitar el acceso anónimo --anonymous-auth=false
Registro de auditoría Política de auditoría (08-06)
Limitación de peticiones --max-requests-inflight, prioridad y equidad de la API
# Comprobar si el apiserver está expuesto públicamente
kubectl cluster-info
Kubernetes control plane is running at https://10.0.4.11:6443

Una IP privada es buena señal. Si vieras una IP pública sin restricción de origen, es un hallazgo de seguridad de primer nivel.

En Kubernetes gestionado (10-06), los tres grandes proveedores permiten restringir el acceso al punto final. Es de las primeras cosas que hay que configurar en un clúster nuevo.

etcd

etcd guarda todo el estado del clúster, incluidos los Secrets. Como dijimos en 03-02, si etcd no está cifrado, los Secrets están en claro en el disco.

Cifrado en reposo:

# /etc/kubernetes/cifrado/config.yaml (en los nodos del plano de control)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
      - configmaps
    providers:
      # KMS externo: la clave maestra vive fuera del clúster. Preferible.
      - kms:
          apiVersion: v2
          name: kms-rutasnorte
          endpoint: unix:///var/run/kmsplugin/socket.sock
          cachesize: 1000
      - identity: {}     # sin cifrar: necesario para poder leer lo antiguo

Detalle importante: el orden de los proveedores importa. El primero se usa para escribir; todos se usan para leer. Tener identity: {} al final permite leer los objetos que se escribieron antes de activar el cifrado. Y tras activarlo, hay que reescribirlos:

kubectl get secrets -A -o json | kubectl replace -f -

Sin ese paso, los Secrets antiguos siguen en claro en etcd para siempre.

Otros controles de etcd:

Control Por qué
TLS mutuo entre etcd y el apiserver Nadie más debe poder hablar con etcd
Acceso restringido al puerto 2379 Solo desde el plano de control
Copias de seguridad cifradas Una copia de etcd sin cifrar es una copia de todos los Secrets
Custodia de las copias Con la misma protección que la base de datos de clientes

Ese punto de las copias es fácil de pasar por alto y muy grave. En 05-06 hablamos de Velero y de los datos personales; una copia de etcd contiene, además, todas las credenciales del clúster. Debe cifrarse, almacenarse con acceso restringido y registrado, y probarse su restauración periódicamente.

El kubelet

Cada nodo ejecuta un kubelet con una API en el puerto 10250. Sin protección, permite listar pods, leer logs y ejecutar comandos en cualquier contenedor de ese nodo.

Ajuste Valor correcto Qué evita
--anonymous-auth false Peticiones sin credenciales
--authorization-mode Webhook Que cualquier identidad válida pueda todo
--read-only-port 0 El puerto 10255, sin autenticación, expone metadatos de los pods
--protect-kernel-defaults true Que el kubelet modifique parámetros del kernel

Comprobación:

# Debe responder 401: la API del kubelet no acepta peticiones anónimas
kubectl get --raw /api/v1/nodes/nodo-pro-01/proxy/pods -v6 2>&1 | grep -i "response"

Y una NetworkPolicy o regla de cortafuegos que impida a los pods alcanzar el puerto 10250 de los nodos es una defensa muy rentable, porque cierra una vía de escalada conocida.

Recuerda de 08-01 que el permiso get nodes/proxy es de nivel crítico precisamente por esto: permite hablar con el kubelet y, a través de él, ejecutar en cualquier pod del nodo.

Aislamiento de los nodos del plano de control

Los nodos del plano de control no deben ejecutar cargas de aplicación. Kubernetes lo consigue con un taint (06-05):

kubectl describe node nodo-control-01 | grep -A2 Taints
Taints:  node-role.kubernetes.io/control-plane:NoSchedule

Ese taint impide que se planifiquen pods normales ahí. La razón es directa: un pod en un nodo del plano de control está a un fallo de distancia de los certificados del clúster y de la base de datos de etcd.

Y hay que verificar que nadie lo ha tolerado por comodidad:

kubectl get pods -A -o json | jq -r '
  .items[]
  | select(.spec.tolerations[]? |
      .key == "node-role.kubernetes.io/control-plane" or
      (.operator == "Exists" and (.key // "") == ""))
  | "\(.metadata.namespace)/\(.metadata.name)"'
kube-system/kube-proxy-hd8k2
kube-system/cilium-p2m4x

Solo componentes del sistema que legítimamente corren en todos los nodos. Si apareciera aquí un pod de rutas-norte-pro, hay que investigar por qué se puso esa tolerancia. Ojo con la tolerancia universal (operator: Exists sin key): tolera todos los taints, incluido el del plano de control, y a menudo se pone sin darse cuenta.

  1. Registro y visibilidad del tráfico

Volvemos al segundo límite que señalamos en 04-06: las NetworkPolicies no registran nada.

Qué significa exactamente

Cuando una NetworkPolicy deniega una conexión, el paquete se descarta en el kernel. No hay evento de Kubernetes, no hay log, no hay métrica. Desde el pod origen, la conexión simplemente agota su tiempo de espera.

Las consecuencias son dos, y las dos son malas:

Para operar: depurar "por qué mi servicio no conecta" es doloroso. Puede ser DNS, una política, un Service mal configurado o la aplicación. Nada te dice cuál.

Para la seguridad: si alguien compromete un pod y empieza a sondear la red interna, generará decenas de conexiones denegadas y no se enterará nadie. Justo la señal más clara de un movimiento lateral es invisible.

Hubble: observabilidad de flujos con Cilium

Hubble, el componente de observabilidad de Cilium, resuelve esto aprovechando que Cilium ya inspecciona cada paquete en eBPF.

helm upgrade cilium cilium/cilium --namespace kube-system --reuse-values \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true \
  --set hubble.metrics.enabled="{dns,drop,tcp,flow,port-distribution,httpV2}"

Ver flujos en tiempo real:

hubble observe --namespace rutas-norte-pro --follow
TIMESTAMP             SOURCE                          DESTINATION                    TYPE          VERDICT
Aug  6 09:14:22.481   rutas-norte-pro/tienda-web-x2   rutas-norte-pro/api-reservas-a1:8080   to-endpoint   FORWARDED
Aug  6 09:14:22.503   rutas-norte-pro/api-reservas-a1 rutas-norte-pro/postgres-reservas-0:5432  to-endpoint   FORWARDED
Aug  6 09:14:23.117   rutas-norte-pro/api-reservas-a1 rutas-norte-pro/redis-cache-0:6379      to-endpoint   FORWARDED

Y lo que de verdad importa, las denegaciones:

hubble observe --namespace rutas-norte-pro --verdict DROPPED --last 100
TIMESTAMP             SOURCE                            DESTINATION                              TYPE          VERDICT   REASON
Aug  6 03:47:11.229   rutas-norte-pro/tienda-web-x2k4   rutas-norte-pro/postgres-reservas-0:5432 to-endpoint   DROPPED   Policy denied
Aug  6 03:47:11.884   rutas-norte-pro/tienda-web-x2k4   rutas-norte-pro/redis-cache-0:6379       to-endpoint   DROPPED   Policy denied
Aug  6 03:47:12.401   rutas-norte-pro/tienda-web-x2k4   203.0.113.44:443                         to-stack     DROPPED   Policy denied
Aug  6 03:47:13.055   rutas-norte-pro/tienda-web-x2k4   198.51.100.7:22                          to-stack     DROPPED   Policy denied

Léelo con atención, porque esa salida cuenta una historia completa. tienda-web —el componente más expuesto— ha intentado, en cinco segundos: conectar con la base de datos de clientes, conectar con la caché, salir a internet por HTTPS y salir a internet por SSH. tienda-web no hace nada de eso en su funcionamiento normal. Es exactamente el patrón de alguien explorando qué puede alcanzar desde un pod comprometido.

Las políticas hicieron su trabajo: todo DROPPED. Pero sin Hubble, esto habría pasado completamente desapercibido.

Preguntas que se pueden responder tras un incidente

Pregunta Consulta
¿Qué intentó alcanzar este pod? hubble observe --pod <nombre> --last 1000
¿Alguien salió a internet desde producción? hubble observe --namespace rutas-norte-pro --to-fqdn "*"
¿Cuándo empezaron los intentos anómalos? hubble observe --verdict DROPPED --since 24h
¿Quién habló con la base de datos? hubble observe --to-pod rutas-norte-pro/postgres-reservas-0
¿Qué rutas HTTP se pidieron? hubble observe --protocol http --http-path "/admin*"

Convertirlo en alertas

Hubble expone métricas Prometheus, así que se integra directamente con el módulo 7:

# k8s/base/monitorizacion/reglas-red.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: alertas-seguridad-red
  namespace: monitorizacion
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  groups:
    - name: seguridad-red
      rules:
        - alert: ConexionesDenegadasAnomalas
          expr: |
            sum by (source_pod, source_namespace) (
              rate(hubble_drop_total{
                source_namespace="rutas-norte-pro",
                reason="POLICY_DENIED"
              }[5m])
            ) > 0.5
          for: 5m
          labels:
            severity: warning
            equipo: plataforma
          annotations:
            summary: >-
              El pod {{ $labels.source_pod }} genera conexiones denegadas de forma
              sostenida
            description: >-
              Más de 0,5 conexiones denegadas por segundo durante 5 minutos.
              Puede ser un despliegue con una política mal configurada, o un pod
              comprometido sondeando la red. Investiga con:
              hubble observe --pod {{ $labels.source_namespace }}/{{ $labels.source_pod }} --verdict DROPPED
            runbook_url: https://wiki.rutasnorte.example/runbooks/conexiones-denegadas

        - alert: SalidaInesperadaDesdeProduccion
          expr: |
            sum by (source_pod) (
              rate(hubble_flows_processed_total{
                source_namespace="rutas-norte-pro",
                destination_namespace="",
                verdict="FORWARDED"
              }[5m])
            ) > 0
          for: 2m
          labels:
            severity: critical
            equipo: plataforma
          annotations:
            summary: >-
              Tráfico saliente a internet desde {{ $labels.source_pod }}
            description: >-
              Solo api-reservas (pasarela de pagos) y worker-notificaciones (SMTP)
              deben salir a internet. Cualquier otro origen es una posible vía de
              exfiltración de datos de clientes. Escalar de inmediato.

Esa segunda alerta es de las más valiosas de toda la plataforma: avisa de una posible exfiltración de datos personales en curso. La ruta a Alertmanager y su encaminamiento son las del módulo 7; lo único nuevo es la fuente de la métrica.

Alternativas sin Cilium

Herramienta Qué aporta
Registro de flujos del proveedor (VPC Flow Logs) Flujos a nivel de red virtual, sin identidad de pod
Calico Enterprise Registro de flujos con contexto de Kubernetes (producto comercial)
Falco con reglas de red Conexiones inesperadas observadas desde las llamadas al sistema (08-06)
Registro del proxy de salida Destinos externos solicitados, con nombre de dominio

Para Rutas Norte, sin Cilium hoy, la combinación práctica es: registro del proxy de salida para el tráfico externo (que es el crítico) y Falco para las conexiones anómalas, que veremos en la lección siguiente.

  1. Confianza cero aplicada a Rutas Norte

La confianza cero (zero trust) es un modelo que se resume en una frase: nunca confíes, verifica siempre. Nada se considera de confianza por su ubicación en la red.

Los principios y su traducción

Principio En Rutas Norte
Nada es de confianza por su ubicación Un pod en rutas-norte-pro no tiene acceso privilegiado por estar ahí
Verificar identidad en cada petición ServiceAccounts (03-06) y RBAC (08-01); mTLS si hubiera malla
Mínimo privilegio RBAC ajustado y políticas de red por componente
Asumir el compromiso Microsegmentación y control de salida limitan el daño
Cifrar en tránsito TLS en el Ingress, WireGuard en el CNI, TLS en PostgreSQL
Registrar y verificar todo Hubble, auditoría del apiserver (08-06), Falco

La idea de asumir el compromiso es la que más cambia el diseño. No se trata de "cómo evito que entren", sino de "cuando entren, ¿qué pueden hacer y en cuánto tiempo me entero?".

La tabla de defensa completa

Capa Mecanismo Ataque que mitiga Estado
Perímetro WAF + OWASP CRS Inyección SQL, XSS, recorrido de directorios Por implantar
Perímetro Limitación de peticiones Abuso, fuerza bruta, denegación de servicio a nivel de aplicación Por implantar
Perímetro Solo Ingress, sin NodePort Exposición accidental de servicios internos Hecho
Transporte TLS con cert-manager Escucha del tráfico cliente-plataforma Hecho (04-05)
Transporte Cabeceras de seguridad Clickjacking, degradación a HTTP, filtrado de URL Por implantar
Transporte WireGuard en el CNI Escucha del tráfico entre nodos Recomendado
Transporte TLS en PostgreSQL Escucha de datos personales en tránsito Recomendado
Segmentación deny-all en los tres entornos Movimiento lateral tras un compromiso Hecho (04-06) + Kyverno (08-03)
Segmentación Políticas por componente Alcance del movimiento lateral Hecho (04-06)
Segmentación Control de salida por ipBlock Exfiltración de datos de clientes Prioritario
Identidad ServiceAccounts dedicadas Suplantación de procesos ante la API Hecho (03-06)
Identidad mTLS con malla Suplantación entre servicios Aplazado, con criterios
Autorización RBAC de mínimo privilegio Abuso de credenciales legítimas Hecho (08-01)
Autorización Políticas L7 Acceso a rutas no autorizadas de la API Aplazado
Endurecimiento securityContext restrictivo Escape del contenedor al nodo Hecho (08-02)
Endurecimiento PSA + Kyverno Que alguien despliegue algo inseguro Hecho (08-03)
Plano de control apiserver privado, etcd cifrado Compromiso total del clúster Por revisar
Plano de control kubelet autenticado, sin puerto de solo lectura Ejecución en pods vía kubelet Por revisar
Visibilidad Hubble o registro del proxy Ceguera ante el sondeo interno y la exfiltración Prioritario
Visibilidad Alertas sobre denegaciones Detección tardía de un incidente Por implantar

Las tres prioridades que salen de esta tabla, por orden: control de salida, visibilidad del tráfico y revisión del plano de control. Todas atacan el mismo escenario: alguien ya está dentro.

Errores Comunes y Consejos

Escribir solo políticas de Ingress. Si policyTypes no incluye Egress, la salida está abierta y con ella la vía de exfiltración. Es el error más grave y más frecuente de esta lección.

Activar Egress y olvidar el DNS. Todo deja de funcionar de una forma muy confusa: los nombres no resuelven y parece un problema de Service. La regla hacia kube-dns en el puerto 53, UDP y TCP, es la primera que hay que escribir.

Usar ipBlock: 0.0.0.0/0 sin except. Incluye la red interna del clúster, así que la política es mucho más permisiva de lo que parece.

Confiar en que los ipBlock de un proveedor externo no cambian. Cambian. Programa una verificación mensual o usa un proxy con lista de dominios.

Creer que el contenedor embajador aísla la red. Comparte el namespace de red con el resto del pod. Es un patrón de arquitectura, no un límite de seguridad.

Escribir políticas por namespace en lugar de por componente. Reproduce la red plana dentro del namespace. El coste de hacerlo bien son cinco políticas más.

Dejar rutas-norte-dev y rutas-norte-pre sin políticas. La red del clúster es plana entre namespaces, y una política que solo se prueba en producción se prueba en el peor momento.

Adoptar una malla de servicio sin necesitarla. Es infraestructura crítica: mal operada, empeora la seguridad. Define criterios objetivos para revisar la decisión.

Activar un WAF directamente en modo bloqueo. Bloqueará peticiones legítimas y tendrás una incidencia. DetectionOnly primero, semanas de análisis, y luego bloqueo.

Usar NodePort en producción "porque es más rápido de configurar". Se salta el TLS, el WAF y la limitación de peticiones, y abre el puerto en todos los nodos.

Olvidar que el autoescalado no defiende de la denegación de servicio. Convierte una caída en una factura. Pon un límite superior al HPA.

Cifrar etcd y no reescribir los Secrets existentes. Los antiguos siguen en claro. kubectl get secrets -A -o json | kubectl replace -f -.

No cifrar las copias de seguridad de etcd. Una copia de etcd contiene todos los Secrets del clúster.

Tolerancias universales. Un operator: Exists sin key tolera todos los taints, incluido el del plano de control, y suele ponerse sin darse cuenta.

Consejo de oro: dibuja el diagrama de qué habla con qué y comprueba que las políticas lo reproducen exactamente, ni una flecha más. Después, haz la pregunta clave: si compromenten este pod, ¿a dónde puede llegar y por dónde pueden sacar los datos? Si la respuesta a la segunda parte no es "a ningún sitio", hay trabajo pendiente en el egreso.

Ejercicios

Ejercicio 1: cerrar la salida de los componentes que no la necesitan

Cuatro de los seis componentes de Rutas Norte no necesitan salir a internet: tienda-web, postgres-reservas, redis-cache e informes-ocupacion. Escribe una única NetworkPolicy que les permita solo lo imprescindible dentro del clúster y les cierre por completo la salida a internet.

Ten en cuenta que:

  • Todos necesitan DNS.
  • postgres-reservas e informes-ocupacion deben poder hablar entre sí (el CronJob consulta la base de datos).
  • tienda-web debe poder llegar a api-reservas.
  • Todos deben poder ser consultados por Prometheus.

Incluye la orden que verificaría que la salida está efectivamente cerrada.

Ejercicio 2: decidir sobre la malla de servicio

La dirección de Rutas Norte plantea desplegar Istio "porque es lo que usan las empresas grandes". Prepara una respuesta técnica de una página que incluya:

  1. Qué problemas concretos resolvería en la plataforma actual.
  2. Qué costaría, de forma cuantificada.
  3. Qué alternativas más baratas cubren parte del beneficio.
  4. Criterios objetivos y medibles para revisar la decisión.
  5. Una recomendación clara.

Ejercicio 3: investigar un incidente con los flujos de red

A las 03:47 salta la alerta ConexionesDenegadasAnomalas. Consultas Hubble y obtienes:

TIMESTAMP             SOURCE                              DESTINATION                                TYPE         VERDICT   REASON
Aug  6 03:47:09.112   rutas-norte-pro/worker-notif-k9x2   rutas-norte-pro/api-reservas-a1:8080       to-endpoint  FORWARDED
Aug  6 03:47:11.229   rutas-norte-pro/worker-notif-k9x2   rutas-norte-pro/postgres-reservas-0:5432   to-endpoint  FORWARDED
Aug  6 03:47:11.884   rutas-norte-pro/worker-notif-k9x2   rutas-norte-pro/redis-cache-0:6379         to-endpoint  DROPPED   Policy denied
Aug  6 03:47:12.401   rutas-norte-pro/worker-notif-k9x2   192.0.2.55:443                             to-stack     DROPPED   Policy denied
Aug  6 03:47:12.902   rutas-norte-pro/worker-notif-k9x2   192.0.2.55:8443                            to-stack     DROPPED   Policy denied
Aug  6 03:47:13.455   rutas-norte-pro/worker-notif-k9x2   192.0.2.55:53                              to-stack     DROPPED   Policy denied
Aug  6 03:47:20.001   rutas-norte-pro/worker-notif-k9x2   rutas-norte-pro/postgres-reservas-0:5432   to-endpoint  FORWARDED
  1. ¿Qué está ocurriendo? Justifica tu lectura línea por línea.
  2. ¿Qué capas de defensa han funcionado y cuáles no?
  3. Enumera las acciones inmediatas, en orden de prioridad.
  4. ¿Qué habría pasado sin control de salida? ¿Y sin Hubble?

Soluciones

Solución 1

# k8s/base/red/salida-cerrada.yaml
# Política de salida para los componentes que NO deben alcanzar internet.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: sin-salida-a-internet
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
  annotations:
    seguridad.rutasnorte.example/justificacion: >-
      tienda-web, postgres-reservas, redis-cache e informes-ocupacion no tienen
      ninguna necesidad de alcanzar internet. Cerrar su salida elimina la vía de
      exfiltración de datos personales para cuatro de los seis componentes.
spec:
  podSelector:
    matchExpressions:
      - key: app
        operator: In
        values:
          - tienda-web
          - postgres-reservas
          - redis-cache
          - informes-ocupacion
  policyTypes: [Egress]
  egress:
    # 1. DNS: imprescindible para todos
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

    # 2. tienda-web -> api-reservas
    #    (el podSelector de esta regla filtra el destino, no el origen;
    #     los demás componentes simplemente no usan este permiso)
    - to:
        - podSelector:
            matchLabels:
              app: api-reservas
      ports:
        - { protocol: TCP, port: 8080 }

    # 3. informes-ocupacion -> postgres-reservas
    - to:
        - podSelector:
            matchLabels:
              app: postgres-reservas
      ports:
        - { protocol: TCP, port: 5432 }

    # NO HAY ninguna regla con ipBlock: la salida a internet está cerrada.

Y la política de entrada para que Prometheus pueda raspar métricas (es Ingress, complementaria):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: permitir-prometheus
  namespace: rutas-norte-pro
spec:
  podSelector: {}
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitorizacion
          podSelector:
            matchLabels:
              app.kubernetes.io/name: prometheus
      ports:
        - { protocol: TCP, port: 9090 }

Una observación importante sobre la primera política: al usar un podSelector con matchExpressions que agrupa cuatro componentes, todos comparten las mismas reglas de salida. Eso significa que redis-cache técnicamente podría conectar con api-reservas:8080, aunque no lo haga.

Es un compromiso consciente entre concisión y precisión. La versión estrictamente correcta sería una política por componente, y es lo que recomendaríamos en producción siguiendo el principio 2 del apartado 2. Esta versión agrupada es aceptable porque el objetivo declarado del ejercicio —cerrar la salida a internet— se cumple igualmente, y ninguno de los permisos internos concedidos de más da acceso a datos personales. Documentar ese razonamiento es tan importante como el YAML.

Verificación:

#!/usr/bin/env bash
NS=rutas-norte-pro
for objetivo in deploy/tienda-web statefulset/postgres-reservas statefulset/redis-cache; do
  echo -n "$objetivo -> internet: "
  if kubectl exec -n "$NS" "$objetivo" -- \
       timeout 5 sh -c 'wget -q -O- --timeout=4 https://ejemplo-externo.example' \
       >/dev/null 2>&1; then
    echo "ALCANZA <-- FALLO"
  else
    echo "bloqueado (correcto)"
  fi
  echo -n "$objetivo -> DNS: "
  kubectl exec -n "$NS" "$objetivo" -- \
    timeout 5 nslookup api-reservas >/dev/null 2>&1 \
    && echo "resuelve (correcto)" || echo "NO RESUELVE <-- FALLO"
done
deploy/tienda-web -> internet: bloqueado (correcto)
deploy/tienda-web -> DNS: resuelve (correcto)
statefulset/postgres-reservas -> internet: bloqueado (correcto)
statefulset/postgres-reservas -> DNS: resuelve (correcto)
statefulset/redis-cache -> internet: bloqueado (correcto)
statefulset/redis-cache -> DNS: resuelve (correcto)

Comprobar el DNS es tan importante como comprobar el bloqueo: una política de salida que bloquea internet y el DNS rompe la plataforma sin que sea evidente por qué.

Solución 2

Informe técnico: adopción de una malla de servicio en la plataforma Rutas Norte

1. Qué problemas resolvería

Problema real ¿Lo resuelve? ¿Es urgente hoy?
Tráfico interno sin cifrar, incluidos datos personales Sí, mTLS automático Sí, es un riesgo real
Un servicio no puede verificar criptográficamente quién le llama Sí, identidad SPIFFE Moderado: hay 6 servicios, todos internos y controlados
No hay autorización por ruta HTTP Sí, AuthorizationPolicy Bajo: el grafo de llamadas es casi lineal
Reintentos y tiempos de espera duplicados en cada servicio Bajo: ya está implementado
Falta observabilidad de latencia entre servicios Parcialmente Bajo: el módulo 7 ya cubre lo esencial
Despliegues canary por porcentaje de tráfico Bajo: hoy se hace por réplicas (11-04)

De seis problemas, uno es urgente (cifrado) y cinco son mejoras deseables sin urgencia.

2. Coste cuantificado

Concepto Estimación
Memoria de sidecars 6 componentes × ~15 pods × 80 MiB ≈ 1,2 GiB de memoria adicional
CPU de sidecars ~0,05 núcleos por pod ≈ 0,75 núcleos
Latencia añadida 2-5 ms por salto; en el camino tienda-webapi-reservaspostgres, hasta 10 ms por petición
Implantación inicial 3-4 semanas de una persona de plataforma
Operación continua ~1 día al mes: actualizaciones, certificados, diagnóstico
Formación del equipo 2 semanas para 4 personas
Riesgo de incidencia Medio-alto los primeros meses: es la causa más común de "el servicio devuelve 503 y no sé por qué"

Con Istio en modo ambient o con Linkerd, la memoria y la latencia bajarían sustancialmente. La complejidad operativa y el riesgo de los primeros meses no.

3. Alternativas más baratas

Alternativa Cubre Coste
WireGuard en el CNI Cifrado de todo el tráfico entre nodos Una opción de configuración; horas de trabajo
TLS en postgres-reservas Cifrado del tramo con datos personales, incluso dentro del mismo nodo 1-2 días
NetworkPolicies estrictas (ya hechas) Segmentación L3/L4 Hecho
Tokens de servicio en la aplicación Autenticación básica entre servicios 2-3 días por servicio
Hubble o registro del proxy de salida Visibilidad de flujos 1 semana

Las dos primeras juntas cubren el 80 % del beneficio de seguridad por menos del 5 % del coste.

4. Criterios objetivos para revisar la decisión

Se reabrirá la evaluación cuando se cumpla cualquiera de estos:

  • La plataforma supere los 15 servicios con comunicación entre ellos.
  • Más de tres equipos desplieguen en el clúster sin coordinación diaria.
  • Aparezca un requisito normativo o contractual explícito de mTLS entre servicios (por ejemplo, una certificación de un cliente corporativo).
  • Se necesiten despliegues canary por porcentaje de tráfico que no se puedan aproximar con réplicas.
  • Se necesite encaminamiento entre varios clústeres (11-05).
  • El equipo de plataforma alcance 5 personas o más, con capacidad para dedicar una a la malla.

5. Recomendación

No adoptar una malla de servicio ahora. En su lugar, en este orden:

  1. Activar WireGuard en el CNI (esta semana).
  2. Configurar TLS en postgres-reservas (este trimestre).
  3. Implantar visibilidad de flujos (este trimestre).
  4. Revisar la decisión cada seis meses contra los criterios anteriores.

Si en el futuro se adopta, la primera candidata sería Linkerd por su relación entre beneficio y complejidad, o Istio en modo ambient si para entonces se necesita encaminamiento avanzado.

Nota final: esta recomendación es una valoración de arquitectura basada en el tamaño y la topología actuales. Debe validarla el profesional de seguridad de la organización, especialmente en lo relativo al cifrado del tráfico con datos personales, donde el responsable de cumplimiento normativo tiene la última palabra sobre qué nivel de protección es exigible.

Solución 3

1. Qué está ocurriendo, línea por línea

Hora Flujo Lectura
03:47:09 worker-notifapi-reservas:8080 FORWARDED Anómalo. El worker lee de la base de datos, no llama a la API. La política lo permite (probablemente un permiso amplio de más), pero no es su comportamiento normal
03:47:11 worker-notifpostgres:5432 FORWARDED Legítimo por sí solo: el worker consulta reservas pendientes
03:47:11 worker-notifredis-cache:6379 DROPPED Anómalo. El worker no usa la caché. Es un sondeo
03:47:12 worker-notif192.0.2.55:443 DROPPED Muy grave. Intento de salida a una IP externa que no es la pasarela de pagos ni el SMTP
03:47:12 worker-notif192.0.2.55:8443 DROPPED Reintento en otro puerto: busca una salida
03:47:13 worker-notif192.0.2.55:53 DROPPED Reintento en el puerto DNS: una técnica clásica para atravesar cortafuegos permisivos
03:47:20 worker-notifpostgres:5432 FORWARDED Lo más preocupante: tras fallar todas las salidas, vuelve a la base de datos

La secuencia completa —cuatro segundos, tres puertos distintos hacia la misma IP externa, sondeo de servicios internos, y vuelta a la base de datos— no corresponde a ningún comportamiento legítimo de worker-notificaciones. La firma es la de un proceso comprometido buscando por dónde sacar información, y la última línea sugiere que sigue accediendo a datos.

Un detalle temporal importante: son las 03:47. El CronJob informes-ocupacion corre a las 03:00. Habrá que descartar que haya relación.

2. Capas que funcionaron y que no

Capa Resultado
Control de salida (Egress) Funcionó. Los tres intentos de salir a internet fueron bloqueados. Esta es la capa que ha impedido la fuga de datos
Microsegmentación Funcionó parcialmente. Bloqueó Redis, pero permitió llegar a api-reservas
Visibilidad (Hubble) Funcionó. Sin ella, este incidente sería invisible
Alertas Funcionó. La alerta saltó y por eso estamos investigando
Prevención del compromiso inicial Falló. Algo permitió ejecutar código en el worker
Política de red del worker Demasiado permisiva. No debería poder llegar a api-reservas
Autorización L7 Ausente. Con acceso a api-reservas:8080, puede llamar a cualquier ruta
Cifrado interno Ausente. El tráfico a postgres va en claro

3. Acciones inmediatas, por prioridad

# Acción Por qué
1 Aislar el pod sin destruirlo: cambiar su etiqueta app para que los Services y las políticas dejen de aplicarle, dejándolo corriendo Corta el acceso conservando la memoria y el estado para el análisis. Borrarlo destruye las pruebas
2 Aplicar una política que le deniegue todo, incluida la base de datos Detiene el acceso a datos personales en curso
3 Rotar las credenciales de postgres-reservas y revisar los pg_stat y logs de la base de datos El proceso tuvo acceso legítimo; hay que saber qué consultó
4 Consultar el registro de auditoría del apiserver (08-06): ¿usó la ServiceAccount? ¿leyó Secrets? Determinar el alcance real
5 Revisar los flujos de las últimas 72 horas de ese pod y de todos los del namespace Establecer cuándo empezó y si hay otros pods afectados
6 Notificar al responsable de cumplimiento normativo Hay indicios de acceso no autorizado a datos personales. Los plazos de notificación empiezan a correr desde el conocimiento del hecho
7 Escanear la imagen del worker en busca de vulnerabilidades conocidas (08-06) Encontrar el vector de entrada
8 Endurecer la política del worker: quitar el acceso a api-reservas Corregir el permiso de más
9 Revisar el resto de políticas buscando permisos igual de amplios El mismo error puede estar en otros sitios

Las órdenes de los dos primeros pasos:

# 1. Aislar: quitarle la etiqueta que lo hace destinatario de políticas y Services
kubectl label pod -n rutas-norte-pro worker-notif-k9x2 app-
kubectl label pod -n rutas-norte-pro worker-notif-k9x2 estado=en-cuarentena

# 2. Denegación total para el pod en cuarentena
kubectl apply -f - <<'YAML'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: cuarentena
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      estado: en-cuarentena
  policyTypes: [Ingress, Egress]
  # Sin reglas: se deniega absolutamente todo
YAML

Al quitar la etiqueta app, el ReplicaSet detecta que le falta una réplica y crea un pod nuevo y limpio: el servicio se recupera solo mientras el pod comprometido queda aislado para el análisis. Es una técnica que conviene tener ensayada antes de necesitarla.

4. Los escenarios contrafactuales

Sin control de salida: las tres conexiones a 192.0.2.55 habrían sido FORWARDED. El proceso tenía acceso legítimo a postgres-reservas, es decir, a la tabla de clientes con nombres, DNI, teléfonos y correos. Esto habría sido una fuga de datos personales consumada, con obligación de notificación a la autoridad de control y a los afectados. La política de egreso —esas líneas de YAML que casi nadie escribe— es lo único que separó un incidente contenido de una brecha de datos.

Sin Hubble: no habría alerta. Las conexiones denegadas son silenciosas: sin registro de flujos, el pod comprometido habría seguido consultando la base de datos indefinidamente, y probablemente habría encontrado antes o después una vía de salida (una configuración cambiada, un permiso nuevo, una política mal revisada). El incidente se habría descubierto semanas o meses después, o nunca.

Este ejercicio resume la lección entera: la prevención falló —siempre acaba fallando en algún punto—, la contención funcionó, y la detección permitió reaccionar. Las tres son necesarias, y la que más se descuida es la tercera.

Conclusión

Hemos construido la estrategia de red completa de Rutas Norte, por capas:

  • La microsegmentación parte de denegar por defecto en los tres entornos, con políticas por componente y no por namespace, y con revisión periódica de qué conversaciones siguen siendo necesarias.
  • El control del tráfico saliente es la capa que decide si un compromiso se convierte en una fuga de datos. Cuatro de los seis componentes de Rutas Norte no necesitan salir a internet en absoluto, y cerrarles la salida es la medida de mayor valor por menor coste de toda la lección.
  • Las NetworkPolicies trabajan con IP, no con nombres de dominio. Las salidas son la pasarela de salida, el proxy con lista de dominios permitidos (que además registra los destinos) o las políticas toFQDNs de Cilium.
  • Dentro del clúster, tras el TLS del Ingress, todo viaja en claro. Las opciones son TLS en la aplicación (prioritario para postgres-reservas), cifrado a nivel de CNI con WireGuard (barato y efectivo entre nodos) o mTLS con una malla de servicio.
  • Una malla de servicio da identidad criptográfica por carga, mTLS automático y autorización L7, a cambio de complejidad operativa, recursos y latencia. Rutas Norte no la necesita hoy, y hemos fijado criterios objetivos para revisar esa decisión. Istio (sidecar o ambient), Linkerd y Cilium Service Mesh cubren perfiles distintos.
  • En el perímetro: un único punto de entrada (nada de NodePort), WAF adoptado gradualmente, limitación de peticiones y cabeceras de seguridad.
  • El plano de control merece revisión propia: apiserver no expuesto, etcd cifrado y con los Secrets reescritos, copias de seguridad cifradas, kubelet autenticado sin puerto de solo lectura, y nodos de control aislados con taints.
  • Lo que le falta a NetworkPolicy es registro. Hubble, o el registro de un proxy de salida, convierte el sondeo interno y los intentos de exfiltración en algo visible y alertable.
  • La confianza cero se resume en asumir el compromiso: no "cómo evito que entren", sino "cuando entren, qué pueden hacer y en cuánto me entero".

Con esto, la red de Rutas Norte está segmentada, con la salida controlada y con visibilidad de lo que ocurre. Pero fíjate en algo: todo lo que hemos hecho en este módulo protege el clúster en tiempo de ejecución. Damos por sentado que las imágenes que ejecutamos son las que creemos.

Y esa suposición es frágil. registry.rutasnorte.example/api-reservas:2.7.1 es una etiqueta, y una etiqueta se puede reasignar: la imagen que se descargó ayer puede no ser la de hoy. La imagen base sobre la que se construyó puede contener software que nadie ha revisado. Las dependencias que se instalaron durante la construcción vienen de repositorios públicos. Y nada, absolutamente nada, impide hoy que alguien con acceso al registro suba una imagen modificada con el mismo nombre y que el clúster la ejecute sin rechistar, con todos nuestros securityContext perfectamente aplicados a un contenedor que hace algo distinto de lo que creemos.

La siguiente lección, 08-05, Seguridad de Imágenes, recorre la cadena de suministro completa: dónde se puede envenenar una imagen, cómo construir imágenes mínimas, por qué el digest es la única referencia realmente reproducible, cómo firmar con Cosign y —lo más importante— cómo hacer que el clúster rechace cualquier imagen que no esté firmada, con la política de admisión que lo garantiza.

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