Tot el que hem desplegat fins ara comparteix una premissa: el procés no ha d'acabar mai. botiga-web serveix pàgines indefinidament, api-reserves atén peticions indefinidament, el recol·lector de logs de la lliçó anterior llegeix fitxers indefinidament. Si algun d'aquests contenidors surt, encara que sigui amb codi 0, Kubernetes ho considera una anomalia i el reinicia.

Però una part important de la feina real d'una plataforma consisteix precisament a acabar: generar l'informe d'ocupació d'ahir a la nit, migrar l'esquema de la base de dades abans d'un desplegament, reprocessar un lot de bitllets amb un preu mal calculat. Per a aquestes càrregues Kubernetes té dos objectes: el Job, que executa alguna cosa fins que es completa amb èxit, i el CronJob, que crea Jobs segons un calendari.

Amb ells desplegarem per fi l'últim component pendent de Rutas Norte: informes-ocupacio, la tasca nocturna que consulta postgres-reserves i deixa un informe al disc.

Contingut

  1. Càrregues que acaben davant de càrregues que no acaben
  2. L'objecte Job: anatomia i camps de control
  3. restartPolicy: Never davant d'OnFailure
  4. Els tres patrons de Job
  5. completionMode: Indexed i el repartiment determinista
  6. L'objecte CronJob: calendari i política de concurrència
  7. Cas central: informes-ocupacio com a CronJob nocturn
  8. Segon exemple: un Job de migració d'esquema
  9. Depuració de treballs fallits i neteja d'historial

  1. Càrregues que acaben davant de càrregues que no acaben

La diferència arrenca al mateix contenidor. Un procés de llarga durada es queda bloquejat en un bucle d'esdeveniments; un procés per lots fa la seva feina, escriu un resultat i crida exit(0).

Kubernetes distingeix tots dos casos pel controlador que gestiona el pod, no pel contingut de la imatge.

Aspecte Deployment / StatefulSet / DaemonSet Job / CronJob
Durada esperada Indefinida Finita
Sortida amb codi 0 Anomalia; es reinicia Èxit; el Job es marca com a completat
restartPolicy permesa Només Always Només Never o OnFailure
Estat terminal No existeix Complete o Failed
Què mesura l'èxit Que continuï viu Que hagi acabat bé N vegades
Recollida de resultats Logs continus Logs del pod acabat, fins que es netegen

Un detall que sol sorprendre: el pod d'un Job acabat no desapareix. Queda en estat Completed, sense consumir CPU ni memòria, perquè en puguis llegir els logs. Aquest és també el motiu pel qual un clúster mal mantingut acumula milers de pods Completed, cosa que resoldrem a l'apartat 9.

graph LR
  subgraph Indefinides
    D[Deployment] --> P1[Pod Running sempre]
    P1 -->|surt| P1
  end
  subgraph Finites
    C[CronJob] -->|cada nit| J[Job]
    J --> P2[Pod]
    P2 -->|exit 0| OK[Completed]
    P2 -->|exit != 0| RT[Reintent]
    RT --> P2
    RT -->|backoffLimit esgotat| KO[Failed]
  end

  1. L'objecte Job: anatomia i camps de control

El Job més simple possible:

apiVersion: batch/v1
kind: Job
metadata:
  name: calcul-tarifes
  namespace: rutas-norte-dev
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: calcul
          image: busybox:1.36
          command: ["sh", "-c", "echo 'Recalculant tarifes...'; sleep 5; echo 'Fet'"]

Observa que no hi ha selector: el Job genera el seu automàticament amb una etiqueta única (batch.kubernetes.io/controller-uid). És una de les poques càrregues on no cal declarar-lo, i és millor no intentar-ho: un selector escrit a mà que col·lideixi amb un altre Job provoca que un controlador adopti pods aliens.

Els camps que governen el comportament:

Camp Per defecte Què fa
completions 1 Quants pods han d'acabar amb èxit per donar el Job per completat
parallelism 1 Quants pods poden córrer simultàniament
backoffLimit 6 Quantes fallades es toleren abans de marcar el Job com a Failed
activeDeadlineSeconds sense límit Segons màxims des de l'inici; en superar-los el Job es talla encara que no hagi fallat
ttlSecondsAfterFinished sense límit Segons després d'acabar (amb èxit o no) abans que el Job i els seus pods s'esborrin sols
completionMode NonIndexed NonIndexed o Indexed (apartat 5)
suspend false Si és true, el Job no crea pods; serveix per encuar i alliberar després
podFailurePolicy cap Regles fines per codi de sortida (estable des de 1.31)

backoffLimit i el retrocés exponencial

Quan un pod falla, el Job en crea un altre, però no immediatament: espera 10 s, després 20 s, 40 s, 80 s… fins a un màxim de 6 minuts. Aquesta espera creixent evita que una fallada permanent —una contrasenya incorrecta, un host inabastable— consumeixi el clúster amb milers d'intents per minut.

En arribar a backoffLimit fallades, el Job passa a Failed amb la raó BackoffLimitExceeded i deixa de crear pods.

Valor pràctic per a Rutas Norte:

  • Tasques idempotents amb dependències de xarxa (consultar la passarel·la de pagaments): backoffLimit: 6, el valor per defecte, té sentit; una fallada transitòria es recupera sola.
  • Migracions d'esquema: backoffLimit: 0. Si falla, vols saber-ho i mirar-ho, no que es reintenti cinc vegades sobre una base de dades a mig migrar.

activeDeadlineSeconds

És un tallafoc temporal. Es compta des que el Job arrenca i inclou els reintents:

spec:
  backoffLimit: 3
  activeDeadlineSeconds: 1800   # 30 minuts com a molt, passi el que passi

Si s'esgota, els pods actius s'acaben i el Job queda Failed amb raó DeadlineExceeded. És la protecció contra el procés que no falla però tampoc avança: una consulta bloquejada per un lock a postgres-reserves, per exemple. activeDeadlineSeconds té prioritat sobre backoffLimit: mana el que es compleixi primer.

ttlSecondsAfterFinished

spec:
  ttlSecondsAfterFinished: 86400   # s'autodestrueix 24 h després d'acabar

