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

  1. Qué aísla realmente un contenedor
  2. El securityContext: nivel de pod y nivel de contenedor
  3. Ejecutar sin root: runAsNonRoot, runAsUser y runAsGroup
  4. Volúmenes y permisos de fichero: fsGroup y fsGroupChangePolicy
  5. allowPrivilegeEscalation y el bit no_new_privs
  6. privileged: true y por qué equivale a entregar el nodo
  7. Capacidades de Linux: quitarlo todo y añadir lo justo
  8. readOnlyRootFilesystem y cómo hacerlo viable
  9. Seccomp, AppArmor y SELinux
  10. Los ajustes del pod que hay que evitar
  11. RuntimeClass y los entornos de ejecución aislados
  12. El endurecimiento completo de Rutas Norte
  13. Cómo comprobar el resultado desde dentro del contenedor
  14. Errores comunes y consejos
  15. Ejercicios
  16. Conclusión

  1. 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.

  1. El securityContext: nivel de pod y nivel de contenedor

securityContext 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 UID con el que corre el proceso
runAsGroup GID primario
runAsNonRoot El kubelet rechaza arrancar si el UID es 0
supplementalGroups No GIDs adicionales
fsGroup No GID aplicado a los volúmenes montados
fsGroupChangePolicy No Cuándo se recalculan los permisos del volumen
seccompProfile Filtro de llamadas al sistema
seLinuxOptions Etiquetas SELinux
sysctls No Parámetros del kernel del pod
appArmorProfile Perfil AppArmor (campo nativo desde 1.30)
allowPrivilegeEscalation No Bloquea no_new_privs
capabilities No Añadir y quitar capacidades
privileged No Desactiva casi todo el aislamiento
readOnlyRootFilesystem No Monta / en solo lectura
procMount No 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 sobrescribe

Un 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.

  1. Ejecutar sin root: runAsNonRoot, runAsUser y runAsGroup

Por 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-root

De 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:

Error: container's runAsUser breaks non-root policy

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 capacidad

Y 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 privilegios

Nadie fuera del clúster nota la diferencia, y hemos eliminado una capacidad y el arranque como root.

  1. Volúmenes y permisos de fichero: fsGroup y fsGroupChangePolicy

Aquí 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.

initdb: error: could not create directory "/var/lib/postgresql/data/pgdata":
Permission denied

La solución: fsGroup

spec:
  securityContext:
    runAsUser: 999
    runAsGroup: 999
    fsGroup: 999        # <-- la clave

Cuando fsGroup está presente, el kubelet, antes de arrancar los contenedores:

  1. Cambia el grupo propietario de los ficheros y directorios del volumen a ese GID.
  2. Añade el bit de escritura para el grupo.
  3. Pone el bit setgid en los directorios, para que lo nuevo herede el grupo.
  4. 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: 50Gi

Cuatro decisiones para justificar:

  1. runAsUser: 999: el UID que la imagen ya espera. Poner otro obligaría a modificar la imagen.
  2. 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.
  3. readOnlyRootFilesystem: false en PostgreSQL: es una excepción consciente y documentada. PostgreSQL escribe ficheros de socket y temporales fuera del volumen de datos. Se podría arreglar con emptyDir en /var/run/postgresql y /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.
  4. 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.

  1. allowPrivilegeEscalation y el bit no_new_privs

securityContext:
  allowPrivilegeEscalation: false

Esta 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_privs activo, ningún proceso hijo puede obtener más privilegios que su padre, ni siquiera ejecutando un binario con el bit setuid puesto 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:

kubectl exec -n rutas-norte-pro deploy/tienda-web -- \
  find / -xdev -perm -4000 -type f 2>/dev/null
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/bin/su
/bin/mount
/bin/umount

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.

  1. privileged: true y por qué equivale a entregar el nodo

securityContext:
  privileged: true     # casi nunca es la respuesta correcta

Cuando 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: Directory

Sigue 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.

  1. 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 imprescindible

drop: ["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 root

Comparemos 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)
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

kubectl exec -n rutas-norte-pro deploy/api-reservas -- grep Cap /proc/1/status
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000
CapAmb: 0000000000000000

Todos 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)'
0x0000000000000000=

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.

  1. readOnlyRootFilesystem y cómo hacerlo viable

securityContext:
  readOnlyRootFilesystem: true

El 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: 128Mi

Puntos a destacar:

  • medium: Memory hace que el emptyDir viva 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.
  • sizeLimit es obligatorio en la práctica. Un emptyDir sin límite puede crecer hasta agotar el disco (o la memoria) del nodo. Con medium: Memory cuenta además contra el límite de memoria del pod.
  • Los emptyDir se 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.

kubectl logs -n rutas-norte-dev deploy/tienda-web
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

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)

  1. 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.

spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
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 seccomp

Advertencias 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 SIGSYS o un EPERM incomprensible.
  • 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 nodo

Las 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:

spec:
  securityContext:
    seLinuxOptions:
      level: "s0:c123,c456"

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).

  1. 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: Directory

Un 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/docker o /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:

  1. La ruta más específica posible, nunca / ni /var.
  2. Con readOnly: true siempre que sea posible.
  3. Con type: explícito (Directory, File, Socket) para que no se cree por accidente.
  4. En un namespace aparte con perfil de seguridad relajado (08-03).

