Die meisten Kubernetes-Tutorials beginnen mit Deployments. Das ist sinnvoll. Eine Web-API, ein Frontend, ein Message-Consumer, der dauerhaft laufen soll — das sind langlaufende Services. Kubernetes erstellt Pods, beobachtet sie und ersetzt sie bei Ausfall.
Aber nicht jeder Workload ist ein Service.
Manche Aufgaben sollen einmal laufen und fertig werden: ein Datenbankschema migrieren, einen Monatsbericht erzeugen, einen Datensatz importieren, ein Verzeichnis sichern oder nachts einen Suchindex neu aufbauen. Andere sollen nach Plan laufen: temporäre Dateien jede Nacht aufräumen, Referenzdaten um 03:00 synchronisieren oder werktags einen Digest-Mail versenden.
Für diese Muster ist ein Deployment das falsche Werkzeug. Deployments gehen davon aus, dass der Pod weiterlaufen soll. Beendet sich der Container mit Exit-Code 0, behandelt Kubernetes das als Problem und startet einen neuen Pod. Für einen Server ist das richtig. Für ein einmaliges Skript ist es falsch.
Jobs und CronJobs existieren für Batch- und geplante Arbeit. Sie sind keine exotischen Objekte. Es sind Controller mit einem anderen Ziel: diese Arbeit ausführen, bis sie erfolgreich ist oder die Retry-Grenze erreicht ist — und dann stoppen.
Job vs Deployment: unterschiedliche Lebenszyklen
Ein Deployment sagt: halte N Kopien dieser Pod-Vorlage am Laufen. Stirbt ein Pod, erstelle Ersatz. Rollst du ein neues Image aus, ersetze schrittweise alte Pods durch neue. Der gewünschte Zustand ist dauerhafte Verfügbarkeit.
Ein Job sagt: erstelle einen oder mehrere Pods aus dieser Vorlage und führe sie aus, bis sie erfolgreich abgeschlossen sind. Ist die Arbeit erledigt, ist der Job erledigt. Kubernetes behandelt einen erfolgreichen Exit nicht als Fehler.
Der Unterschied klingt klein, bis man ein Migrationsskript als Deployment ausrollt und sich wundert, warum Kubernetes es nach dem Abschluss immer wieder neu startet.
Vergleich der Absicht:
| Frage | Deployment | Job |
|---|---|---|
| Soll der Container dauerhaft laufen? | Ja | Nein — er soll fertig werden |
| Bedeutet Exit-Code 0 Erfolg? | Meist wird der Pod neu erstellt | Bedeutet, dass der Job erfolgreich war |
| Typischer Einsatz | APIs, Worker, Webserver | Migrationen, Batch-Imports, einmalige Skripte |
| Bedeutung von Skalierung | Mehr dauerhaft laufende Replikate | Mehr parallele Task-Pods |
Wenn der Prozess dauerhaft auf einem Port lauschen soll, nutzt man ein Deployment. Wenn er „done” ausgeben und beenden soll, nutzt man einen Job.
Ein einfacher Job
Hier ein minimaler Job mit kurzem Befehl:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-job
namespace: demo
spec:
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: busybox:1.36
command: ["sh", "-c", "echo starting && sleep 5 && echo done"]
Anwenden und beobachten:
kubectl apply -f job.yaml
kubectl get jobs -n demo
kubectl get pods -n demo -l job-name=hello-job
kubectl logs -n demo -l job-name=hello-job
Zwei Felder sind sofort wichtig.
restartPolicy: Never (oder OnFailure) gehört in die Pod-Vorlage innerhalb des Jobs. Jobs nutzen nicht die Standard-Restart-Policy Always, auf die Deployments setzen. Fehlt sie oder steht Always drin, verhält sich der Job möglicherweise nicht wie erwartet.
Der Job-Controller erstellt einen Pod, wartet auf dessen Ende und protokolliert Erfolg oder Fehler am Job-Objekt.
Status prüfen:
kubectl describe job hello-job -n demo
In den Status-Feldern sucht man nach Succeeded. Ein abgeschlossener Job lässt seinen Pod standardmäßig bestehen, damit Logs lesbar bleiben. Das hilft beim Debugging. Es bedeutet auch, dass abgeschlossene Jobs sich ansammeln, solange man sie nicht aufräumt oder eine TTL setzt.
Einmalige Aufgaben in der Praxis
Echte einmalige Jobs wrappen meist etwas, das das Team ohnehin als Skript oder CLI ausführt:
- Datenbankmigrationen vor einem Release
- Daten-Backfills nach einer Schema-Änderung
- Datei-Exports in Object Storage
- Test-Harnesses, die genau einmal im Cluster-Kontext laufen müssen
Beispiel für einen Migrations-Job:
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate-20260812
namespace: demo
spec:
backoffLimit: 3
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: my-registry.example/app-migrate:1.4.2
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-db
key: url
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
backoffLimit steuert, wie oft Kubernetes bei einem fehlgeschlagenen Pod einen neuen Versuch startet, bevor der Job als fehlgeschlagen gilt. Dazu gleich mehr.
activeDeadlineSeconds ist ein Sicherheitsnetz. Läuft der Job länger als zehn Minuten, beendet Kubernetes ihn. So hängt keine Migration ewig fest.
Jobs so benennen, dass Menschen erkennen, was passiert ist. db-migrate-20260812 ist leichter zu auditieren als migrate.
Parallelität und Completions
Jobs können mehr als einen Pod ausführen. Zwei Felder steuern das:
completions: wie viele erfolgreiche Pod-Läufe der Job brauchtparallelism: wie viele Pods gleichzeitig laufen dürfen
Für ein einfaches Einmal-Skript kann man beides weglassen. Standard ist completions: 1 und parallelism: 1.
Für Arbeit, die sich auf Shards aufteilen lässt:
apiVersion: batch/v1
kind: Job
metadata:
name: shard-import
namespace: demo
spec:
completions: 5
parallelism: 2
template:
spec:
restartPolicy: Never
containers:
- name: importer
image: my-registry.example/importer:2.0.0
env:
- name: SHARD_INDEX
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
Jeder Pod bekommt bei aktiviertem indexed completion einen Index als Annotation. Das Muster eignet sich für partitionierte Batch-Arbeit.
Braucht man nur einen Pod, aber Retries bei Fehlern, bleibt completions: 1 und man justiert backoffLimit.
Backoff und Retries
Schlägt ein Job-Pod fehl — Crash, Exit ungleich null, Eviction, Image-Pull-Fehler — kann der Job-Controller einen weiteren Pod erstellen. Er versucht es nicht unbegrenzt.
backoffLimit ist standardmäßig 6. Das heißt: bis zu sechs fehlgeschlagene Pod-Versuche, bevor der Job den Status Failed bekommt. Zwischen Versuchen wartet Kubernetes mit exponentiellem Backoff, begrenzt auf wenige Minuten. In Events sieht man wachsende Abstände zwischen Versuchen.
Fehler untersuchen:
kubectl describe job db-migrate-20260812 -n demo
kubectl get pods -n demo -l job-name=db-migrate-20260812
kubectl logs -n demo <pod-name>
kubectl logs -n demo <pod-name> --previous
Fehlgeschlagene Job-Pods heißen oft db-migrate-20260812-abc12 mit zufälligem Suffix. Mehrere Pods durch Retries sind normal. Zuerst den neuesten Pod prüfen; --previous hilft, wenn der Container im selben Pod neu gestartet wurde.
Mit backoffLimit: 0 gibt es keine Retries. Sinnvoll, wenn ein Teil-Retry ohne idempotentes Skript Daten korrumpieren würde.
Häufiger Irrtum: „Der Job ist fehlgeschlagen, also versucht Kubernetes morgen weiter.” Nein. Ein fehlgeschlagener Job bleibt fehlgeschlagen, bis jemand ein neues Job-Objekt erstellt. Für wiederkehrende Arbeit nutzt man einen CronJob oder einen externen Scheduler.
CronJobs: Jobs nach Zeitplan
Ein CronJob erstellt Job-Objekte nach einem Cron-Zeitplan. Der Schedule-String nutzt das übliche fünfteilige Cron-Format:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-cleanup
namespace: demo
spec:
schedule: "0 3 * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: cleanup
image: my-registry.example/cleanup:1.0.0
command: ["sh", "-c", "find /tmp/work -type f -mtime +7 -delete"]
Wichtige CronJob-Felder:
schedule nutzt Minute, Stunde, Tag im Monat, Monat, Wochentag. "0 3 * * *" heißt täglich um 03:00. Schedules in YAML in Anführungszeichen setzen, damit * und Sonderzeichen den Parser nicht verwirren.
timeZone (in neueren Kubernetes-Versionen) lässt den Schedule eine benannte Zeitzone respektieren statt der UTC-Default-Zeit der Control Plane. Ohne dieses Feld: UTC annehmen.
concurrencyPolicy steuert Überlappung:
Allow— mehrere Jobs dürfen gleichzeitig laufen, wenn der vorherige noch läuftForbid— neuen Lauf überspringen, wenn der letzte noch nicht fertig istReplace— laufenden Job abbrechen und einen neuen starten
Für Aufräum- oder Sync-Tasks ist Forbid oft die sichere Default-Wahl. Für idempotente Metrics-Collector kann Allow passen.
successfulJobsHistoryLimit und failedJobsHistoryLimit halten den Namespace ordentlich. Ohne sie stapeln sich alte Jobs.
CronJobs prüfen:
kubectl get cronjobs -n demo
kubectl describe cronjob nightly-cleanup -n demo
kubectl get jobs -n demo --sort-by=.metadata.creationTimestamp
Zum Testen ohne auf die Uhr zu warten: einmaligen Job aus der CronJob-Vorlage erzeugen oder in einer Dev-Umgebung den Schedule kurzzeitig eine Minute voraus setzen.
Wann man keine Jobs nutzen sollte
Jobs sind einfach — deshalb lassen sie sich leicht missbrauchen.
Langlaufende Services. Soll der Prozess dauerhaft laufen und Traffic annehmen, nutzt man Deployment oder StatefulSet. Einen Webserver in einen Job zu packen und zu hoffen, ist kein Plan.
Arbeit mit dauerhafter Reconciliation. Ein Queue-Consumer, der den ganzen Tag läuft, ist ein Deployment, kein Job.
Komplexe mehrstufige Workflows. CronJobs feuern nach Plan. Sie ersetzen Argo Workflows, Tekton oder ähnliche Orchestratoren nicht, wenn Schritte verzweigen oder voneinander abhängen.
Nicht-idempotente Skripte mit Retries. Hinterlässt ein fehlgeschlagener Versuch schlechten Zustand, backoffLimit: 0 setzen oder das Skript vor dem Retry fixen.
Hochfrequente Schedules. Alle paar Sekunden per CronJob auszulösen erzeugt Objekt-Churn. Ein langlaufender Pod mit internem Ticker ist meist sauberer.
Bei der Wahl zwischen CronJob und Alternative lohnt die Frage: „Ist das eine kurze Aufgabe, die zu einer bestimmten Zeit starten und fertig werden soll?” Wenn ja, passt CronJob. Soll sie dauerhaft laufen und intern periodisch ticken, nutzt man ein Deployment.
Fehlgeschlagene Jobs debuggen
Wenn ein Job fehlschlägt, arbeite ich von oben nach unten.
Schritt 1: Job-Objekt lesen.
kubectl get job <job-name> -n <namespace>
kubectl describe job <job-name> -n <namespace>
Status, Conditions, Failed, Succeeded und Events unten prüfen. Meldungen zu BackoffLimitExceeded bedeuten: Retries aufgebraucht.
Schritt 2: Pod(s) finden.
kubectl get pods -n <namespace> -l job-name=<job-name>
kubectl describe pod <pod-name> -n <namespace>
Nach Init:-Fehlern, Image-Pull-Problemen, Mount-Fehlern, OOMKilled und Probe-Issues suchen. Jobs können Probes nutzen; viele Batch-Container beenden sich aber zu schnell, damit Probes relevant werden.
Schritt 3: Logs lesen.
kubectl logs <pod-name> -n <namespace>
kubectl logs <pod-name> -n <namespace> --previous
Application-Stacktraces stehen meist hier — nicht im Job-Statusfeld.
Schritt 4: Kontext prüfen — falscher Controller?
Zeigen Logs erfolgreichen Abschluss, erstellt der Job aber weiter Pods, steckt vielleicht ein langlaufender Entrypoint dahinter. Beendet sich das Skript sofort mit Code 0, erwartet der Cluster aber einen Server, war möglicherweise ein Job gemeint, wo ein Deployment hingehört.
Schritt 5: Bei CronJobs den zuletzt ausgelösten Job prüfen.
kubectl get jobs -n <namespace> --selector=job-name
kubectl describe cronjob <cronjob-name> -n <namespace>
Auf Last Schedule Time, suspend: true und Überspringen durch Concurrency Policy achten.
Fürs Aufräumen ttlSecondsAfterFinished an Jobs und History Limits an CronJobs setzen, damit abgeschlossene Läufe den Namespace nicht vollstopfen.
Abschluss
Jobs und CronJobs sind die Batch-Schicht von Kubernetes. Deployments halten Services am Leben. Jobs führen Arbeit bis zum Abschluss aus. CronJobs wrappen Jobs in Zeit.
Der Anfängerfehler ist selten das YAML. Es ist die falsche Lebenszyklus-Wahl. Soll der Container erfolgreich beenden und beendet bleiben, braucht man einen Job. Soll er nächsten Dienstag um 03:00 wieder laufen, einen CronJob. Soll er morgen noch laufen, wenn man nachschaut, braucht man etwas anderes.
Job-Status lesen, Backoff-Limits respektieren, über Pods und Logs debuggen — mit dieser Gewohnheit fühlt sich Batch-Arbeit in Kubernetes vorhersehbar statt überraschend an.