Passat aquest temps des que el Job arriba a estat terminal, el controlador de TTL esborra el Job i els seus pods. És la manera correcta d'evitar l'acumulació d'objectes. Posa'l sempre en Jobs creats per automatismes; deixa temps suficient perquè algú pugui llegir els logs d'una fallada nocturna (24 hores és un bon valor).

  1. restartPolicy: Never davant d'OnFailure

L'API només admet aquests dos valors a la plantilla d'un Job. Always es rebutja, perquè contradiu la idea mateixa d'una tasca que acaba.

La diferència entre tots dos no és cosmètica: canvia què es reinicia.

restartPolicy: Never restartPolicy: OnFailure
Què passa en fallar el contenidor El pod queda Failed; el Job crea un pod nou El kubelet reinicia el contenidor dins del mateix pod
Comptador que s'incrementa backoffLimit del Job restartCount del contenidor (i també backoffLimit)
Rastre que queda Un pod per intent, tots consultables Un sol pod; els logs d'intents previs es perden llevat que facis servir --previous
Node del reintent Pot ser un altre Sempre el mateix
Volum emptyDir Buit a cada intent Es conserva entre reinicis

Exemple del que es veu amb Never:

kubectl get pods -n rutas-norte-dev -l job-name=migracio-esquema
NAME                     READY   STATUS   RESTARTS   AGE
migracio-esquema-4kx2p   0/1     Error    0          3m
migracio-esquema-9dz7w   0/1     Error    0          2m
migracio-esquema-t6m1c   0/1     Error    0          1m

Tres pods, un per intent, cadascun amb els seus logs íntegres. Amb OnFailure veuries:

NAME                     READY   STATUS             RESTARTS      AGE
migracio-esquema-4kx2p   0/1     CrashLoopBackOff   3 (45s ago)   3m

Un sol pod amb RESTARTS: 3, i per veure el log de l'intent anterior et caldria kubectl logs <pod> --previous.

Recomanació per a Rutas Norte: fes servir Never llevat de motiu concret. Conservar un pod per intent fa la depuració molt més honesta, i en una tasca nocturna que ha fallat a les 03:00 voldràs poder llegir exactament què va dir el primer intent. OnFailure és preferible quan l'arrencada del contenidor és molt costosa —descarregar un model, restaurar una memòria cau— i vols aprofitar l'estat d'un emptyDir entre reintents.

Un matís important: si el node sencer falla, el pod es recrea en tots dos casos, perquè qui actua és el controlador del Job, no el kubelet.

  1. Els tres patrons de Job

Amb completions i parallelism es construeixen tres patrons que cobreixen gairebé tota la feina per lots.

Patró completions parallelism Quan
Tasca única 1 (o absent) 1 (o absent) Migració, informe, tasca puntual
Paral·lelisme fix N M (≤ N) N unitats de treball conegudes per endavant
Cua de treball absent M Els treballadors consumeixen d'una cua externa fins a buidar-la

Patró 1: tasca única

El més habitual. Un pod, una vegada, amb èxit.

apiVersion: batch/v1
kind: Job
metadata:
  name: informe-puntual-juliol
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 900
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        app: informes-ocupacio
        entorn: dev
    spec:
      restartPolicy: Never
      containers:
        - name: generador
          image: postgres:16.4
          command: ["sh", "-c", "psql -h postgres-reserves -U rutasnorte -d reserves -c 'SELECT count(*) FROM reserves'"]

Patró 2: paral·lelisme fix amb nombre de finalitzacions

Rutas Norte té 12 línies d'autobús i vol recalcular l'ocupació històrica de totes. Són 12 unitats de treball, i no vol més de 4 consultes simultànies contra postgres-reserves:

apiVersion: batch/v1
kind: Job
metadata:
  name: recalcul-ocupacio-linies
  namespace: rutas-norte-dev
spec:
  completions: 12       # 12 pods han d'acabar bé
  parallelism: 4        # com a molt 4 alhora
  backoffLimit: 6
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: recalcul
          image: busybox:1.36
          command: ["sh", "-c", "echo 'Processant una línia'; sleep 10"]

El Job va mantenint quatre pods vius: així que un acaba, arrenca el següent, fins a acumular 12 èxits. parallelism és el mecanisme de control de càrrega: sense ell, dotze consultes simultànies podrien saturar la base de dades.

Observant l'evolució:

kubectl get job recalcul-ocupacio-linies -n rutas-norte-dev -w
NAME                          COMPLETIONS   DURATION   AGE
recalcul-ocupacio-linies      0/12          3s         3s
recalcul-ocupacio-linies      4/12          15s        15s
recalcul-ocupacio-linies      8/12          27s        27s
recalcul-ocupacio-linies      12/12         39s        39s

El problema d'aquest patró en la seva forma bàsica: tots els pods executen la mateixa ordre. No hi ha res que digui a cadascun quina línia li toca. Això ho resol l'apartat 5.

Patró 3: cua de treball

Si s'omet completions però es fixa parallelism, el Job entra en mode cua: arrenca M treballadors i considera la feina acabada quan un qualsevol d'ells surt amb èxit estant la resta ja finalitzada. Els treballadors s'han de coordinar per si mateixos a través d'una cua externa (Redis, RabbitMQ, una taula amb SELECT ... FOR UPDATE SKIP LOCKED).

apiVersion: batch/v1
kind: Job
metadata:
  name: reproces-bitllets-cua
  namespace: rutas-norte-dev
spec:
  parallelism: 5        # sense completions: mode cua de treball
  backoffLimit: 10
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: treballador
          image: registry.rutasnorte.example/worker-bitllets:1.4.2
          env:
            - name: CUA_URL
              value: "redis://redis-cache.rutas-norte-dev.svc.cluster.local:6379/3"
          command: ["/app/consumir-cua"]

Cada treballador treu un bitllet de la cua de redis-cache, el reprocessa i repeteix fins que la cua és buida, moment en què surt amb codi 0. És el patró més flexible i el que pitjor tolera errors de disseny: si un treballador mor a mitja unitat, aquesta unitat ha de tornar a la cua, i això ho ha de garantir l'aplicació.

  1. completionMode: Indexed i el repartiment determinista