hostPort

ports:
  - containerPort: 8080
    hostPort: 8080      # evítalo

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.

  1. RuntimeClass y los entornos de ejecución aislados

Todo 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 nodo
apiVersion: 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.0

Las 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í
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.

  1. 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 drop: ALL RuntimeDefault 3 emptyDir
api-reservas 10001 drop: ALL RuntimeDefault emptyDir en /tmp
postgres-reservas 999 No drop: ALL RuntimeDefault RootFS escribible (documentado)
redis-cache 999 drop: ALL RuntimeDefault Volumen en /data
worker-notificaciones 10002 drop: ALL RuntimeDefault emptyDir en /tmp
informes-ocupacion 10003 drop: ALL RuntimeDefault emptyDir para el informe
Embajador de pagos 10004 drop: ALL RuntimeDefault Ninguna
Recolector de logs 0 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: 64Mi

Nota 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.

  1. 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

kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- id
uid=10001 gid=10001 groups=10001

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"'
touch: /prueba-escritura: Read-only file system
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'
correcto: /tmp escribible

Capacidades efectivas

kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- \
  grep -E 'CapEff|CapBnd|NoNewPrivs' /proc/1/status
CapEff:	0000000000000000
CapBnd:	0000000000000000
NoNewPrivs:	1

CapEff: 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

kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- grep Seccomp /proc/1/status
Seccomp:	2
Seccomp_filters:	1
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

kubectl exec -n rutas-norte-pro deploy/api-reservas -c api -- ps aux
PID   USER     TIME  COMMAND
    1 10001     0:04 node /app/servidor.js
   28 10001     0:00 ps aux

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: 3

Nota 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: RuntimeDefault

Ejercicios

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 redis con 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:

NAME                                     READY   STATUS             RESTARTS   AGE
worker-notificaciones-7d9c8b6f5-x2k4m    0/2     CrashLoopBackOff   4          2m
kubectl logs -n rutas-norte-pro worker-notificaciones-7d9c8b6f5-x2k4m -c worker
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"] }
  1. ¿Cuál es la causa exacta?
  2. Propón dos soluciones y razona cuál es mejor.
  3. 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: true en todos sus contenedores.
  • allowPrivilegeEscalation: false en todos sus contenedores.
  • capabilities.drop incluye ALL en todos sus contenedores.
  • No usan hostNetwork, hostPID ni hostIPC.
  • 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: 8Gi

Sin 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:

Can't open the append-only file: Permission denied

Detección:

kubectl logs -n rutas-norte-pro redis-cache-0
kubectl exec -n rutas-norte-pro redis-cache-0 -- ls -ld /data
drwxr-xr-x 2 root root 4096 Aug  6 03:11 /data

Con fsGroup: 999 aplicado:

drwxrwsr-x 2 root 999 4096 Aug  6 03:14 /data

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:

          env:
            - name: RUTA_COLA
              value: /tmp/cola

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'
drwxr-xr-x 2 10002 10002 4096 Aug  6 02:58 /app/cola
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'
bash k8s/seguridad/auditar-endurecimiento.sh rutas-norte-pro
postgres-reservas-0
    rootfs-escribible:postgres
fluent-bit-collector-nx7q2
    hostPath

Interpretación de esta salida concreta:

  • postgres-reservas: es la excepción documentada del apartado 12. Está justificada, aunque la tarea de resolverla con emptyDir sigue abierta.
  • fluent-bit-collector: DaemonSet de logs con hostPath en solo lectura. Legítimo y ya reducido al mínimo (sin privileged), pero en 08-03 lo moveremos a un namespace aparte para que no relaje el perfil de todo rutas-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 securityContext vive en dos niveles: identidad y volúmenes en el pod; capacidades, escalada y sistema de ficheros en el contenedor. El del contenedor gana, y capabilities, privileged, readOnlyRootFilesystem y allowPrivilegeEscalation hay que repetirlos en cada contenedor, incluidos initContainers y sidecars.
  • No correr como root (runAsNonRoot + runAsUser numérico), y resolver los permisos de volumen con fsGroup y fsGroupChangePolicy: OnRootMismatch cuando el volumen es grande, como en postgres-reservas.
  • allowPrivilegeEscalation: false activa no_new_privs y neutraliza los binarios setuid. Cuesta una línea.
  • privileged: true equivale 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 incluso NET_BIND_SERVICE cambiando a un puerto alto.
  • readOnlyRootFilesystem: true hace el contenedor inmutable en ejecución, y se vuelve viable con unos pocos emptyDir con sizeLimit.
  • seccompProfile: RuntimeDefault siempre; no des por hecho que ya está activo.
  • Evitar hostNetwork, hostPID, hostIPC, hostPath y hostPort; recuerda que hostNetwork anula las NetworkPolicies del módulo 4.
  • Para código en el que no confías, RuntimeClass con gVisor o Kata; para el tuyo, no compensa.
  • Y comprobarlo todo desde dentro: id, la escritura fallida, CapEff, NoNewPrivs y Seccomp en /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

Módulo 2: Componentes Principales de Kubernetes

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

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

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

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

© Copyright 2026. Todos los derechos reservados