Durant deu mòduls hem anat coneixent les peces de Kubernetes per separat: Pods, Deployments, Services, ConfigMaps, Ingress, sondes, HPA, polítiques de xarxa, RBAC, Helm, Argo CD. Cadascuna tenia sentit per si mateixa, però cap no és suficient tota sola per posar una aplicació en producció. Aquest mòdul canvia l'enfocament: en lloc d'estudiar una peça, resolem un escenari complet de principi a fi.

Comencem pel cas més freqüent i, en aparença, més senzill: portar una aplicació web sense estat a producció. A Rutas Norte S.L. són dos components, botiga-web (nginx servint la SPA de venda de bitllets) i api-reserves (l'API REST en Node.js que consulta disponibilitat i confirma reserves). Cap dels dos no desa dades localment, així que no hi ha volums per gestionar ni commutació per error per coordinar. I tot i així, el conjunt d'objectes que calen perquè aquest desplegament sigui defensable en una revisió de producció és de tretze peces per component.

Aquesta lliçó és la referència de manifests de tot el curs. Els fitxers que hi apareixen són els que es donen per bons a les lliçons següents: quan a 11-03 la canalització actualitzi un digest, serà el d'aquests Deployments; quan a 11-04 es converteixi api-reserves en un Rollout, es partirà d'aquest manifest; quan a 11-06 s'escrigui el runbook de "la latència de l'API s'ha disparat", es referirà a aquestes sondes i a aquest HPA.

Contingut

  1. El que cal tenir resolt abans del primer manifest
  2. El conjunt complet d'objectes d'un component de producció
  3. Manifests complets d'api-reserves
  4. Manifests complets de botiga-web
  5. Ordre d'aplicació i per què importa
  6. Verificació per capes, de dins cap enfora
  7. La llista de comprovació de "a punt per a producció"
  8. L'error característic de cada capa

  1. El que cal tenir resolt abans del primer manifest

Un error comú és tractar Kubernetes com el lloc on s'arreglen els problemes de l'aplicació. No ho és. Kubernetes amplifica el que l'aplicació ja fa: si l'aplicació arrenca lenta, l'escalat serà lent; si no tanca netament, cada desplegament perdrà peticions; si desa la sessió en memòria local, les rèpliques s'entrebancaran entre elles.

Abans d'escriure la primera línia de YAML, aquests set punts han d'estar resolts al codi i a la imatge, no al clúster.

1.1. Imatge reproduïble i identificada per digest

La imatge s'ha de construir sempre igual a partir del mateix commit, i desplegar-se referenciada per digest, no per etiqueta mòbil.

Referència Exemple Val en producció?
Etiqueta mòbil api-reserves:latest No. Dos pods del mateix Deployment poden acabar amb codi diferent
Etiqueta semàntica api-reserves:2.7.0 Només si el registre és immutable; una etiqueta es pot reescriure
Digest api-reserves@sha256:3f9c... Sí. És l'única referència criptogràficament estable

Ja vam veure a 08-05 com construir amb multietapa i signar amb Cosign. El requisit previ aquí és organitzatiu: el procés que desplega ha de conèixer el digest, i això ho garanteix la canalització que veurem a 11-03.

Per què importa: sense digest, un RollingUpdate que reemplaça pods al llarg de vint minuts pot agafar dos artefactes diferents si algú reescriu l'etiqueta a mitges. És una fallada gairebé impossible de diagnosticar després.

1.2. Configuració totalment externalitzada

Cap valor que canviï entre rutas-norte-dev, rutas-norte-pre i rutas-norte-pro no pot estar dins de la imatge. La mateixa imatge, byte a byte, ha de poder executar-se als tres entorns.

  • El que canvia i no és secret → ConfigMap (URL internes, temps d'espera, nivell de log).
  • El que canvia i és secret → Secret, poblat per External Secrets Operator des del magatzem corporatiu (10-05).
  • El que no canvia → pot quedar-se a la imatge.

Per què importa: si la imatge porta configuració a dins, el que es prova a pre no és el que es desplega a pro, i tota la cadena de confiança que hem construït a 08-05 deixa de significar res.

1.3. Processos sense estat local

Sessions, fitxers temporals que hagin de sobreviure, comptadors en memòria compartits: res d'això no pot viure al pod. A Rutas Norte, la sessió de l'usuari es signa amb JWT i el carretó de la compra es desa a redis-cache.

Per què importa: l'HPA crea i destrueix rèpliques contínuament durant el pont de maig. Si l'estat viu al pod, l'usuari perd el carretó cada vegada que l'autoescalat redueix rèpliques.

1.4. Tancament net davant de SIGTERM

Quan Kubernetes retira un pod, envia SIGTERM al procés principal i espera terminationGracePeriodSeconds. L'aplicació ha de:

  1. Deixar d'acceptar peticions noves.
  2. Acabar les que té en curs.
  3. Tancar connexions a la base de dades i a Redis.
  4. Sortir amb codi 0.
// Fragment de l'arrencada d'api-reserves (server.js)
const servidor = app.listen(8080);

let tancant = false;

// /preparat retorna 503 tan bon punt comença el tancament: així l'Endpoint
// es retira ABANS que deixem d'acceptar connexions.
app.get('/preparat', (req, res) => {
  if (tancant) return res.status(503).json({ estat: 'tancant' });
  return res.status(200).json({ estat: 'preparat' });
});

process.on('SIGTERM', async () => {
  tancant = true;
  // Donem marge perquè kube-proxy i l'Ingress actualitzin les seves taules.
  await new Promise((r) => setTimeout(r, 5000));
  servidor.close(async () => {
    await pool.end();          // connexions a postgres-reserves
    await redis.quit();
    process.exit(0);
  });
});

Aquesta pausa de cinc segons sembla un truc brut, i en certa manera ho és, però respon a una cosa real: la retirada de l'Endpoint i l'enviament de SIGTERM passen en paral·lel, no en seqüència. Sense la pausa, el pod deixa d'acceptar connexions mentre el balancejador encara n'hi envia, i l'usuari veu errors 502 a cada desplegament.

1.5. Logs estructurats a stdout

Com vam veure a 07-05, un objecte JSON per línia a stdout, sense fitxers de log, sense rotació pròpia. Camps mínims a Rutas Norte: ts, nivell, missatge, traca_id, ruta, estat, duracio_ms.

1.6. Endpoints de salut diferenciats

Dos endpoints amb semàntica diferent, no un de sol:

Endpoint Pregunta que respon Què comprova a api-reserves
/salut El procés és viu? Només que el bucle d'esdeveniments respon
/preparat Pot atendre trànsit ara? Connexió a postgres-reserves i a redis-cache, i que no s'estigui tancant

Per què importa: si /salut comprova la base de dades, una caiguda de PostgreSQL provoca que el kubelet reiniciï tots els pods de l'API en bucle, i converteix una degradació en una caiguda total.

1.7. Mètriques exposades

api-reserves exposa /metriques en format Prometheus, amb com a mínim el comptador de peticions per ruta i codi, i l'histograma de latència. És el que alimentarà l'SLO d'11-06 i l'anàlisi del canari d'11-04.

  1. El conjunt complet d'objectes d'un component de producció

Aquesta és la taula que convé tenir a mà en revisar qualsevol desplegament nou. Cada fila és un objecte, què aporta, i què passa exactament si falta.

Objecte Què aporta Què passa si falta
Namespace Frontera d'aïllament, quotes i polítiques per entorn Tot cau a default, sense quota ni política; impossible separar entorns
ServiceAccount Identitat del pod davant de l'API i davant del núvol S'utilitza la default, amb permisos indeterminats i sense traçabilitat en auditoria
ConfigMap Configuració no sensible, versionada a Git Valors incrustats a la imatge o al Deployment; canviar-ne un obliga a reconstruir
Secret (via External Secrets) Credencials sincronitzades des del magatzem corporatiu Secrets a mà al clúster, sense rotació ni rastre de qui els hi ha posat
Deployment Rèpliques, actualització controlada, securityContext, sondes, recursos, repartiment topològic Sense ell no hi ha pods gestionats: un pod solt no es recrea ni s'actualitza
Service Nom DNS estable i balanceig intern Cal descobrir IP de pod a mà; cada reinici trenca la connexió
Ingress amb TLS Entrada des d'internet amb certificat El servei no és accessible des de fora, o ho és per un NodePort sense xifrar
HPA Ajust de rèpliques a la càrrega real El pont de maig satura les rèpliques fixes o es paga de més tot l'any
PodDisruptionBudget Mínim de rèpliques durant manteniments i drenatges Un drain de nodes pot deixar el servei a zero rèpliques
NetworkPolicy Només les converses autoritzades Qualsevol pod compromès del clúster arriba a l'API i a la base de dades
ServiceMonitor Prometheus descobreix i rasca les mètriques El component no apareix a Grafana ni dispara alertes: està cec
topologySpreadConstraints (al Deployment) Rèpliques repartides entre zones i nodes Les tres rèpliques poden acabar al mateix node; aquest node cau i cau el servei
Sondes (al Deployment) Trànsit només a pods sans i reinici dels penjats S'enruta trànsit a pods que encara arrenquen; els penjats no es recuperen mai

Tretze files. La temptació en desplegar alguna cosa nova és fer les cinc primeres i deixar les altres "per quan hi hagi temps". L'experiència diu que les que es deixen fora són exactament les que calen el dia de l'incident.

graph TB
  subgraph ns["Namespace rutas-norte-pro"]
    subgraph seg["Seguretat i identitat"]
      SA[ServiceAccount]
      NP[NetworkPolicy]
    end
    subgraph cfg["Configuració"]
      CM[ConfigMap]
      SEC[Secret via ESO]
    end
    subgraph carga["Càrrega de treball"]
      DEP[Deployment<br/>sondes + recursos + spread]
      HPA[HPA]
      PDB[PodDisruptionBudget]
    end
    subgraph red["Exposició"]
      SVC[Service]
      ING[Ingress + TLS]
    end
    SM[ServiceMonitor]
  end
  SA --> DEP
  CM --> DEP
  SEC --> DEP
  DEP --> SVC
  SVC --> ING
  HPA -.escala.-> DEP
  PDB -.protegeix.-> DEP
  NP -.filtra.-> DEP
  SM -.rasca.-> SVC

  1. Manifests complets d'api-reserves

Els fitxers següents viuen a k8s/base/api-reserves/ del repositori de manifests, i les superposicions de Kustomize (10-04) ajusten rèpliques, recursos i domini per entorn. Aquí es mostren ja resolts per a rutas-norte-pro.

3.1. ServiceAccount i configuració

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  annotations:
    # Identitat federada a EKS (10-06): permet llegir del bucket de factures
    # sense cap clau d'accés de llarga vida.
    eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/rutasnorte-pro-api-reserves
# El pod no necessita parlar amb l'API de Kubernetes: no li muntem el token.
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: api-reserves-config
  namespace: rutas-norte-pro
data:
  NODE_ENV: "production"
  LOG_NIVELL: "info"
  # DNS intern: nom curt perquè som al mateix namespace.
  REDIS_URL: "redis://redis-cache:6379"
  # L'agrupador de connexions de CloudNativePG; el veurem a 11-02.
  PG_HOST: "postgres-reserves-pooler-rw"
  PG_PORT: "5432"
  PG_BASE: "reserves"
  PG_POOL_MAX: "20"
  PAGAMENTS_URL: "https://pagos.proveedorexterno.example/v2"
  PAGAMENTS_TIMEOUT_MS: "4000"
  RESERVA_TTL_SEGONS: "900"
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: api-reserves-secrets
  namespace: rutas-norte-pro
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: magatzem-rutasnorte
    kind: ClusterSecretStore
  target:
    name: api-reserves-secrets   # nom del Secret que es crearà
    creationPolicy: Owner
  data:
    - secretKey: PG_USUARI
      remoteRef: { key: pro/api-reserves/bd, property: usuari }
    - secretKey: PG_PASSWORD
      remoteRef: { key: pro/api-reserves/bd, property: password }
    - secretKey: JWT_SIGNATURA
      remoteRef: { key: pro/api-reserves/jwt, property: clau }
    - secretKey: PAGAMENTS_API_KEY
      remoteRef: { key: pro/api-reserves/pagaments, property: api_key }

Dos detalls que sovint passen desapercebuts. El primer, automountServiceAccountToken: false: api-reserves no crida l'API de Kubernetes, així que muntar-li el token només afegeix superfície d'atac (08-01). El segon, refreshInterval: 1h: l'ExternalSecret torna a llegir del magatzem cada hora, de manera que una rotació de credencials es propaga sola; el que no fa per si sol és reiniciar els pods, i per això més avall fem servir una suma de comprovació a les anotacions.

3.2. Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/name: api-reserves
    app.kubernetes.io/part-of: rutas-norte
    app.kubernetes.io/component: backend
    rutasnorte.example/equip: desenvolupament
spec:
  # replicas NO es fixa aquí: ho governa l'HPA i Argo CD ho ignora
  # amb ignoreDifferences (10-05). Si ho fixéssim, cada sincronització
  # tornaria les rèpliques al valor del fitxer.
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0        # mai no baixem del nombre actual de rèpliques sanes
  selector:
    matchLabels:
      app.kubernetes.io/name: api-reserves
  template:
    metadata:
      labels:
        app.kubernetes.io/name: api-reserves
        app.kubernetes.io/part-of: rutas-norte
        rutasnorte.example/equip: desenvolupament
      annotations:
        # Canvia si canvia el ConfigMap: força un rollout en canviar configuració.
        rutasnorte.example/config-checksum: "sha256-8f1d2a"
    spec:
      serviceAccountName: api-reserves
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 45    # > els 5 s d'espera + peticions en curs
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway   # preferim servei a simetria perfecta
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: api-reserves
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: api-reserves
      containers:
        - name: api
          # Digest, no etiqueta: l'escriu la canalització d'11-03.
          image: registry.rutasnorte.example/rutasnorte/api-reserves@sha256:3f9c1b7e5a04c2d8f61b93ae7c05d2419e8f6ab3c4d5e6f708192a3b4c5d6e7f
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
            - name: metriques
              containerPort: 9090
          envFrom:
            - configMapRef:
                name: api-reserves-config
            - secretRef:
                name: api-reserves-secrets
          env:
            - name: POD_NOM
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              memory: 512Mi        # sense límit de CPU: evita l'estrangulament (09-06)
          startupProbe:
            # Protegeix l'arrencada: fins a 60 s (12 x 5 s) abans de donar per mort el pod.
            httpGet: { path: /salut, port: http }
            periodSeconds: 5
            failureThreshold: 12
          livenessProbe:
            httpGet: { path: /salut, port: http }
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /preparat, port: http }
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2      # surt del balanceig ràpid
            successThreshold: 1
          lifecycle:
            preStop:
              exec:
                # Xarxa de seguretat addicional a la gestió de SIGTERM de l'apartat 1.4.
                command: ["/bin/sh", "-c", "sleep 5"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}

Comentaris sobre les decisions menys òbvies:

  • maxUnavailable: 0 amb maxSurge: 25%: durant el desplegament mai no hi ha menys capacitat de la que hi havia. Costa un 25 % de recursos temporals; a pro val la pena.
  • Sense limits.cpu: com vam raonar a 09-06, un límit de CPU provoca estrangulament amb pics de latència al percentil 95 encara que el node estigui ociós. Amb les requests ben posades i ResourceQuota al namespace (03-04), el risc està acotat.
  • readOnlyRootFilesystem: true obliga a l'emptyDir a /tmp. És una molèstia de cinc minuts que talla d'arrel mitja dotzena de tècniques d'atac (08-02).
  • whenUnsatisfiable: ScheduleAnyway: amb DoNotSchedule, durant el pont de maig l'HPA demanaria rèpliques que no es poden col·locar si una zona és plena. Preferim rèpliques mal repartides a rèpliques inexistents.

3.3. Service, Ingress, HPA, PDB, NetworkPolicy i ServiceMonitor

apiVersion: v1
kind: Service
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/name: api-reserves
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: api-reserves
  ports:
    - name: http
      port: 80
      targetPort: http
    - name: metriques
      port: 9090
      targetPort: metriques
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-produccio
    nginx.ingress.kubernetes.io/proxy-body-size: "1m"
    nginx.ingress.kubernetes.io/limit-rps: "200"
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["api.rutasnorte.example"]
      secretName: api-rutasnorte-tls     # l'omple cert-manager (04-05)
  rules:
    - host: api.rutasnorte.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-reserves
                port:
                  name: http
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reserves
  minReplicas: 4
  maxReplicas: 40          # dimensionat per al pont de maig
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 65 }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0     # pujar ràpid
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300   # baixar a poc a poc
      policies:
        - type: Percent
          value: 25
          periodSeconds: 60
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
spec:
  minAvailable: 75%
  selector:
    matchLabels:
      app.kubernetes.io/name: api-reserves
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: api-reserves
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
      ports:
        - { protocol: TCP, port: 8080 }
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: monitoratge }
      ports:
        - { protocol: TCP, port: 9090 }
  egress:
    - to:
        - podSelector:
            matchLabels: { cnpg.io/cluster: postgres-reserves }
      ports: [{ protocol: TCP, port: 5432 }]
    - to:
        - podSelector:
            matchLabels: { app.kubernetes.io/name: redis-cache }
      ports: [{ protocol: TCP, port: 6379 }]
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:
            matchLabels: { k8s-app: kube-dns }
      ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
      ports: [{ protocol: TCP, port: 443 }]   # pagos.proveedorexterno.example
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  labels:
    release: kube-prometheus-stack     # etiqueta que el Prometheus selecciona
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: api-reserves
  endpoints:
    - port: metriques
      path: /metriques
      interval: 30s