El mode indexat resol la mancança del patró 2: donar a cada pod un número que li digui quina porció de la feina li correspon.

apiVersion: batch/v1
kind: Job
metadata:
  name: recalcul-ocupacio-linies
  namespace: rutas-norte-dev
spec:
  completionMode: Indexed     # <- la clau
  completions: 12
  parallelism: 4
  backoffLimit: 6
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: recalcul
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              LINIA=$(( JOB_COMPLETION_INDEX + 1 ))
              echo "Pod amb índex $JOB_COMPLETION_INDEX -> recalculant la línia $LINIA de Rutas Norte"
              sleep 5
              echo "Línia $LINIA recalculada"

Amb Indexed, Kubernetes assigna a cada pod un índex únic de 0 a completions - 1, i l'exposa de dues maneres:

  • La variable d'entorn JOB_COMPLETION_INDEX.
  • L'anotació batch.kubernetes.io/job-completion-index del pod.

A més, els noms dels pods incorporen l'índex, cosa que fa l'observació trivial:

kubectl get pods -n rutas-norte-dev -l job-name=recalcul-ocupacio-linies \
  --sort-by=.metadata.name
NAME                                READY   STATUS      RESTARTS   AGE
recalcul-ocupacio-linies-0-h2k9x    0/1     Completed   0          62s
recalcul-ocupacio-linies-1-m4p2t    0/1     Completed   0          62s
recalcul-ocupacio-linies-2-w8j5r    0/1     Completed   0          61s
...
recalcul-ocupacio-linies-11-z3q7n   0/1     Completed   0          18s
kubectl logs -n rutas-norte-dev recalcul-ocupacio-linies-7-x1v4b
Pod amb índex 7 -> recalculant la línia 8 de Rutas Norte
Línia 8 recalculada

Propietats que fan Indexed molt superior al mode per defecte quan cal repartir feina:

  • Determinisme: l'índex 7 processa sempre la línia 8, s'executi quan s'executi.
  • Reintents correctes: si el pod de l'índex 7 falla, el reintent torna a rebre l'índex 7, no un altre. Cap unitat no es queda sense processar ni es processa dues vegades.
  • Traçabilitat: el nom del pod et diu quina unitat de treball va mirar aquell pod.

Si necessites repartir un rang, l'índex n'hi ha prou per calcular-lo:

TOTAL=12000
PER_POD=$(( TOTAL / COMPLETIONS ))
DES_DE=$(( JOB_COMPLETION_INDEX * PER_POD ))
FINS_A=$(( DES_DE + PER_POD ))
echo "Processant reserves de la $DES_DE a la $FINS_A"

  1. L'objecte CronJob: calendari i política de concurrència

Un CronJob no executa res per si mateix: crea Jobs segons un calendari. I cada Job crea els seus pods. Són tres nivells, i entendre'ls evita molta confusió en depurar.

graph LR
  CJ[CronJob<br/>informes-ocupacio] -->|03:15 del dia 1| J1[Job informes-ocupacio-28935120]
  CJ -->|03:15 del dia 2| J2[Job informes-ocupacio-28936560]
  J1 --> P1[Pod ...-28935120-4kx2p]
  J2 --> P2[Pod ...-28936560-9dz7w]

La sintaxi del schedule

Cinc camps separats per espais, en el format cron de sempre:

┌───────────── minut (0 - 59)
│ ┌───────────── hora (0 - 23)
│ │ ┌───────────── dia del mes (1 - 31)
│ │ │ ┌───────────── mes (1 - 12)
│ │ │ │ ┌───────────── dia de la setmana (0 - 6, diumenge = 0)
│ │ │ │ │
* * * * *

Operadors admesos a cada camp:

Operador Significat Exemple
* Qualsevol valor * * * * * = cada minut
, Llista 0 8,14,20 * * * = a les 8, 14 i 20
- Rang 0 9-17 * * 1-5 = cada hora de 9 a 17, de dilluns a divendres
/ Pas */15 * * * * = cada 15 minuts

Exemples amb significat per a Rutas Norte:

Expressió Quan s'executa
15 3 * * * Tots els dies a les 03:15
0 * * * * En punt de cada hora
*/10 * * * * Cada 10 minuts
0 4 * * 1 Els dilluns a les 04:00
0 5 1 * * El dia 1 de cada mes a les 05:00
30 2 * * 0 Els diumenges a les 02:30

timeZone

Sense aquest camp, el calendari s'interpreta en la zona horària del kube-controller-manager, que en la majoria de clústers és UTC. Per a Rutas Norte, que opera a Espanya, això significa que 15 3 * * * s'executaria a les 04:15 en horari d'estiu i a les 03:15 a l'hivern: una hora diferent segons l'època de l'any, justament el que no vols en una finestra nocturna.

spec:
  schedule: "15 3 * * *"
  timeZone: "Europe/Madrid"

El camp timeZone és estable des de Kubernetes 1.27 i accepta qualsevol identificador de la base de dades IANA. Fes-lo servir sempre; és una de les millores més útils i menys conegudes de l'API de batch.

concurrencyPolicy

Què passa si arriba l'hora de l'execució següent i l'anterior encara no ha acabat?

Valor Comportament Quan fer-lo servir
Allow (per defecte) Es crea el Job nou; conviuen Tasques independents i lleugeres
Forbid S'omet l'execució nova; es registra el salt Tasques que no es poden solapar
Replace Es cancel·la l'anterior i arrenca la nova Tasques on només importa el resultat més recent

El cas real que justifica Forbid a Rutas Norte: informes-ocupacio s'executa cada nit i fa consultes agregades pesades sobre postgres-reserves. Si una nit de pont el volum de reserves és tan gran que l'informe triga 26 hores, amb Allow la nit següent hi hauria dos informes agregant simultàniament sobre la mateixa base de dades: el doble de càrrega en el pitjor moment possible, i dos fitxers escrivint al mateix PVC. Amb Forbid, la segona execució simplement no es llança i l'esdeveniment queda registrat perquè algú investigui per què l'informe triga tant.

Replace seria adequat per a, per exemple, un recàlcul de disponibilitat de places cada cinc minuts: si el càlcul de les 10:00 encara corre a les 10:05, el seu resultat ja és obsolet i és millor cancel·lar-lo i començar amb dades fresques.

