En la lección anterior cerramos la puerta de quién puede hacer qué: RBAC decide si tienes derecho a crear un pod en rutas-norte-pro. Pero terminamos con una incomodidad muy concreta. Alguien de plataforma, con permisos perfectamente legítimos, puede desplegar hoy mismo un contenedor con privileged: true, que monte el disco del nodo con hostPath y corra como root. RBAC dirá que sí, porque tiene permiso para crear pods. Y a partir de ahí, el nodo entero —con todos los pods que corran en él, incluido postgres-reservas y sus datos personales— queda al descubierto.
Esta lección trata de la segunda barrera: qué puede hacer el proceso una vez está corriendo dentro del contenedor. Es la diferencia entre "alguien ha conseguido ejecutar código en tienda-web" —un incidente serio pero contenido— y "alguien ha conseguido ejecutar código en tienda-web y desde ahí ha leído la base de datos de clientes" —un incidente de otra magnitud—.
La herramienta se llama securityContext y es, junto a RBAC, lo que más rendimiento de seguridad da por línea de YAML escrita.
Advertencia importante. Esta lección explica los mecanismos de endurecimiento y propone una configuración de ejemplo. El endurecimiento real de una plataforma en producción debe revisarlo un profesional de seguridad, que conoce el modelo de amenazas concreto de la organización. Cuando el sistema trata datos personales —como
postgres-reservas— el diseño debe conocerlo también el responsable de cumplimiento normativo. El enfoque aquí es exclusivamente defensivo: entender los mecanismos para cerrarlos, nunca para explotarlos.
Contenido
- Qué aísla realmente un contenedor
- El
securityContext: nivel de pod y nivel de contenedor - Ejecutar sin root:
runAsNonRoot,runAsUseryrunAsGroup - Volúmenes y permisos de fichero:
fsGroupyfsGroupChangePolicy allowPrivilegeEscalationy el bitno_new_privsprivileged: truey por qué equivale a entregar el nodo- Capacidades de Linux: quitarlo todo y añadir lo justo
readOnlyRootFilesystemy cómo hacerlo viable- Seccomp, AppArmor y SELinux
- Los ajustes del pod que hay que evitar
RuntimeClassy los entornos de ejecución aislados- El endurecimiento completo de Rutas Norte
- Cómo comprobar el resultado desde dentro del contenedor
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Qué aísla realmente un contenedor
Antes de configurar nada hay que entender qué estamos configurando. Y la afirmación de partida es incómoda:
Un contenedor no es una máquina virtual. Todos los contenedores de un nodo comparten el mismo kernel de Linux. Un contenedor es un proceso normal del sistema operativo del nodo, al que se le han restringido tres cosas: lo que ve, lo que consume y lo que puede pedirle al kernel.
Compáralo con una máquina virtual:
| Máquina virtual | Contenedor | |
|---|---|---|
| Kernel | Propio, aislado | Compartido con el nodo |
| Frontera de aislamiento | Hipervisor (hardware) | Funciones del kernel (software) |
| Superficie de ataque | Interfaz del hipervisor (pequeña) | Todas las llamadas al sistema (~350) |
| Arranque | Segundos o minutos | Milisegundos |
| Consumo | Sistema operativo completo | Solo el proceso |
| Escape | Muy difícil | Posible si hay un fallo del kernel o mala configuración |
Los tres mecanismos que hacen el aislamiento:
Namespaces del kernel: qué ve el proceso
No confundir con los Namespace de Kubernetes: son un concepto de Linux completamente distinto. Un namespace del kernel da al proceso una vista propia de un recurso del sistema.
| Namespace | Qué aísla | Ajuste de Kubernetes que lo rompe |
|---|---|---|
| PID | Los procesos visibles | hostPID: true |
| Network | Interfaces, IP, puertos, tablas de rutas | hostNetwork: true |
| Mount | El árbol de ficheros | hostPath (parcialmente) |
| IPC | Memoria compartida, colas de mensajes | hostIPC: true |
| UTS | Nombre de máquina | — |
| User | Correspondencia de UIDs | (user namespaces, aún opcional) |
Cuando dentro de un contenedor haces ps aux y ves solo tu proceso como PID 1, es el namespace PID actuando. Cuando hostPID: true está activo, ves todos los procesos del nodo, incluidos los de los demás pods, con sus líneas de órdenes y sus variables de entorno.
cgroups: cuánto puede consumir
Los control groups limitan CPU, memoria, E/S y número de procesos. Es lo que hay detrás de resources.limits del módulo 3. Su función es sobre todo de estabilidad —evitar que un pod tumbe el nodo— pero también de seguridad, porque una denegación de servicio local es un ataque.
Capacidades y filtros de llamadas al sistema: qué puede pedirle al kernel
Aquí es donde el securityContext marca la diferencia y donde se juega el partido. Volveremos en los apartados 7 y 9.
Qué significa esto en la práctica
Que todos los contenedores compartan kernel tiene una consecuencia directa:
- Un fallo del kernel de Linux puede permitir salir del contenedor. Estos fallos aparecen periódicamente y se corrigen; por eso mantener los nodos parcheados es una tarea de seguridad de primer orden, no de mantenimiento rutinario.
- Cuanto menos pueda pedirle un contenedor al kernel, menos superficie tiene para aprovechar uno de esos fallos. Esa es la lógica de todo lo que sigue.
- Si necesitas aislamiento fuerte para algo que no controlas, los contenedores normales no son la respuesta: hay que usar un entorno de ejecución aislado (apartado 11) o directamente una máquina virtual.
flowchart TB
subgraph Nodo["Nodo de Kubernetes"]
K["Kernel de Linux — COMPARTIDO"]
subgraph C1["Pod tienda-web"]
P1["nginx<br/>ns PID, net, mount<br/>cgroups<br/>capacidades reducidas<br/>seccomp"]
end
subgraph C2["Pod postgres-reservas"]
P2["postgres<br/>datos personales"]
end
P1 -.->|llamadas al sistema| K
P2 -.->|llamadas al sistema| K
end
style K fill:#f9d5d5,stroke:#c33
Lee el diagrama al revés: si un proceso de tienda-web consigue abusar del kernel compartido, está en el mismo plano que postgres-reservas. La única barrera es cuánto le hemos permitido pedirle al kernel.
- El
securityContext: nivel de pod y nivel de contenedor
securityContext: nivel de pod y nivel de contenedorsecurityContext aparece en dos sitios del manifiesto, y qué campos admite cada uno es distinto. Confundirlos es el error número uno.
apiVersion: v1
kind: Pod
metadata:
name: ejemplo
spec:
securityContext: # <-- NIVEL DE POD: afecta a todos los contenedores
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.rutasnorte.example/api-reservas:2.7.1
securityContext: # <-- NIVEL DE CONTENEDOR: solo este contenedor
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]Qué campo vive en cada nivel
| Campo | Pod | Contenedor | Qué hace |
|---|---|---|---|
runAsUser |
Sí | Sí | UID con el que corre el proceso |
runAsGroup |
Sí | Sí | GID primario |
runAsNonRoot |
Sí | Sí | El kubelet rechaza arrancar si el UID es 0 |
supplementalGroups |
Sí | No | GIDs adicionales |
fsGroup |
Sí | No | GID aplicado a los volúmenes montados |
fsGroupChangePolicy |
Sí | No | Cuándo se recalculan los permisos del volumen |
seccompProfile |
Sí | Sí | Filtro de llamadas al sistema |
seLinuxOptions |
Sí | Sí | Etiquetas SELinux |
sysctls |
Sí | No | Parámetros del kernel del pod |
appArmorProfile |
Sí | Sí | Perfil AppArmor (campo nativo desde 1.30) |
allowPrivilegeEscalation |
No | Sí | Bloquea no_new_privs |
capabilities |
No | Sí | Añadir y quitar capacidades |
privileged |
No | Sí | Desactiva casi todo el aislamiento |
readOnlyRootFilesystem |
No | Sí | Monta / en solo lectura |
procMount |
No | Sí | Cómo se monta /proc |
Regla mnemotécnica: lo que tiene que ver con identidad y con volúmenes va en el pod; lo que tiene que ver con lo que puede hacer el proceso va en el contenedor.
Cuál gana
Cuando un campo existe en los dos niveles y se define en ambos, gana el del contenedor. Esto permite una estrategia muy limpia: poner la línea base restrictiva en el pod y hacer excepciones puntuales y visibles en el contenedor que lo necesite.
spec:
securityContext:
runAsUser: 10001 # línea base para todos
containers:
- name: app
# hereda runAsUser: 10001
- name: adaptador-logs
securityContext:
runAsUser: 10002 # este contenedor lo sobrescribeUn matiz que confunde: capabilities y allowPrivilegeEscalation no se heredan del pod porque no existen a nivel de pod. Hay que repetirlos en cada contenedor, incluidos los initContainers y los sidecars. Es tedioso y es la causa más frecuente de que un pod "endurecido" tenga un contenedor sin endurecer. Kustomize o Helm ayudan; una política de admisión (08-03) lo garantiza.
Los initContainers también cuentan
spec:
initContainers:
- name: preparar-datos
image: registry.rutasnorte.example/utilidades:1.4.2
securityContext: # NO se puede omitir
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
containers:
- name: app
# ...Un initContainer privilegiado es un pod privilegiado: se ejecuta con acceso completo antes de que arranque el contenedor principal. Repasa 06-04 y comprueba que todos tus initContainers y sidecars están endurecidos igual que los contenedores principales.
- Ejecutar sin root:
runAsNonRoot, runAsUser y runAsGroup
runAsNonRoot, runAsUser y runAsGroupPor defecto, un contenedor corre con el usuario que declare la imagen; y muchísimas imágenes públicas, incluida nginx, declaran root (UID 0). Correr como root dentro del contenedor no es lo mismo que ser root del nodo, pero está mucho más cerca de lo que a nadie le gustaría:
- Root dentro del contenedor tiene, por defecto, un conjunto de capacidades que un usuario normal no tiene.
- Si el contenedor comparte algo con el nodo (un
hostPath, un socket), root escribe donde un usuario normal no podría. - Si aparece un fallo de escape del kernel, la mayoría requieren ser root dentro del contenedor para funcionar. No serlo elimina buena parte de esa clase de problemas.
Los tres campos
spec:
securityContext:
runAsNonRoot: true # verificación: el kubelet rechaza el pod si el UID es 0
runAsUser: 10001 # UID efectivo del proceso
runAsGroup: 10001 # GID primario| Campo | Qué hace exactamente |
|---|---|
runAsNonRoot: true |
No cambia el usuario. Es una comprobación: si el UID resultante es 0, el kubelet no arranca el contenedor |
runAsUser: N |
Fuerza el UID, ignorando el USER de la imagen |
runAsGroup: N |
Fuerza el GID primario. Si se omite, suele quedar 0 (grupo root), lo cual no es ideal |
runAsNonRoot sin runAsUser funciona solo si la imagen declara un usuario numérico. Si el Dockerfile pone USER appuser (nombre, no número), el kubelet no puede resolver el nombre —no tiene acceso al /etc/passwd de la imagen antes de arrancarla— y el pod falla:
Error: container has runAsNonRoot and image has non-numeric user (appuser),
cannot verify user is non-rootDe ahí una regla que retomaremos en 08-05: declara siempre el usuario numérico en el Dockerfile (USER 10001), y además especifica runAsUser en el manifiesto. Cinturón y tirantes.
Si intentas correr como root con la verificación activa:
El caso de nginx en tienda-web
tienda-web usa la imagen oficial nginx, que arranca como root para poder escuchar en el puerto 80 y luego baja de privilegios en sus procesos trabajadores. Si le ponemos runAsNonRoot: true sin más, falla al intentar escribir en /var/cache/nginx y al abrir el puerto 80.
Hay dos caminos, y solo uno es bueno:
| Opción | Qué implica |
|---|---|
Añadir la capacidad NET_BIND_SERVICE para el puerto 80 |
Funciona, pero mantiene una capacidad innecesaria |
Usar la imagen nginxinc/nginx-unprivileged y escuchar en el 8080 |
Sin capacidades, sin root, y el Service traduce el puerto |
La segunda es claramente superior y es la que adoptamos:
# k8s/base/tienda-web/deployment.yaml (fragmento)
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101 # el usuario nginx de la imagen unprivileged
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: registry.rutasnorte.example/nginx-unprivileged:1.27.1
ports:
- containerPort: 8080 # puerto alto: no hace falta ninguna capacidadY el Service sigue publicando el 80 hacia fuera:
apiVersion: v1
kind: Service
metadata:
name: tienda-web
namespace: rutas-norte-pro
spec:
selector:
app: tienda-web
ports:
- port: 80 # lo que ven los clientes
targetPort: 8080 # lo que escucha el contenedor sin privilegiosNadie fuera del clúster nota la diferencia, y hemos eliminado una capacidad y el arranque como root.
- Volúmenes y permisos de fichero:
fsGroup y fsGroupChangePolicy
fsGroup y fsGroupChangePolicyAquí llega el problema práctico que hace que mucha gente se rinda y vuelva a root. Es exactamente el caso de postgres-reservas.
El problema
Un volumen aprovisionado dinámicamente (módulo 5) se monta con los permisos que le ponga el sistema de ficheros, normalmente propiedad de root:root con modo 0755. Si el contenedor corre como UID 10001, no puede escribir en su propio volumen de datos.
La solución: fsGroup
Cuando fsGroup está presente, el kubelet, antes de arrancar los contenedores:
- Cambia el grupo propietario de los ficheros y directorios del volumen a ese GID.
- Añade el bit de escritura para el grupo.
- Pone el bit
setgiden los directorios, para que lo nuevo herede el grupo. - Añade ese GID a los grupos suplementarios del proceso.
Resultado: el proceso puede escribir en el volumen sin ser root y sin tocar la imagen.
fsGroup no aplica a todos los tipos de volumen. Funciona con los que tienen sistema de ficheros propio (la mayoría de volúmenes en bloque vía CSI, emptyDir, configMap, secret) y no con NFS ni otros sistemas de ficheros de red, donde los permisos los gobierna el servidor. Con NFS hay que coordinar los UID/GID con quien administra el almacenamiento.
fsGroupChangePolicy: el detalle que importa en producción
Cambiar el propietario de cada fichero es una operación recursiva. En el volumen de postgres-reservas, con millones de ficheros, eso puede tardar minutos en cada arranque del pod. Y ocurre en cada reinicio, en cada actualización, en cada movimiento de nodo.
spec:
securityContext:
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch # <-- imprescindible en volúmenes grandes| Valor | Comportamiento | Cuándo usarlo |
|---|---|---|
Always (por defecto) |
Recorre todo el volumen en cada arranque | Volúmenes pequeños |
OnRootMismatch |
Comprueba solo el permiso del directorio raíz del volumen; si ya coincide, no hace nada | Volúmenes grandes: casi siempre la correcta |
OnRootMismatch funciona porque, si el directorio raíz ya tiene el grupo y los permisos esperados, es que el cambio se hizo en un arranque anterior. La primera vez sí hace el recorrido completo; las siguientes son instantáneas.
El caso completo de postgres-reservas
La imagen postgres:16.4 define el usuario postgres con UID y GID 999. Configuración final:
# k8s/base/postgres-reservas/statefulset.yaml (fragmento)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres-reservas
namespace: rutas-norte-pro
spec:
serviceName: postgres-reservas
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # usuario postgres de la imagen
runAsGroup: 999
fsGroup: 999 # el volumen pasa a ser escribible
fsGroupChangePolicy: OnRootMismatch # sin esto, arranques de varios minutos
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: registry.rutasnorte.example/postgres:16.4
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false # PostgreSQL escribe en /var/run y /tmp
capabilities:
drop: ["ALL"]
env:
# PGDATA en un subdirectorio: evita problemas con lost+found
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: datos
mountPath: /var/lib/postgresql/data
# Sidecar exportador de métricas del módulo 7: también endurecido
- name: exportador-metricas
image: registry.rutasnorte.example/postgres-exporter:0.15.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65534
capabilities:
drop: ["ALL"]
volumeClaimTemplates:
- metadata:
name: datos
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-ssd
resources:
requests:
storage: 50GiCuatro decisiones para justificar:
runAsUser: 999: el UID que la imagen ya espera. Poner otro obligaría a modificar la imagen.fsGroupChangePolicy: OnRootMismatch: sin él, con 50 GiB de datos personales, cada reinicio del pod tardaría minutos y aparecería como una caída del servicio.readOnlyRootFilesystem: falseen PostgreSQL: es una excepción consciente y documentada. PostgreSQL escribe ficheros de socket y temporales fuera del volumen de datos. Se podría arreglar conemptyDiren/var/run/postgresqly/tmp, y es lo recomendable a medio plazo; aquí lo dejamos explícito para que se vea que una excepción documentada es mejor que una excepción silenciosa.- El sidecar exportador sí lleva
readOnlyRootFilesystem: true: no escribe nada, así que no hay razón para dejarlo abierto. Cada contenedor se endurece según lo que necesita, no según lo que necesita su vecino.
allowPrivilegeEscalation y el bit no_new_privs
allowPrivilegeEscalation y el bit no_new_privsEsta línea, que cuesta cero, se traduce en el kernel al bit no_new_privs del proceso (documentado en prctl(2)). Su significado exacto:
Con
no_new_privsactivo, ningún proceso hijo puede obtener más privilegios que su padre, ni siquiera ejecutando un binario con el bitsetuidpuesto o con capacidades de fichero.
Qué es un binario setuid
En Linux, un ejecutable puede llevar el bit setuid, que hace que al ejecutarse corra con el UID de su propietario en lugar del de quien lo lanza. El ejemplo clásico es /usr/bin/passwd: lo lanza un usuario normal, pero corre como root porque necesita escribir en /etc/shadow.
Dentro de un contenedor, un binario setuid propiedad de root es un puente entre "soy el usuario 10001" y "soy root en este contenedor". Muchas imágenes base traen varios sin que nadie se dé cuenta:
Con allowPrivilegeEscalation: false, ejecutar cualquiera de ellos no eleva privilegios: el bit setuid queda neutralizado.
Cuándo debe ser true
Prácticamente nunca en una aplicación. Hay tres situaciones legítimas:
| Situación | Por qué |
|---|---|
El contenedor es privileged: true |
Sería contradictorio; Kubernetes obliga a que sea true |
El contenedor añade capacidades con CAP_SYS_ADMIN |
Suele necesitar la escalada |
| Herramientas que dependen de setuid | Rarísimo en cargas modernas |
Nota de coherencia: allowPrivilegeEscalation: false es incompatible con privileged: true. Si pones ambos, el pod es rechazado. Es una comprobación deliberada de Kubernetes.
Efecto secundario útil
no_new_privs es además la condición para que seccomp funcione sin capacidades especiales. Ponerlo a false prepara el terreno para el apartado 9.
Regla práctica: allowPrivilegeEscalation: false en todos los contenedores, sin excepciones que no estén documentadas y aprobadas.
privileged: true y por qué equivale a entregar el nodo
privileged: true y por qué equivale a entregar el nodoCuando un contenedor es privilegiado:
| Se desactiva | Consecuencia |
|---|---|
| El recorte de capacidades | El proceso tiene todas las capacidades de Linux |
| Las restricciones de dispositivos | Ve /dev del nodo entero, incluidos los discos en bruto |
| El perfil seccomp por defecto | Puede hacer cualquier llamada al sistema |
| AppArmor / SELinux | Sin confinamiento obligatorio |
| Restricciones de montaje | Puede montar sistemas de ficheros del nodo |
La consecuencia práctica es sencilla de enunciar:
Un contenedor privilegiado es, a efectos prácticos, root en el nodo. Puede leer y escribir los discos, cargar módulos del kernel, acceder al sistema de ficheros del anfitrión y, con ello, a los secretos montados de todos los demás pods de ese nodo.
Aplicado a Rutas Norte: si tienda-web corriera privilegiado y estuviera en el mismo nodo que postgres-reservas, quien controlase tienda-web tendría acceso al volumen de datos personales. Y ni RBAC ni las NetworkPolicies del módulo 4 lo impedirían, porque no es una petición a la API ni tráfico de red: es acceso directo al disco.
Los usos legítimos y cómo reducirlos
privileged: true tiene usos reales, todos en el plano de infraestructura, no en aplicaciones:
| Componente | Por qué lo necesita | Alternativa más fina |
|---|---|---|
| Plugin CNI (Calico, Cilium) | Configura la red del nodo | NET_ADMIN + hostNetwork |
| Controladores CSI | Monta volúmenes en el nodo | SYS_ADMIN + montaje bidireccional |
| Agentes de seguridad (Falco) | Observa llamadas al sistema | SYS_PTRACE, BPF, PERFMON |
| Recolector de logs (módulo 7) | Lee /var/log del nodo |
hostPath en solo lectura, sin privilegios |
Ese último es importante y lo aprovechamos: el recolector de logs del módulo 7 no necesita ser privilegiado. Con montar /var/log en solo lectura basta.
# k8s/base/logs/daemonset.yaml (fragmento) — versión endurecida
containers:
- name: recolector
image: registry.rutasnorte.example/fluent-bit:3.1.7
securityContext:
privileged: false # NO hace falta
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsUser: 0 # sí necesita root para leer los logs del nodo
capabilities:
drop: ["ALL"]
add: ["DAC_READ_SEARCH"] # solo para saltarse permisos de LECTURA
volumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true # <-- solo lectura
volumes:
- name: varlog
hostPath:
path: /var/log
type: DirectorySigue siendo un pod con más privilegios que una aplicación normal —lo advertimos en 06-02— pero hemos bajado de "control total del nodo" a "lectura de un directorio". Esa reducción es exactamente el trabajo de esta lección.
Regla: si un manifiesto de un tercero pide privileged: true, exige una justificación concreta antes de aplicarlo. Muy a menudo es un valor por defecto cómodo, no una necesidad.
- Capacidades de Linux: quitarlo todo y añadir lo justo
Históricamente Linux tenía dos clases de proceso: root (podía todo) y el resto (no podía casi nada). Las capacidades partieron los poderes de root en unas cuarenta piezas independientes que se conceden por separado.
Capacidades relevantes:
| Capacidad | Qué permite | Riesgo |
|---|---|---|
NET_BIND_SERVICE |
Escuchar en puertos < 1024 | Bajo |
CHOWN |
Cambiar el propietario de ficheros | Bajo/medio |
DAC_OVERRIDE |
Saltarse todos los permisos de fichero | Alto |
DAC_READ_SEARCH |
Saltarse los permisos de lectura | Medio |
SETUID / SETGID |
Cambiar de identidad | Medio |
NET_RAW |
Sockets en bruto (ping, rastreo de red) | Medio |
NET_ADMIN |
Configurar la red: interfaces, cortafuegos, rutas | Muy alto |
SYS_ADMIN |
Un cajón de sastre: montar, namespaces... | Crítico |
SYS_PTRACE |
Inspeccionar la memoria de otros procesos | Alto |
SYS_MODULE |
Cargar módulos del kernel | Crítico: es el nodo |
SYS_TIME |
Cambiar el reloj del sistema | Medio |
BPF, PERFMON |
Programas eBPF y perfilado | Alto |
Un contenedor sin configuración especial no arranca con todas: el entorno de ejecución concede un conjunto por defecto de unas catorce, entre ellas CHOWN, DAC_OVERRIDE, NET_RAW, SETUID, SETGID y NET_BIND_SERVICE. Ninguna aplicación web normal usa ninguna.
La práctica correcta
securityContext:
capabilities:
drop: ["ALL"] # empieza desde cero
# add: [...] # y añade solo lo estrictamente imprescindibledrop: ["ALL"] seguido de nada es la configuración objetivo de todos los componentes de Rutas Norte. Ninguno necesita ni una sola capacidad.
Detalle importante: drop: ["ALL"] y add: ["NET_BIND_SERVICE"] juntos funcionan y son la forma correcta de expresar "solo esta". El drop se procesa antes que el add.
El ejemplo de NET_BIND_SERVICE y su mejor alternativa
Supón que quieres mantener la imagen nginx oficial escuchando en el 80, sin ser root:
# Funciona, pero no es la mejor opción
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # única forma de abrir el puerto 80 sin ser rootComparemos con la alternativa que ya adoptamos:
Con NET_BIND_SERVICE en el 80 |
Con puerto 8080 | |
|---|---|---|
| Capacidades | Una | Ninguna |
Cumple restricted de PSS (08-03) |
Sí (es la única excepción permitida) | Sí |
| Cambios en la imagen | Ninguno | Configuración de nginx o imagen unprivileged |
| Lo que ve el cliente | Puerto 80 | Puerto 80 (lo traduce el Service) |
El puerto bajo es una restricción de una era en la que los servidores eran máquinas físicas compartidas. En Kubernetes, donde el Service traduce puertos sin coste, mantenerlo solo añade una capacidad innecesaria. La regla general: si puedes evitar una capacidad cambiando un número de puerto, cámbialo.
Ver las capacidades efectivas
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000
CapAmb: 0000000000000000Todos a cero: exactamente lo que queremos. Para traducir un valor no nulo a nombres:
kubectl exec -n rutas-norte-pro deploy/api-reservas -- \
sh -c 'capsh --decode=$(grep CapEff /proc/1/status | cut -f2)'Si vieras algo como cap_chown,cap_dac_override,cap_net_raw,cap_setgid,cap_setuid, es que falta el drop: ["ALL"] en ese contenedor.
readOnlyRootFilesystem y cómo hacerlo viable
readOnlyRootFilesystem y cómo hacerlo viableEl sistema de ficheros raíz del contenedor se monta en solo lectura. Los volúmenes montados explícitamente siguen siendo escribibles.
Por qué es tan valioso
Sin readOnlyRootFilesystem |
Con readOnlyRootFilesystem: true |
|---|---|
Se puede escribir un binario en /usr/bin |
No |
| Se puede modificar una librería cargada | No |
| Se puede sobrescribir un fichero de configuración | No |
| Un cambio persiste mientras viva el contenedor | Cualquier cambio es imposible |
Convierte el contenedor en algo inmutable en tiempo de ejecución: lo que se despliega es lo que se ejecuta, de principio a fin. Y da una propiedad muy útil para el módulo 7: si algo intenta escribir en /, salta un error registrado. Es una señal de detección gratis, y en 08-06 la convertiremos en una regla de Falco.
El problema y su solución
Casi todo software escribe algo: ficheros temporales, sockets, cachés, PIDs. La solución es montar emptyDir exactamente en esos sitios.
tienda-web con nginx es el ejemplo canónico, porque nginx necesita cuatro directorios de caché más /tmp y su fichero de PID:
# k8s/base/tienda-web/deployment.yaml (fragmento completo)
apiVersion: apps/v1
kind: Deployment
metadata:
name: tienda-web
namespace: rutas-norte-pro
labels:
app: tienda-web
app.kubernetes.io/part-of: rutas-norte
spec:
replicas: 3
selector:
matchLabels:
app: tienda-web
template:
metadata:
labels:
app: tienda-web
app.kubernetes.io/part-of: rutas-norte
spec:
automountServiceAccountToken: false # de 03-06: no habla con la API
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: registry.rutasnorte.example/nginx-unprivileged:1.27.1
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # <-- el objetivo
capabilities:
drop: ["ALL"]
volumeMounts:
# Todo lo que nginx necesita escribir, en memoria y efímero
- name: cache-nginx
mountPath: /var/cache/nginx
- name: run-nginx
mountPath: /var/run # aquí va el fichero de PID
- name: temporal
mountPath: /tmp
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 500m, memory: 128Mi }
volumes:
- name: cache-nginx
emptyDir:
medium: Memory # en RAM: más rápido y no toca el disco
sizeLimit: 64Mi # límite: un emptyDir sin límite puede llenar el nodo
- name: run-nginx
emptyDir:
medium: Memory
sizeLimit: 8Mi
- name: temporal
emptyDir:
sizeLimit: 128MiPuntos a destacar:
medium: Memoryhace que elemptyDirviva en RAM (tmpfs). Para cachés pequeñas es más rápido y evita escribir datos en el disco del nodo, cosa que importa si por ellas pasa información de sesión.sizeLimites obligatorio en la práctica. UnemptyDirsin límite puede crecer hasta agotar el disco (o la memoria) del nodo. Conmedium: Memorycuenta además contra el límite de memoria del pod.- Los
emptyDirse vacían al reiniciar el contenedor, lo cual es justo lo que queremos: nada persiste.
Cómo descubrir qué directorios hacen falta
No adivines: mide. Despliega con readOnlyRootFilesystem: true en rutas-norte-dev y mira qué falla.
El log te dice exactamente la ruta. Añades el emptyDir, repites, y en dos o tres iteraciones tienes la lista completa. Es un procedimiento de diez minutos por componente que se hace una vez.
Directorios habituales por tipo de aplicación:
| Aplicación | Directorios que suele necesitar |
|---|---|
| nginx | /var/cache/nginx, /var/run, /tmp |
Node.js (api-reservas) |
/tmp (y /home/node/.npm si instala en caliente, que no debería) |
| Java | /tmp (el JVM escribe ahí los ficheros de rendimiento) |
| Python | /tmp; evita .pyc con PYTHONDONTWRITEBYTECODE=1 |
| PostgreSQL | /var/run/postgresql, /tmp, además del volumen de datos |
| Redis | /data (volumen real, no emptyDir) |
- Seccomp, AppArmor y SELinux
Seccomp: filtrar las llamadas al sistema
Secure Computing Mode limita qué llamadas al sistema puede hacer el proceso. Es la defensa más directa contra los fallos del kernel: si un fallo está en una llamada que tu contenedor no puede hacer, no te afecta.
Valor de type |
Qué hace |
|---|---|
Unconfined |
Sin filtro. Todas las llamadas disponibles |
RuntimeDefault |
El perfil del entorno de ejecución: bloquea unas 60 llamadas peligrosas |
Localhost |
Un perfil propio en un fichero del nodo (requiere localhostProfile) |
Atención a un detalle histórico: el valor por defecto de Kubernetes ha sido Unconfined durante años. Desde 1.27 la puerta de enlace SeccompDefault del kubelet permite cambiarlo a RuntimeDefault, pero hay que activarla explícitamente en la configuración del kubelet. Mientras no lo hagas, si no pones seccompProfile, tu contenedor no tiene ningún filtro seccomp. Esta es una de esas cosas que la gente da por hecha y no lo es.
RuntimeDefault bloquea llamadas como mount, reboot, init_module, kexec_load, ptrace en algunos contextos, bpf y pivot_root. Es compatible con prácticamente cualquier aplicación normal, así que ponerlo es casi gratis y hay que hacerlo siempre.
Perfiles a medida
Cuando RuntimeDefault no basta —por ejemplo, para un componente muy expuesto como api-reservas— puedes escribir un perfil propio. El fichero se coloca en /var/lib/kubelet/seccomp/perfiles/ de cada nodo (normalmente con un DaemonSet o con el Security Profiles Operator):
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
"syscalls": [
{
"names": [
"accept4", "bind", "listen", "socket", "connect", "getsockname",
"read", "write", "readv", "writev", "close", "openat", "fstat",
"epoll_create1", "epoll_ctl", "epoll_pwait", "futex",
"mmap", "munmap", "mprotect", "brk", "rt_sigaction", "rt_sigprocmask",
"clock_gettime", "getpid", "gettid", "exit", "exit_group",
"nanosleep", "sched_yield", "madvise", "getrandom", "fcntl"
],
"action": "SCMP_ACT_ALLOW"
}
]
}spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: perfiles/api-reservas.json # ruta relativa al directorio seccompAdvertencias serias sobre los perfiles a medida:
- Son frágiles. Una actualización de la librería estándar o del entorno de ejecución puede introducir una llamada nueva y el proceso muere con
SIGSYSo unEPERMincomprensible. - Hay que generarlos observando, no escribiéndolos a mano. El Security Profiles Operator puede grabar el perfil de una carga en ejecución.
- Empieza siempre por
RuntimeDefault. El salto a un perfil a medida solo compensa en componentes muy críticos y con capacidad de mantenerlo.
Para depurar, se puede usar defaultAction: SCMP_ACT_LOG, que registra en lugar de bloquear. Nunca dejes eso en producción: no protege de nada.
AppArmor
Control de acceso obligatorio basado en rutas, habitual en Debian y Ubuntu. Desde Kubernetes 1.30 tiene campo nativo en el securityContext, sin necesidad de anotaciones:
spec:
securityContext:
appArmorProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: rutasnorte-api # perfil cargado en el nodoLas anotaciones container.apparmor.security.beta.kubernetes.io/<contenedor> siguen funcionando por compatibilidad, pero están obsoletas: usa el campo.
SELinux
Control de acceso obligatorio basado en etiquetas, habitual en Red Hat y derivados. Se configura con seLinuxOptions:
En la práctica, el uso más frecuente en Kubernetes es dejar que el entorno de ejecución asigne etiquetas automáticamente. Modificarlas a mano suele ser contraproducente salvo que la organización tenga una política SELinux propia.
Comparación
| Seccomp | AppArmor | SELinux | |
|---|---|---|---|
| Qué controla | Llamadas al sistema | Rutas de fichero y capacidades | Etiquetas sobre todo objeto |
| Dónde es habitual | Todas las distribuciones | Debian, Ubuntu, SUSE | RHEL, Fedora, CentOS |
| Dificultad | Baja con RuntimeDefault |
Media | Alta |
| Portabilidad entre clústeres | Alta | Depende del nodo | Depende del nodo |
| Recomendación | RuntimeDefault siempre |
Si tus nodos lo traen | Si tu organización ya lo usa |
Recomendación para Rutas Norte: seccompProfile: RuntimeDefault en absolutamente todos los pods, y AppArmor/SELinux según lo que traigan los nodos, sin depender de ellos para la seguridad esencial (porque el clúster local es minikube y el de producción podría no coincidir).
- Los ajustes del pod que hay que evitar
Hay cinco ajustes que rompen el aislamiento del pod respecto al nodo. Los hemos ido mencionando en 05-01 y 06-02; aquí los reunimos.
| Ajuste | Qué rompe | Riesgo concreto en Rutas Norte |
|---|---|---|
hostNetwork: true |
El namespace de red | El pod ve todo el tráfico del nodo y evita las NetworkPolicies de 04-06 |
hostPID: true |
El namespace PID | Ve los procesos de los demás pods, con sus líneas de orden y su memoria |
hostIPC: true |
El namespace IPC | Accede a la memoria compartida de otros procesos del nodo |
hostPath |
El namespace de montaje | Lee o escribe el disco del nodo, incluidos secretos montados de otros pods |
hostPort |
La asignación de puertos | Abre un puerto en la IP del nodo, saltándose el Service y el Ingress |
hostNetwork y su interacción con las NetworkPolicies
Este merece un párrafo aparte porque desmonta trabajo de un módulo anterior. En 04-06 construimos una política deny-all en rutas-norte-pro y fuimos autorizando cada conversación una a una. Un pod con hostNetwork: true usa la pila de red del nodo, no la del pod, así que las NetworkPolicies —que actúan sobre las IP de pod— no le aplican. Todo aquel trabajo queda anulado para ese pod.
Es la razón por la que un DaemonSet con hostNetwork no debe compartir namespace con las aplicaciones y por la que sus manifiestos merecen una revisión especialmente cuidadosa.
hostPath: la advertencia de 05-01, ampliada
# NUNCA en producción para una aplicación
volumes:
- name: peligroso
hostPath:
path: / # el disco del nodo entero
type: DirectoryUn hostPath a / da acceso a:
/var/lib/kubelet/pods/*/volumes/kubernetes.io~projected/— los tokens de ServiceAccount de todos los pods del nodo./etc/kubernetes/— en un nodo de plano de control, las claves del clúster./var/lib/dockero/var/lib/containerd— todas las imágenes y capas./var/run/containerd/containerd.sock— el socket del entorno de ejecución, que permite arrancar contenedores privilegiados.
Ese último punto es clave y a menudo se pasa por alto: montar el socket del entorno de ejecución equivale a privileged: true, aunque el pod no lo declare. Cualquier manifiesto que monte /var/run/docker.sock o containerd.sock debe tratarse como un pod privilegiado.
Los usos legítimos de hostPath son de infraestructura (leer /var/log para el recolector, exponer dispositivos a un driver CSI) y siempre deben ser:
- La ruta más específica posible, nunca
/ni/var. - Con
readOnly: truesiempre que sea posible. - Con
type:explícito (Directory,File,Socket) para que no se cree por accidente. - En un namespace aparte con perfil de seguridad relajado (08-03).
hostPort
Abre el puerto en la IP del nodo. Problemas: solo cabe un pod por nodo con ese puerto, se salta el Service y el Ingress (y por tanto el TLS de 04-05 y el WAF de 08-04), y expone el servicio a cualquiera que alcance la IP del nodo. Para publicar servicios se usa Ingress, como en el módulo 4.
RuntimeClass y los entornos de ejecución aislados
RuntimeClass y los entornos de ejecución aisladosTodo lo anterior endurece un contenedor, pero no cambia el hecho fundamental: el kernel es compartido. Si necesitas aislamiento fuerte, hace falta otra cosa.
Un RuntimeClass selecciona qué entorno de ejecución usa el pod:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc # configurado en containerd en el nodoapiVersion: v1
kind: Pod
metadata:
name: carga-aislada
spec:
runtimeClassName: gvisor # este pod usa gVisor en lugar de runc
containers:
- name: app
image: registry.rutasnorte.example/procesador-terceros:1.2.0Las opciones
| Entorno | Cómo aísla | Coste en arranque | Coste en E/S | Compatibilidad |
|---|---|---|---|---|
runc (por defecto) |
Namespaces + cgroups | Mínimo | Mínimo | Total |
gVisor (runsc) |
Kernel de usuario que intercepta las llamadas | +50-100 ms | Notable en E/S intensiva | Alta, con excepciones |
| Kata Containers | Máquina virtual ligera por pod | +200-500 ms | Bajo con virtio |
Muy alta |
| Firecracker | Micro-VM (base de Kata y de varios servicios en la nube) | +125 ms | Bajo | Alta |
Cuándo compensa
| Carga | ¿Aislamiento reforzado? |
|---|---|
tienda-web, api-reservas, worker-notificaciones |
No. Código propio y revisado; el endurecimiento normal basta |
postgres-reservas |
No. El coste de E/S sería inaceptable; se protege con RBAC, red y cifrado |
| Ejecutar código enviado por terceros | Sí, imprescindible |
| Multiinquilino con clientes que no se conocen entre sí | Sí |
| Analizar un fichero de un cliente con una librería compleja | Probablemente sí |
Rutas Norte no tiene hoy ninguna carga que lo justifique. Si mañana permitiera a las empresas de autobuses subir un fichero de horarios y lo procesara con una librería de análisis compleja, ese componente sería el candidato natural: se ejecuta código sobre datos que vienen de fuera.
Lo importante es conocer la herramienta y su criterio: el aislamiento reforzado es para código en el que no confías, no para el tuyo.
- El endurecimiento completo de Rutas Norte
Reunimos todo en la configuración final de cada componente, con la justificación de cada excepción.
Tabla resumen
| Componente | UID | RootFS RO | Caps | Seccomp | Excepciones |
|---|---|---|---|---|---|
tienda-web |
101 | Sí | drop: ALL |
RuntimeDefault |
3 emptyDir |
api-reservas |
10001 | Sí | drop: ALL |
RuntimeDefault |
emptyDir en /tmp |
postgres-reservas |
999 | No | drop: ALL |
RuntimeDefault |
RootFS escribible (documentado) |
redis-cache |
999 | Sí | drop: ALL |
RuntimeDefault |
Volumen en /data |
worker-notificaciones |
10002 | Sí | drop: ALL |
RuntimeDefault |
emptyDir en /tmp |
informes-ocupacion |
10003 | Sí | drop: ALL |
RuntimeDefault |
emptyDir para el informe |
| Embajador de pagos | 10004 | Sí | drop: ALL |
RuntimeDefault |
Ninguna |
| Recolector de logs | 0 | Sí | drop: ALL + DAC_READ_SEARCH |
RuntimeDefault |
hostPath en solo lectura |
api-reservas: el caso completo
# k8s/base/api-reservas/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-reservas
namespace: rutas-norte-pro
labels:
app: api-reservas
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
replicas: 4
selector:
matchLabels:
app: api-reservas
template:
metadata:
labels:
app: api-reservas
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
serviceAccountName: api-reservas
automountServiceAccountToken: true # sí lee un ConfigMap por API (ver 08-01)
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
ports:
- name: http
containerPort: 8080
- name: metricas
containerPort: 9090
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Sondas del módulo 7, sin cambios
livenessProbe:
httpGet: { path: /salud, port: http }
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet: { path: /preparado, port: http }
periodSeconds: 5
volumeMounts:
- name: temporal
mountPath: /tmp
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: "1", memory: 512Mi }
# Contenedor embajador hacia la pasarela de pagos externa (06-04)
- name: embajador-pagos
image: registry.rutasnorte.example/embajador-pagos:1.3.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10004
capabilities:
drop: ["ALL"]
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { cpu: 100m, memory: 64Mi }
volumes:
- name: temporal
emptyDir:
sizeLimit: 64MiNota la imagen referenciada por digest en lugar de por etiqueta. Es el tema de 08-05 y encaja aquí de forma natural: de nada sirve endurecer un contenedor si no sabes con certeza qué imagen se está ejecutando.
worker-notificaciones con su adaptador de logs
# k8s/base/worker-notificaciones/deployment.yaml (fragmento)
spec:
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10002
runAsGroup: 10002
fsGroup: 10002
seccompProfile:
type: RuntimeDefault
containers:
- name: worker
image: registry.rutasnorte.example/worker-notificaciones:3.2.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: temporal
mountPath: /tmp
- name: logs-compartidos
mountPath: /var/log/worker
# Adaptador que convierte los logs a JSON (06-04 y 07-05)
- name: adaptador-logs
image: registry.rutasnorte.example/adaptador-logs:1.1.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10002 # mismo UID: comparte el volumen de logs
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: logs-compartidos
mountPath: /var/log/worker
readOnly: true # solo lee: no tiene por qué escribir
volumes:
- name: temporal
emptyDir: { sizeLimit: 64Mi }
- name: logs-compartidos
emptyDir: { sizeLimit: 256Mi }Dos detalles finos: el adaptador usa el mismo UID que el worker para poder leer los ficheros que este escribe (alternativa: fsGroup compartido), y monta el volumen con readOnly: true porque solo lee. Endurecer un sidecar es tan importante como endurecer el contenedor principal: comparten el namespace de red y a menudo volúmenes.
El CronJob informes-ocupacion
# k8s/base/informes-ocupacion/cronjob.yaml (fragmento)
apiVersion: batch/v1
kind: CronJob
metadata:
name: informes-ocupacion
namespace: rutas-norte-pro
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
serviceAccountName: informes-ocupacion
automountServiceAccountToken: false # no habla con la API (08-01)
securityContext:
runAsNonRoot: true
runAsUser: 10003
runAsGroup: 10003
fsGroup: 10003
seccompProfile:
type: RuntimeDefault
containers:
- name: informes
image: registry.rutasnorte.example/informes-ocupacion:1.5.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: trabajo
mountPath: /trabajo
volumes:
- name: trabajo
emptyDir: { sizeLimit: 512Mi }Este componente es especialmente sensible porque lee datos personales para agregarlos. Recuerda lo dicho en 07-05: los informes solo contienen agregados (ocupación por línea y por franja horaria), nunca registros individuales, y nada de ello va a los logs.
Un fragmento reutilizable
Como estos bloques se repiten, lo razonable es extraerlos. Con Kustomize (módulo 10) se aplica un parche estratégico a todos los Deployments:
# k8s/base/endurecimiento-comun.yaml
# Bloque de referencia. Cópialo o aplícalo como parche.
podSecurityContext: &pod
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containerSecurityContext: &contenedor
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]Y en 08-03 daremos el paso definitivo: hacer que el clúster rechace cualquier pod que no cumpla estas reglas, para que ningún despliegue futuro pueda saltárselas por olvido.
- Cómo comprobar el resultado desde dentro del contenedor
Un endurecimiento sin verificar es una suposición. Estas comprobaciones tardan un minuto y deben hacerse tras cada cambio.
Quién soy
Si vieras uid=0(root), el runAsUser no está aplicándose (comprueba que no lo sobrescriba el securityContext del contenedor).
El sistema de ficheros raíz es de solo lectura
kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- \
sh -c 'touch /prueba-escritura 2>&1 || echo "correcto: bloqueado"'Y que los emptyDir sí son escribibles:
kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- \
sh -c 'touch /tmp/ok && echo "correcto: /tmp escribible" && rm /tmp/ok'Capacidades efectivas
kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- \
grep -E 'CapEff|CapBnd|NoNewPrivs' /proc/1/statusCapEff: 0 confirma el drop: ["ALL"]. NoNewPrivs: 1 confirma allowPrivilegeEscalation: false. Es la comprobación más directa de las dos líneas más importantes del manifiesto.
Seccomp está activo
Valor de Seccomp |
Significado |
|---|---|
0 |
Desactivado: el perfil no se está aplicando |
1 |
Modo estricto (raro) |
2 |
Modo filtro: RuntimeDefault o Localhost activo |
Si ves 0 cuando esperabas RuntimeDefault, revisa que el campo esté en el nivel correcto y que el nodo lo soporte.
El aislamiento de namespaces funciona
Solo los procesos del contenedor. Si vieras procesos del nodo (kubelet, containerd, procesos de otros pods), es que hostPID: true está activo.
Un guion de verificación completo
#!/usr/bin/env bash
# k8s/seguridad/verificar-endurecimiento.sh
# Comprueba el endurecimiento efectivo de un contenedor en ejecución.
set -uo pipefail
NS="${1:-rutas-norte-pro}"
CARGA="${2:-deploy/api-reservas}"
CONTENEDOR="${3:-api}"
ejec() { kubectl exec -n "$NS" "$CARGA" -c "$CONTENEDOR" -- "$@" 2>/dev/null; }
echo "=== $NS / $CARGA / $CONTENEDOR ==="
echo -n "Usuario: "; ejec id
echo -n "RootFS RO: "
if ejec sh -c 'touch /.prueba' >/dev/null 2>&1; then
echo "NO (escribible) <-- REVISAR"; ejec rm -f /.prueba
else
echo "sí"
fi
echo -n "Capacidades: "
caps=$(ejec grep CapEff /proc/1/status | awk '{print $2}')
[[ "$caps" == "0000000000000000" ]] && echo "ninguna (correcto)" || echo "$caps <-- REVISAR"
echo -n "NoNewPrivs: "
nnp=$(ejec grep NoNewPrivs /proc/1/status | awk '{print $2}')
[[ "$nnp" == "1" ]] && echo "1 (correcto)" || echo "$nnp <-- REVISAR"
echo -n "Seccomp: "
sec=$(ejec grep '^Seccomp:' /proc/1/status | awk '{print $2}')
[[ "$sec" == "2" ]] && echo "filtro activo (correcto)" || echo "$sec <-- REVISAR"
echo -n "Procesos vistos: "; ejec sh -c 'ps aux | wc -l'=== rutas-norte-pro / deploy/api-reservas / api ===
Usuario: uid=10001 gid=10001 groups=10001
RootFS RO: sí
Capacidades: ninguna (correcto)
NoNewPrivs: 1 (correcto)
Seccomp: filtro activo (correcto)
Procesos vistos: 3Nota práctica: este guion necesita pods/exec, un permiso que en 08-01 no dimos a soporte ni a desarrollo. Es una tarea de plataforma, y lo lógico es integrarla como comprobación automática en rutas-norte-pre antes de promocionar a producción.
Errores Comunes y Consejos
Poner capabilities o readOnlyRootFilesystem en el securityContext del pod. No existen a ese nivel. El manifiesto se rechaza o el campo se ignora, según la herramienta. Van en cada contenedor.
Olvidar los initContainers y los sidecars. Un pod con el contenedor principal perfectamente endurecido y un initContainer privilegiado es un pod privilegiado. Repasa 06-04.
Usar runAsNonRoot: true con una imagen que declara USER por nombre. El kubelet no puede verificarlo y el pod no arranca. Usa siempre UID numérico en el Dockerfile (08-05) y runAsUser en el manifiesto.
Rendirse ante readOnlyRootFilesystem y quitarlo. El log te dice la ruta exacta que falla. Dos o tres emptyDir resuelven casi todos los casos.
emptyDir sin sizeLimit. Puede llenar el disco o la memoria del nodo y provocar el desalojo de otros pods. Con medium: Memory, además, cuenta contra el límite de memoria del pod.
Dar por hecho que seccomp está activo. El valor por defecto ha sido Unconfined durante años. Si no pones seccompProfile: RuntimeDefault, probablemente no hay filtro. Compruébalo con grep Seccomp /proc/1/status.
Escribir un perfil seccomp a medida a mano. Es frágil y se rompe con cada actualización de la imagen. Empieza por RuntimeDefault; si necesitas más, genera el perfil observando la carga.
Montar el socket del entorno de ejecución. /var/run/docker.sock o containerd.sock equivalen a privileged: true aunque el manifiesto no lo diga.
Aceptar privileged: true en un manifiesto de un tercero sin preguntar. Muchas veces es comodidad, no necesidad. Pide la justificación.
Olvidar fsGroupChangePolicy: OnRootMismatch en volúmenes grandes. El pod tarda minutos en arrancar en cada reinicio y parece una caída.
Creer que runAsNonRoot cambia el usuario. No lo cambia: lo verifica. Sin runAsUser ni un USER numérico en la imagen, no arranca.
Suponer que un contenedor aísla como una VM. Comparte kernel. Es la premisa de toda la lección y la razón de que los nodos deban estar parcheados.
Consejo de oro: el objetivo para toda aplicación es este bloque exacto, y cualquier desviación debe estar comentada en el propio YAML explicando por qué:
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefaultEjercicios
Ejercicio 1: endurecer redis-cache
redis-cache es un StatefulSet de una réplica con la imagen redis:7.4-alpine, que corre como root y monta un PVC en /data. Escribe el fragmento de securityContext completo (nivel de pod y de contenedor) sabiendo que:
- La imagen oficial de Redis define el usuario
rediscon UID y GID 999. - Redis escribe su fichero de datos en
/data(el PVC) y su fichero de PID en/var/run/redis. - No necesita ninguna capacidad de Linux.
- El volumen es de 8 GiB.
Indica qué se rompería si omitieras fsGroup y cómo lo detectarías.
Ejercicio 2: diagnosticar un pod que no arranca
Tras endurecer worker-notificaciones, el pod queda en CrashLoopBackOff:
Error: EACCES: permission denied, open '/app/cola/pendientes.dat'
at Object.openSync (node:fs:596:3)
at guardarPendiente (/app/lib/cola.js:44:18)El securityContext aplicado es:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10002
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }- ¿Cuál es la causa exacta?
- Propón dos soluciones y razona cuál es mejor.
- Escribe la orden que confirmaría el diagnóstico.
Ejercicio 3: auditar los pods sin endurecer del clúster
Escribe una orden que liste todos los pods de rutas-norte-pro que incumplan alguna de estas condiciones, indicando cuál:
readOnlyRootFilesystem: trueen todos sus contenedores.allowPrivilegeEscalation: falseen todos sus contenedores.capabilities.dropincluyeALLen todos sus contenedores.- No usan
hostNetwork,hostPIDnihostIPC. - Ningún contenedor es
privileged.
Debe recorrer también los initContainers.
Soluciones
Solución 1
# k8s/base/redis-cache/statefulset.yaml (fragmento)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis-cache
namespace: rutas-norte-pro
spec:
serviceName: redis-cache
replicas: 1
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999 # hace escribible el PVC de /data
fsGroupChangePolicy: OnRootMismatch # 8 GiB: evita recorridos lentos
seccompProfile:
type: RuntimeDefault
containers:
- name: redis
image: registry.rutasnorte.example/redis:7.4-alpine
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: datos
mountPath: /data # PVC: escribible vía fsGroup
- name: run
mountPath: /var/run/redis # emptyDir: el fichero de PID
- name: temporal
mountPath: /tmp
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
volumes:
- name: run
emptyDir: { medium: Memory, sizeLimit: 8Mi }
- name: temporal
emptyDir: { sizeLimit: 32Mi }
volumeClaimTemplates:
- metadata:
name: datos
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-ssd
resources:
requests:
storage: 8GiSin fsGroup: el PVC se monta con propietario root:root y modo 0755. Redis, corriendo como UID 999, no puede escribir en /data. El pod arranca y muere en cuanto intenta persistir:
Detección:
kubectl logs -n rutas-norte-pro redis-cache-0
kubectl exec -n rutas-norte-pro redis-cache-0 -- ls -ld /dataCon fsGroup: 999 aplicado:
El grupo pasa a 999, aparece el bit de escritura de grupo y la s indica el setgid en el directorio.
Nota adicional: como es una caché, la pérdida del volumen no es crítica. Pero sí lo es que no arranque, porque api-reservas respondería más lento y podría degradarse la disponibilidad de plazas.
Solución 2
1. Causa. readOnlyRootFilesystem: true impide escribir en cualquier sitio del sistema de ficheros raíz que no sea un volumen montado. El worker intenta escribir en /app/cola/pendientes.dat, que está dentro de la imagen y por tanto en el rootfs de solo lectura. El mensaje EACCES en un openSync de escritura es la firma típica de este problema. No es un problema de UID: aunque corriera como root, / seguiría siendo de solo lectura.
2. Dos soluciones:
Opción A — montar un emptyDir en /app/cola:
containers:
- name: worker
volumeMounts:
- name: cola
mountPath: /app/cola
volumes:
- name: cola
emptyDir: { sizeLimit: 256Mi }Opción B — cambiar la configuración del worker para que use /tmp, ya montado:
Cuál es mejor: depende de si esos datos deben sobrevivir. Y aquí hay una consideración de negocio que va más allá del YAML: /app/cola/pendientes.dat guarda notificaciones pendientes de enviar. Con cualquiera de las dos opciones, esos datos se pierden al reiniciar el contenedor, porque emptyDir es efímero. Si un cliente compra un billete y el worker se reinicia antes de enviar el correo, ese cliente no recibe su confirmación.
La solución correcta, por tanto, es la C: worker-notificaciones no debería tener cola local. La cola debe estar en un almacén compartido y duradero —Redis con persistencia, o la propia base de datos— de modo que cualquier réplica pueda retomar el trabajo. Eso además arregla un problema que existía antes de endurecer nada: con varias réplicas, cada una tenía su propia cola local invisible para las demás.
Como solución inmediata para desbloquear, la A con emptyDir es preferible a la B, porque deja el problema visible en el manifiesto (volumes: cola) en lugar de esconderlo en una variable de entorno. Y hay que abrir la tarea para la C.
Este ejercicio ilustra algo importante: endurecer un contenedor a menudo destapa fallos de diseño preexistentes. El readOnlyRootFilesystem no ha creado el problema; lo ha hecho visible.
3. Confirmación del diagnóstico:
# Arrancar temporalmente sin readOnlyRootFilesystem en rutas-norte-dev y comprobar
kubectl exec -n rutas-norte-dev deploy/worker-notificaciones -c worker -- \
sh -c 'ls -ld /app/cola; touch /app/cola/prueba && echo ESCRIBIBLE || echo BLOQUEADO'Los permisos del directorio son correctos (propietario 10002, el mismo UID del proceso), y aun así falla: eso descarta un problema de permisos y confirma que la causa es el montaje de solo lectura.
Solución 3
#!/usr/bin/env bash
# k8s/seguridad/auditar-endurecimiento.sh
# Lista los pods que incumplen la línea base de endurecimiento.
set -euo pipefail
NS="${1:-rutas-norte-pro}"
kubectl get pods -n "$NS" -o json | jq -r '
.items[] as $pod
| ($pod.metadata.name) as $nombre
| [
# Ajustes a nivel de pod
(if $pod.spec.hostNetwork == true then "hostNetwork" else empty end),
(if $pod.spec.hostPID == true then "hostPID" else empty end),
(if $pod.spec.hostIPC == true then "hostIPC" else empty end),
(if ($pod.spec.volumes // [] | map(select(.hostPath)) | length) > 0
then "hostPath" else empty end),
# Ajustes por contenedor, incluidos los initContainers
( (($pod.spec.containers // []) + ($pod.spec.initContainers // []))[]
| . as $c
| (
(if ($c.securityContext.privileged // false) == true
then "privileged:\($c.name)" else empty end),
(if ($c.securityContext.readOnlyRootFilesystem // false) != true
then "rootfs-escribible:\($c.name)" else empty end),
(if ($c.securityContext.allowPrivilegeEscalation // true) != false
then "escalada-permitida:\($c.name)" else empty end),
(if (($c.securityContext.capabilities.drop // []) | index("ALL")) == null
then "sin-drop-ALL:\($c.name)" else empty end)
)
)
] as $problemas
| if ($problemas | length) > 0
then "\($nombre)\n " + ($problemas | join("\n "))
else empty
end'Interpretación de esta salida concreta:
postgres-reservas: es la excepción documentada del apartado 12. Está justificada, aunque la tarea de resolverla conemptyDirsigue abierta.fluent-bit-collector: DaemonSet de logs conhostPathen solo lectura. Legítimo y ya reducido al mínimo (sinprivileged), pero en 08-03 lo moveremos a un namespace aparte para que no relaje el perfil de todorutas-norte-pro.
Que ningún otro pod aparezca es la evidencia de que el endurecimiento está aplicado. Este guion debería ejecutarse en la canalización sobre rutas-norte-pre antes de cada promoción a producción, con una lista de excepciones conocidas para que solo alerte de las nuevas.
Nota sobre // true y // false en el jq: los valores por defecto están escritos en la dirección insegura a propósito. allowPrivilegeEscalation ausente equivale a permitido, así que su defecto es true; readOnlyRootFilesystem ausente equivale a escribible, así que su defecto es false. Una auditoría debe asumir lo peor cuando el campo no está.
Conclusión
Hemos añadido la segunda capa de defensa. Repasando lo esencial:
- Un contenedor no es una máquina virtual. Comparte kernel con el nodo, y el aislamiento lo dan los namespaces, los cgroups y los límites de lo que puede pedirle al kernel. Por eso parchear los nodos es una tarea de seguridad de primer nivel.
- El
securityContextvive en dos niveles: identidad y volúmenes en el pod; capacidades, escalada y sistema de ficheros en el contenedor. El del contenedor gana, ycapabilities,privileged,readOnlyRootFilesystemyallowPrivilegeEscalationhay que repetirlos en cada contenedor, incluidos initContainers y sidecars. - No correr como root (
runAsNonRoot+runAsUsernumérico), y resolver los permisos de volumen confsGroupyfsGroupChangePolicy: OnRootMismatchcuando el volumen es grande, como enpostgres-reservas. allowPrivilegeEscalation: falseactivano_new_privsy neutraliza los binarios setuid. Cuesta una línea.privileged: trueequivale a entregar el nodo, y montar el socket del entorno de ejecución también, aunque no lo declare.capabilities: drop: ["ALL"]y añadir solo lo imprescindible; y muchas veces se puede evitar inclusoNET_BIND_SERVICEcambiando a un puerto alto.readOnlyRootFilesystem: truehace el contenedor inmutable en ejecución, y se vuelve viable con unos pocosemptyDirconsizeLimit.seccompProfile: RuntimeDefaultsiempre; no des por hecho que ya está activo.- Evitar
hostNetwork,hostPID,hostIPC,hostPathyhostPort; recuerda quehostNetworkanula las NetworkPolicies del módulo 4. - Para código en el que no confías,
RuntimeClasscon gVisor o Kata; para el tuyo, no compensa. - Y comprobarlo todo desde dentro:
id, la escritura fallida,CapEff,NoNewPrivsySeccompen/proc/1/status.
Ahora todos los componentes de Rutas Norte están endurecidos. Pero fíjate en la naturaleza de lo que hemos hecho: hemos escrito un YAML correcto. Nada impide que mañana alguien despliegue un pod nuevo sin securityContext, o que copie un manifiesto de internet con privileged: true, o que un compañero añada un sidecar y olvide el drop: ["ALL"]. Todo el endurecimiento de esta lección depende de que cada persona se acuerde, cada vez. Y eso no es una garantía: es una esperanza.
La siguiente lección, 08-03, Políticas de Seguridad de Pods y Pod Security Standards, da el paso definitivo: pasar de "cada equipo pone bien su securityContext" a "el clúster no acepta un pod que no lo tenga". Veremos los Pod Security Standards, el Pod Security Admission integrado en el apiserver, y los motores de política como Kyverno que permiten exigir cualquier regla que se te ocurra, con el mensaje de rechazo exacto que devuelve la API cuando un pod no cumple.
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
