A la lliçó anterior vam tancar la porta de qui pot fer què: RBAC decideix si tens dret a crear un pod a rutas-norte-pro. Però vam acabar amb una incomoditat molt concreta. Algú de plataforma, amb permisos perfectament legítims, pot desplegar avui mateix un contenidor amb privileged: true, que munti el disc del node amb hostPath i corri com a root. RBAC dirà que sí, perquè té permís per crear pods. I a partir d'aquí, el node sencer —amb tots els pods que hi corrin, inclòs postgres-reserves i les seves dades personals— queda al descobert.

Aquesta lliçó tracta de la segona barrera: què pot fer el procés un cop està corrent dins del contenidor. És la diferència entre "algú ha aconseguit executar codi a botiga-web" —un incident seriós però contingut— i "algú ha aconseguit executar codi a botiga-web i des d'allà ha llegit la base de dades de clients" —un incident d'una altra magnitud—.

L'eina es diu securityContext i és, juntament amb RBAC, el que més rendiment de seguretat dona per línia de YAML escrita.

Advertiment important. Aquesta lliçó explica els mecanismes d'enduriment i proposa una configuració d'exemple. L'enduriment real d'una plataforma en producció l'ha de revisar un professional de seguretat, que coneix el model d'amenaces concret de l'organització. Quan el sistema tracta dades personals —com postgres-reserves— el disseny l'ha de conèixer també el responsable de compliment normatiu. L'enfocament aquí és exclusivament defensiu: entendre els mecanismes per tancar-los, mai per explotar-los.

Contingut

  1. Què aïlla realment un contenidor
  2. El securityContext: nivell de pod i nivell de contenidor
  3. Executar sense root: runAsNonRoot, runAsUser i runAsGroup
  4. Volums i permisos de fitxer: fsGroup i fsGroupChangePolicy
  5. allowPrivilegeEscalation i el bit no_new_privs
  6. privileged: true i per què equival a lliurar el node
  7. Capacitats de Linux: treure-ho tot i afegir el just
  8. readOnlyRootFilesystem i com fer-lo viable
  9. Seccomp, AppArmor i SELinux
  10. Els ajustos del pod que cal evitar
  11. RuntimeClass i els entorns d'execució aïllats
  12. L'enduriment complet de Rutas Norte
  13. Com comprovar el resultat des de dins del contenidor
  14. Errors comuns i consells
  15. Exercicis
  16. Conclusió

  1. Què aïlla realment un contenidor

Abans de configurar res cal entendre què estem configurant. I l'afirmació de partida és incòmoda:

Un contenidor no és una màquina virtual. Tots els contenidors d'un node comparteixen el mateix kernel de Linux. Un contenidor és un procés normal del sistema operatiu del node, al qual se li han restringit tres coses: el que veu, el que consumeix i el que pot demanar al kernel.

Compara'l amb una màquina virtual:

Màquina virtual Contenidor
Kernel Propi, aïllat Compartit amb el node
Frontera d'aïllament Hipervisor (maquinari) Funcions del kernel (programari)
Superfície d'atac Interfície de l'hipervisor (petita) Totes les crides al sistema (~350)
Arrencada Segons o minuts Mil·lisegons
Consum Sistema operatiu complet Només el procés
Escapada Molt difícil Possible si hi ha una fallada del kernel o mala configuració

Els tres mecanismes que fan l'aïllament:

Namespaces del kernel: què veu el procés

No confondre amb els Namespace de Kubernetes: són un concepte de Linux completament diferent. Un namespace del kernel dona al procés una vista pròpia d'un recurs del sistema.

Namespace Què aïlla Ajust de Kubernetes que el trenca
PID Els processos visibles hostPID: true
Network Interfícies, IP, ports, taules de rutes hostNetwork: true
Mount L'arbre de fitxers hostPath (parcialment)
IPC Memòria compartida, cues de missatges hostIPC: true
UTS Nom de màquina
User Correspondència d'UIDs (user namespaces, encara opcional)

Quan dins d'un contenidor fas ps aux i veus només el teu procés com a PID 1, és el namespace PID actuant. Quan hostPID: true està actiu, veus tots els processos del node, inclosos els dels altres pods, amb les seves línies d'ordres i les seves variables d'entorn.

cgroups: quant pot consumir

Els control groups limiten CPU, memòria, E/S i nombre de processos. És el que hi ha darrere de resources.limits del mòdul 3. La seva funció és sobretot d'estabilitat —evitar que un pod tombi el node— però també de seguretat, perquè una denegació de servei local és un atac.

Capacitats i filtres de crides al sistema: què pot demanar al kernel

Aquí és on el securityContext marca la diferència i on es juga el partit. Hi tornarem als apartats 7 i 9.

Què significa això a la pràctica

Que tots els contenidors comparteixin kernel té una conseqüència directa:

  • Una fallada del kernel de Linux pot permetre sortir del contenidor. Aquestes fallades apareixen periòdicament i es corregeixen; per això mantenir els nodes apedaçats és una tasca de seguretat de primer ordre, no de manteniment rutinari.
  • Com menys pugui demanar un contenidor al kernel, menys superfície té per aprofitar una d'aquestes fallades. Aquesta és la lògica de tot el que segueix.
  • Si necessites aïllament fort per a alguna cosa que no controles, els contenidors normals no són la resposta: cal fer servir un entorn d'execució aïllat (apartat 11) o directament una màquina virtual.
flowchart TB
    subgraph Node["Node de Kubernetes"]
        K["Kernel de Linux — COMPARTIT"]
        subgraph C1["Pod botiga-web"]
            P1["nginx<br/>ns PID, net, mount<br/>cgroups<br/>capacitats reduïdes<br/>seccomp"]
        end
        subgraph C2["Pod postgres-reserves"]
            P2["postgres<br/>dades personals"]
        end
        P1 -.->|crides al sistema| K
        P2 -.->|crides al sistema| K
    end
    style K fill:#f9d5d5,stroke:#c33

Llegeix el diagrama al revés: si un procés de botiga-web aconsegueix abusar del kernel compartit, està al mateix pla que postgres-reserves. L'única barrera és quant li hem permès demanar al kernel.

  1. El securityContext: nivell de pod i nivell de contenidor

securityContext apareix a dos llocs del manifest, i quins camps admet cadascun és diferent. Confondre'ls és l'error número u.

apiVersion: v1
kind: Pod
metadata:
  name: exemple
spec:
  securityContext:            # <-- NIVELL DE POD: afecta tots els contenidors
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.rutasnorte.example/api-reserves:2.7.1
      securityContext:        # <-- NIVELL DE CONTENIDOR: només aquest contenidor
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]

Quin camp viu a cada nivell

Camp Pod Contenidor Què fa
runAsUser UID amb què corre el procés
runAsGroup GID primari
runAsNonRoot El kubelet rebutja arrencar si l'UID és 0
supplementalGroups No GIDs addicionals
fsGroup No GID aplicat als volums muntats
fsGroupChangePolicy No Quan es recalculen els permisos del volum
seccompProfile Filtre de crides al sistema
seLinuxOptions Etiquetes SELinux
sysctls No Paràmetres del kernel del pod
appArmorProfile Perfil AppArmor (camp natiu des de 1.30)
allowPrivilegeEscalation No Bloqueja no_new_privs
capabilities No Afegir i treure capacitats
privileged No Desactiva gairebé tot l'aïllament
readOnlyRootFilesystem No Munta / en només lectura
procMount No Com es munta /proc

Regla mnemotècnica: el que té a veure amb identitat i amb volums va al pod; el que té a veure amb el que pot fer el procés va al contenidor.