startingDeadlineSeconds i el clúster caigut

Suposem que el pla de control va estar caigut entre les 03:00 i les 05:00 per un manteniment, i el CronJob havia de disparar a les 03:15. Què passa en tornar?

  • Sense startingDeadlineSeconds: el controlador veu una execució perduda i la llança tan bon punt pot, a les 05:00. Un informe "de les 03:15" executant-se a les 05:00 pot ser inofensiu o desastrós segons què faci.
  • Amb startingDeadlineSeconds: 600: l'execució només es llança si han passat menys de 600 segons des de l'hora prevista. A les 05:00 han passat 6.300 s, així que s'omet i es registra el salt.
spec:
  schedule: "15 3 * * *"
  timeZone: "Europe/Madrid"
  startingDeadlineSeconds: 600

Hi ha a més un mecanisme de protecció que convé conèixer: si el controlador detecta més de 100 execucions perdudes dins de la finestra de la deadline, deixa de programar el CronJob del tot i emet aquest esdeveniment:

Warning  FailedNeedsStart  cronjob-controller  Cannot determine if job needs to be started:
too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check
clock skew

És el motiu pel qual un CronJob que no s'ha executat en mesos pot quedar-se permanentment mort. Fixar startingDeadlineSeconds a un valor raonable ho evita.

Historial i suspensió

spec:
  successfulJobsHistoryLimit: 3    # per defecte 3
  failedJobsHistoryLimit: 5        # per defecte 1
  suspend: false

El controlador conserva els N Jobs més recents de cada classe i esborra la resta amb els seus pods. Consell pràctic: puja failedJobsHistoryLimit. El valor per defecte d'1 significa que si l'informe falla tres nits seguides, només conserves els logs de l'última, justament quan voldries comparar les tres.

suspend: true atura la programació sense esborrar l'objecte. És la maniobra correcta durant una finestra de manteniment de postgres-reserves:

kubectl patch cronjob informes-ocupacio -n rutas-norte-pro -p '{"spec":{"suspend":true}}'
# ... manteniment ...
kubectl patch cronjob informes-ocupacio -n rutas-norte-pro -p '{"spec":{"suspend":false}}'

Compte: mentre està suspès, les execucions perdudes no es recuperen en reprendre; el controlador reprèn a partir de l'hora prevista següent.

  1. Cas central: informes-ocupacio com a CronJob nocturn

És el moment de desplegar el component que portem sis mòduls citant i mai creant. informes-ocupacio consulta cada matinada les reserves del dia anterior a postgres-reserves, calcula l'ocupació per línia i deixa un fitxer CSV en un PVC.

El PVC de l'informe

Els informes s'acumulen i es conserven un temps. Fem servir la classe estàndard, no la ràpida: no hi ha exigències de latència.

# k8s/base/informes-ocupacio-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: informes-ocupacio-dades
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: rutasnorte-estandar
  resources:
    requests:
      storage: 5Gi

La ServiceAccount

Seguint la pràctica de 03-06: compte dedicat i sense token muntat, perquè el treball no parla amb l'API de Kubernetes.

# k8s/base/informes-ocupacio-sa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: informes-ocupacio
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
automountServiceAccountToken: false

La NetworkPolicy

A 04-06 vam deixar rutas-norte-pro amb un deny-all per defecte i vam anar autoritzant conversa a conversa. informes-ocupacio és una conversa nova i sense aquesta política no travessarà el mur: els pods arrencaran, la consulta es quedarà penjada i el Job fallarà per activeDeadlineSeconds sense cap missatge que expliqui la causa. És una de les fallades més difícils de diagnosticar de tot el curs.

Calen dues regles: sortida des de l'informe i entrada a la base de dades.

# k8s/entorns/pro/informes-ocupacio-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: informes-ocupacio-sortida-postgres
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
spec:
  podSelector:
    matchLabels:
      app: informes-ocupacio
      entorn: pro
  policyTypes: ["Egress"]
  egress:
    # Resolució DNS: sense això no es resol el nom del servei
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Accés a la base de dades
    - to:
        - podSelector:
            matchLabels:
              app: postgres-reserves
              entorn: pro
      ports:
        - protocol: TCP
          port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: postgres-reserves-entrada-informes
  namespace: rutas-norte-pro
  labels:
    app: postgres-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
spec:
  podSelector:
    matchLabels:
      app: postgres-reserves
      entorn: pro
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: informes-ocupacio
              entorn: pro
      ports:
        - protocol: TCP
          port: 5432

La regla de DNS és la que més s'oblida. Amb deny-all, un pod sense permís de sortida al port 53 de CoreDNS no resol postgres-reserves i falla amb un error de host desconegut que sembla un problema de configuració de l'aplicació.

El CronJob

# k8s/base/informes-ocupacio-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: informes-ocupacio
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
spec:
  schedule: "15 3 * * *"
  timeZone: "Europe/Madrid"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  suspend: false
  jobTemplate:
    metadata:
      labels:
        app: informes-ocupacio
        app.kubernetes.io/part-of: rutas-norte
        entorn: pro
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 5400      # 90 minuts com a sostre absolut
      ttlSecondsAfterFinished: 172800  # s'autoneteja a les 48 h
      template:
        metadata:
          labels:
            app: informes-ocupacio
            app.kubernetes.io/part-of: rutas-norte
            entorn: pro
        spec:
          restartPolicy: Never
          serviceAccountName: informes-ocupacio
          automountServiceAccountToken: false
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            fsGroup: 10001
          containers:
            - name: generador
              image: postgres:16.4
              command:
                - /bin/bash
                - -c
                - |
                  set -euo pipefail
                  AHIR=$(date -d 'yesterday' +%Y-%m-%d)
                  SORTIDA="/informes/ocupacio-${AHIR}.csv"
                  echo "Generant informe d'ocupació de ${AHIR}"
                  psql -h postgres-reserves -U "${PGUSER}" -d reserves \
                       --csv --no-psqlrc -o "${SORTIDA}" <<SQL
                  SELECT l.codi              AS linia,
                         COUNT(r.id)         AS bitllets,
                         SUM(r.places)       AS places_ocupades,
                         ROUND(100.0 * SUM(r.places) / NULLIF(SUM(s.capacitat), 0), 2) AS pct_ocupacio
                  FROM reserves r
                  JOIN sortides s ON s.id = r.sortida_id
                  JOIN linies   l ON l.id = s.linia_id
                  WHERE s.data = DATE '${AHIR}'
                  GROUP BY l.codi
                  ORDER BY pct_ocupacio DESC;
                  SQL
                  echo "Informe escrit a ${SORTIDA} ($(wc -l < "${SORTIDA}") línies)"
                  find /informes -name 'ocupacio-*.csv' -mtime +90 -delete
                  echo "Informes de més de 90 dies eliminats"
              env:
                - name: PGUSER
                  valueFrom:
                    secretKeyRef:
                      name: postgres-reserves-credencials
                      key: usuari
                - name: PGPASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: postgres-reserves-credencials
                      key: password
                - name: PGCONNECT_TIMEOUT
                  value: "10"
              resources:
                requests:
                  cpu: 200m
                  memory: 256Mi
                limits:
                  cpu: "1"
                  memory: 512Mi
              volumeMounts:
                - name: informes
                  mountPath: /informes
          volumes:
            - name: informes
              persistentVolumeClaim:
                claimName: informes-ocupacio-dades