La regla d'egrés cap a 0.0.0.0/0 amb exclusions privades mereix un comentari: com vam veure a 04-06, les NetworkPolicy no entenen de noms DNS, així que no es pot escriure "deixa sortir cap a pagos.proveedorexterno.example". El que sí que es pot fer és permetre el 443 cap a internet excloent-ne els rangs privats, de manera que un pod compromès no pugui fer servir aquesta regla per moure's lateralment dins de la xarxa corporativa.

  1. Manifests complets de botiga-web

botiga-web és més simple: nginx servint fitxers estàtics. No té secrets, no parla amb la base de dades i les seves sondes són trivials. Però necessita exactament la mateixa disciplina.

apiVersion: v1
kind: ConfigMap
metadata:
  name: botiga-web-nginx
  namespace: rutas-norte-pro
data:
  default.conf: |
    server {
      listen 8080;
      root /usr/share/nginx/html;
      # SPA: qualsevol ruta desconeguda retorna index.html
      location / { try_files $uri $uri/ /index.html; }
      location = /salut { access_log off; return 200 "ok\n"; }
      location ~* \.(js|css|woff2|png|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/name: botiga-web
    app.kubernetes.io/part-of: rutas-norte
    rutasnorte.example/equip: desenvolupament
spec:
  revisionHistoryLimit: 5
  strategy:
    rollingUpdate: { maxSurge: 25%, maxUnavailable: 0 }
  selector:
    matchLabels: { app.kubernetes.io/name: botiga-web }
  template:
    metadata:
      labels:
        app.kubernetes.io/name: botiga-web
        app.kubernetes.io/part-of: rutas-norte
    spec:
      serviceAccountName: botiga-web
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 10002
        seccompProfile: { type: RuntimeDefault }
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app.kubernetes.io/name: botiga-web }
      containers:
        - name: nginx
          image: registry.rutasnorte.example/rutasnorte/botiga-web@sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
          ports: [{ name: http, containerPort: 8080 }]
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 128Mi }
          readinessProbe:
            httpGet: { path: /salut, port: http }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /salut, port: http }
            periodSeconds: 15
          lifecycle:
            preStop: { exec: { command: ["/bin/sh", "-c", "sleep 5"] } }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: conf, mountPath: /etc/nginx/conf.d }
            - { name: cache, mountPath: /var/cache/nginx }
            - { name: run, mountPath: /var/run }
      volumes:
        - name: conf
          configMap: { name: botiga-web-nginx }
        - { name: cache, emptyDir: {} }
        - { name: run, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
spec:
  selector: { app.kubernetes.io/name: botiga-web }
  ports: [{ name: http, port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-produccio
    nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["www.rutasnorte.example"]
      secretName: www-rutasnorte-tls
  rules:
    - host: www.rutasnorte.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: { name: botiga-web, port: { name: http } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: botiga-web }
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: botiga-web
  namespace: rutas-norte-pro
spec:
  minAvailable: 2
  selector:
    matchLabels: { app.kubernetes.io/name: botiga-web }

Fixa't que readOnlyRootFilesystem: true amb nginx obliga a muntar emptyDir a /var/cache/nginx i /var/run, i que el listen és el 8080 i no el 80: un procés sense privilegis no pot obrir ports per sota del 1024.

  1. Ordre d'aplicació i per què importa

Amb Argo CD (10-05) l'ordre el resolen les ones de sincronització (argocd.argoproj.io/sync-wave), però convé entendre'n la lògica, perquè és la mateixa que s'aplica en desplegar a mà a dev o en depurar.

Ona Objectes Motiu
-2 Namespace, ResourceQuota, LimitRange Res no existeix fora d'un namespace
-1 ServiceAccount, RBAC, NetworkPolicy La identitat i les regles han d'existir abans que la càrrega
0 ConfigMap, ExternalSecret El Deployment falla en arrencar si el ConfigMap no hi és
1 Deployment, Service La càrrega i el seu nom estable
2 Ingress, HPA, PDB, ServiceMonitor Depenen que Service i Deployment existeixin
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-1"

Kubernetes és eventualment consistent: si apliques el Deployment abans que el ConfigMap, els pods entren en CreateContainerConfigError i es recuperen sols quan el ConfigMap apareix. L'ordre no és una obligació tècnica estricta, és una manera que el desplegament no generi alertes espúries ni minuts de confusió.

Hi ha dues excepcions on l'ordre sí que és obligatori: els CRD abans que els seus recursos personalitzats (aplicar un ServiceMonitor sense l'operador de Prometheus instal·lat dona error dur), i la NetworkPolicy abans que el pod en entorns amb deny-all, perquè si no el pod arrenca, falla la seva sonda de preparació contra la base de dades i entra en reinicis innecessaris.

  1. Verificació per capes, de dins cap enfora

Desplegat no és funcionant. Aquesta seqüència de vuit comprovacions va de la capa més interna a la més externa, i cada pas només té sentit si l'anterior ha passat. És la rutina que ha d'executar qui desplega, i la que estructura el runbook de "un desplegament ha sortit malament" d'11-06.

graph LR
  A[1 Pod arrenca] --> B[2 Contenidor a punt]
  B --> C[3 Endpoints poblats]
  C --> D[4 DNS resol]
  D --> E[5 Service respon<br/>des del clúster]
  E --> F[6 Ingress respon<br/>des de fora]
  F --> G[7 Certificat vàlid]
  G --> H[8 Mètriques rascades]
  H --> I[9 Alertes en silenci]

Capa 1: el pod arrenca

kubectl -n rutas-norte-pro rollout status deploy/api-reserves --timeout=5m
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=api-reserves -o wide
NAME                            READY   STATUS    RESTARTS   AGE   NODE
api-reserves-7d4b8c9f5d-2xk9p   1/1     Running   0          62s   ip-10-0-2-41
api-reserves-7d4b8c9f5d-8mqrt   1/1     Running   0          58s   ip-10-0-3-17
api-reserves-7d4b8c9f5d-p4vzn   1/1     Running   0          55s   ip-10-0-1-93
api-reserves-7d4b8c9f5d-w6hxl   1/1     Running   0          51s   ip-10-0-2-88

Si falla: kubectl describe pod i mirar els esdeveniments. ImagePullBackOff apunta al registre o al digest; CreateContainerConfigError, a un ConfigMap o Secret que no existeix; Pending, a recursos insuficients o a un topologySpreadConstraint impossible.

Capa 2: el contenidor està a punt

READY 1/1 ja ho diu, però convé distingir "arrencat" de "preparat":

kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=api-reserves \
  -o custom-columns='POD:.metadata.name,LLEST:.status.conditions[?(@.type=="Ready")].status,REINICIS:.status.containerStatuses[0].restartCount'

Si un pod està Running però 0/1, la sonda de preparació falla: gairebé sempre api-reserves no aconsegueix connectar amb postgres-reserves o amb redis-cache. Es comprova amb kubectl logs i es resol mirant la NetworkPolicy o les credencials.

Capa 3: els Endpoints estan poblats

Aquest és el pas que més gent es salta i el que més vegades explica un 503.

kubectl -n rutas-norte-pro get endpointslice -l kubernetes.io/service-name=api-reserves -o yaml | grep -A3 addresses
    addresses:
    - 10.244.2.31
      conditions:
        ready: true

Endpoints buits amb pods sans significa selector desalineat: les etiquetes del spec.selector del Service no coincideixen amb les de template.metadata.labels. És una fallada silenciosa: cap objecte no dona error, simplement no arriba trànsit.

Capa 4: el DNS resol

kubectl -n rutas-norte-pro run depurar --rm -it --restart=Never \
  --image=registry.rutasnorte.example/utils/netdebug:1.4 -- \
  nslookup api-reserves.rutas-norte-pro.svc.cluster.local

Si no resol: CoreDNS caigut, o una NetworkPolicy que no permet l'egrés al port 53 cap a kube-system (la fallada més habitual després d'implantar deny-all).

Capa 5: el Service respon des de dins del clúster

kubectl -n rutas-norte-pro run depurar --rm -it --restart=Never \
  --image=registry.rutasnorte.example/utils/netdebug:1.4 -- \
  curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
  http://api-reserves/salut
200 0.004s

Si el pod respon però el Service no, sospita del targetPort o de la NetworkPolicy d'ingrés.

Capa 6: l'Ingress respon des de fora

curl -s -o /dev/null -w 'http=%{http_code} tls=%{ssl_verify_result} t=%{time_total}s\n' \
  https://api.rutasnorte.example/salut

Si dona 404 des de nginx, revisa ingressClassName i el host. Si dona 503, l'Ingress apunta a un Service sense Endpoints: t'has saltat la capa 3.

Capa 7: el certificat és vàlid

kubectl -n rutas-norte-pro get certificate api-rutasnorte-tls
echo | openssl s_client -connect api.rutasnorte.example:443 -servername api.rutasnorte.example 2>/dev/null \
  | openssl x509 -noout -issuer -dates
NAME                 READY   SECRET               AGE
api-rutasnorte-tls   True    api-rutasnorte-tls   184d

issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Apr 18 09:12:44 2026 GMT
notAfter=Jul 17 09:12:43 2026 GMT

Un Certificate en False durant més d'uns minuts sol ser el repte ACME que falla: mira l'Order i el Challenge (04-05).

Capa 8: Prometheus rasca les mètriques

kubectl -n monitoratge port-forward svc/prometheus-operated 9090:9090 &
curl -sG 'http://localhost:9090/api/v1/query' \
  --data-urlencode 'query=up{job="api-reserves"}' | jq '.data.result[] | {pod: .metric.pod, valor: .value[1]}'
{"pod":"api-reserves-7d4b8c9f5d-2xk9p","valor":"1"}
{"pod":"api-reserves-7d4b8c9f5d-8mqrt","valor":"1"}

Un objectiu absent gairebé sempre és l'etiqueta release: del ServiceMonitor, que ha de coincidir amb el serviceMonitorSelector del Prometheus.

Capa 9: les alertes estan en silenci

curl -s http://alertmanager.rutas-norte.example/api/v2/alerts \
  | jq '[.[] | select(.labels.service=="api-reserves")] | length'

Zero alertes actives quinze minuts després del desplegament és el criteri real d'"ha anat bé". Abans d'això, les finestres de les regles (for: 10m) encara no han tancat.

  1. La llista de comprovació de "a punt per a producció"

Aquesta taula s'enganxa tal qual a la descripció de la petició de canvi que promociona a rutas-norte-pro. Cada línia la marca una persona, no un script.

# Comprovació Com es verifica OK
1 Imatge per digest, signada i escanejada sense crítiques cosign verify + informe de Trivy a la canalització
2 Configuració fora de la imatge Diff de ConfigMap entre pre i pro
3 Sense estat local al pod Revisió de codi; sessió en JWT i carretó a Redis
4 Tancament net verificat Prova de càrrega durant un rollout restart: 0 errors 5xx
5 Sondes diferenciades i amb startupProbe Manifest revisat
6 requests i limits posats, sense límit de CPU Manifest revisat; recomanació de VPA consultada
7 securityContext sense root, sense escalada, arrel de només lectura Kyverno en mode bloqueig ho garanteix (08-03)
8 topologySpreadConstraints per zona i node Manifest revisat
9 PDB coherent amb minReplicas de l'HPA minAvailable < minReplicas
10 HPA amb min i max dimensionats per al pic previst Càlcul del pont de maig documentat
11 NetworkPolicy d'ingrés i egrés explícites kubectl describe netpol
12 ServiceMonitor actiu i panell de Grafana existent up{job=...} == 1 i enllaç al panell
13 Alertes amb enllaç a runbook a les anotacions Regla de PrometheusRule revisada
14 Ingress amb TLS vàlid i renovació automàtica Certificate en Ready
15 Verificació per capes 1-9 executada després de desplegar Sortida enganxada a la petició de canvi

La fila 9 és la que més incidències evita i la que més s'oblida: si l'HPA pot baixar a 2 rèpliques i el PDB exigeix minAvailable: 3, el drenatge d'un node es bloqueja indefinidament i el manteniment del clúster es queda penjat.

  1. L'error característic de cada capa

Cada capa falla d'una manera reconeixible. Saber el símptoma característic estalvia la meitat del temps de diagnòstic; el procediment profund és a 07-06.

Capa Símptoma Causa més probable Primera comanda
Pod ImagePullBackOff Digest inexistent o credencial del registre kubectl describe pod
Pod Pending perllongat Sense recursos, o topologySpread amb DoNotSchedule kubectl describe pod (esdeveniments del planificador)
Pod CrashLoopBackOff Falla en arrencar, o livenessProbe amb llindar molt curt kubectl logs --previous
Contenidor Running però 0/1 Dependència no assolible des de /preparat kubectl logs + provar la dependència
Endpoints Llista buida amb pods sans Selector del Service desalineat kubectl get endpointslice
DNS NXDOMAIN des d'un pod CoreDNS, o egrés al 53 bloquejat nslookup des d'un pod de depuració
Service Connecta però dona temps d'espera targetPort erroni o NetworkPolicy d'ingrés curl al Service des del clúster
Ingress 404 de nginx ingressClassName o host mal posats kubectl describe ing
Ingress 503 Service sense Endpoints a punt tornar a la capa 3
TLS Avís del navegador Certificate no Ready, repte ACME fallit kubectl describe certificate
Mètriques Objectiu absent a Prometheus Etiqueta release: del ServiceMonitor up{job="..."}
Alertes Alerta que no s'apaga Regla mal escrita o llindar irreal Executar l'expressió a Prometheus

Errors Comuns i Consells

  • Desplegar sense preStop ni gestió de SIGTERM i culpar l'Ingress dels 502. És l'error més freqüent en passar de dev a producció. La prova definitiva: llança una càrrega sostinguda amb k6 i executa kubectl rollout restart a mitges. Si apareix un sol 5xx, el tancament net no està bé.
  • Posar livenessProbe sobre un endpoint que comprova dependències. Converteix una degradació de la base de dades en un reinici massiu de tota l'API. La sonda de vida només ha de mirar cap endins.
  • Fixar replicas al Deployment tenint HPA. Cada sincronització d'Argo CD torna les rèpliques al valor del fitxer, i desfà l'escalat. Es resol amb ignoreDifferences (10-05) i traient el camp.
  • Un PDB amb minAvailable igual al nombre de rèpliques. Bloqueja tot drenatge. Fes servir percentatges i comprova la coherència amb el minReplicas de l'HPA.
  • Oblidar l'etiqueta release: al ServiceMonitor. El component es desplega perfectament i queda invisible per al monitoratge. Ningú no se n'adona fins al primer incident.
  • Consell: automatitza la llista de comprovació en el que es pugui, però mantén la revisió humana. Kyverno pot exigir les files 6, 7 i 11; ningú no pot exigir per política la fila 10, que requereix haver pensat en el pont de maig.
  • Consell: desa la sortida de la verificació per capes a la petició de canvi. Quan alguna cosa falli tres setmanes després, saber que el dia del desplegament el certificat era vàlid i les mètriques es rascaven acota enormement la cerca.

Exercicis

Exercici 1: detectar objectes absents

Un company ha desplegat un component nou, promocions-web, a rutas-norte-pro amb aquests objectes: Deployment (3 rèpliques fixes, sense sondes), Service i Ingress. Enumera quins objectes falten dels tretze de la taula de l'apartat 2 i descriu, per a cadascun, l'escenari concret en què la seva absència causarà una incidència a Rutas Norte.

Exercici 2: verificació per capes d'una fallada real

Després de desplegar api-reserves a rutas-norte-pre, aquesta és la situació:

$ kubectl -n rutas-norte-pre get pods -l app.kubernetes.io/name=api-reserves
NAME                            READY   STATUS    RESTARTS   AGE
api-reserves-6c9f7d8b4c-hh2ks   0/1     Running   0          4m
api-reserves-6c9f7d8b4c-tq5rl   0/1     Running   0          4m

$ curl -s -o /dev/null -w '%{http_code}\n' https://api-pre.rutasnorte.example/salut
503

Indica en quina capa de les nou és la fallada, quines tres comandes executaries i en quin ordre, i quines són les dues causes més probables.

Exercici 3: coherència entre HPA i PDB

worker-notificacions té un HPA governat per KEDA amb minReplicaCount: 1 i maxReplicaCount: 20, i un PDB amb minAvailable: 2. L'equip de plataforma necessita drenar un node per actualitzar el clúster i el kubectl drain es queda penjat. Explica per què i proposa'n la correcció, justificant el valor triat.

Solucions

Solució 1. Falten deu objectes:

Falta Escenari d'incidència
ServiceAccount propi Fa servir la default; l'auditoria no pot atribuir accions i hereta permisos no previstos
ConfigMap La configuració va a la imatge: promocionar de pre a pro exigeix reconstruir, i trenca la traçabilitat del digest
Secret via ESO Credencials posades a mà, sense rotació
Sondes S'enruta trànsit a pods que encara arrenquen: 502 a cada desplegament
securityContext Kyverno en mode bloqueig (08-03) rebutjarà el pod; si està en mode avís, corre com a root
topologySpreadConstraints Les 3 rèpliques poden caure al mateix node; aquest node es perd i el servei també
HPA Les 3 rèpliques fixes se saturen el pont de maig
PDB Un drenatge pot endur-se les 3 rèpliques alhora
NetworkPolicy Amb deny-all al namespace, ni tan sols arrencarà; sense ell, queda exposat a moviment lateral
ServiceMonitor Sense mètriques ni alertes: el component és invisible durant el primer incident

Solució 2. La fallada és a la capa 2 (contenidor no preparat), i el 503 de la capa 6 n'és una conseqüència: sense pods a punt, no hi ha Endpoints. Comandes, en ordre:

kubectl -n rutas-norte-pre logs -l app.kubernetes.io/name=api-reserves --tail=50
kubectl -n rutas-norte-pre describe pod -l app.kubernetes.io/name=api-reserves | grep -A10 Events
kubectl -n rutas-norte-pre get endpointslice -l kubernetes.io/service-name=api-reserves

Causes més probables: (a) /preparat falla perquè no hi ha connexió amb postgres-reserves (credencial de l'ExternalSecret no sincronitzada a pre, o NetworkPolicy sense la regla d'egrés al 5432); (b) l'aplicació arrenca però triga més que la startupProbe, encara que en aquest cas el normal seria veure reinicis, que aquí són 0, cosa que reforça la hipòtesi (a).

Solució 3. Amb minReplicaCount: 1, KEDA pot tenir el worker a una sola rèplica en hores vall. El PDB exigeix que en quedin 2 de disponibles, condició impossible amb 1 rèplica total, així que drain no pot desallotjar el pod i espera indefinidament. Correcció: fer servir minAvailable: 1 o, millor, maxUnavailable: 1, que s'expressa en termes de quantes rèpliques es poden perdre i funciona correctament en tot el rang d'1 a 20. Alternativa complementària: pujar minReplicaCount a 2 si el negoci exigeix que les notificacions no quedin mai sense capacitat, assumint el cost de la rèplica permanent.

Conclusió

Hem recorregut, de principi a fi, el camí d'una aplicació web sense estat fins a producció: els set requisits que s'han de complir a l'aplicació abans de tocar el YAML, els tretze objectes que formen un component complet i què es perd amb cadascun que falti, els manifests íntegres d'api-reserves i botiga-web que serveixen de referència per a la resta del curs, l'ordre d'aplicació, la verificació per capes de dins cap enfora amb la comanda exacta de cada pas, la llista de comprovació revisable en una petició de canvi i el símptoma característic de la fallada de cada capa.

La lliçó clau és que "està desplegat" i "està a punt per a producció" són afirmacions molt diferents, i que la diferència entre totes dues són precisament els objectes i les comprovacions que resulta temptador deixar per a després.

Tot això ha estat relativament còmode per una raó: botiga-web i api-reserves no desen res. Es poden matar, moure i multiplicar sense conseqüències. A la propera lliçó, Execució d'Aplicacions amb Estat, entrem al terreny on això deixa de ser cert: postgres-reserves desa les dades personals dels clients de Rutas Norte, no es pot reemplaçar un pod alegrement, i la primera pregunta que cal respondre honestament és si aquesta base de dades hauria de viure a Kubernetes.

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