Quin guanya

Quan un camp existeix als dos nivells i es defineix a tots dos, guanya el del contenidor. Això permet una estratègia molt neta: posar la línia base restrictiva al pod i fer excepcions puntuals i visibles al contenidor que ho necessiti.

spec:
  securityContext:
    runAsUser: 10001          # línia base per a tots
  containers:
    - name: app
      # hereta runAsUser: 10001
    - name: adaptador-logs
      securityContext:
        runAsUser: 10002      # aquest contenidor la sobreescriu

Un matís que confon: capabilities i allowPrivilegeEscalation no s'hereten del pod perquè no existeixen a nivell de pod. Cal repetir-los a cada contenidor, inclosos els initContainers i els sidecars. És tediós i és la causa més freqüent que un pod "endurit" tingui un contenidor sense endurir. Kustomize o Helm ajuden; una política d'admissió (08-03) ho garanteix.

Els initContainers també compten

spec:
  initContainers:
    - name: preparar-dades
      image: registry.rutasnorte.example/utilitats:1.4.2
      securityContext:              # NO es pot ometre
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
  containers:
    - name: app
      # ...

Un initContainer privilegiat és un pod privilegiat: s'executa amb accés complet abans que arrenqui el contenidor principal. Repassa 06-04 i comprova que tots els teus initContainers i sidecars estan endurits igual que els contenidors principals.

  1. Executar sense root: runAsNonRoot, runAsUser i runAsGroup

Per defecte, un contenidor corre amb l'usuari que declari la imatge; i moltíssimes imatges públiques, inclosa nginx, declaren root (UID 0). Córrer com a root dins del contenidor no és el mateix que ser root del node, però està molt més a prop del que a ningú li agradaria:

  • Root dins del contenidor té, per defecte, un conjunt de capacitats que un usuari normal no té.
  • Si el contenidor comparteix alguna cosa amb el node (un hostPath, un socket), root escriu on un usuari normal no podria.
  • Si apareix una fallada d'escapada del kernel, la majoria requereixen ser root dins del contenidor per funcionar. No ser-ho elimina bona part d'aquesta classe de problemes.

Els tres camps

spec:
  securityContext:
    runAsNonRoot: true      # verificació: el kubelet rebutja el pod si l'UID és 0
    runAsUser: 10001        # UID efectiu del procés
    runAsGroup: 10001       # GID primari
Camp Què fa exactament
runAsNonRoot: true No canvia l'usuari. És una comprovació: si l'UID resultant és 0, el kubelet no arrenca el contenidor
runAsUser: N Força l'UID, ignorant el USER de la imatge
runAsGroup: N Força el GID primari. Si s'omet, sol quedar 0 (grup root), cosa que no és ideal

runAsNonRoot sense runAsUser funciona només si la imatge declara un usuari numèric. Si el Dockerfile posa USER appuser (nom, no número), el kubelet no pot resoldre el nom —no té accés al /etc/passwd de la imatge abans d'arrencar-la— i el pod falla:

Error: container has runAsNonRoot and image has non-numeric user (appuser),
cannot verify user is non-root

D'aquí una regla que reprendrem a 08-05: declara sempre l'usuari numèric al Dockerfile (USER 10001), i a més especifica runAsUser al manifest. Cinturó i tirants.

Si intentes córrer com a root amb la verificació activa:

Error: container's runAsUser breaks non-root policy

El cas de nginx a botiga-web

botiga-web fa servir la imatge oficial nginx, que arrenca com a root per poder escoltar al port 80 i després baixa de privilegis en els seus processos treballadors. Si li posem runAsNonRoot: true sense més, falla en intentar escriure a /var/cache/nginx i en obrir el port 80.

Hi ha dos camins, i només un és bo:

Opció Què implica
Afegir la capacitat NET_BIND_SERVICE per al port 80 Funciona, però manté una capacitat innecessària
Fer servir la imatge nginxinc/nginx-unprivileged i escoltar al 8080 Sense capacitats, sense root, i el Service tradueix el port

La segona és clarament superior i és la que adoptem:

# k8s/base/botiga-web/deployment.yaml (fragment)
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 101          # l'usuari nginx de la imatge unprivileged
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: nginx
          image: registry.rutasnorte.example/nginx-unprivileged:1.27.1
          ports:
            - containerPort: 8080     # port alt: no cal cap capacitat

I el Service continua publicant el 80 cap enfora:

apiVersion: v1
kind: Service
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
spec:
  selector:
    app: botiga-web
  ports:
    - port: 80            # el que veuen els clients
      targetPort: 8080    # el que escolta el contenidor sense privilegis

Ningú fora del clúster nota la diferència, i hem eliminat una capacitat i l'arrencada com a root.

  1. Volums i permisos de fitxer: fsGroup i fsGroupChangePolicy

Aquí arriba el problema pràctic que fa que molta gent es rendeixi i torni a root. És exactament el cas de postgres-reserves.

El problema

Un volum aprovisionat dinàmicament (mòdul 5) es munta amb els permisos que li posi el sistema de fitxers, normalment propietat de root:root amb mode 0755. Si el contenidor corre com a UID 10001, no pot escriure al seu propi volum de dades.

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

La solució: fsGroup

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

Quan fsGroup hi és present, el kubelet, abans d'arrencar els contenidors:

  1. Canvia el grup propietari dels fitxers i directoris del volum a aquell GID.
  2. Afegeix el bit d'escriptura per al grup.
  3. Posa el bit setgid als directoris, perquè el que sigui nou hereti el grup.
  4. Afegeix aquell GID als grups suplementaris del procés.

Resultat: el procés pot escriure al volum sense ser root i sense tocar la imatge.

fsGroup no s'aplica a tots els tipus de volum. Funciona amb els que tenen sistema de fitxers propi (la majoria de volums en bloc via CSI, emptyDir, configMap, secret) i no amb NFS ni altres sistemes de fitxers de xarxa, on els permisos els governa el servidor. Amb NFS cal coordinar els UID/GID amb qui administra l'emmagatzematge.

fsGroupChangePolicy: el detall que importa en producció

Canviar el propietari de cada fitxer és una operació recursiva. Al volum de postgres-reserves, amb milions de fitxers, això pot trigar minuts en cada arrencada del pod. I passa en cada reinici, en cada actualització, en cada moviment de node.

spec:
  securityContext:
    fsGroup: 999
    fsGroupChangePolicy: OnRootMismatch    # <-- imprescindible en volums grans
Valor Comportament Quan fer-lo servir
Always (per defecte) Recorre tot el volum en cada arrencada Volums petits
OnRootMismatch Comprova només el permís del directori arrel del volum; si ja coincideix, no fa res Volums grans: gairebé sempre la correcta

OnRootMismatch funciona perquè, si el directori arrel ja té el grup i els permisos esperats, és que el canvi es va fer en una arrencada anterior. La primera vegada sí que fa el recorregut complet; les següents són instantànies.

El cas complet de postgres-reserves

La imatge postgres:16.4 defineix l'usuari postgres amb UID i GID 999. Configuració final:

# k8s/base/postgres-reserves/statefulset.yaml (fragment)
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-reserves
  namespace: rutas-norte-pro