Repàs de les decisions, que resumeixen mitja dotzena de lliçons anteriors:

  • schedule a les 03:15 amb timeZone: Europe/Madrid: fora de les hores de venda i amb hora estable tot l'any.
  • concurrencyPolicy: Forbid: dos informes simultanis sobre la mateixa base de dades i el mateix PVC serien nocius.
  • activeDeadlineSeconds: 5400: si la consulta es queda bloquejada per un lock, el Job mor als 90 minuts en comptes d'arrossegar-se fins al matí.
  • backoffLimit: 2: un reintent és útil davant d'un tall de xarxa; cinc només prolonguen la càrrega.
  • ttlSecondsAfterFinished: 172800 més els límits d'historial: dues capes de neteja automàtica.
  • Credencials per Secret (03-02), mai al manifest. PGPASSWORD i PGUSER són variables que psql reconeix directament.
  • Recursos declarats: sense ells el pod seria BestEffort (03-05) i el primer node amb pressió de memòria el desallotjaria.
  • securityContext amb usuari no root i fsGroup per poder escriure al PVC.
  • Retenció de 90 dies aplicada al mateix script: el PVC és finit i els informes contenen dades agregades, no personals, però acumular sense límit és una fuita d'espai garantida.

Desplegar i provar sense esperar a les tres de la matinada

kubectl apply -f k8s/base/informes-ocupacio-pvc.yaml
kubectl apply -f k8s/base/informes-ocupacio-sa.yaml
kubectl apply -f k8s/entorns/pro/informes-ocupacio-networkpolicy.yaml
kubectl apply -f k8s/base/informes-ocupacio-cronjob.yaml

kubectl get cronjob informes-ocupacio -n rutas-norte-pro
NAME                SCHEDULE     TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
informes-ocupacio   15 3 * * *   Europe/Madrid   False     0        <none>          20s

L'ordre imprescindible: disparar una execució manual a partir del CronJob.

kubectl create job -n rutas-norte-pro \
  --from=cronjob/informes-ocupacio informes-prova-manual
job.batch/informes-prova-manual created
kubectl wait --for=condition=complete job/informes-prova-manual \
  -n rutas-norte-pro --timeout=600s
kubectl logs -n rutas-norte-pro job/informes-prova-manual
Generant informe d'ocupació de 2026-08-04
Informe escrit a /informes/ocupacio-2026-08-04.csv (13 línies)
Informes de més de 90 dies eliminats

kubectl create job --from=cronjob/... copia el jobTemplate tal qual, així que prova exactament el que s'executarà de matinada, incloses la ServiceAccount, la NetworkPolicy i els volums. És la manera correcta de validar un CronJob nou.

  1. Segon exemple: un Job de migració d'esquema

Abans de desplegar la versió 2.5.0 d'api-reserves cal afegir una columna a la taula de reserves. És un cas de Job puntual amb exigències molt diferents de les de l'informe.

# k8s/entorns/pre/migracio-esquema-2-5-0.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migracio-esquema-2-5-0
  namespace: rutas-norte-pre
  labels:
    app: api-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pre
spec:
  backoffLimit: 0                # una migració NO es reintenta a cegues
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 604800  # 7 dies: volem rastre de les migracions
  template:
    metadata:
      labels:
        app: api-reserves
        entorn: pre
    spec:
      restartPolicy: Never
      serviceAccountName: api-reserves
      automountServiceAccountToken: false
      containers:
        - name: migracio
          image: registry.rutasnorte.example/api-reserves-migracions:2.5.0
          command:
            - /bin/sh
            - -c
            - |
              set -euo pipefail
              echo "Migració 2.5.0 sobre reserves"
              psql -h postgres-reserves -U "${PGUSER}" -d reserves -v ON_ERROR_STOP=1 <<'SQL'
              BEGIN;
              ALTER TABLE reserves
                ADD COLUMN IF NOT EXISTS canal_venda text NOT NULL DEFAULT 'web';
              CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reserves_canal
                ON reserves (canal_venda);
              INSERT INTO migracions (versio, aplicada_el)
                VALUES ('2.5.0', now())
                ON CONFLICT (versio) DO NOTHING;
              COMMIT;
              SQL
              echo "Migració 2.5.0 aplicada"
          env:
            - name: PGUSER
              valueFrom:
                secretKeyRef:
                  name: postgres-reserves-credencials
                  key: usuari
            - name: PGPASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reserves-credencials
                  key: password
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi

Contrastos deliberats amb informes-ocupacio:

Decisió Informe nocturn Migració d'esquema Motiu
backoffLimit 2 0 Reintentar sobre una base de dades a mig migrar pot empitjorar l'estat
ttlSecondsAfterFinished 48 h 7 dies El rastre de què va migrar i quan té valor d'auditoria
Idempotència No aplica IF NOT EXISTS, ON CONFLICT DO NOTHING Si algú rellança el Job, no ha de trencar res
Transacció No BEGIN/COMMIT amb ON_ERROR_STOP=1 O s'aplica tot o no s'aplica res

ON_ERROR_STOP=1 és imprescindible: sense ell, psql continua després d'un error i retorna codi 0, i el Job apareixeria com a completat amb la migració a mitges. És un parany clàssic.

Un advertiment de disseny: aquest Job desacobla la migració del desplegament. Cal executar-lo, verificar i només llavors desplegar la versió 2.5.0 d'api-reserves. L'alternativa —encadenar la migració a l'arrencada de cada pod de l'aplicació mitjançant un initContainer— és el tema de la lliçó 06-04, i té els seus propis avantatges i inconvenients.

  1. Depuració de treballs fallits i neteja d'historial

Quan un treball nocturn falla, el diagnòstic segueix sempre el mateix camí: CronJob → Job → Pod → logs.

Pas 1: estat dels Jobs

kubectl get jobs -n rutas-norte-pro
NAME                           STATUS     COMPLETIONS   DURATION   AGE
informes-ocupacio-29175300     Complete   1/1           94s        2d
informes-ocupacio-29176740     Complete   1/1           88s        1d
informes-ocupacio-29178180     Failed     0/1           7m12s      3h

El sufix numèric és la marca temporal programada, no un identificador aleatori: els Jobs s'ordenen cronològicament sols.

Pas 2: per què va fallar el Job

kubectl describe job informes-ocupacio-29178180 -n rutas-norte-pro
Name:           informes-ocupacio-29178180
Parallelism:    1
Completions:    1
Pods Statuses:  0 Running / 0 Succeeded / 3 Failed
Conditions:
  Type     Status  Reason                Message
  ----     ------  ------                -------
  Failed   True    BackoffLimitExceeded  Job has reached the specified backoff limit
Events:
  Type     Reason                Age   From            Message
  ----     ------                ----  --------------  -------
  Normal   SuccessfulCreate      3h    job-controller  Created pod: informes-ocupacio-29178180-4kx2p
  Normal   SuccessfulCreate      3h    job-controller  Created pod: informes-ocupacio-29178180-9dz7w
  Normal   SuccessfulCreate      3h    job-controller  Created pod: informes-ocupacio-29178180-t6m1c
  Warning  BackoffLimitExceeded  3h    job-controller  Job has reached the specified backoff limit

Les raons més freqüents i què signifiquen:

Raó Significat On mirar
BackoffLimitExceeded Els pods van fallar més vegades de les permeses Logs dels pods, especialment el primer
DeadlineExceeded Es va esgotar activeDeadlineSeconds Bloqueig? Consulta lenta? Xarxa tallada?
FailedCreate No es va poder crear el pod ResourceQuota esgotada, PVC inexistent, SA sense permisos

Pas 3: el pod correcte i els seus logs

Amb restartPolicy: Never hi ha un pod per intent. Mira el primer, que sol contenir l'error original sense soroll de reintents:

kubectl get pods -n rutas-norte-pro \
  -l job-name=informes-ocupacio-29178180 \
  --sort-by=.metadata.creationTimestamp
NAME                               READY   STATUS   RESTARTS   AGE
informes-ocupacio-29178180-4kx2p   0/1     Error    0          3h
informes-ocupacio-29178180-9dz7w   0/1     Error    0          3h
informes-ocupacio-29178180-t6m1c   0/1     Error    0          3h
kubectl logs -n rutas-norte-pro informes-ocupacio-29178180-4kx2p
Generant informe d'ocupació de 2026-08-02
psql: error: connection to server at "postgres-reserves" (10.96.144.21), port 5432 failed:
        Connection timed out
        Is the server running on that host and accepting TCP/IP connections?

Un temps d'espera esgotat cap a la base de dades, amb el nom resolt correctament, apunta gairebé sempre a una NetworkPolicy que no autoritza aquella conversa: és exactament el que passaria si haguéssim oblidat el manifest de l'apartat 7.

Dreceres útils:

# Logs de tots els pods del Job d'un cop
kubectl logs -n rutas-norte-pro job/informes-ocupacio-29178180 --all-containers --tail=50

# Codi de sortida exacte del contenidor
kubectl get pod informes-ocupacio-29178180-4kx2p -n rutas-norte-pro \
  -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'

# Amb restartPolicy: OnFailure, el log de l'intent anterior
kubectl logs -n rutas-norte-pro <pod> --previous

Codis de sortida que convé reconèixer:

Codi Causa habitual
1 Error genèric de l'aplicació
2 Ús incorrecte d'una ordre de shell
126 L'ordre existeix però no és executable (permisos)
127 Ordre no trobada (típic d'un command mal escrit)
137 SIGKILL: gairebé sempre OOMKilled, es va superar limits.memory
143 SIGTERM: terminació ordenada, sovint per activeDeadlineSeconds

Neteja de l'historial

Amb ttlSecondsAfterFinished i els límits d'historial, la neteja és automàtica. Per al manteniment manual:

# Quants objectes acabats hi ha acumulats
kubectl get jobs -A --field-selector status.successful=1 --no-headers | wc -l

# Esborrar Jobs completats d'un namespace
kubectl delete jobs -n rutas-norte-pro --field-selector status.successful=1

# Esborrar pods Completed i Error de tot el clúster
kubectl delete pods -A --field-selector status.phase=Succeeded
kubectl delete pods -A --field-selector status.phase=Failed

Esborrar un Job elimina també els seus pods, perquè el Job és el seu ownerReference (el mecanisme de propietat que vam veure a 02-02). Si vols conservar els pods per inspeccionar-los:

kubectl delete job informes-ocupacio-29178180 -n rutas-norte-pro --cascade=orphan

Errors Comuns i Consells

Posar restartPolicy: Always en un Job. L'API ho rebutja amb un missatge clar (spec.template.spec.restartPolicy: Unsupported value: "Always"). Copiar i enganxar d'un Deployment n'és la causa habitual.

Oblidar la zona horària. Sense timeZone, un schedule: "15 3 * * *" es dispara en UTC, i a l'estiu això són les 05:15 a Espanya: dins de la finestra de manteniment d'altres equips o justament quan comença a haver-hi trànsit. Posa sempre timeZone: "Europe/Madrid".

Confiar en el concurrencyPolicy per defecte. Allow està bé per a tasques trivials, però per a qualsevol treball que toqui la base de dades o escrigui en un PVC compartit és una font de corrupció silenciosa. Pensa activament quin valor vols.

Un CronJob que no arrenca mai i no diu per què. Mira l'esdeveniment FailedNeedsStart amb "too many missed start times". Passa després d'una aturada llarga i es resol fixant startingDeadlineSeconds.

failedJobsHistoryLimit: 1. El valor per defecte et deixa sense evidència de la primera fallada, que és la més informativa. Puja'l a 5 en qualsevol treball amb importància operativa.

No posar ttlSecondsAfterFinished. Un CronJob cada cinc minuts genera gairebé 300 objectes al dia. Sense TTL ni límits d'historial, en setmanes tindràs desenes de milers de Jobs i pods a etcd, i l'apiserver se'n ressentirà.

Oblidar la NetworkPolicy a rutas-norte-pro. El símptoma és un Connection timed out cap a un nom que resol bé. I recorda incloure-hi la regla de sortida a CoreDNS: sense ella la fallada és un could not translate host name, encara més desconcertant.

Ignorar les ResourceQuota. Els Jobs consumeixen quota del namespace (03-04). Un Job amb parallelism: 20 pot esgotar-la i bloquejar el desplegament d'api-reserves. El símptoma és un esdeveniment FailedCreate amb exceeded quota.

Consell: kubectl create job --from=cronjob/<nom>. És l'eina de validació imprescindible. No despleguis mai un CronJob nou sense haver-lo disparat manualment almenys una vegada.

Consell: fes els teus treballs idempotents. Pot ser que s'executin dues vegades: per un reintent, per una execució manual, per una recuperació després d'una caiguda. IF NOT EXISTS, ON CONFLICT DO NOTHING i sobreescriure en comptes d'annexar són la diferència entre un incident i un no-esdeveniment.

Consell: registra sempre el principi i el final. Un echo en començar i un altre en acabar amb el resultat converteixen els logs en una cosa útil. Un Job que només escriu quan falla és un Job l'èxit del qual no pots verificar.

Exercicis

Exercici 1: Job indexat que reparteix línies

A rutas-norte-dev, crea un Job anomenat ocupacio-per-linia en mode Indexed amb 6 finalitzacions i paral·lelisme 2, que faci servir busybox:1.36. Cada pod ha d'imprimir quina línia de Rutas Norte li ha tocat (línia = índex + 1), trigar 5 segons i acabar. Afegeix-hi ttlSecondsAfterFinished d'una hora.

Verifica que s'han creat els 6 pods amb els seus índexs i que mai no n'hi va haver més de 2 corrent alhora.

Exercici 2: CronJob amb Forbid i prova manual

Crea un CronJob resum-vendes a rutas-norte-dev que s'executi cada 5 minuts en horari de Madrid, amb concurrencyPolicy: Forbid, startingDeadlineSeconds: 120, historial de 2 èxits i 3 fallades, i ttlSecondsAfterFinished d'una hora a la seva plantilla. El contingut ha de simular un resum que triga 30 segons.

Dispara una execució manual sense esperar el calendari i comprova els logs. Després suspèn el CronJob.

Exercici 3: diagnosticar un Job fallit

Crea deliberadament un Job informe-trencat a rutas-norte-dev amb backoffLimit: 2 i restartPolicy: Never, el contenidor del qual intenti connectar a un host inexistent postgres-inexistent i falli. Després:

  1. Esbrina la raó per la qual el Job està en Failed.
  2. Localitza el pod del primer intent i llegeix-ne el log.
  3. Obtén el codi de sortida del contenidor.
  4. Neteja.

Solucions

Solució 1

# /tmp/ocupacio-per-linia.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: ocupacio-per-linia
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  completionMode: Indexed
  completions: 6
  parallelism: 2
  backoffLimit: 3
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app: informes-ocupacio
        entorn: dev
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      containers:
        - name: calcul
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              LINIA=$(( JOB_COMPLETION_INDEX + 1 ))
              echo "índex=$JOB_COMPLETION_INDEX -> línia L$LINIA de Rutas Norte"
              sleep 5
              echo "línia L$LINIA calculada"
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
kubectl apply -f /tmp/ocupacio-per-linia.yaml
kubectl get pods -n rutas-norte-dev -l job-name=ocupacio-per-linia -w

Durant l'execució mai no es veuen més de dos pods en Running alhora, perquè parallelism: 2 ho impedeix:

NAME                         READY   STATUS      RESTARTS   AGE
ocupacio-per-linia-0-p4m2x   1/1     Running     0          3s
ocupacio-per-linia-1-r7k9d   1/1     Running     0          3s
ocupacio-per-linia-0-p4m2x   0/1     Completed   0          9s
ocupacio-per-linia-2-w3j5t   1/1     Running     0          1s
kubectl get job ocupacio-per-linia -n rutas-norte-dev
kubectl logs -n rutas-norte-dev ocupacio-per-linia-4-b8n6q
NAME                 STATUS     COMPLETIONS   DURATION   AGE
ocupacio-per-linia   Complete   6/6           27s        30s

índex=4 -> línia L5 de Rutas Norte
línia L5 calculada

L'índex 4 processa la línia 5, de manera determinista i reproduïble. Sense completionMode: Indexed els sis pods haurien executat la mateixa ordre sense saber quina era la seva porció.

Solució 2

# /tmp/resum-vendes.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: resum-vendes
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacio
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  schedule: "*/5 * * * *"
  timeZone: "Europe/Madrid"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 120
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 1
      activeDeadlineSeconds: 300
      ttlSecondsAfterFinished: 3600
      template:
        metadata:
          labels:
            app: informes-ocupacio
            entorn: dev
        spec:
          restartPolicy: Never
          automountServiceAccountToken: false
          containers:
            - name: resum
              image: busybox:1.36
              command:
                - sh
                - -c
                - |
                  echo "$(date '+%Y-%m-%d %H:%M:%S') inici del resum de vendes"
                  sleep 30
                  echo "bitllets venuts ahir: 1842 (dada ficticia)"
                  echo "resum completat"
              resources:
                requests:
                  cpu: 20m
                  memory: 32Mi
                limits:
                  cpu: 100m
                  memory: 64Mi
kubectl apply -f /tmp/resum-vendes.yaml
kubectl get cronjob resum-vendes -n rutas-norte-dev
NAME           SCHEDULE      TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
resum-vendes   */5 * * * *   Europe/Madrid   False     0        <none>          8s
# Execució manual sense esperar el calendari
kubectl create job -n rutas-norte-dev --from=cronjob/resum-vendes resum-manual
kubectl wait --for=condition=complete job/resum-manual -n rutas-norte-dev --timeout=120s
kubectl logs -n rutas-norte-dev job/resum-manual
2026-08-05 18:47:02 inici del resum de vendes
bitllets venuts ahir: 1842 (dada ficticia)
resum completat
# Suspendre i comprovar
kubectl patch cronjob resum-vendes -n rutas-norte-dev -p '{"spec":{"suspend":true}}'
kubectl get cronjob resum-vendes -n rutas-norte-dev
NAME           SCHEDULE      TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
resum-vendes   */5 * * * *   Europe/Madrid   True      0        3m              6m

Amb SUSPEND en True no es crearan més Jobs, però l'objecte i el seu historial continuen allà. És el que faries abans d'una finestra de manteniment de postgres-reserves.

Solució 3

# /tmp/informe-trencat.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: informe-trencat
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacio
    entorn: dev
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 300
  template:
    metadata:
      labels:
        app: informes-ocupacio
        entorn: dev
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      containers:
        - name: generador
          image: postgres:16.4
          command:
            - sh
            - -c
            - |
              echo "Connectant a la base de dades de reserves..."
              psql -h postgres-inexistent -U rutasnorte -d reserves -c 'SELECT 1'
          env:
            - name: PGCONNECT_TIMEOUT
              value: "5"
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
kubectl apply -f /tmp/informe-trencat.yaml
# Esperar uns dos minuts pel retrocés exponencial entre intents
kubectl get job informe-trencat -n rutas-norte-dev
NAME              STATUS   COMPLETIONS   DURATION   AGE
informe-trencat   Failed   0/1           2m14s      2m30s

1. Raó de la fallada:

kubectl describe job informe-trencat -n rutas-norte-dev | grep -A5 Conditions
Conditions:
  Type     Status  Reason                Message
  ----     ------  ------                -------
  Failed   True    BackoffLimitExceeded  Job has reached the specified backoff limit

Tres intents (l'original més dos reintents amb backoffLimit: 2) i cap no va tenir èxit.

2. Pod del primer intent i el seu log:

kubectl get pods -n rutas-norte-dev -l job-name=informe-trencat \
  --sort-by=.metadata.creationTimestamp \
  -o custom-columns=POD:.metadata.name,ESTAT:.status.phase,CREAT:.metadata.creationTimestamp
POD                     ESTAT    CREAT
informe-trencat-2xh4m   Failed   2026-08-05T18:52:03Z
informe-trencat-8kq7p   Failed   2026-08-05T18:52:18Z
informe-trencat-v5n1w   Failed   2026-08-05T18:52:49Z
kubectl logs -n rutas-norte-dev informe-trencat-2xh4m
Connectant a la base de dades de reserves...
psql: error: could not translate host name "postgres-inexistent" to address:
      Name or service not known

El missatge could not translate host name indica una fallada de resolució DNS, no de connectivitat: el nom no existeix. Si el nom existís però la NetworkPolicy bloquegés el trànsit, veuríem Connection timed out. Distingir aquests dos missatges estalvia moltíssim temps de diagnòstic.

3. Codi de sortida:

kubectl get pod informe-trencat-2xh4m -n rutas-norte-dev \
  -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'
2

Codi 2: psql no va poder connectar. Un 137 hauria indicat OOMKilled i un 127 una ordre inexistent.

4. Neteja:

kubectl delete -f /tmp/informe-trencat.yaml
kubectl delete -f /tmp/resum-vendes.yaml
kubectl delete -f /tmp/ocupacio-per-linia.yaml
kubectl delete job -n rutas-norte-dev resum-manual --ignore-not-found

Conclusió

Els Jobs i els CronJobs completen el catàleg de càrregues de treball de Kubernetes amb les que acaben. El Job executa pods fins a acumular completions èxits, amb parallelism com a control de càrrega, backoffLimit i activeDeadlineSeconds com a tallafocs, i ttlSecondsAfterFinished com a neteja automàtica. L'elecció entre restartPolicy: Never i OnFailure decideix si cada intent deixa el seu propi pod amb els seus logs —gairebé sempre el que vols— o si es reinicia el contenidor al lloc. Els tres patrons (tasca única, paral·lelisme fix, cua de treball) cobreixen la feina per lots, i completionMode: Indexed amb JOB_COMPLETION_INDEX converteix el segon en un repartiment determinista i reintentable.

El CronJob hi afegeix el calendari: cinc camps, timeZone per no dependre d'UTC, concurrencyPolicy: Forbid quan dues execucions simultànies serien perjudicials, startingDeadlineSeconds per decidir què fer amb les execucions perdudes i els límits d'historial per no omplir etcd.

Amb això Rutas Norte està completa: botiga-web, api-reserves, postgres-reserves sobre StatefulSet, redis-cache, worker-notificacions i ara informes-ocupacio generant cada matinada a les 03:15 el seu informe sobre un PVC, amb la seva ServiceAccount, els seus recursos i la NetworkPolicy que li obre pas a través del deny-all.

Tots els pods que hem escrit fins aquí tenen una cosa en comú: un únic contenidor. Però en desplegar la migració d'esquema va aparèixer una pregunta que vam deixar sense resposta: i si en comptes d'un Job separat volguéssim que la migració s'executés automàticament abans que arrenqui cada pod d'api-reserves? I si api-reserves hagués d'esperar que postgres-reserves acceptés connexions abans de considerar-se arrencada? I si volguéssim exportar mètriques de PostgreSQL sense modificar-ne la imatge? Tot això es resol posant més d'un contenidor al mateix pod, i és el tema de la lliçó següent: Init Containers, Sidecars i Patrons Multicontenidor.

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