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
- Què aïlla realment un contenidor
- El
securityContext: nivell de pod i nivell de contenidor - Executar sense root:
runAsNonRoot,runAsUserirunAsGroup - Volums i permisos de fitxer:
fsGroupifsGroupChangePolicy allowPrivilegeEscalationi el bitno_new_privsprivileged: truei per què equival a lliurar el node- Capacitats de Linux: treure-ho tot i afegir el just
readOnlyRootFilesystemi com fer-lo viable- Seccomp, AppArmor i SELinux
- Els ajustos del pod que cal evitar
RuntimeClassi els entorns d'execució aïllats- L'enduriment complet de Rutas Norte
- Com comprovar el resultat des de dins del contenidor
- Errors comuns i consells
- Exercicis
- Conclusió
- 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.
- El
securityContext: nivell de pod i nivell de contenidor
securityContext: nivell de pod i nivell de contenidorsecurityContext 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 |
Sí | Sí | UID amb què corre el procés |
runAsGroup |
Sí | Sí | GID primari |
runAsNonRoot |
Sí | Sí | El kubelet rebutja arrencar si l'UID és 0 |
supplementalGroups |
Sí | No | GIDs addicionals |
fsGroup |
Sí | No | GID aplicat als volums muntats |
fsGroupChangePolicy |
Sí | No | Quan es recalculen els permisos del volum |
seccompProfile |
Sí | Sí | Filtre de crides al sistema |
seLinuxOptions |
Sí | Sí | Etiquetes SELinux |
sysctls |
Sí | No | Paràmetres del kernel del pod |
appArmorProfile |
Sí | Sí | Perfil AppArmor (camp natiu des de 1.30) |
allowPrivilegeEscalation |
No | Sí | Bloqueja no_new_privs |
capabilities |
No | Sí | Afegir i treure capacitats |
privileged |
No | Sí | Desactiva gairebé tot l'aïllament |
readOnlyRootFilesystem |
No | Sí | Munta / en només lectura |
procMount |
No | Sí | 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 sobreescriuUn 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.
- Executar sense root:
runAsNonRoot, runAsUser i runAsGroup
runAsNonRoot, runAsUser i runAsGroupPer 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-rootD'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:
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 capacitatI 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 privilegisNingú fora del clúster nota la diferència, i hem eliminat una capacitat i l'arrencada com a root.
- Volums i permisos de fitxer:
fsGroup i fsGroupChangePolicy
fsGroup i fsGroupChangePolicyAquí 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.
La solució: fsGroup
Quan fsGroup hi és present, el kubelet, abans d'arrencar els contenidors:
- Canvia el grup propietari dels fitxers i directoris del volum a aquell GID.
- Afegeix el bit d'escriptura per al grup.
- Posa el bit
setgidals directoris, perquè el que sigui nou hereti el grup. - 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: 50GiQuatre decisions per justificar:
runAsUser: 999: l'UID que la imatge ja espera. Posar-ne un altre obligaria a modificar la imatge.fsGroupChangePolicy: OnRootMismatch: sense ell, amb 50 GiB de dades personals, cada reinici del pod trigaria minuts i apareixeria com una caiguda del servei.readOnlyRootFilesystem: falsea PostgreSQL: és una excepció conscient i documentada. PostgreSQL escriu fitxers de socket i temporals fora del volum de dades. Es podria arreglar ambemptyDira/var/run/postgresqli/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.- 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í.
allowPrivilegeEscalation i el bit no_new_privs
allowPrivilegeEscalation i el bit no_new_privsAquesta 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_privsactiu, cap procés fill pot obtenir més privilegis que el seu pare, ni tan sols executant un binari amb el bitsetuidposat 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:
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.
privileged: true i per què equival a lliurar el node
privileged: true i per què equival a lliurar el nodeQuan 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: DirectoryContinua 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.
- 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 imprescindibledrop: ["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 rootComparem-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) | Sí |
| 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
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000
CapAmb: 0000000000000000Tots 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)'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.
readOnlyRootFilesystem i com fer-lo viable
readOnlyRootFilesystem i com fer-lo viableEl 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: 128MiPunts a destacar:
medium: Memoryfa que l'emptyDirvisqui 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. UnemptyDirsense límit pot créixer fins a esgotar el disc (o la memòria) del node. Ambmedium: Memorycompta a més contra el límit de memòria del pod.- Els
emptyDires 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.
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) |
- 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.
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 seccompAdvertiments 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
SIGSYSo unEPERMincomprensible. - 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 nodeLes 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:
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).
- 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: DirectoryUn 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/dockero/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:
- La ruta més específica possible, mai
/ni/var. - Amb
readOnly: truesempre que sigui possible. - Amb
type:explícit (Directory,File,Socket) perquè no es creï per accident. - En un namespace a part amb perfil de seguretat relaxat (08-03).
hostPort
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.
RuntimeClass i els entorns d'execució aïllats
RuntimeClass i els entorns d'execució aïllatsTot 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 nodeapiVersion: 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.0Les 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 | Sí |
| 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.
- 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 | Sí | drop: ALL |
RuntimeDefault |
3 emptyDir |
api-reserves |
10001 | Sí | drop: ALL |
RuntimeDefault |
emptyDir a /tmp |
postgres-reserves |
999 | No | drop: ALL |
RuntimeDefault |
RootFS escrivible (documentat) |
redis-cache |
999 | Sí | drop: ALL |
RuntimeDefault |
Volum a /data |
worker-notificacions |
10002 | Sí | drop: ALL |
RuntimeDefault |
emptyDir a /tmp |
informes-ocupacio |
10003 | Sí | drop: ALL |
RuntimeDefault |
emptyDir per a l'informe |
| Ambaixador de pagaments | 10004 | Sí | drop: ALL |
RuntimeDefault |
Cap |
| Recol·lector de logs | 0 | Sí | 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: 64MiFixa'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.
- 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
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"'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'Capacitats efectives
kubectl exec -n rutas-norte-pro deploy/api-reserves -c api -- \
grep -E 'CapEff|CapBnd|NoNewPrivs' /proc/1/statusCapEff: 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
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
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: 3Nota 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: RuntimeDefaultExercicis
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
redisamb 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:
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"] }- Quina és la causa exacta?
- Proposa dues solucions i raona quina és millor.
- 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: truea tots els seus contenidors.allowPrivilegeEscalation: falsea tots els seus contenidors.capabilities.dropinclouALLa tots els seus contenidors.- No fan servir
hostNetwork,hostPIDnihostIPC. - 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: 8GiSense 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:
Detecció:
kubectl logs -n rutas-norte-pro redis-cache-0
kubectl exec -n rutas-norte-pro redis-cache-0 -- ls -ld /dataAmb fsGroup: 999 aplicat:
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:
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'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'Interpretació d'aquesta sortida concreta:
postgres-reserves: és l'excepció documentada de l'apartat 12. Està justificada, encara que la tasca de resoldre-la ambemptyDircontinua oberta.fluent-bit-collector: DaemonSet de logs ambhostPathen només lectura. Legítim i ja reduït al mínim (senseprivileged), però a 08-03 el mourem a un namespace a part perquè no relaxi el perfil de totrutas-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
securityContextviu a dos nivells: identitat i volums al pod; capacitats, escalada i sistema de fitxers al contenidor. El del contenidor guanya, icapabilities,privileged,readOnlyRootFilesystemiallowPrivilegeEscalationcal repetir-los a cada contenidor, inclosos initContainers i sidecars. - No córrer com a root (
runAsNonRoot+runAsUsernumèric), i resoldre els permisos de volum ambfsGroupifsGroupChangePolicy: OnRootMismatchquan el volum és gran, com apostgres-reserves. allowPrivilegeEscalation: falseactivano_new_privsi neutralitza els binaris setuid. Costa una línia.privileged: trueequival 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 totNET_BIND_SERVICEcanviant a un port alt.readOnlyRootFilesystem: truefa el contenidor immutable en execució, i esdevé viable amb uns quantsemptyDirambsizeLimit.seccompProfile: RuntimeDefaultsempre; no donis per fet que ja està actiu.- Evitar
hostNetwork,hostPID,hostIPC,hostPathihostPort; recorda quehostNetworkanul·la les NetworkPolicies del mòdul 4. - Per a codi en què no confies,
RuntimeClassamb gVisor o Kata; per al teu, no compensa. - I comprovar-ho tot des de dins:
id, l'escriptura fallida,CapEff,NoNewPrivsiSeccompa/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
- Què és Kubernetes?
- Arquitectura de Kubernetes
- Conceptes i Terminologia Clau
- Configuració d'un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objectes, Manifests YAML i el Model Declaratiu
- El Projecte del Curs: la Plataforma Rutas Norte
Mòdul 2: Components Principals de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualitzacions, Rollbacks i Estratègies de Desplegament
- Serveis
- Namespaces
- Etiquetes, Selectors i Anotacions
Mòdul 3: Gestió de Configuració i Secrets
- ConfigMaps
- Secrets
- Variables d'Entorn
- Quotes i Límits de Recursos
- LimitRanges i Classes de Qualitat de Servei (QoS)
- ServiceAccounts i Accés a l'API des dels Pods
Mòdul 4: Xarxes a Kubernetes
- Xarxes de Clúster
- Tipus de Serveis
- DNS Intern i Descobriment de Serveis
- Controladors d'Ingress
- TLS i Gestió de Certificats amb cert-manager
- Polítiques de Xarxa
Mòdul 5: Emmagatzematge a Kubernetes
- Volums
- Volums Persistents
- Reclamacions de Volums Persistents
- Classes d'Emmagatzematge
- Aprovisionament Dinàmic, Expansió i Snapshots
- Còpies de Seguretat i Restauració de Dades
Mòdul 6: Conceptes Avançats de Kubernetes
- StatefulSets
- DaemonSets
- Treballs i CronJobs
- Init Containers, Sidecars i Patrons Multicontenidor
- Planificació: Afinitat, Taints i Toleracions
- Definicions de Recursos Personalitzats (CRDs)
- Operadors i el Patró Controlador
Mòdul 7: Monitoratge i Registre
- Verificacions de Salut i Sondes
- Servidor de Mètriques i kubectl top
- Monitoratge amb Prometheus
- Visualització i Alertes amb Grafana i Alertmanager
- Registre Centralitzat amb Elasticsearch, Fluentd i Kibana (EFK)
- Depuració d'Aplicacions i Esdeveniments del Clúster
Mòdul 8: Seguretat a Kubernetes
- Control d'Accés Basat en Rols (RBAC)
- Contextos de Seguretat i Enduriment del Contenidor
- Polítiques de Seguretat de Pods i Pod Security Standards
- Seguretat de Xarxa
- Seguretat d'Imatges
- Auditoria, Escaneig i Gestió de Vulnerabilitats
Mòdul 9: Escalat i Rendiment
- Autoescalat Horitzontal de Pods
- Autoescalat Vertical de Pods
- Autoescalat de Clúster
- Escalat per Esdeveniments i Mètriques Personalitzades amb KEDA
- Alta Disponibilitat: PodDisruptionBudgets i Topologia
- Ajust de Rendiment
Mòdul 10: Ecosistema i Eines de Kubernetes
- Minikube i Entorns Locals amb kind
- Kubeadm
- Helm
- Kustomize
- GitOps amb Argo CD i Flux
- Kubernetes Gestionat: EKS, AKS i GKE
Mòdul 11: Estudis de Cas i Aplicacions del Món Real
- Desplegament d'una Aplicació Web
- Execució d'Aplicacions amb Estat
- CI/CD amb Kubernetes
- Estratègies de Desplegament: Blue-Green i Canary
- Gestió Multi-Clúster
- Operació en Producció: Incidències, Runbooks i Costos