spec:
  serviceName: postgres-reserves
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 999                      # usuari postgres de la imatge
        runAsGroup: 999
        fsGroup: 999                        # el volum passa a ser escrivible
        fsGroupChangePolicy: OnRootMismatch  # sense això, arrencades de diversos minuts
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: postgres
          image: registry.rutasnorte.example/postgres:16.4
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: false   # PostgreSQL escriu a /var/run i /tmp
            capabilities:
              drop: ["ALL"]
          env:
            # PGDATA en un subdirectori: evita problemes amb lost+found
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: dades
              mountPath: /var/lib/postgresql/data
        # Sidecar exportador de mètriques del mòdul 7: també endurit
        - name: exportador-metriques
          image: registry.rutasnorte.example/postgres-exporter:0.15.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 65534
            capabilities:
              drop: ["ALL"]
  volumeClaimTemplates:
    - metadata:
        name: dades
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-ssd
        resources:
          requests:
            storage: 50Gi

Quatre decisions per justificar:

  1. runAsUser: 999: l'UID que la imatge ja espera. Posar-ne un altre obligaria a modificar la imatge.
  2. fsGroupChangePolicy: OnRootMismatch: sense ell, amb 50 GiB de dades personals, cada reinici del pod trigaria minuts i apareixeria com una caiguda del servei.
  3. readOnlyRootFilesystem: false a PostgreSQL: és una excepció conscient i documentada. PostgreSQL escriu fitxers de socket i temporals fora del volum de dades. Es podria arreglar amb emptyDir a /var/run/postgresql i /tmp, i és el recomanable a mitjà termini; aquí ho deixem explícit perquè es vegi que una excepció documentada és millor que una excepció silenciosa.
  4. El sidecar exportador sí que porta readOnlyRootFilesystem: true: no escriu res, així que no hi ha raó per deixar-lo obert. Cada contenidor s'endureix segons el que necessita, no segons el que necessita el seu veí.

  1. allowPrivilegeEscalation i el bit no_new_privs

securityContext:
  allowPrivilegeEscalation: false

Aquesta línia, que costa zero, es tradueix al kernel al bit no_new_privs del procés (documentat a prctl(2)). El seu significat exacte:

Amb no_new_privs actiu, cap procés fill pot obtenir més privilegis que el seu pare, ni tan sols executant un binari amb el bit setuid posat o amb capacitats de fitxer.

Què és un binari setuid

A Linux, un executable pot portar el bit setuid, que fa que en executar-se corri amb l'UID del seu propietari en lloc del de qui el llança. L'exemple clàssic és /usr/bin/passwd: el llança un usuari normal, però corre com a root perquè necessita escriure a /etc/shadow.

Dins d'un contenidor, un binari setuid propietat de root és un pont entre "soc l'usuari 10001" i "soc root en aquest contenidor". Moltes imatges base en porten uns quants sense que ningú se n'adoni:

kubectl exec -n rutas-norte-pro deploy/botiga-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

Amb allowPrivilegeEscalation: false, executar qualsevol d'ells no eleva privilegis: el bit setuid queda neutralitzat.

Quan ha de ser true

Pràcticament mai en una aplicació. Hi ha tres situacions legítimes:

Situació Per què
El contenidor és privileged: true Seria contradictori; Kubernetes obliga que sigui true
El contenidor afegeix capacitats amb CAP_SYS_ADMIN Sol necessitar l'escalada
Eines que depenen de setuid Raríssim en càrregues modernes

Nota de coherència: allowPrivilegeEscalation: false és incompatible amb privileged: true. Si poses tots dos, el pod és rebutjat. És una comprovació deliberada de Kubernetes.

Efecte secundari útil

no_new_privs és a més la condició perquè seccomp funcioni sense capacitats especials. Posar-lo a false prepara el terreny per a l'apartat 9.

Regla pràctica: allowPrivilegeEscalation: false a tots els contenidors, sense excepcions que no estiguin documentades i aprovades.

  1. privileged: true i per què equival a lliurar el node

securityContext:
  privileged: true     # gairebé mai és la resposta correcta

Quan un contenidor és privilegiat:

Es desactiva Conseqüència
La retallada de capacitats El procés té totes les capacitats de Linux
Les restriccions de dispositius Veu /dev del node sencer, inclosos els discos en brut
El perfil seccomp per defecte Pot fer qualsevol crida al sistema
AppArmor / SELinux Sense confinament obligatori
Restriccions de muntatge Pot muntar sistemes de fitxers del node

La conseqüència pràctica és senzilla d'enunciar:

Un contenidor privilegiat és, a efectes pràctics, root al node. Pot llegir i escriure els discos, carregar mòduls del kernel, accedir al sistema de fitxers de l'amfitrió i, amb això, als secrets muntats de tots els altres pods d'aquell node.

Aplicat a Rutas Norte: si botiga-web corregués privilegiat i estigués al mateix node que postgres-reserves, qui controlés botiga-web tindria accés al volum de dades personals. I ni RBAC ni les NetworkPolicies del mòdul 4 ho impedirien, perquè no és una petició a l'API ni trànsit de xarxa: és accés directe al disc.

Els usos legítims i com reduir-los

privileged: true té usos reals, tots al pla d'infraestructura, no en aplicacions:

Component Per què el necessita Alternativa més fina
Plugin CNI (Calico, Cilium) Configura la xarxa del node NET_ADMIN + hostNetwork
Controladors CSI Munta volums al node SYS_ADMIN + muntatge bidireccional
Agents de seguretat (Falco) Observa crides al sistema SYS_PTRACE, BPF, PERFMON
Recol·lector de logs (mòdul 7) Llegeix /var/log del node hostPath en només lectura, sense privilegis

Aquest últim és important i l'aprofitem: el recol·lector de logs del mòdul 7 no necessita ser privilegiat. Amb muntar /var/log en només lectura n'hi ha prou.

# k8s/base/registre/daemonset.yaml (fragment) — versió endurida
containers:
  - name: recollector
    image: registry.rutasnorte.example/fluent-bit:3.1.7
    securityContext:
      privileged: false                 # NO cal
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      runAsUser: 0                      # sí que necessita root per llegir els logs del node
      capabilities:
        drop: ["ALL"]
        add: ["DAC_READ_SEARCH"]        # només per saltar-se permisos de LECTURA
    volumeMounts:
      - name: varlog
        mountPath: /var/log
        readOnly: true                  # <-- només lectura
volumes:
  - name: varlog
    hostPath:
      path: /var/log
      type: Directory

Continua sent un pod amb més privilegis que una aplicació normal —ho vam advertir a 06-02— però hem baixat de "control total del node" a "lectura d'un directori". Aquesta reducció és exactament la feina d'aquesta lliçó.

Regla: si un manifest d'un tercer demana privileged: true, exigeix una justificació concreta abans d'aplicar-lo. Molt sovint és un valor per defecte còmode, no una necessitat.

  1. Capacitats de Linux: treure-ho tot i afegir el just

Històricament Linux tenia dues classes de procés: root (ho podia tot) i la resta (no podia gairebé res). Les capacitats van partir els poders de root en una quarantena de peces independents que es concedeixen per separat.

Capacitats rellevants:

Capacitat Què permet Risc
NET_BIND_SERVICE Escoltar en ports < 1024 Baix
CHOWN Canviar el propietari de fitxers Baix/mitjà
DAC_OVERRIDE Saltar-se tots els permisos de fitxer Alt
DAC_READ_SEARCH Saltar-se els permisos de lectura Mitjà
SETUID / SETGID Canviar d'identitat Mitjà
NET_RAW Sockets en brut (ping, rastreig de xarxa) Mitjà
NET_ADMIN Configurar la xarxa: interfícies, tallafocs, rutes Molt alt
SYS_ADMIN Un calaix de sastre: muntar, namespaces... Crític
SYS_PTRACE Inspeccionar la memòria d'altres processos Alt
SYS_MODULE Carregar mòduls del kernel Crític: és el node
SYS_TIME Canviar el rellotge del sistema Mitjà
BPF, PERFMON Programes eBPF i perfilat Alt

