En la lección anterior endurecimos cada componente de Rutas Norte: tienda-web corre sin root con el sistema de ficheros en solo lectura, api-reservas no tiene ni una capacidad de Linux, postgres-reservas escribe en su volumen gracias a fsGroup y todos llevan seccompProfile: RuntimeDefault. El trabajo está hecho y verificado.
Y aun así, hay un agujero enorme: todo eso depende de que alguien se acuerde. Mañana un compañero añade un sidecar y olvida el drop: ["ALL"]. Un desarrollador copia un manifiesto de un blog que trae privileged: true porque "así funcionaba". Un operador de terceros se instala con un DaemonSet que monta / del nodo. Ninguna de esas cosas rompe nada visible, nadie recibe una alerta, y el endurecimiento que construimos con tanto cuidado deja de aplicarse en el punto exacto donde importa.
Esta lección da el salto que lo cambia todo: pasar de "cada equipo pone bien su securityContext" a "el clúster rechaza un pod que no lo tenga". Es la tercera puerta que vimos en 08-01, el control de admisión, y es la diferencia entre una norma escrita y una norma aplicada.
Advertencia importante. Las políticas de admisión son el mecanismo que hace cumplir el diseño de seguridad, y por eso mismo un error en ellas puede bloquear despliegues legítimos o, peor, dar una falsa sensación de protección. El conjunto de políticas de un clúster de producción debe diseñarlo y revisarlo un profesional de seguridad. Cuando las cargas afectadas tratan datos personales —
postgres-reservas,api-reservaseinformes-ocupacionen Rutas Norte— el responsable de cumplimiento normativo debe conocer y aprobar qué se exige y qué excepciones existen.
Contenido
- De la norma escrita a la norma aplicada
- Contexto histórico: las PodSecurityPolicy y por qué desaparecieron
- Los Pod Security Standards:
privileged,baselineyrestricted - Pod Security Admission: activarlo con etiquetas en el namespace
- Aplicación progresiva en Rutas Norte
- Cargas que legítimamente necesitan privilegios
- Las limitaciones del Pod Security Admission
- Motores de política general: Kyverno y OPA Gatekeeper
- Políticas de Kyverno para Rutas Norte
- ValidatingAdmissionPolicy: CEL nativo sin instalar nada
- Errores comunes y consejos
- Ejercicios
- Conclusión
- De la norma escrita a la norma aplicada
Recordemos el recorrido de una petición a la API de 08-01:
flowchart LR
A["kubectl apply -f pod.yaml"] --> B["Autenticación<br/>¿quién eres?"]
B --> C["Autorización RBAC<br/>¿puedes crear pods?"]
C --> D["Admisión mutante<br/>modifica el objeto"]
D --> E["Admisión validante<br/>¿el objeto cumple?<br/>ESTA LECCIÓN"]
E -->|No cumple| X["Rechazado<br/>el objeto NUNCA llega a etcd"]
E -->|Cumple| F[("etcd")]
style E fill:#d5e8f9,stroke:#36c
La diferencia con RBAC es fundamental y conviene tenerla clarísima:
| RBAC (08-01) | Control de admisión (esta lección) | |
|---|---|---|
| Pregunta | ¿Tienes derecho a hacer esta operación? | ¿El objeto que envías es aceptable? |
| Mira | Sujeto, verbo, recurso, namespace | El contenido del objeto |
| Ejemplo de decisión | "Ana puede crear pods en dev" | "Este pod no puede ser privilegiado" |
| Si falla | 403 Forbidden |
400 Bad Request o 422 con el motivo |
Alguien de plataforma tiene permiso RBAC para crear pods en rutas-norte-pro. Eso no cambia. Lo que añadimos es que, aunque tenga permiso, el pod concreto que envía debe cumplir unas reglas.
Y hay una propiedad muy valiosa: el objeto rechazado nunca llega a etcd. No se crea y hay que borrarlo después; sencillamente no existe. La respuesta llega al instante a quien hizo el kubectl apply, con el motivo escrito.
Los dos tipos de controlador de admisión
| Tipo | Qué hace | Ejemplos |
|---|---|---|
| Mutante (mutating) | Modifica el objeto antes de guardarlo | Inyectar la ServiceAccount por defecto, añadir el sidecar de una malla de servicio, aplicar los valores de un LimitRange (03-05) |
| Validante (validating) | Solo acepta o rechaza, no modifica | Pod Security Admission, ResourceQuota (03-04), políticas de Kyverno de tipo validate |
Los mutantes se ejecutan antes que los validantes, lo cual tiene sentido: primero se completa el objeto, luego se comprueba el resultado final.
- Contexto histórico: las PodSecurityPolicy y por qué desaparecieron
Si buscas información sobre este tema encontrarás muchísimo material sobre PodSecurityPolicy (PSP). Es importante que sepas qué eran, porque aparecen constantemente en documentación antigua, en respuestas de foros y en manifiestos heredados. Pero también que tengas muy claro esto:
Las PodSecurityPolicy quedaron obsoletas en Kubernetes 1.21 y se eliminaron por completo en 1.25. En un clúster 1.30 no existen. Si un tutorial te dice que crees una
PodSecurityPolicy, ese tutorial tiene al menos cinco años.
Qué eran
Un recurso a nivel de clúster que describía qué se permitía en un pod: si podía ser privilegiado, qué usuarios podía usar, qué volúmenes, qué capacidades. Su forma se parecía a esto:
# NO USAR: recurso eliminado en Kubernetes 1.25. Solo como referencia histórica.
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
name: restrictiva
spec:
privileged: false
allowPrivilegeEscalation: false
requiredDropCapabilities: ["ALL"]
runAsUser:
rule: MustRunAsNonRoot
seLinux:
rule: RunAsAny
fsGroup:
rule: MustRunAs
ranges: [{min: 1, max: 65535}]
volumes: ["configMap", "emptyDir", "secret", "persistentVolumeClaim"]Conceptualmente la idea era buena. La implementación tenía tres problemas graves que resultaron insalvables.
Problema 1: RBAC contraintuitivo
Una PSP no se aplicaba a nadie por sí sola. Se "activaba" concediendo el verbo use sobre ella mediante RBAC, y —esto es lo raro— al sujeto que creaba el pod, que muchas veces no era la persona sino un controlador. Cuando un Deployment creaba pods, el sujeto relevante era la ServiceAccount del controlador de ReplicaSets, no el usuario que hizo el kubectl apply.
El resultado: nadie sabía con certeza qué política se le estaba aplicando a un pod concreto, y responder a "¿por qué se ha rechazado esto?" era un ejercicio de arqueología.
Problema 2: orden de aplicación impredecible
Si a un sujeto le aplicaban varias PSP —cosa habitual— Kubernetes elegía una según un algoritmo complicado: primero las que no mutaban el pod, y si todas mutaban, la primera por orden alfabético. El orden alfabético. Renombrar una política cambiaba qué política se aplicaba.
Problema 3: mutación
Las PSP no solo validaban: también modificaban el pod (rellenaban runAsUser, añadían fsGroup, quitaban capacidades). Eso significaba que el pod que se ejecutaba no era el que estaba en tu manifiesto, y que aplicar el mismo YAML en dos clústeres podía dar resultados distintos sin aviso alguno.
Y un problema de fondo: la usabilidad
En la práctica, activar PSP en un clúster existente rompía casi todo, y como no había un modo "avísame pero no bloquees", el camino habitual era crear una PSP permisiva "temporal" que se quedaba para siempre. La conclusión de la comunidad fue clara: un mecanismo de seguridad que nadie consigue activar no protege a nadie.
Qué se aprendió
El sustituto, el Pod Security Admission, se diseñó explícitamente para corregir cada uno de esos defectos:
| Problema de PSP | Cómo lo resuelve el PSA |
|---|---|
| RBAC contraintuitivo | Se activa con etiquetas en el namespace. Sin RBAC de por medio |
| Orden impredecible | Tres perfiles fijos definidos por el proyecto. No hay que elegir |
| Mutación | Nunca muta. Solo valida |
| Todo o nada | Tres modos: warn, audit y enforce. Se puede adoptar gradualmente |
| Complejidad | Una etiqueta en un namespace |
La contrapartida es que el PSA es mucho menos flexible: solo tres perfiles, sin posibilidad de personalizarlos. Ese hueco lo cubren los motores de política del apartado 8.
- Los Pod Security Standards:
privileged, baseline y restricted
privileged, baseline y restrictedLos Pod Security Standards (PSS) son tres perfiles definidos por el proyecto Kubernetes. No son un recurso ni un objeto: son una especificación, un acuerdo sobre qué significa "seguro" en tres niveles.
| Perfil | Filosofía | Para qué |
|---|---|---|
privileged |
Sin restricciones | Infraestructura del sistema: CNI, CSI, agentes de nodo |
baseline |
Impide las escaladas de privilegios conocidas | Aplicaciones normales; adopción realista y compatible |
restricted |
Buenas prácticas de endurecimiento actuales | El objetivo para toda aplicación |
Tabla detallada de qué controla cada perfil
| Control | privileged |
baseline |
restricted |
|---|---|---|---|
privileged: true |
Permitido | Prohibido | Prohibido |
hostNetwork |
Permitido | Prohibido | Prohibido |
hostPID / hostIPC |
Permitido | Prohibido | Prohibido |
hostPath |
Permitido | Prohibido | Prohibido |
hostPort |
Permitido | Prohibido | Prohibido |
| Capacidades añadidas | Todas | Solo una lista corta (ver abajo) | Solo NET_BIND_SERVICE |
capabilities.drop: ["ALL"] |
No exigido | No exigido | Obligatorio |
allowPrivilegeEscalation |
Libre | Libre | Debe ser false |
runAsNonRoot |
Libre | Libre | Debe ser true |
runAsUser: 0 |
Permitido | Permitido | Prohibido |
seccompProfile |
Libre | No puede ser Unconfined |
RuntimeDefault o Localhost |
| Tipos de volumen | Todos | Todos menos los de host | Lista restringida |
sysctls no seguros |
Permitidos | Prohibidos | Prohibidos |
procMount: Unmasked |
Permitido | Prohibido | Prohibido |
| SELinux: tipos peligrosos | Permitidos | Prohibidos | Prohibidos |
AppArmor Unconfined |
Permitido | Prohibido | Prohibido |
/dev/... como hostPath |
Permitido | Prohibido | Prohibido |
Capacidades permitidas en baseline
baseline permite añadir solo estas, que son las del conjunto por defecto y todas relativamente inocuas:
AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL,
MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOTLo que baseline prohíbe añadir es lo verdaderamente peligroso: NET_ADMIN, SYS_ADMIN, SYS_MODULE, SYS_PTRACE, BPF, NET_RAW...
restricted va mucho más allá: exige drop: ["ALL"] y solo permite volver a añadir NET_BIND_SERVICE. Es exactamente la excepción de la que hablamos en 08-02 y por eso decíamos que el puerto alto es preferible: con puerto 8080 no necesitas ni siquiera esa.
Tipos de volumen permitidos en restricted
Fíjate en que están todos los que usa Rutas Norte —emptyDir para los directorios de escritura, persistentVolumeClaim para los datos de PostgreSQL, configMap y secret para la configuración— y no está hostPath. Es la formalización de la advertencia de 05-01.
El campo securityContext mínimo que cumple restricted
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.rutasnorte.example/api-reservas:2.7.1
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]Compáralo con el bloque de "consejo de oro" de 08-02: es prácticamente el mismo. Los Pod Security Standards no son otra cosa que la codificación oficial de lo que ya hicimos a mano. Todo lo de la lección anterior encaja ahora en una etiqueta.
Nota importante: restricted no exige readOnlyRootFilesystem. Es una omisión conocida (era demasiado disruptiva para muchas cargas) y una de las razones por las que un motor de política complementa al PSA.
- Pod Security Admission: activarlo con etiquetas en el namespace
El Pod Security Admission (PSA) es el controlador que aplica los Pod Security Standards. Va integrado en el apiserver desde 1.25: no hay que instalar nada, no hay ningún pod que mantener, no hay webhook que pueda caerse.
Se activa poniendo etiquetas en el Namespace:
apiVersion: v1
kind: Namespace
metadata:
name: rutas-norte-pro
labels:
app.kubernetes.io/part-of: rutas-norte
entorno: pro
# --- Pod Security Admission ---
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.30
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.30
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.30La sintaxis de la etiqueta es:
Los tres modos
| Modo | Qué pasa cuando un pod incumple | Dónde se ve |
|---|---|---|
enforce |
El pod se rechaza. No se crea | Error inmediato al aplicar |
audit |
Se crea, pero se anota en el registro de auditoría del apiserver | Log de auditoría (08-06) |
warn |
Se crea, y el cliente recibe un aviso | Salida de kubectl |
Los tres son independientes y se pueden combinar. Ahí está la clave de la adopción gradual: puedes poner enforce: baseline (bloquea lo peor) junto con warn: restricted y audit: restricted (te avisa de lo que aún no cumple, sin bloquear).
Un detalle esencial sobre enforce:
enforcesolo actúa sobre pods que se crean o actualizan. No afecta a los que ya están corriendo. Si activasenforce: restricteden un namespace con pods que no cumplen, esos pods siguen funcionando tan tranquilos... hasta que el Deployment cree uno nuevo, que fallará. Es un fallo diferido y desconcertante si no lo esperas.
Por eso warn y audit no bloquean pero sí evalúan objetos que crean pods (Deployments, StatefulSets, CronJobs), avisándote en el momento del apply. enforce, en cambio, solo evalúa el pod, no el Deployment. Volveremos sobre esto en el apartado 7.
El fijado de versión
Los Pod Security Standards evolucionan: en cada versión de Kubernetes se pueden añadir controles nuevos. Si no fijas la versión, el valor por defecto es latest, lo que significa que una actualización del clúster puede empezar a rechazar pods que antes se aceptaban, sin que nadie haya tocado nada.
| Valor | Comportamiento |
|---|---|
latest (por defecto) |
Usa siempre los controles de la versión actual del clúster |
v1.30 |
Congela los controles de esa versión |
Fija siempre la versión. Y cuando actualices el clúster, sube la etiqueta como un cambio explícito y revisado, con warn primero. Es la diferencia entre una actualización planificada y un incidente el lunes por la mañana.
Configurar valores por defecto para todo el clúster
Se puede indicar al apiserver un perfil por defecto para los namespaces que no lleven etiquetas, mediante AdmissionConfiguration:
# /etc/kubernetes/admision/pod-security.yaml (en los nodos del plano de control)
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1
kind: PodSecurityConfiguration
defaults:
enforce: "baseline" # línea base para namespaces sin etiquetar
enforce-version: "v1.30"
audit: "restricted"
audit-version: "v1.30"
warn: "restricted"
warn-version: "v1.30"
exemptions:
usernames: []
runtimeClasses: []
namespaces:
- kube-system # el sistema necesita privilegiosEsto es muy potente porque cubre los namespaces futuros: si mañana alguien crea rutas-norte-experimentos sin etiquetas, arranca ya en baseline. Sin esta configuración, un namespace sin etiquetar equivale a privileged, es decir, sin ninguna restricción.
Requiere acceso al plano de control, así que en Kubernetes gestionado (10-06) puede no estar disponible; en ese caso, la alternativa es una política de Kyverno que exija las etiquetas en todo namespace nuevo (lo veremos en el apartado 9).
El modo warn en acción
kubectl label namespace rutas-norte-pre \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.30
kubectl apply -f k8s/entornos/pre/tienda-web.yamlWarning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
(container "nginx" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "nginx" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "nginx" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")
deployment.apps/tienda-web configuredLee bien esa salida: el Deployment se ha aplicado (configured), pero el aviso enumera exactamente los cuatro campos que faltan, contenedor por contenedor. Es la mejor documentación posible de qué hay que arreglar. Anota lo bien redactado que está el mensaje: cada línea te dice el campo, el contenedor y el valor esperado.
- Aplicación progresiva en Rutas Norte
Nunca actives enforce: restricted de golpe en un namespace de producción. El camino correcto tiene cuatro fases y puede llevar semanas. Vamos a recorrerlo.
flowchart TD
A["Fase 0<br/>Sin etiquetas<br/>= privileged"] --> B["Fase 1<br/>warn + audit: restricted<br/>NO bloquea"]
B --> C["Analizar avisos<br/>y arreglar componentes"]
C --> D["Fase 2<br/>enforce: baseline<br/>+ warn/audit: restricted"]
D --> E["Arreglar lo que falta<br/>para restricted"]
E --> F["Fase 3<br/>enforce: restricted<br/>en los tres modos"]
style F fill:#d5f9d5,stroke:#3a3
Fase 1: ver qué se rompería, sin romper nada
kubectl label namespace rutas-norte-pro \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.30 \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=v1.30Aquí no se bloquea nada: el clúster sigue funcionando igual. Ahora hay que provocar la evaluación de todas las cargas existentes. Un truco muy útil es reaplicar los manifiestos sin cambios:
--dry-run=server envía el objeto al apiserver, que lo pasa por toda la cadena de admisión y devuelve el resultado sin guardarlo. Es la forma segura de evaluar la situación completa.
Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
(container "postgres" ...), runAsNonRoot != true (pod or container "postgres" ...)
Warning: would violate PodSecurity "restricted:v1.30": host namespaces (hostPath volume "varlog"),
restricted volume types (volume "varlog" uses restricted volume type "hostPath")Y para ver lo que evalúa el modo audit, la fuente es el registro de auditoría del apiserver, con la anotación pod-security.kubernetes.io/audit-violations. Lo veremos en detalle en 08-06.
Una herramienta que ahorra mucho tiempo en esta fase:
# Evalúa todo el clúster contra un perfil sin aplicar nada
kubectl krew install score # o usa pod-security-admission-checkerTambién se puede consultar directamente qué namespaces están sin protección:
kubectl get namespaces -o custom-columns=\
'NOMBRE:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,WARN:.metadata.labels.pod-security\.kubernetes\.io/warn'NOMBRE ENFORCE WARN
default <none> <none>
kube-system privileged <none>
rutas-norte-dev <none> <none>
rutas-norte-pre <none> restricted
rutas-norte-pro <none> restrictedLos <none> en enforce son namespaces donde ahora mismo se puede desplegar cualquier cosa, incluido default. Esa tabla es una excelente diapositiva para una reunión de seguridad.
Fase 2: enforce: baseline
baseline bloquea lo verdaderamente peligroso —privilegiados, namespaces de host, hostPath— y casi ninguna aplicación normal lo incumple. Es un paso con muy poco riesgo y mucho valor.
# k8s/entornos/pro/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: rutas-norte-pro
labels:
app.kubernetes.io/part-of: rutas-norte
entorno: pro
pod-security.kubernetes.io/enforce: baseline # bloquea lo peor
pod-security.kubernetes.io/enforce-version: v1.30
pod-security.kubernetes.io/audit: restricted # sigue avisando del objetivo
pod-security.kubernetes.io/audit-version: v1.30
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.30Ahora, si alguien intenta desplegar un pod privilegiado:
Error from server (Forbidden): pods "prueba" is forbidden: violates PodSecurity
"baseline:v1.30": privileged (container "prueba" must not set securityContext.privileged=true)Ese es el momento en el que el trabajo de 08-02 deja de depender de la memoria de nadie.
Fase 3: enforce: restricted y el mensaje de rechazo exacto
Antes de dar este paso, todos los componentes deben cumplir. Probemos primero con un manifiesto sin endurecer para ver el mensaje completo:
# /tmp/pod-sin-endurecer.yaml
apiVersion: v1
kind: Pod
metadata:
name: prueba-restricted
namespace: rutas-norte-pro
spec:
containers:
- name: app
image: registry.rutasnorte.example/utilidades:1.4.2kubectl label namespace rutas-norte-pro \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.30 --overwrite
kubectl apply -f /tmp/pod-sin-endurecer.yamlError from server (Forbidden): error when creating "/tmp/pod-sin-endurecer.yaml":
pods "prueba-restricted" is forbidden: violates PodSecurity "restricted:v1.30":
allowPrivilegeEscalation != false (container "app" must set
securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "app" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")El mensaje es una lista de tareas. Corregimos:
# /tmp/pod-endurecido.yaml
apiVersion: v1
kind: Pod
metadata:
name: prueba-restricted
namespace: rutas-norte-pro
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.rutasnorte.example/utilidades:1.4.2
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]El componente que no cumplía: postgres-reservas
En 08-02 dejamos readOnlyRootFilesystem: false en PostgreSQL como excepción documentada. Buena noticia: restricted no exige ese campo, así que ese punto no bloquea. Lo que sí exige es runAsNonRoot: true y drop: ["ALL"], que ya teníamos. postgres-reservas pasa restricted sin cambios.
Comprobémoslo antes de tocar el namespace:
Sin avisos. Perfecto.
El componente que no cumple: el recolector de logs
Warning: would violate PodSecurity "restricted:v1.30": hostPath volumes (volume "varlog"),
runAsNonRoot != true (pod or container "recolector" must set securityContext.runAsNonRoot=true),
restricted volume types (volume "varlog" uses restricted volume type "hostPath")Y aquí no hay nada que arreglar: el recolector necesita legítimamente leer /var/log del nodo y necesita ser root para hacerlo. Es el tema del siguiente apartado.
Estado final de los tres entornos
| Namespace | enforce |
audit / warn |
Razón |
|---|---|---|---|
rutas-norte-dev |
baseline |
restricted |
Los desarrolladores necesitan margen para depurar |
rutas-norte-pre |
restricted |
restricted |
Debe ser idéntico a producción |
rutas-norte-pro |
restricted |
restricted |
Objetivo |
rutas-norte-sistema |
privileged |
— | Agentes de nodo (apartado 6) |
Que rutas-norte-pre sea igual de estricto que producción es innegociable: si en preproducción se permite lo que en producción no, la promoción falla justo en el peor momento. Ese es todo el sentido de tener un entorno intermedio.
rutas-norte-dev en baseline es una concesión deliberada: bloquea lo peligroso pero permite, por ejemplo, correr como root para probar algo. Con warn: restricted, el desarrollador ve en cada apply exactamente qué le faltará para promocionar. Es una decisión de equilibrio que hay que documentar.
- Cargas que legítimamente necesitan privilegios
Hay software que no puede cumplir restricted, y no por dejadez. El plugin CNI configura las interfaces de red del nodo. El driver CSI monta volúmenes en el sistema de ficheros del anfitrión. El recolector de logs lee /var/log. El agente de detección en tiempo de ejecución (Falco, que veremos en 08-06) observa llamadas al sistema.
La respuesta correcta no es relajar el perfil de rutas-norte-pro. Es aislar esas cargas.
Solución 1: namespace aparte con perfil privileged
apiVersion: v1
kind: Namespace
metadata:
name: rutas-norte-sistema
labels:
app.kubernetes.io/part-of: rutas-norte
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/enforce-version: v1.30
# audit y warn en restricted: nos deja rastro de todo lo que se salta el perfil
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.30
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.30
annotations:
seguridad.rutasnorte.example/justificacion: >-
Namespace para agentes de nodo que requieren acceso al anfitrión:
recolector de logs (lectura de /var/log) y agente de detección en
tiempo de ejecución. Revisión trimestral por el equipo de plataforma
y por seguridad. Ninguna aplicación de negocio puede desplegarse aquí.Tres decisiones importantes en ese manifiesto:
auditywarnsiguen enrestricted. Aunqueenforceseaprivileged, cada pod que se salte el perfil deja rastro en el registro de auditoría. Así el namespace privilegiado no es un agujero negro: sabemos exactamente qué se ejecuta ahí y con qué privilegios.- La anotación de justificación. Un namespace con perfil
privilegedes una excepción de seguridad y debe estar documentada en el propio objeto, con quién la revisa y cuándo. - RBAC estricto encima. Recordando 08-01: quien puede crear pods aquí puede crear pods privilegiados. Solo
plataformadebe tener permiso, y en 08-01 vimos que crear pods en un namespace equivale a poder usar cualquier ServiceAccount de ese namespace: otra razón para que las aplicaciones no vivan ahí.
Y el complemento imprescindible en RBAC:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: plataforma-sistema
namespace: rutas-norte-sistema
subjects:
- kind: Group
name: plataforma # SOLO plataforma. Ni desarrollo, ni CI, ni soporte
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: admin
apiGroup: rbac.authorization.k8s.ioSolución 2: exenciones en la configuración del apiserver
El PSA admite exenciones globales por tres criterios:
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1
kind: PodSecurityConfiguration
defaults:
enforce: "baseline"
enforce-version: "v1.30"
exemptions:
# Por usuario: el sujeto que crea el pod queda exento
usernames:
- "system:serviceaccount:kube-system:daemon-set-controller"
# Por clase de ejecución: los pods con este RuntimeClass quedan exentos
runtimeClasses:
- "gvisor"
# Por namespace: nada del namespace se evalúa
namespaces:
- "kube-system"
- "rutas-norte-sistema"| Criterio | Cuándo usarlo | Riesgo |
|---|---|---|
namespaces |
Namespaces de infraestructura completos | Medio: hay que vigilar quién puede desplegar ahí |
usernames |
Un controlador concreto que crea pods de sistema | Alto: esa identidad queda exenta en todo el clúster |
runtimeClasses |
Pods que ya corren aislados (gVisor, Kata) | Bajo: el aislamiento lo aporta el entorno de ejecución |
Un aviso sobre usernames: exime al sujeto en todos los namespaces, no solo donde te interesa. Es un permiso muy amplio. La exención por namespace es más fácil de razonar y de auditar.
Y la advertencia general: cada exención es una excepción de seguridad. Debe tener justificación escrita, responsable, y una revisión periódica que confirme que sigue siendo necesaria. En Rutas Norte, la lista de exenciones se revisa cada trimestre junto con el RBAC.
- Las limitaciones del Pod Security Admission
El PSA es excelente en lo suyo, y lo suyo es bastante estrecho. Conocer sus límites evita confiar en él para cosas que no hace.
Limitación 1: solo mira el pod
El PSA evalúa objetos Pod. Punto. No mira Services, ni Ingresses, ni PVCs, ni ConfigMaps, ni NetworkPolicies.
Esto tiene un efecto práctico incómodo con enforce:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedCreate 12s replicaset-controller Error creating: pods
"aplicacion-mala-6c5f9b8d4-" is forbidden: violates PodSecurity "restricted:v1.30":
allowPrivilegeEscalation != false (container "app" must set
securityContext.allowPrivilegeEscalation=false), ...El Deployment se crea correctamente, el ReplicaSet se crea, y es el ReplicaSet el que no consigue crear pods. Quien hizo el apply ve un éxito y tiene que ir a mirar los eventos para descubrir el problema.
Por eso warn es tan valioso: sí evalúa los objetos que crean pods y avisa en el momento del apply. Mantén siempre warn activo aunque tengas enforce, precisamente por este motivo.
Limitación 2: no puede exigir nada que no sea del securityContext
Cosas que el PSA no puede exigir:
| No puede exigir | Por qué importa en Rutas Norte |
|---|---|
Etiquetas (app.kubernetes.io/part-of) |
Sin ellas se rompen los selectores y los cuadros de mando del módulo 7 |
resources.requests y limits |
Sin ellos, la QoS es BestEffort (03-05) y el pod es el primero en ser desalojado |
| Un registro de imágenes concreto | Cualquiera puede desplegar desde Docker Hub sin revisión |
Que la imagen no use latest |
Despliegues irreproducibles (08-05) |
Sondas de salud (liveness, readiness) |
Sin ellas no funciona lo del módulo 7 |
readOnlyRootFilesystem |
Ni siquiera restricted lo exige |
| Que exista una NetworkPolicy | La microsegmentación de 08-04 |
automountServiceAccountToken: false |
Lo que trabajamos en 03-06 |
Esa lista es exactamente el hueco que cubren Kyverno y Gatekeeper.
Limitación 3: los tres perfiles no son configurables
No puedes crear un perfil "restricted pero permitiendo hostPath en solo lectura" ni "restricted más readOnlyRootFilesystem". Los perfiles los define el proyecto Kubernetes y son inmutables.
Limitación 4: no muta
Es una virtud de diseño (evita el problema 3 de las PSP), pero significa que el PSA nunca te va a ayudar rellenando campos. Si quieres que a todo pod se le añada automáticamente seccompProfile: RuntimeDefault, necesitas un motor de política mutante.
Resumen
| Necesidad | Herramienta |
|---|---|
Impedir pods privilegiados, hostPath, root |
PSA (integrado, sin instalar nada) |
| Exigir etiquetas, límites, registro de imágenes | Kyverno o Gatekeeper |
| Rellenar campos automáticamente | Kyverno (mutación) |
| Verificar firmas de imágenes | Kyverno o el policy-controller de Sigstore (08-05) |
| Reglas sencillas sin instalar nada | ValidatingAdmissionPolicy (apartado 10) |
La recomendación práctica: PSA siempre, como línea base que no puede fallar (está en el apiserver, no depende de ningún pod), y encima un motor de política para todo lo demás.
- Motores de política general: Kyverno y OPA Gatekeeper
Un motor de política es un webhook de admisión: el apiserver le envía cada objeto y él responde si lo acepta, lo rechaza o lo modifica.
flowchart LR
A["kubectl apply"] --> B["apiserver"]
B --> C["PSA<br/>(integrado)"]
C --> D["Webhook mutante<br/>Kyverno"]
D --> E["Webhook validante<br/>Kyverno / Gatekeeper"]
E -->|Rechaza| X["Error con el motivo"]
E -->|Acepta| F[("etcd")]
Esto trae una consideración operativa muy seria: el motor de política se convierte en una dependencia del apiserver. Si el webhook está configurado con failurePolicy: Fail y los pods del motor están caídos, nadie puede crear nada en el clúster. Se han caído clústeres enteros así.
failurePolicy |
Si el webhook no responde | Cuándo usarlo |
|---|---|---|
Ignore |
El objeto se acepta | Adopción inicial, políticas no críticas |
Fail |
El objeto se rechaza | Políticas de seguridad, con alta disponibilidad del motor |
Las mitigaciones habituales: varias réplicas del motor con anti-afinidad (06-05), un PodDisruptionBudget (09-05), y excluir kube-system de los webhooks para poder recuperar el clúster.
Comparación: Kyverno y OPA Gatekeeper
| Kyverno | OPA Gatekeeper | |
|---|---|---|
| Lenguaje de políticas | YAML (como Kubernetes) | Rego (lenguaje propio de OPA) |
| Curva de aprendizaje | Baja: si sabes YAML, sabes escribir políticas | Alta: Rego es un lenguaje declarativo distinto |
| Validar | Sí | Sí |
| Mutar | Sí, muy bien resuelto | Sí, más limitado |
| Generar objetos | Sí (crear una NetworkPolicy con cada namespace) | No |
| Verificar firmas de imágenes | Sí, integrado con Cosign | Requiere trabajo adicional |
| Limpieza de recursos | Sí (CleanupPolicy) |
No |
| Informes de cumplimiento | PolicyReport como CRD |
Objetos de violación |
| Alcance | Solo Kubernetes | Genérico: también CI, Terraform, APIs |
| Políticas prehechas | Catálogo amplio y mantenido | Biblioteca de plantillas |
| Comunidad y madurez | CNCF, crecimiento rápido | CNCF, más veterano |
| Rendimiento con muchas políticas | Bueno | Muy bueno |
Recomendación para Rutas Norte: Kyverno. Las razones son concretas:
- El equipo ya escribe YAML todo el día. Aprender Rego sería una barrera real para que las políticas las escriban también los desarrolladores.
- La verificación de firmas con Cosign está integrada, y es justo lo que necesitaremos en 08-05.
- La capacidad de generar objetos permite, por ejemplo, crear automáticamente la NetworkPolicy
deny-allde 04-06 en cada namespace nuevo. - La mutación resuelve el "añade
seccompProfilea todo" sin tocar cien manifiestos.
Gatekeeper es la mejor opción si la organización ya usa OPA para otras cosas (control de acceso en APIs, políticas en Terraform) y quiere un único lenguaje de políticas para todo.
Instalación de Kyverno
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno \
--namespace kyverno --create-namespace \
--set admissionController.replicas=3 \
--set admissionController.podDisruptionBudget.minAvailable=2Tres réplicas y un PDB: el motor de política es infraestructura crítica y hay que tratarla como tal.
- Políticas de Kyverno para Rutas Norte
Una política de Kyverno es un ClusterPolicy (todo el clúster) o Policy (un namespace) con una lista de reglas.
Política 1: todas las imágenes desde el registro de la empresa
Esta es probablemente la política de más valor por línea escrita. Impide que se despliegue nada que no haya pasado por el registro de la empresa, donde en 08-05 y 08-06 pondremos el escaneo y la firma.
# k8s/politicas/registro-obligatorio.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: registro-obligatorio
annotations:
policies.kyverno.io/title: Registro de imágenes obligatorio
policies.kyverno.io/category: Seguridad de la cadena de suministro
policies.kyverno.io/severity: high
policies.kyverno.io/description: >-
Toda imagen desplegada en los namespaces de Rutas Norte debe venir de
registry.rutasnorte.example. Las imágenes públicas se replican allí
previamente, donde se escanean y se firman. Ver lecciones 08-05 y 08-06.
spec:
# Fail: si Kyverno no puede evaluar, se rechaza. Es una política de seguridad.
validationFailureAction: Enforce
background: true # también evalúa objetos existentes, para los informes
rules:
- name: comprobar-registro
match:
any:
- resources:
kinds:
- Pod
namespaces:
- rutas-norte-dev
- rutas-norte-pre
- rutas-norte-pro
validate:
message: >-
La imagen "{{ request.object.spec.containers[0].image }}" no procede de
registry.rutasnorte.example. Todas las imágenes deben replicarse en el
registro de la empresa, donde se escanean y se firman.
Consulta la guía de imágenes de la plataforma.
pattern:
spec:
# El signo = indica "si el campo existe, debe cumplir"
=(ephemeralContainers):
- image: "registry.rutasnorte.example/*"
=(initContainers):
- image: "registry.rutasnorte.example/*"
containers:
- image: "registry.rutasnorte.example/*"Desmenucemos el manifiesto:
validationFailureAction: Enforcerechaza. La alternativa esAudit, que solo genera un informe. Igual que con el PSA, se empieza enAudity se pasa aEnforcecuando ya no hay incumplimientos.background: truehace que Kyverno evalúe también los objetos que ya existen, produciendoPolicyReport. Sin esto solo verías los objetos nuevos.match.any.resourcesdefine el ámbito. Aquí, pods de los tres namespaces de Rutas Norte.kyverno,kube-systemyrutas-norte-sistemaquedan fuera porque usan imágenes de infraestructura externas.=(initContainers)y=(ephemeralContainers): el prefijo=()significa "si este campo existe, debe cumplir el patrón". Sin él, un pod sin initContainers fallaría la validación. No olvides los contenedores efímeros: son la vía dekubectl debug(07-06) y sin esta línea alguien podría inyectar una imagen arbitraria en un pod de producción.- El mensaje aparece literalmente en la terminal de quien despliega. Escribirlo bien —qué falla, por qué, y qué hacer— ahorra muchísimas preguntas.
Probando:
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/rutas-norte-pro/prueba was blocked due to the following policies
registro-obligatorio:
comprobar-registro: 'validation error: La imagen "nginx:latest" no procede de
registry.rutasnorte.example. Todas las imágenes deben replicarse en el registro
de la empresa, donde se escanean y se firman. Consulta la guía de imágenes de
la plataforma. rule comprobar-registro failed at path /spec/containers/0/image/'Política 2: exigir las etiquetas del esquema oficial
En 02-07 definimos el esquema de etiquetas de Rutas Norte, y en el módulo 7 los cuadros de mando y las alertas dependen de él. Una carga sin app.kubernetes.io/part-of: rutas-norte es invisible para la monitorización.
# k8s/politicas/etiquetas-obligatorias.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: etiquetas-obligatorias
annotations:
policies.kyverno.io/title: Esquema de etiquetas de Rutas Norte
policies.kyverno.io/category: Gobierno
policies.kyverno.io/severity: medium
spec:
validationFailureAction: Enforce
background: true
rules:
- name: etiquetas-en-cargas
match:
any:
- resources:
kinds:
- Deployment
- StatefulSet
- DaemonSet
- CronJob
- Job
namespaces:
- rutas-norte-dev
- rutas-norte-pre
- rutas-norte-pro
validate:
message: >-
Faltan etiquetas obligatorias. Toda carga de Rutas Norte debe llevar,
en metadata y en la plantilla de pod: "app" (nombre del componente),
"app.kubernetes.io/part-of: rutas-norte" y "entorno" (dev|pre|pro).
Sin ellas, la carga no aparece en los cuadros de mando ni en las alertas.
pattern:
metadata:
labels:
app: "?*" # ?* = no vacío
app.kubernetes.io/part-of: "rutas-norte" # valor exacto
entorno: "dev | pre | pro" # una de las tres
spec:
template:
metadata:
labels:
app: "?*"
app.kubernetes.io/part-of: "rutas-norte"
entorno: "dev | pre | pro"
- name: coherencia-entorno-namespace
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet, CronJob]
namespaces: [rutas-norte-pro]
validate:
message: >-
En rutas-norte-pro la etiqueta "entorno" debe valer "pro".
Una etiqueta incorrecta hace que las alertas de producción se
encaminen al canal equivocado (ver 07-04).
pattern:
metadata:
labels:
entorno: "pro"Sintaxis de patrones de Kyverno que aparece aquí:
| Sintaxis | Significado |
|---|---|
"?*" |
Cualquier valor no vacío |
"rutas-norte" |
Ese valor exacto |
"dev | pre | pro" |
Uno de esos tres (el | es un "o") |
"registry.rutasnorte.example/*" |
Comodín al final |
=(campo) |
Si el campo existe, debe cumplir |
X(campo) |
El campo no debe existir |
La segunda regla es un detalle que parece menor y no lo es: una etiqueta entorno: dev en un manifiesto copiado a producción hace que Alertmanager encamine las alertas al canal de desarrollo y nadie se entere de una caída real. La política lo impide.
Política 3: exigir límites de recursos
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: recursos-obligatorios
spec:
validationFailureAction: Enforce
background: true
rules:
- name: requests-y-limits
match:
any:
- resources:
kinds: [Pod]
namespaces: [rutas-norte-pre, rutas-norte-pro]
validate:
message: >-
Todo contenedor debe declarar requests y limits de CPU y memoria.
Sin ellos la clase de QoS es BestEffort (ver 03-05) y el pod es el
primero en ser desalojado cuando el nodo va justo de recursos.
pattern:
spec:
containers:
- resources:
requests:
cpu: "?*"
memory: "?*"
limits:
memory: "?*"Fíjate en que exigimos limits.memory pero no limits.cpu. Es deliberado y es una decisión discutida: limitar la CPU provoca estrangulamiento (throttling) que puede degradar la latencia de api-reservas sin que se vea claro por qué, mientras que no limitar la memoria puede tumbar el nodo. Es un buen ejemplo de política que codifica una decisión técnica del equipo; lo trataremos a fondo en 09-06.
Política 4: mutar para añadir la línea base de seguridad
Aquí brilla Kyverno frente al PSA:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: anadir-linea-base-seguridad
spec:
rules:
- name: seccomp-por-defecto
match:
any:
- resources:
kinds: [Pod]
namespaces: [rutas-norte-dev, rutas-norte-pre, rutas-norte-pro]
mutate:
patchStrategicMerge:
spec:
# +() significa "añade este valor SOLO si el campo no existe"
+(securityContext):
seccompProfile:
type: RuntimeDefaultCon +(), si el manifiesto ya define securityContext, Kyverno no lo toca; si no lo define, lo añade. Es una red de seguridad, no una sustitución del trabajo de 08-02: lo correcto sigue siendo declararlo explícitamente en el manifiesto, para que quien lo lea sepa qué se está ejecutando.
Una advertencia sobre mutar: es exactamente el problema 3 de las PodSecurityPolicy. Úsalo con moderación y solo para valores por defecto seguros, nunca para "arreglar" manifiestos incorrectos, porque entonces el YAML del repositorio deja de describir lo que corre.
Política 5: generar la NetworkPolicy deny-all en cada namespace nuevo
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: generar-deny-all
spec:
rules:
- name: deny-all-en-namespaces-rutas-norte
match:
any:
- resources:
kinds: [Namespace]
selector:
matchLabels:
app.kubernetes.io/part-of: rutas-norte
generate:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
name: deny-all
namespace: "{{request.object.metadata.name}}"
synchronize: true # si alguien la borra, Kyverno la recrea
data:
spec:
podSelector: {}
policyTypes: [Ingress, Egress]Esto garantiza que ningún namespace de Rutas Norte pueda existir sin la denegación por defecto que construimos en 04-06. Y synchronize: true hace que, si alguien la borra, vuelva sola. Es una capacidad que Gatekeeper no tiene y que resuelve un problema real.
Consultar los informes
kubectl get policyreport -n rutas-norte-pro -o json | jq -r '
.items[].results[] | select(.result == "fail")
| "\(.policy)/\(.rule): \(.resources[0].kind)/\(.resources[0].name)\n \(.message)"'etiquetas-obligatorias/etiquetas-en-cargas: Deployment/exportador-legado
validation error: Faltan etiquetas obligatorias...
recursos-obligatorios/requests-y-limits: Pod/depuracion-temporal-x8k2p
validation error: Todo contenedor debe declarar requests y limits...Esos informes son la lista de deberes pendientes y, con background: true, se mantienen al día solos. En 08-06 los convertiremos en evidencia para auditorías de cumplimiento.
- ValidatingAdmissionPolicy: CEL nativo sin instalar nada
Desde Kubernetes 1.30, la ValidatingAdmissionPolicy permite escribir reglas de admisión dentro del apiserver, sin webhook, usando expresiones CEL (Common Expression Language).
Ventajas frente a un motor externo:
| ValidatingAdmissionPolicy | Webhook (Kyverno/Gatekeeper) | |
|---|---|---|
| Instalación | Ninguna | Helm, pods, certificados |
| Disponibilidad | La del apiserver | Puede caerse y bloquear el clúster |
| Latencia | Sin salto de red | Una llamada HTTP por objeto |
| Mutación | No (hay MutatingAdmissionPolicy en alfa) |
Sí |
| Generar objetos | No | Kyverno sí |
| Verificar firmas | No | Kyverno sí |
| Expresividad | CEL: buena para reglas de campos | Muy alta |
Ejemplo: prohibir la etiqueta latest
Se compone de dos objetos: la política (qué se comprueba) y el binding (dónde se aplica).
# k8s/politicas/vap-prohibir-latest.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: prohibir-etiqueta-latest
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c,
!c.image.endsWith(":latest") && c.image.contains(":"))
message: >-
Las imágenes no pueden usar la etiqueta "latest" y deben llevar una
etiqueta explícita. Usa una etiqueta inmutable o, mejor, un digest
(imagen@sha256:...). Ver la lección 08-05.
reason: Invalid
- expression: >-
!has(object.spec.initContainers) ||
object.spec.initContainers.all(c,
!c.image.endsWith(":latest") && c.image.contains(":"))
message: 'La misma regla aplica a los initContainers.'
reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: prohibir-etiqueta-latest
spec:
policyName: prohibir-etiqueta-latest
validationActions: [Deny] # Deny | Warn | Audit, combinables
matchResources:
namespaceSelector:
matchLabels:
app.kubernetes.io/part-of: rutas-nortePuntos a observar:
validationActionscumple el mismo papel que los modos del PSA:[Warn, Audit]para probar,[Deny]cuando estás seguro. Se pueden combinar:[Deny, Audit].namespaceSelectoraplica la política solo a los namespaces etiquetados como parte de Rutas Norte. Los de sistema quedan fuera.- CEL tiene funciones muy legibles:
all(),exists(),has(),endsWith(),contains(),matches()para expresiones regulares. - Separar política y binding permite escribir la regla una vez y aplicarla con distinta severidad en distintos entornos:
[Warn]endevy[Deny]enpro, con dos bindings.
Prueba:
The pods "prueba" is invalid: ValidatingAdmissionPolicy 'prohibir-etiqueta-latest'
with binding 'prohibir-etiqueta-latest' denied request: Las imágenes no pueden usar
la etiqueta "latest" y deben llevar una etiqueta explícita. Usa una etiqueta inmutable
o, mejor, un digest (imagen@sha256:...). Ver la lección 08-05.Ejemplo con parámetros
Las VAP pueden leer configuración de un recurso externo, lo que las hace reutilizables:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: limitar-replicas
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
validations:
- expression: >-
object.spec.replicas <= int(params.data.maxReplicas)
messageExpression: >-
"El número de réplicas (" + string(object.spec.replicas) +
") supera el máximo permitido (" + params.data.maxReplicas + ")."messageExpression construye el mensaje con los valores reales, algo que se agradece mucho al depurar.
Criterio de uso
Usa VAP para reglas sencillas sobre campos: prohibir latest, exigir una etiqueta, limitar un valor numérico, comprobar un prefijo. Son gratis en disponibilidad y no añaden ninguna pieza que mantener.
Usa Kyverno para lo que VAP no puede: verificar firmas de imágenes (08-05), generar objetos, mutar, o políticas que necesitan consultar otros recursos del clúster.
Y usa el PSA siempre como línea base de securityContext, porque está integrado y no puede fallar.
En Rutas Norte usamos los tres, en capas:
flowchart TD
A["Todo pod que se crea"] --> B["PSA: restricted<br/>línea base de securityContext<br/>(integrado, infalible)"]
B --> C["VAP: reglas simples<br/>sin latest, campos obligatorios<br/>(integrado)"]
C --> D["Kyverno: registro, firmas,<br/>etiquetas, recursos, generación<br/>(webhook)"]
D --> E["Pod admitido"]
style B fill:#d5f9d5,stroke:#3a3
style C fill:#d5e8f9,stroke:#36c
style D fill:#f9f0d5,stroke:#ca3
Errores Comunes y Consejos
Buscar tutoriales de PodSecurityPolicy. Se eliminaron en 1.25. Si un recurso te habla de kind: PodSecurityPolicy, está desactualizado y probablemente todo lo demás también.
Activar enforce: restricted directamente en producción. Los pods existentes siguen corriendo, pero el primer reinicio o la primera actualización falla. Recorre siempre las fases: warn/audit, luego baseline, luego restricted.
No fijar -version en las etiquetas. Sin ella se usa latest y una actualización del clúster puede rechazar pods que antes pasaban. Fija la versión y súbela como cambio explícito.
Creer que enforce avisa al aplicar un Deployment. No: el PSA solo evalúa pods. El Deployment se crea y es el ReplicaSet el que falla, en un evento que hay que ir a buscar. Mantén siempre warn activo, que sí evalúa los objetos que crean pods.
Dejar namespaces sin etiquetar. Un namespace sin etiquetas de PSA equivale a privileged. Comprueba default y cualquier namespace creado a mano; configura el valor por defecto en el apiserver o exige las etiquetas con Kyverno.
Relajar el perfil de todo un namespace por una carga. Si el recolector de logs necesita hostPath, va a rutas-norte-sistema con perfil privileged, no se baja rutas-norte-pro a baseline.
Olvidar el RBAC del namespace privilegiado. Un namespace con perfil privileged donde desarrollo pueda crear pods es peor que no tener PSA: da una falsa sensación de seguridad. Y recuerda de 08-01 que crear pods ahí permite usar cualquier ServiceAccount de ese namespace.
Usar failurePolicy: Fail sin alta disponibilidad del motor. Si Kyverno cae, nadie puede crear nada. Tres réplicas, PDB y anti-afinidad como mínimo, y excluye kube-system.
Olvidar initContainers y ephemeralContainers en las políticas. Un initContainer con una imagen sin verificar se salta toda la política de registro. Los contenedores efímeros son la vía de kubectl debug.
Poner validationFailureAction: Enforce desde el primer día. Igual que con el PSA: primero Audit, mira los PolicyReport, arregla, y luego Enforce.
Abusar de la mutación. Si Kyverno "arregla" los manifiestos, el YAML del repositorio deja de describir lo que corre. Úsala solo para valores por defecto seguros.
Mensajes de error inútiles. El mensaje lo lee una persona que está intentando desplegar. Dile qué falla, por qué existe la regla y qué tiene que hacer. Un buen mensaje ahorra una interrupción al equipo de plataforma.
Consejo de oro: las políticas son código. Van en k8s/politicas/ del repositorio, se revisan en pull request, y se prueban en rutas-norte-dev antes de llegar a producción. Una política aplicada a mano en producción es exactamente el mismo problema que un RoleBinding aplicado a mano.
Ejercicios
Ejercicio 1: preparar un namespace para restricted
El equipo crea rutas-norte-analitica para desplegar un servicio de agregación de estadísticas de ocupación. Escribe:
- El manifiesto del
Namespaceen la fase 1 (avisar sin bloquear) con la versión fijada. - La orden que evaluaría un manifiesto existente contra el perfil sin desplegarlo.
- El manifiesto del
Namespaceen su estado final, dado que el servicio ya está endurecido.
Ejercicio 2: interpretar y corregir un rechazo
Al desplegar un componente nuevo en rutas-norte-pro, que tiene enforce: restricted, obtienes:
Error from server (Forbidden): error when creating "exportador.yaml":
pods "exportador-horarios" is forbidden: violates PodSecurity "restricted:v1.30":
non-default capabilities (container "exportador" must not include "NET_RAW" in
securityContext.capabilities.add), restricted volume types (volume "config-nodo"
uses restricted volume type "hostPath"), runAsNonRoot != true (pod or container
"exportador" must set securityContext.runAsNonRoot=true), seccompProfile
(pod or container "exportador" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")El manifiesto original:
apiVersion: v1
kind: Pod
metadata:
name: exportador-horarios
namespace: rutas-norte-pro
spec:
containers:
- name: exportador
image: registry.rutasnorte.example/exportador-horarios:2.1.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["NET_RAW"]
volumeMounts:
- name: config-nodo
mountPath: /etc/horarios
volumes:
- name: config-nodo
hostPath:
path: /etc/rutasnorte/horarios
type: Directory- Enumera las cuatro violaciones y explica qué significa cada una.
- Reescribe el manifiesto para que cumpla
restricted, sabiendo queNET_RAWse añadió "por si acaso" y que el fichero de/etc/rutasnorte/horarioses en realidad un fichero de configuración estático. - ¿Qué habrías hecho si
NET_RAWfuese realmente imprescindible?
Ejercicio 3: política de Kyverno para automountServiceAccountToken
En 03-06 establecimos que las cargas que no hablan con la API deben llevar automountServiceAccountToken: false. Escribe un ClusterPolicy de Kyverno que:
- Se aplique a Pods de
rutas-norte-preyrutas-norte-pro. - Rechace los pods que no declaren
automountServiceAccountTokenexplícitamente (ni atrueni afalse), obligando a que sea una decisión consciente. - Exceptúe los pods cuya ServiceAccount sea
api-reservasoagente-inventario, que sí necesitan el token. - Incluya un mensaje que explique qué hacer.
Indica también cómo lo desplegarías de forma segura.
Soluciones
Solución 1
1. Fase 1: avisar sin bloquear
# k8s/entornos/analitica/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: rutas-norte-analitica
labels:
app.kubernetes.io/part-of: rutas-norte
entorno: pro
# Fase 1: NO se pone enforce. Solo observamos.
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.30
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.30
annotations:
seguridad.rutasnorte.example/fase-psa: >-
Fase 1 (warn+audit) desde 2026-08-06. Objetivo: enforce:restricted
antes de 2026-09-01. Responsable: equipo de plataforma.La anotación con fecha objetivo evita que "temporal" se convierta en "permanente", que es como acaban casi todas las fases 1.
2. Evaluar sin desplegar
Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
(container "agregador" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "agregador" must set
securityContext.capabilities.drop=["ALL"])
deployment.apps/agregador created (server dry run)--dry-run=server pasa el objeto por toda la cadena de admisión —PSA, VAP y Kyverno incluidos— y devuelve el veredicto sin guardar nada. Es distinto de --dry-run=client, que solo valida el YAML localmente y no detectaría nada de esto.
3. Estado final
apiVersion: v1
kind: Namespace
metadata:
name: rutas-norte-analitica
labels:
app.kubernetes.io/part-of: rutas-norte
entorno: pro
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.30
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.30
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.30Se mantienen audit y warn aunque enforce ya bloquee: warn sigue evaluando los Deployments (que enforce no ve) y audit deja constancia en el registro de auditoría.
Solución 2
1. Las cuatro violaciones:
| Violación | Significado |
|---|---|
non-default capabilities: NET_RAW |
restricted solo permite volver a añadir NET_BIND_SERVICE. NET_RAW (sockets en bruto) está prohibido |
restricted volume types: hostPath |
hostPath no está en la lista de volúmenes permitidos por restricted. Es la formalización de la advertencia de 05-01 |
runAsNonRoot != true |
No se declaró; restricted exige la declaración explícita, no basta con que la imagen tenga un USER |
seccompProfile |
Falta RuntimeDefault. Recuerda de 08-02 que sin declararlo probablemente no hay ningún filtro |
2. Manifiesto corregido:
# k8s/entornos/pro/exportador-horarios.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: horarios-config
namespace: rutas-norte-pro
labels:
app: exportador-horarios
app.kubernetes.io/part-of: rutas-norte
entorno: pro
data:
horarios.yaml: |
lineas:
- codigo: N-101
origen: Bilbao
destino: Santander
salidas: ["07:00", "10:30", "15:00", "19:45"]
---
apiVersion: v1
kind: Pod
metadata:
name: exportador-horarios
namespace: rutas-norte-pro
labels:
app: exportador-horarios
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true # violación 3
runAsUser: 10005
runAsGroup: 10005
seccompProfile:
type: RuntimeDefault # violación 4
containers:
- name: exportador
image: registry.rutasnorte.example/exportador-horarios:2.1.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # no lo exige restricted, pero es lo correcto
capabilities:
drop: ["ALL"] # violación 1: sin el add de NET_RAW
volumeMounts:
- name: horarios # violación 2: ConfigMap en lugar de hostPath
mountPath: /etc/horarios
readOnly: true
- name: temporal
mountPath: /tmp
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
volumes:
- name: horarios
configMap:
name: horarios-config
- name: temporal
emptyDir: { sizeLimit: 32Mi }Los cuatro cambios y su justificación:
NET_RAWeliminado. Era "por si acaso": permite crear sockets en bruto, lo que sirve para hacerpingy para inspeccionar tráfico. Un exportador de horarios no lo necesita. Si el proceso falla al arrancar, el log lo dirá y entonces investigaremos por qué; no se conceden capacidades preventivamente.hostPathsustituido por unConfigMap. El fichero era configuración estática, exactamente para lo que sirve un ConfigMap (03-01). Además de cumplir la política, esto gana: el fichero deja de depender de que alguien lo haya copiado a cada nodo, queda versionado en Git y es idéntico en los tres entornos. La corrección de la política ha mejorado el diseño, que es lo que suele ocurrir.runAsNonRoot: true+runAsUser: 10005declarados explícitamente.seccompProfile: RuntimeDefaulten el pod, heredado por el contenedor.
Extras que no exigía restricted pero sí las políticas de Kyverno del apartado 9: etiquetas del esquema, resources y automountServiceAccountToken: false. Y readOnlyRootFilesystem: true, que ninguna política exige pero es la línea base de 08-02.
3. Si NET_RAW fuese imprescindible:
El proceso sería este, en orden:
- Cuestionar el requisito. ¿Por qué necesita sockets en bruto un exportador de horarios? En el 90 % de los casos hay una alternativa (una comprobación TCP en lugar de ICMP, por ejemplo).
- Si es real, usar una biblioteca que no lo necesite o cambiar el diseño.
- Si no hay alternativa, no relajar
rutas-norte-pro. Se despliega en un namespace propio conenforce: baseline(que sí permiteNET_RAW), con RBAC restringido, con justificación anotada, con revisión trimestral y con aprobación del profesional de seguridad. - Documentar la excepción en el registro de excepciones de seguridad, con fecha de caducidad. Volveremos a este registro en 08-06.
Lo que nunca se hace es bajar rutas-norte-pro a baseline: eso convierte una excepción de un componente en una excepción de toda la plataforma.
Solución 3
# k8s/politicas/token-serviceaccount-explicito.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: token-serviceaccount-explicito
annotations:
policies.kyverno.io/title: Decisión explícita sobre el token de ServiceAccount
policies.kyverno.io/category: Seguridad
policies.kyverno.io/severity: medium
policies.kyverno.io/description: >-
Todo pod debe declarar automountServiceAccountToken de forma explícita.
Montar el token sin necesitarlo entrega una credencial de la API a un
proceso que no la usa (ver 03-06). Las cargas que sí hablan con la API
están exceptuadas por nombre de ServiceAccount.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: exigir-declaracion-explicita
match:
any:
- resources:
kinds: [Pod]
namespaces:
- rutas-norte-pre
- rutas-norte-pro
exclude:
any:
# Cargas que legítimamente necesitan el token
- resources:
subjects:
- kind: ServiceAccount
name: api-reservas
- kind: ServiceAccount
name: agente-inventario
preconditions:
all:
# No aplicar a los pods que crea el propio sistema
- key: "{{ request.object.metadata.namespace }}"
operator: AnyIn
value: ["rutas-norte-pre", "rutas-norte-pro"]
validate:
message: >-
El pod debe declarar spec.automountServiceAccountToken de forma
explícita. Si la carga NO habla con la API de Kubernetes (lo habitual),
pon "automountServiceAccountToken: false". Si sí lo hace, ponlo a true
y asegúrate de que su ServiceAccount tiene un Role con el mínimo
privilegio (ver 08-01 y 03-06).
pattern:
spec:
automountServiceAccountToken: "false | true"Notas sobre la solución:
- El patrón
"false | true"obliga a que el campo exista con uno de esos dos valores. Si el campo no está, la validación falla, que es justo lo que pedía el enunciado: convertir una omisión en una decisión. exclude.any.resources.subjectspermite exceptuar por ServiceAccount. Una alternativa más mantenible a largo plazo sería exceptuar por una etiqueta (seguridad.rutasnorte.example/usa-api: "true"), para no tener que editar la política cada vez que una carga nueva necesite la API.- Es una política de gobierno, no de bloqueo de un ataque: su valor está en forzar que alguien piense en cada despliegue.
Despliegue seguro, en cuatro pasos:
# 1. Aplicar en modo Audit: no bloquea nada
sed 's/validationFailureAction: Enforce/validationFailureAction: Audit/' \
k8s/politicas/token-serviceaccount-explicito.yaml | kubectl apply -f -
# 2. Esperar a que el análisis en segundo plano complete el informe y revisarlo
kubectl get policyreport -A -o json | jq -r '
.items[].results[]
| select(.policy == "token-serviceaccount-explicito" and .result == "fail")
| "\(.resources[0].namespace)/\(.resources[0].name)"'rutas-norte-pro/worker-notificaciones-6d4f8b9c7-k2m4x
rutas-norte-pro/informes-ocupacion-29187360-9wzqt
rutas-norte-pre/tienda-web-7c8d9f6b5-p3n8v# 3. Corregir los manifiestos de esas cargas (añadir el campo explícito)
# y verificar que el informe queda limpio.
# 4. Solo entonces, pasar a Enforce
kubectl apply -f k8s/politicas/token-serviceaccount-explicito.yamlY la verificación final:
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/rutas-norte-pro/prueba was blocked due to the following policies
token-serviceaccount-explicito:
exigir-declaracion-explicita: 'validation error: El pod debe declarar
spec.automountServiceAccountToken de forma explícita...'Una precaución adicional importante: antes de pasar a Enforce, comprueba que la política no bloquea a los controladores del sistema que crean pods en esos namespaces (el controlador de ReplicaSets, el de Jobs). Si un controlador crea pods sin el campo, tus despliegues dejarán de funcionar. Probarlo primero en rutas-norte-dev con Enforce durante unos días es la forma barata de descubrirlo.
Conclusión
Hemos convertido el endurecimiento de 08-02 en algo que el clúster hace cumplir por sí solo:
- El control de admisión es la tercera puerta: RBAC decide si tienes derecho a la operación, la admisión decide si el objeto es aceptable. El objeto rechazado nunca llega a etcd.
- Las PodSecurityPolicy son historia: obsoletas en 1.21, eliminadas en 1.25. Sus tres defectos —RBAC contraintuitivo, orden alfabético impredecible y mutación— explican el diseño de su sustituto.
- Los Pod Security Standards definen tres perfiles:
privileged(sin restricciones),baseline(bloquea las escaladas conocidas) yrestricted(el objetivo para toda aplicación), que es esencialmente la codificación oficial de lo que hicimos a mano en 08-02. - El Pod Security Admission va integrado en el apiserver y se activa con etiquetas en el namespace, con tres modos —
enforce,audit,warn— que permiten la adopción gradual. Fija siempre-versionpara que una actualización del clúster no rompa nada. - La adopción correcta es por fases:
warn+audit, luegoenforce: baseline, luegoenforce: restricted. Nunca de golpe en producción. Yrutas-norte-predebe ser tan estricto comorutas-norte-pro. - Las cargas que legítimamente necesitan privilegios —CNI, CSI, recolector de logs— van a un namespace aparte con perfil
privileged, RBAC restringido y justificación anotada. Nunca se relaja el perfil de producción por una carga. - El PSA tiene límites claros: solo mira el pod, no puede exigir etiquetas, límites de recursos ni registros de imágenes, y no muta.
- Ese hueco lo cubren Kyverno (YAML, muta, genera, verifica firmas: la elección de Rutas Norte) y OPA Gatekeeper (Rego, genérico más allá de Kubernetes), y las ValidatingAdmissionPolicy con CEL cubren las reglas sencillas sin instalar nada.
- Rutas Norte usa los tres en capas: PSA como base infalible, VAP para reglas simples, Kyverno para el registro obligatorio, las etiquetas del esquema, los límites de recursos y la generación automática de la NetworkPolicy
deny-all.
Ahora el clúster rechaza por sí mismo un pod privilegiado, uno que monte hostPath, uno que corra como root o uno cuya imagen no venga del registro de la empresa. Nadie puede saltárselo por olvido.
Pero volvamos a mirar el conjunto. Hemos protegido quién puede hacer qué (08-01) y qué puede hacer un contenedor (08-02 y 08-03). Falta una dimensión entera: qué puede hablar con qué. En 04-06 pusimos una NetworkPolicy deny-all en rutas-norte-pro y autorizamos las conversaciones una a una, y ya entonces señalamos dos límites incómodos: las políticas trabajan en L3/L4 —no entienden de rutas HTTP ni de métodos— y no registran nada, así que un intento de conexión denegado es invisible. Además, dentro del clúster todo el tráfico viaja sin cifrar una vez pasado el TLS del Ingress: quien pueda observar la red del nodo ve las consultas a postgres-reservas en claro, con los datos personales incluidos. Y el tráfico saliente sigue prácticamente sin restringir, que es la vía natural por la que se exfiltra una base de datos de clientes.
La siguiente lección, 08-04, Seguridad de Red, construye la estrategia completa sobre lo que ya sabes: microsegmentación, control del tráfico de salida y el problema de las IP frente a los nombres de dominio, cifrado en tránsito con mTLS y mallas de servicio —Istio, Linkerd y Cilium comparadas, con el criterio honesto de cuándo hace falta una y cuándo no—, protección del perímetro y del plano de control, y la visibilidad del tráfico que es justo lo que le falta a NetworkPolicy.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