Un contenidor sense configuració especial no arrenca amb totes: l'entorn d'execució concedeix un conjunt per defecte d'unes catorze, entre elles CHOWN, DAC_OVERRIDE, NET_RAW, SETUID, SETGID i NET_BIND_SERVICE. Cap aplicació web normal no en fa servir cap.

La pràctica correcta

securityContext:
  capabilities:
    drop: ["ALL"]      # comença des de zero
    # add: [...]       # i afegeix només l'estrictament imprescindible

drop: ["ALL"] seguit de res és la configuració objectiu de tots els components de Rutas Norte. Cap no necessita ni una sola capacitat.

Detall important: drop: ["ALL"] i add: ["NET_BIND_SERVICE"] junts funcionen i són la forma correcta d'expressar "només aquesta". El drop es processa abans que l'add.

L'exemple de NET_BIND_SERVICE i la seva millor alternativa

Suposem que vols mantenir la imatge nginx oficial escoltant al 80, sense ser root:

# Funciona, però no és la millor opció
securityContext:
  runAsNonRoot: true
  runAsUser: 101
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]     # única forma d'obrir el port 80 sense ser root

Comparem-ho amb l'alternativa que ja hem adoptat:

Amb NET_BIND_SERVICE al 80 Amb port 8080
Capacitats Una Cap
Compleix restricted de PSS (08-03) Sí (és l'única excepció permesa)
Canvis a la imatge Cap Configuració de nginx o imatge unprivileged
El que veu el client Port 80 Port 80 (el tradueix el Service)

El port baix és una restricció d'una era en què els servidors eren màquines físiques compartides. A Kubernetes, on el Service tradueix ports sense cost, mantenir-lo només afegeix una capacitat innecessària. La regla general: si pots evitar una capacitat canviant un número de port, canvia'l.

Veure les capacitats efectives

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

Tots a zero: exactament el que volem. Per traduir un valor no nul a noms:

kubectl exec -n rutas-norte-pro deploy/api-reserves -- \
  sh -c 'capsh --decode=$(grep CapEff /proc/1/status | cut -f2)'
0x0000000000000000=

Si veiessis alguna cosa com cap_chown,cap_dac_override,cap_net_raw,cap_setgid,cap_setuid, és que falta el drop: ["ALL"] en aquell contenidor.

  1. readOnlyRootFilesystem i com fer-lo viable

securityContext:
  readOnlyRootFilesystem: true

El sistema de fitxers arrel del contenidor es munta en només lectura. Els volums muntats explícitament continuen sent escrivibles.

Per què és tan valuós

Sense readOnlyRootFilesystem Amb readOnlyRootFilesystem: true
Es pot escriure un binari a /usr/bin No
Es pot modificar una llibreria carregada No
Es pot sobreescriure un fitxer de configuració No
Un canvi persisteix mentre visqui el contenidor Qualsevol canvi és impossible

Converteix el contenidor en una cosa immutable en temps d'execució: el que es desplega és el que s'executa, de principi a fi. I dona una propietat molt útil per al mòdul 7: si alguna cosa intenta escriure a /, salta un error registrat. És un senyal de detecció gratis, i a 08-06 el convertirem en una regla de Falco.

El problema i la seva solució

Gairebé tot el programari escriu alguna cosa: fitxers temporals, sockets, cachés, PIDs. La solució és muntar emptyDir exactament en aquests llocs.

botiga-web amb nginx és l'exemple canònic, perquè nginx necessita quatre directoris de caché més /tmp i el seu fitxer de PID:

# k8s/base/botiga-web/deployment.yaml (fragment complet)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
  labels:
    app: botiga-web
    app.kubernetes.io/part-of: rutas-norte
spec:
  replicas: 3
  selector:
    matchLabels:
      app: botiga-web
  template:
    metadata:
      labels:
        app: botiga-web
        app.kubernetes.io/part-of: rutas-norte
    spec:
      automountServiceAccountToken: false     # de 03-06: no parla amb l'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      # <-- l'objectiu
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            # Tot el que nginx necessita escriure, en memòria i efímer
            - name: cache-nginx
              mountPath: /var/cache/nginx
            - name: run-nginx
              mountPath: /var/run              # aquí va el fitxer de PID
            - name: temporal
              mountPath: /tmp
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits:   { cpu: 500m, memory: 128Mi }
      volumes:
        - name: cache-nginx
          emptyDir:
            medium: Memory                     # a RAM: més ràpid i no toca el disc
            sizeLimit: 64Mi                    # límit: un emptyDir sense límit pot omplir el node
        - name: run-nginx
          emptyDir:
            medium: Memory
            sizeLimit: 8Mi
        - name: temporal
          emptyDir:
            sizeLimit: 128Mi

Punts a destacar:

  • medium: Memory fa que l'emptyDir visqui a RAM (tmpfs). Per a cachés petites és més ràpid i evita escriure dades al disc del node, cosa que importa si per elles hi passa informació de sessió.
  • sizeLimit és obligatori a la pràctica. Un emptyDir sense límit pot créixer fins a esgotar el disc (o la memòria) del node. Amb medium: Memory compta a més contra el límit de memòria del pod.
  • Els emptyDir es buiden en reiniciar el contenidor, cosa que és just el que volem: res no persisteix.

Com descobrir quins directoris calen

No ho endevinis: mesura-ho. Desplega amb readOnlyRootFilesystem: true a rutas-norte-dev i mira què falla.

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

El log et diu exactament la ruta. Hi afegeixes l'emptyDir, repeteixes, i en dues o tres iteracions tens la llista completa. És un procediment de deu minuts per component que es fa una vegada.

Directoris habituals per tipus d'aplicació:

Aplicació Directoris que sol necessitar
nginx /var/cache/nginx, /var/run, /tmp
Node.js (api-reserves) /tmp (i /home/node/.npm si instal·la en calent, cosa que no hauria de fer)
Java /tmp (la JVM hi escriu els fitxers de rendiment)
Python /tmp; evita els .pyc amb PYTHONDONTWRITEBYTECODE=1
PostgreSQL /var/run/postgresql, /tmp, a més del volum de dades
Redis /data (volum real, no emptyDir)

  1. Seccomp, AppArmor i SELinux

Seccomp: filtrar les crides al sistema

Secure Computing Mode limita quines crides al sistema pot fer el procés. És la defensa més directa contra les fallades del kernel: si una fallada és en una crida que el teu contenidor no pot fer, no t'afecta.

spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
Valor de type Què fa
Unconfined Sense filtre. Totes les crides disponibles
RuntimeDefault El perfil de l'entorn d'execució: bloqueja unes 60 crides perilloses
Localhost Un perfil propi en un fitxer del node (requereix localhostProfile)

Atenció a un detall històric: el valor per defecte de Kubernetes ha estat Unconfined durant anys. Des de 1.27 la porta d'enllaç SeccompDefault del kubelet permet canviar-lo a RuntimeDefault, però cal activar-la explícitament a la configuració del kubelet. Mentre no ho facis, si no poses seccompProfile, el teu contenidor no té cap filtre seccomp. Aquesta és una d'aquelles coses que la gent dona per fetes i no ho és.

RuntimeDefault bloqueja crides com ara mount, reboot, init_module, kexec_load, ptrace en alguns contextos, bpf i pivot_root. És compatible amb pràcticament qualsevol aplicació normal, així que posar-lo és gairebé gratis i cal fer-ho sempre.

Perfils a mida

Quan RuntimeDefault no n'hi ha prou —per exemple, per a un component molt exposat com api-reserves— pots escriure un perfil propi. El fitxer es col·loca a /var/lib/kubelet/seccomp/perfils/ de cada node (normalment amb un DaemonSet o amb 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: perfils/api-reserves.json   # ruta relativa al directori seccomp

Advertiments seriosos sobre els perfils a mida:

  • Són fràgils. Una actualització de la llibreria estàndard o de l'entorn d'execució pot introduir una crida nova i el procés mor amb SIGSYS o un EPERM incomprensible.
  • Cal generar-los observant, no escrivint-los a mà. El Security Profiles Operator pot gravar el perfil d'una càrrega en execució.
  • Comença sempre per RuntimeDefault. El salt a un perfil a mida només compensa en components molt crítics i amb capacitat de mantenir-lo.

Per depurar, es pot fer servir defaultAction: SCMP_ACT_LOG, que registra en lloc de bloquejar. No deixis mai això en producció: no protegeix de res.

AppArmor

Control d'accés obligatori basat en rutes, habitual a Debian i Ubuntu. Des de Kubernetes 1.30 té camp natiu al securityContext, sense necessitat d'anotacions:

spec:
  securityContext:
    appArmorProfile:
      type: RuntimeDefault
  containers:
    - name: app
      securityContext:
        appArmorProfile:
          type: Localhost
          localhostProfile: rutasnorte-api      # perfil carregat al node

Les anotacions container.apparmor.security.beta.kubernetes.io/<contenidor> continuen funcionant per compatibilitat, però estan obsoletes: fes servir el camp.

SELinux

Control d'accés obligatori basat en etiquetes, habitual a Red Hat i derivats. Es configura amb seLinuxOptions:

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

A la pràctica, l'ús més freqüent a Kubernetes és deixar que l'entorn d'execució assigni etiquetes automàticament. Modificar-les a mà sol ser contraproduent llevat que l'organització tingui una política SELinux pròpia.

Comparació

Seccomp AppArmor SELinux
Què controla Crides al sistema Rutes de fitxer i capacitats Etiquetes sobre tot objecte
On és habitual Totes les distribucions Debian, Ubuntu, SUSE RHEL, Fedora, CentOS
Dificultat Baixa amb RuntimeDefault Mitjana Alta
Portabilitat entre clústers Alta Depèn del node Depèn del node
Recomanació RuntimeDefault sempre Si els teus nodes el porten Si la teva organització ja el fa servir

Recomanació per a Rutas Norte: seccompProfile: RuntimeDefault a absolutament tots els pods, i AppArmor/SELinux segons el que portin els nodes, sense dependre'n per a la seguretat essencial (perquè el clúster local és minikube i el de producció podria no coincidir).

  1. Els ajustos del pod que cal evitar

Hi ha cinc ajustos que trenquen l'aïllament del pod respecte al node. Els hem anat esmentant a 05-01 i 06-02; aquí els reunim.

Ajust Què trenca Risc concret a Rutas Norte
hostNetwork: true El namespace de xarxa El pod veu tot el trànsit del node i evita les NetworkPolicies de 04-06
hostPID: true El namespace PID Veu els processos dels altres pods, amb les seves línies d'ordre i la seva memòria
hostIPC: true El namespace IPC Accedeix a la memòria compartida d'altres processos del node
hostPath El namespace de muntatge Llegeix o escriu el disc del node, inclosos secrets muntats d'altres pods
hostPort L'assignació de ports Obre un port a la IP del node, saltant-se el Service i l'Ingress

hostNetwork i la seva interacció amb les NetworkPolicies

Aquest mereix un paràgraf a part perquè desmunta feina d'un mòdul anterior. A 04-06 vam construir una política deny-all a rutas-norte-pro i vam anar autoritzant cada conversa una a una. Un pod amb hostNetwork: true fa servir la pila de xarxa del node, no la del pod, així que les NetworkPolicies —que actuen sobre les IP de pod— no se li apliquen. Tota aquella feina queda anul·lada per a aquell pod.

És la raó per la qual un DaemonSet amb hostNetwork no ha de compartir namespace amb les aplicacions i per la qual els seus manifests mereixen una revisió especialment acurada.

hostPath: l'advertiment de 05-01, ampliat

# MAI en producció per a una aplicació
volumes:
  - name: perillos
    hostPath:
      path: /            # el disc del node sencer
      type: Directory

Un hostPath a / dona accés a:

  • /var/lib/kubelet/pods/*/volumes/kubernetes.io~projected/els tokens de ServiceAccount de tots els pods del node.
  • /etc/kubernetes/ — en un node de pla de control, les claus del clúster.
  • /var/lib/docker o /var/lib/containerd — totes les imatges i capes.
  • /var/run/containerd/containerd.sock — el socket de l'entorn d'execució, que permet arrencar contenidors privilegiats.

Aquest últim punt és clau i sovint es passa per alt: muntar el socket de l'entorn d'execució equival a privileged: true, encara que el pod no ho declari. Qualsevol manifest que munti /var/run/docker.sock o containerd.sock s'ha de tractar com un pod privilegiat.

Els usos legítims de hostPath són d'infraestructura (llegir /var/log per al recol·lector, exposar dispositius a un driver CSI) i sempre han de ser:

  1. La ruta més específica possible, mai / ni /var.
  2. Amb readOnly: true sempre que sigui possible.
  3. Amb type: explícit (Directory, File, Socket) perquè no es creï per accident.
  4. En un namespace a part amb perfil de seguretat relaxat (08-03).

hostPort

ports:
  - containerPort: 8080
    hostPort: 8080      # evita'l

Obre el port a la IP del node. Problemes: només hi cap un pod per node amb aquell port, se salta el Service i l'Ingress (i per tant el TLS de 04-05 i el WAF de 08-04), i exposa el servei a qualsevol que arribi a la IP del node. Per publicar serveis es fa servir Ingress, com al mòdul 4.

  1. RuntimeClass i els entorns d'execució aïllats

Tot l'anterior endureix un contenidor, però no canvia el fet fonamental: el kernel és compartit. Si necessites aïllament fort, cal una altra cosa.

Un RuntimeClass selecciona quin entorn d'execució fa servir el pod:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc          # configurat a containerd al node
apiVersion: v1
kind: Pod
metadata:
  name: carrega-aillada
spec:
  runtimeClassName: gvisor      # aquest pod fa servir gVisor en lloc de runc
  containers:
    - name: app
      image: registry.rutasnorte.example/processador-tercers:1.2.0

Les opcions

Entorn Com aïlla Cost en arrencada Cost en E/S Compatibilitat
runc (per defecte) Namespaces + cgroups Mínim Mínim Total
gVisor (runsc) Kernel d'usuari que intercepta les crides +50-100 ms Notable en E/S intensiva Alta, amb excepcions
Kata Containers Màquina virtual lleugera per pod +200-500 ms Baix amb virtio Molt alta
Firecracker Micro-VM (base de Kata i de diversos serveis al núvol) +125 ms Baix Alta

Quan compensa

Càrrega Aïllament reforçat?
botiga-web, api-reserves, worker-notificacions No. Codi propi i revisat; l'enduriment normal n'hi ha prou
postgres-reserves No. El cost d'E/S seria inacceptable; es protegeix amb RBAC, xarxa i xifratge
Executar codi enviat per tercers Sí, imprescindible
Multiinquilí amb clients que no es coneixen entre ells
Analitzar un fitxer d'un client amb una llibreria complexa Probablement sí

Rutas Norte no té avui cap càrrega que ho justifiqui. Si demà permetés a les empreses d'autobusos pujar un fitxer d'horaris i el processés amb una llibreria d'anàlisi complexa, aquell component seria el candidat natural: s'executa codi sobre dades que vénen de fora.

L'important és conèixer l'eina i el seu criteri: l'aïllament reforçat és per a codi en què no confies, no per al teu.

  1. L'enduriment complet de Rutas Norte

Reunim tot en la configuració final de cada component, amb la justificació de cada excepció.

Taula resum

Component UID RootFS RO Caps Seccomp Excepcions
botiga-web 101 drop: ALL RuntimeDefault 3 emptyDir
api-reserves 10001 drop: ALL RuntimeDefault emptyDir a /tmp
postgres-reserves 999 No drop: ALL RuntimeDefault RootFS escrivible (documentat)
redis-cache 999 drop: ALL RuntimeDefault Volum a /data
worker-notificacions 10002 drop: ALL RuntimeDefault emptyDir a /tmp
informes-ocupacio 10003 drop: ALL RuntimeDefault emptyDir per a l'informe
Ambaixador de pagaments 10004 drop: ALL RuntimeDefault Cap
Recol·lector de logs 0 drop: ALL + DAC_READ_SEARCH RuntimeDefault hostPath en només lectura

api-reserves: el cas complet

# k8s/base/api-reserves/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  labels:
    app: api-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reserves
  template:
    metadata:
      labels:
        app: api-reserves
        app.kubernetes.io/part-of: rutas-norte
        entorn: pro
    spec:
      serviceAccountName: api-reserves
      automountServiceAccountToken: true    # sí que llegeix un ConfigMap per API (veure 08-01)
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
          ports:
            - name: http
              containerPort: 8080
            - name: metriques
              containerPort: 9090
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          # Sondes del mòdul 7, sense canvis
          livenessProbe:
            httpGet: { path: /salut, port: http }
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /preparat, port: http }
            periodSeconds: 5
          volumeMounts:
            - name: temporal
              mountPath: /tmp
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { cpu: "1",  memory: 512Mi }
        # Contenidor ambaixador cap a la passarel·la de pagaments externa (06-04)
        - name: ambaixador-pagaments
          image: registry.rutasnorte.example/ambaixador-pagaments: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

Fixa't en la imatge referenciada per digest en lloc de per etiqueta. És el tema de 08-05 i encaixa aquí de forma natural: de res no serveix endurir un contenidor si no saps amb certesa quina imatge s'està executant.

worker-notificacions amb el seu adaptador de logs

# k8s/base/worker-notificacions/deployment.yaml (fragment)
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-notificacions:3.2.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - name: temporal
              mountPath: /tmp
            - name: logs-compartits
              mountPath: /var/log/worker
        # Adaptador que converteix els logs a JSON (06-04 i 07-05)
        - name: adaptador-logs
          image: registry.rutasnorte.example/adaptador-logs:1.1.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 10002        # mateix UID: comparteix el volum de logs
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - name: logs-compartits
              mountPath: /var/log/worker
              readOnly: true        # només llegeix: no té per què escriure
      volumes:
        - name: temporal
          emptyDir: { sizeLimit: 64Mi }
        - name: logs-compartits
          emptyDir: { sizeLimit: 256Mi }

Dos detalls fins: l'adaptador fa servir el mateix UID que el worker per poder llegir els fitxers que aquest escriu (alternativa: fsGroup compartit), i munta el volum amb readOnly: true perquè només llegeix. Endurir un sidecar és tan important com endurir el contenidor principal: comparteixen el namespace de xarxa i sovint volums.

El CronJob informes-ocupacio

# k8s/base/informes-ocupacio/cronjob.yaml (fragment)
apiVersion: batch/v1
kind: CronJob
metadata:
  name: informes-ocupacio
  namespace: rutas-norte-pro
spec:
  schedule: "0 3 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          serviceAccountName: informes-ocupacio
          automountServiceAccountToken: false     # no parla amb l'API (08-01)
          securityContext:
            runAsNonRoot: true
            runAsUser: 10003
            runAsGroup: 10003
            fsGroup: 10003
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: informes
              image: registry.rutasnorte.example/informes-ocupacio:1.5.0
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities: { drop: ["ALL"] }
              volumeMounts:
                - name: treball
                  mountPath: /treball
          volumes:
            - name: treball
              emptyDir: { sizeLimit: 512Mi }

Aquest component és especialment sensible perquè llegeix dades personals per agregar-les. Recorda el que vam dir a 07-05: els informes només contenen agregats (ocupació per línia i per franja horària), mai registres individuals, i res d'això no va als logs.

Un fragment reutilitzable

Com que aquests blocs es repeteixen, el raonable és extreure'ls. Amb Kustomize (mòdul 10) s'aplica un pedaç estratègic a tots els Deployments:

# k8s/base/enduriment-comu.yaml
# Bloc de referència. Copia'l o aplica'l com a pedaç.
podSecurityContext: &pod
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault

containerSecurityContext: &contenidor
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

I a 08-03 farem el pas definitiu: fer que el clúster rebutgi qualsevol pod que no compleixi aquestes regles, perquè cap desplegament futur no se les pugui saltar per oblit.

  1. Com comprovar el resultat des de dins del contenidor

Un enduriment sense verificar és una suposició. Aquestes comprovacions triguen un minut i s'han de fer després de cada canvi.

Qui soc

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

Si veiessis uid=0(root), el runAsUser no s'està aplicant (comprova que no el sobreescrigui el securityContext del contenidor).

El sistema de fitxers arrel és de només lectura

kubectl exec -n rutas-norte-pro deploy/api-reserves -c api -- \
  sh -c 'touch /prova-escriptura 2>&1 || echo "correcte: bloquejat"'
touch: /prova-escriptura: Read-only file system
correcte: bloquejat

I que els emptyDir sí que són escrivibles:

kubectl exec -n rutas-norte-pro deploy/api-reserves -c api -- \
  sh -c 'touch /tmp/ok && echo "correcte: /tmp escrivible" && rm /tmp/ok'
correcte: /tmp escrivible

Capacitats efectives

kubectl exec -n rutas-norte-pro deploy/api-reserves -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. És la comprovació més directa de les dues línies més importants del manifest.

Seccomp està actiu

kubectl exec -n rutas-norte-pro deploy/api-reserves -c api -- grep Seccomp /proc/1/status
Seccomp:	2
Seccomp_filters:	1
Valor de Seccomp Significat
0 Desactivat: el perfil no s'està aplicant
1 Mode estricte (rar)
2 Mode filtre: RuntimeDefault o Localhost actiu

Si veus 0 quan esperaves RuntimeDefault, revisa que el camp estigui al nivell correcte i que el node ho admeti.

L'aïllament de namespaces funciona

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

Només els processos del contenidor. Si veiessis processos del node (kubelet, containerd, processos d'altres pods), és que hostPID: true està actiu.

Un guió de verificació complet

#!/usr/bin/env bash
# k8s/seguretat/verificar-enduriment.sh
# Comprova l'enduriment efectiu d'un contenidor en execució.
set -uo pipefail

NS="${1:-rutas-norte-pro}"
CARREGA="${2:-deploy/api-reserves}"
CONTENIDOR="${3:-api}"

executa() { kubectl exec -n "$NS" "$CARREGA" -c "$CONTENIDOR" -- "$@" 2>/dev/null; }

echo "=== $NS / $CARREGA / $CONTENIDOR ==="

echo -n "Usuari:         "; executa id

echo -n "RootFS RO:      "
if executa sh -c 'touch /.prova' >/dev/null 2>&1; then
  echo "NO (escrivible) <-- REVISAR"; executa rm -f /.prova
else
  echo "sí"
fi

echo -n "Capacitats:     "
caps=$(executa grep CapEff /proc/1/status | awk '{print $2}')
[[ "$caps" == "0000000000000000" ]] && echo "cap (correcte)" || echo "$caps <-- REVISAR"

echo -n "NoNewPrivs:     "
nnp=$(executa grep NoNewPrivs /proc/1/status | awk '{print $2}')
[[ "$nnp" == "1" ]] && echo "1 (correcte)" || echo "$nnp <-- REVISAR"

echo -n "Seccomp:        "
sec=$(executa grep '^Seccomp:' /proc/1/status | awk '{print $2}')
[[ "$sec" == "2" ]] && echo "filtre actiu (correcte)" || echo "$sec <-- REVISAR"

echo -n "Processos vistos: "; executa sh -c 'ps aux | wc -l'
=== rutas-norte-pro / deploy/api-reserves / api ===
Usuari:         uid=10001 gid=10001 groups=10001
RootFS RO:      sí
Capacitats:     cap (correcte)
NoNewPrivs:     1 (correcte)
Seccomp:        filtre actiu (correcte)
Processos vistos: 3

Nota pràctica: aquest guió necessita pods/exec, un permís que a 08-01 no vam donar a suport ni a desenvolupament. És una tasca de plataforma, i el lògic és integrar-la com a comprovació automàtica a rutas-norte-pre abans de promocionar a producció.

Errors Comuns i Consells

Posar capabilities o readOnlyRootFilesystem al securityContext del pod. No existeixen a aquest nivell. El manifest es rebutja o el camp s'ignora, segons l'eina. Van a cada contenidor.

Oblidar els initContainers i els sidecars. Un pod amb el contenidor principal perfectament endurit i un initContainer privilegiat és un pod privilegiat. Repassa 06-04.

Fer servir runAsNonRoot: true amb una imatge que declara USER per nom. El kubelet no ho pot verificar i el pod no arrenca. Fes servir sempre UID numèric al Dockerfile (08-05) i runAsUser al manifest.

Rendir-se davant de readOnlyRootFilesystem i treure'l. El log et diu la ruta exacta que falla. Dos o tres emptyDir resolen gairebé tots els casos.

emptyDir sense sizeLimit. Pot omplir el disc o la memòria del node i provocar el desallotjament d'altres pods. Amb medium: Memory, a més, compta contra el límit de memòria del pod.

Donar per fet que seccomp està actiu. El valor per defecte ha estat Unconfined durant anys. Si no poses seccompProfile: RuntimeDefault, probablement no hi ha filtre. Comprova-ho amb grep Seccomp /proc/1/status.

Escriure un perfil seccomp a mida a mà. És fràgil i es trenca amb cada actualització de la imatge. Comença per RuntimeDefault; si necessites més, genera el perfil observant la càrrega.

Muntar el socket de l'entorn d'execució. /var/run/docker.sock o containerd.sock equivalen a privileged: true encara que el manifest no ho digui.

Acceptar privileged: true en un manifest d'un tercer sense preguntar. Moltes vegades és comoditat, no necessitat. Demana la justificació.

Oblidar fsGroupChangePolicy: OnRootMismatch en volums grans. El pod triga minuts a arrencar en cada reinici i sembla una caiguda.

Creure que runAsNonRoot canvia l'usuari. No el canvia: el verifica. Sense runAsUser ni un USER numèric a la imatge, no arrenca.

Suposar que un contenidor aïlla com una VM. Comparteix kernel. És la premissa de tota la lliçó i la raó que els nodes hagin d'estar apedaçats.

Consell d'or: l'objectiu per a tota aplicació és aquest bloc exacte, i qualsevol desviació ha d'estar comentada al mateix YAML explicant per què:

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault

Exercicis

Exercici 1: endurir redis-cache

redis-cache és un StatefulSet d'una rèplica amb la imatge redis:7.4-alpine, que corre com a root i munta un PVC a /data. Escriu el fragment de securityContext complet (nivell de pod i de contenidor) sabent que:

  • La imatge oficial de Redis defineix l'usuari redis amb UID i GID 999.
  • Redis escriu el seu fitxer de dades a /data (el PVC) i el seu fitxer de PID a /var/run/redis.
  • No necessita cap capacitat de Linux.
  • El volum és de 8 GiB.

Indica què es trencaria si ometessis fsGroup i com ho detectaries.

Exercici 2: diagnosticar un pod que no arrenca

Després d'endurir worker-notificacions, el pod queda en CrashLoopBackOff:

NAME                                     READY   STATUS             RESTARTS   AGE
worker-notificacions-7d9c8b6f5-x2k4m     0/2     CrashLoopBackOff   4          2m
kubectl logs -n rutas-norte-pro worker-notificacions-7d9c8b6f5-x2k4m -c worker
Error: EACCES: permission denied, open '/app/cua/pendents.dat'
    at Object.openSync (node:fs:596:3)
    at guardarPendent (/app/lib/cua.js:44:18)

El securityContext aplicat és:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10002
    seccompProfile: { type: RuntimeDefault }
  containers:
    - name: worker
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }
  1. Quina és la causa exacta?
  2. Proposa dues solucions i raona quina és millor.
  3. Escriu l'ordre que confirmaria el diagnòstic.

Exercici 3: auditar els pods sense endurir del clúster

Escriu una ordre que llisti tots els pods de rutas-norte-pro que incompleixin alguna d'aquestes condicions, indicant quina:

  • readOnlyRootFilesystem: true a tots els seus contenidors.
  • allowPrivilegeEscalation: false a tots els seus contenidors.
  • capabilities.drop inclou ALL a tots els seus contenidors.
  • No fan servir hostNetwork, hostPID ni hostIPC.
  • Cap contenidor no és privileged.

Ha de recórrer també els initContainers.

Solucions

Solució 1

# k8s/base/redis-cache/statefulset.yaml (fragment)
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                        # fa escrivible el PVC de /data
        fsGroupChangePolicy: OnRootMismatch # 8 GiB: evita recorreguts lents
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: redis
          image: registry.rutasnorte.example/redis:7.4-alpine
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: dades
              mountPath: /data              # PVC: escrivible via fsGroup
            - name: run
              mountPath: /var/run/redis     # emptyDir: el fitxer 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: dades
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-ssd
        resources:
          requests:
            storage: 8Gi

Sense fsGroup: el PVC es munta amb propietari root:root i mode 0755. Redis, corrent com a UID 999, no pot escriure a /data. El pod arrenca i mor tan bon punt intenta persistir:

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

Detecció:

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

Amb fsGroup: 999 aplicat:

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

El grup passa a 999, apareix el bit d'escriptura de grup i la s indica el setgid al directori.

Nota addicional: com que és una caché, la pèrdua del volum no és crítica. Però sí que ho és que no arrenqui, perquè api-reserves respondria més lentament i podria degradar-se la disponibilitat de places.

Solució 2

1. Causa. readOnlyRootFilesystem: true impedeix escriure a qualsevol lloc del sistema de fitxers arrel que no sigui un volum muntat. El worker intenta escriure a /app/cua/pendents.dat, que és dins de la imatge i per tant al rootfs de només lectura. El missatge EACCES en un openSync d'escriptura és la signatura típica d'aquest problema. No és un problema d'UID: encara que corregués com a root, / continuaria sent de només lectura.

2. Dues solucions:

Opció A — muntar un emptyDir a /app/cua:

      containers:
        - name: worker
          volumeMounts:
            - name: cua
              mountPath: /app/cua
      volumes:
        - name: cua
          emptyDir: { sizeLimit: 256Mi }

Opció B — canviar la configuració del worker perquè faci servir /tmp, ja muntat:

          env:
            - name: RUTA_CUA
              value: /tmp/cua

Quina és millor: depèn de si aquestes dades han de sobreviure. I aquí hi ha una consideració de negoci que va més enllà del YAML: /app/cua/pendents.dat guarda notificacions pendents d'enviar. Amb qualsevol de les dues opcions, aquestes dades es perden en reiniciar el contenidor, perquè emptyDir és efímer. Si un client compra un bitllet i el worker es reinicia abans d'enviar el correu, aquell client no rep la seva confirmació.

La solució correcta, per tant, és la C: worker-notificacions no hauria de tenir cua local. La cua ha d'estar en un magatzem compartit i durador —Redis amb persistència, o la mateixa base de dades— de manera que qualsevol rèplica pugui reprendre la feina. Això a més arregla un problema que existia abans d'endurir res: amb diverses rèpliques, cadascuna tenia la seva pròpia cua local invisible per a les altres.

Com a solució immediata per desbloquejar, l'A amb emptyDir és preferible a la B, perquè deixa el problema visible al manifest (volumes: cua) en lloc d'amagar-lo en una variable d'entorn. I cal obrir la tasca per a la C.

Aquest exercici il·lustra una cosa important: endurir un contenidor sovint destapa fallades de disseny preexistents. El readOnlyRootFilesystem no ha creat el problema; l'ha fet visible.

3. Confirmació del diagnòstic:

# Arrencar temporalment sense readOnlyRootFilesystem a rutas-norte-dev i comprovar
kubectl exec -n rutas-norte-dev deploy/worker-notificacions -c worker -- \
  sh -c 'ls -ld /app/cua; touch /app/cua/prova && echo ESCRIVIBLE || echo BLOQUEJAT'
drwxr-xr-x 2 10002 10002 4096 Aug  6 02:58 /app/cua
BLOQUEJAT

Els permisos del directori són correctes (propietari 10002, el mateix UID del procés), i tot i així falla: això descarta un problema de permisos i confirma que la causa és el muntatge de només lectura.

Solució 3

#!/usr/bin/env bash
# k8s/seguretat/auditar-enduriment.sh
# Llista els pods que incompleixen la línia base d'enduriment.
set -euo pipefail
NS="${1:-rutas-norte-pro}"

kubectl get pods -n "$NS" -o json | jq -r '
  .items[] as $pod
  | ($pod.metadata.name) as $nom
  | [
      # Ajustos a nivell 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),

      # Ajustos per contenidor, inclosos els 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-escrivible:\($c.name)" else empty end),
            (if ($c.securityContext.allowPrivilegeEscalation // true) != false
               then "escalada-permesa:\($c.name)" else empty end),
            (if (($c.securityContext.capabilities.drop // []) | index("ALL")) == null
               then "sense-drop-ALL:\($c.name)" else empty end)
          )
      )
    ] as $problemes
  | if ($problemes | length) > 0
      then "\($nom)\n    " + ($problemes | join("\n    "))
      else empty
    end'
bash k8s/seguretat/auditar-enduriment.sh rutas-norte-pro
postgres-reserves-0
    rootfs-escrivible:postgres
fluent-bit-collector-nx7q2
    hostPath

Interpretació d'aquesta sortida concreta:

  • postgres-reserves: és l'excepció documentada de l'apartat 12. Està justificada, encara que la tasca de resoldre-la amb emptyDir continua oberta.
  • fluent-bit-collector: DaemonSet de logs amb hostPath en només lectura. Legítim i ja reduït al mínim (sense privileged), però a 08-03 el mourem a un namespace a part perquè no relaxi el perfil de tot rutas-norte-pro.

Que cap altre pod no aparegui és l'evidència que l'enduriment està aplicat. Aquest guió s'hauria d'executar a la canalització sobre rutas-norte-pre abans de cada promoció a producció, amb una llista d'excepcions conegudes perquè només alerti de les noves.

Nota sobre // true i // false al jq: els valors per defecte estan escrits en la direcció insegura a propòsit. allowPrivilegeEscalation absent equival a permès, així que el seu defecte és true; readOnlyRootFilesystem absent equival a escrivible, així que el seu defecte és false. Una auditoria ha d'assumir el pitjor quan el camp no hi és.

Conclusió

Hem afegit la segona capa de defensa. Repassant l'essencial:

  • Un contenidor no és una màquina virtual. Comparteix kernel amb el node, i l'aïllament el donen els namespaces, els cgroups i els límits del que pot demanar al kernel. Per això apedaçar els nodes és una tasca de seguretat de primer nivell.
  • El securityContext viu a dos nivells: identitat i volums al pod; capacitats, escalada i sistema de fitxers al contenidor. El del contenidor guanya, i capabilities, privileged, readOnlyRootFilesystem i allowPrivilegeEscalation cal repetir-los a cada contenidor, inclosos initContainers i sidecars.
  • No córrer com a root (runAsNonRoot + runAsUser numèric), i resoldre els permisos de volum amb fsGroup i fsGroupChangePolicy: OnRootMismatch quan el volum és gran, com a postgres-reserves.
  • allowPrivilegeEscalation: false activa no_new_privs i neutralitza els binaris setuid. Costa una línia.
  • privileged: true equival a lliurar el node, i muntar el socket de l'entorn d'execució també, encara que no ho declari.
  • capabilities: drop: ["ALL"] i afegir només l'imprescindible; i moltes vegades es pot evitar fins i tot NET_BIND_SERVICE canviant a un port alt.
  • readOnlyRootFilesystem: true fa el contenidor immutable en execució, i esdevé viable amb uns quants emptyDir amb sizeLimit.
  • seccompProfile: RuntimeDefault sempre; no donis per fet que ja està actiu.
  • Evitar hostNetwork, hostPID, hostIPC, hostPath i hostPort; recorda que hostNetwork anul·la les NetworkPolicies del mòdul 4.
  • Per a codi en què no confies, RuntimeClass amb gVisor o Kata; per al teu, no compensa.
  • I comprovar-ho tot des de dins: id, l'escriptura fallida, CapEff, NoNewPrivs i Seccomp a /proc/1/status.

Ara tots els components de Rutas Norte estan endurits. Però fixa't en la naturalesa del que hem fet: hem escrit un YAML correcte. Res no impedeix que demà algú desplegui un pod nou sense securityContext, o que copiï un manifest d'internet amb privileged: true, o que un company afegeixi un sidecar i oblidi el drop: ["ALL"]. Tot l'enduriment d'aquesta lliçó depèn que cada persona se'n recordi, cada vegada. I això no és una garantia: és una esperança.

La lliçó següent, 08-03, Polítiques de Seguretat de Pods i Pod Security Standards, fa el pas definitiu: passar de "cada equip posa bé el seu securityContext" a "el clúster no accepta un pod que no el tingui". Veurem els Pod Security Standards, el Pod Security Admission integrat a l'apiserver, i els motors de política com Kyverno que permeten exigir qualsevol regla que se t'acudeixi, amb el missatge de rebuig exacte que retorna l'API quan un pod no compleix.

Curs de Kubernetes

Mòdul 1: Introducció a Kubernetes

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats