Blog · 2. September 2025 · Aktualisiert am 7. September 2026 · 13 Min. Lesezeit

Kubernetes Jobs und CronJobs erklärt

Klassische analoge Kurzzeit-Zeitschaltuhr auf einem alten Gasherd
Foto: Jessica Lewis / Pexels

Ein Kubernetes Job startet einen oder mehrere Pods, lässt sie eine Aufgabe abarbeiten und gilt als erledigt, sobald die gewünschte Zahl an Pods erfolgreich beendet ist. Ein Kubernetes CronJob ist die Zeitschaltuhr davor: Er legt nach einem Cron-Zeitplan immer wieder neue Jobs aus einer Vorlage an. Der Job passt für einmalige Aufgaben wie eine Datenbankmigration, der CronJob für wiederkehrende wie den nächtlichen Backup-Lauf.

Beide Objekte sind bewusst schlicht gehalten, trotzdem stolpern viele Teams an denselben Stellen: fehlgeschlagene Jobs, die endlos neue Pods erzeugen, CronJobs, die sich überholen, oder Hunderte fertige Pods, die niemand aufräumt.

Ich betreibe Kubernetes-Cluster in Produktion und habe das Kubernetes-Praxisbuch bei Rheinwerk geschrieben (2024). Jobs und CronJobs laufen in meinen Clustern für Backups, Datenbank-Wartung und Importe, und die meisten Fehler hier sind mir im eigenen Betrieb oder in Kundenprojekten begegnet. Alle meine Beiträge zu Kubernetes findest du auf der Seite Kubernetes.

Job oder Deployment: wann eine Aufgabe ein Ende hat

Fast alles, was du bisher in Kubernetes angelegt hast, soll dauerhaft laufen. Ein Deployment hält eine feste Zahl an Pods am Leben und ersetzt jeden, der ausfällt. Für einen Webserver ist das richtig, für ein Skript, das eine Tabelle migriert und sich beendet, falsch: Das Deployment würde den Container als Absturz werten und immer wieder neu starten.

Ich erkläre den Unterschied gern mit zwei Arten von Mitarbeitern. Ein Job ist der externe Mitarbeiter mit klarem Projektauftrag: Er kommt, erledigt genau diese eine Sache und geht wieder. Das Deployment ist der festangestellte Kollege mit laufenden Aufgaben ohne Ende, und fällt er aus, muss jemand die Stelle sofort neu besetzen.

Job Deployment
Art der Aufgabe einmalig, mit definiertem Ende fortlaufend, ohne Ende
Was passiert nach Container-Ende Job zählt den Erfolg, Pod bleibt als Completed stehen Pod wird sofort neu gestartet
Reaktion auf Fehler neuer Versuch bis backoffLimit erreicht ist Neustart mit CrashLoopBackOff
Skalierung über completions und parallelism über replicas
Typische Beispiele Migration, Import, Datenkonvertierung, Queue leeren Webserver, API, Worker mit Dauerlast

Die Faustregel: Beendet sich dein Programm von selbst mit Exit-Code 0, sobald es fertig ist, gehört es in einen Job. Läuft es in einer Endlosschleife und wartet auf Anfragen, gehört es in ein Deployment.

Kubernetes Job: das Manifest und die Felder, die zählen

Ein Job-Manifest enthält ein Pod-Template, das du aus dem Deployment kennst, plus ein paar Felder nur für Jobs. Das folgende Beispiel startet fünf Pods, davon zwei gleichzeitig, jeder schläft drei Sekunden und beendet sich dann erfolgreich:

apiVersion: batch/v1
kind: Job
metadata:
  name: demo-job
spec:
  completions: 5
  parallelism: 2
  backoffLimit: 4
  activeDeadlineSeconds: 120
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: worker
          image: busybox:stable
          command: ["/bin/sleep"]
          args: ["3"]

completions legt fest, wie viele Pods erfolgreich abschließen müssen, damit der Job als erfolgreich gilt, parallelism bestimmt, wie viele davon gleichzeitig laufen dürfen. Beide stehen standardmäßig auf 1: ein Pod, eine Aufgabe, fertig. backoffLimit begrenzt die Wiederholungsversuche bei Fehlern, dazu gleich mehr. activeDeadlineSeconds ist der Not-Aus: Läuft der Job insgesamt länger als diese Frist, beendet Kubernetes alle Pods und markiert ihn als fehlgeschlagen. ttlSecondsAfterFinished räumt den fertigen Job nach der angegebenen Zeit automatisch weg.

Ein Feld verdient besondere Aufmerksamkeit: restartPolicy im Pod-Template. Bei einem Deployment ist Always der Standard, bei einem Job ist genau dieser Wert verboten. Du musst Never oder OnFailure setzen, sonst lehnt der API-Server das Manifest ab, der häufigste Anfängerfehler.

Nach dem kubectl apply beobachtest du den Fortschritt so:

$ kubectl get jobs
NAME       STATUS     COMPLETIONS   DURATION   AGE
demo-job   Running    2/5           9s         9s

$ kubectl get pods -l job-name=demo-job
NAME             READY   STATUS      RESTARTS   AGE
demo-job-4xk9p   0/1     Completed   0          9s
demo-job-b7m2q   0/1     Completed   0          9s
demo-job-hs8ww   1/1     Running     0          3s
demo-job-tq5lz   1/1     Running     0          3s

Der Job setzt automatisch das Label job-name auf jeden Pod, den er erzeugt. Damit findest du alle zugehörigen Pods, und mit kubectl logs -l job-name=demo-job bekommst du die Logs aller Pods auf einmal. Sie bleiben nach dem Ende als Completed stehen, damit du sie noch lesen kannst. Wie du das Manifest prüfst, erklärt Kubernetes YAML: Manifeste verstehen.

Drei Job-Typen: einmalig, parallel, Queue Worker

Mit completions und parallelism bildest du drei Muster ab, die fast alle Fälle in der Praxis abdecken:

Typ completions parallelism Wann der Job fertig ist Beispiel
Einmaliger Job 1 (Standard) 1 (Standard) ein Pod erfolgreich beendet Datenbankmigration, Initial-Import
Paralleler Job mit fester Anzahl n m n Pods erfolgreich beendet 1.000 Bilder konvertieren, in Batches
Queue Worker nicht gesetzt m ein Pod erfolgreich beendet, die übrigen dürfen zu Ende arbeiten Message Queue leeren

Der einmalige Job ist der Normalfall: Du setzt nichts, Kubernetes startet einen Pod. Schlägt er fehl, kommt ein neuer, bis einer durchläuft oder das backoffLimit erreicht ist.

Der parallele Job mit fester Anzahl passt für Arbeit, die sich in gleich große Stücke teilen lässt. Mit completions: 10 und parallelism: 3 laufen immer drei Pods gleichzeitig, bis zehn erfolgreich waren, jeder Pod mit derselben Spezifikation. Sollen die Pods verschiedene Teile der Arbeit erledigen, bietet Kubernetes dafür completionMode: Indexed: Jeder Pod bekommt seine Nummer als Umgebungsvariable JOB_COMPLETION_INDEX und wählt damit zum Beispiel seinen Teil einer Liste aus.

Der Queue Worker ist mein Lieblingsmuster, weil er so elegant ist. Du lässt completions weg und setzt nur parallelism. Mehrere Pods holen sich Nachrichten aus einer Queue, etwa RabbitMQ, und arbeiten sie ab. Sobald ein Pod feststellt, dass die Queue leer ist, beendet er sich mit Exit-Code 0. Kubernetes startet danach keine weiteren Pods, lässt die noch laufenden aber zu Ende arbeiten, und der Job gilt als erfolgreich. Die eine Voraussetzung: Dein Programm muss bei leerer Queue tatsächlich aufhören, sonst weiß der Job nie, wann er fertig ist. Im Buch baue ich dieses Muster mit RabbitMQ komplett durch, mit einem Publisher-Job für die Queue und einem Consumer-Job, der sie leert.

backoffLimit: was passiert, wenn ein Job fehlschlägt

Der Unterschied zwischen einem Job, der sauber scheitert, und einem, der den Cluster mit Pods flutet, liegt in drei Feldern: restartPolicy, backoffLimit und activeDeadlineSeconds.

Beendet sich ein Container mit einem Exit-Code ungleich 0, entscheidet zuerst die restartPolicy des Pods. Bei OnFailure startet Kubernetes den Container im selben Pod neu und zählt Neustarts hoch. Bei Never bleibt der fehlgeschlagene Pod als Error stehen, der Job erzeugt einen komplett neuen. Ich bevorzuge Never, weil dann jeder Versuch seinen eigenen Pod mit eigenen Logs hat.

backoffLimit legt fest, wie viele Fehlversuche der Job hinnimmt, bevor er als fehlgeschlagen gilt. Der Standardwert ist 6. Zwischen den Versuchen wartet Kubernetes exponentiell länger, 10 Sekunden, dann 20, dann 40, gedeckelt bei sechs Minuten, dasselbe Verhalten wie beim CrashLoopBackOff eines Deployments, nur mit einem Ende. Danach steht im Job der Status Failed mit dem Grund BackoffLimitExceeded, und es passiert nichts mehr, bis du eingreifst.

activeDeadlineSeconds ergänzt das um eine absolute Obergrenze. Ein Skript, das sich aufhängt und nie beendet, würde vom backoffLimit nicht erfasst, die Deadline schon: Nach Ablauf beendet Kubernetes alle laufenden Pods und setzt den Job auf Failed mit dem Grund DeadlineExceeded, mit Vorrang vor dem backoffLimit. Ich setze sie in jedem Produktions-Job, denn ein Backup, das nach zwei Stunden noch läuft, ist kein Backup mehr, sondern ein Problem.

Warum ein Job gescheitert ist, siehst du so:

$ kubectl describe job demo-job
...
Conditions:
  Type     Status  Reason                Message
  ----     ------  ------                -------
  Failed   True    BackoffLimitExceeded  Job has reached the specified backoff limit

$ kubectl get pods -l job-name=demo-job
NAME             READY   STATUS   RESTARTS   AGE
demo-job-2ndj7   0/1     Error    0          4m
demo-job-k8hxc   0/1     Error    0          3m
demo-job-vv5rn   0/1     Error    0          90s

$ kubectl logs demo-job-vv5rn

Die Events unter describe und die Logs des letzten Pods reichen fast immer für die Diagnose. Für gezieltere Regeln, etwa Job sofort abbrechen bei Exit-Code 42, gibt es die Pod Failure Policy, beschrieben in der Job-Dokumentation von Kubernetes.

Kubernetes CronJob: schedule, Zeitzone und das Manifest

Ein CronJob legt zu festgelegten Zeitpunkten einen Job aus einer Vorlage an, mehr ist es nicht: Der CronJob kennt kein Pod-Template direkt, sondern ein jobTemplate, und darin steckt alles, was du oben schon gesehen hast. Das folgende Beispiel ist ein typischer Fall aus meinen Clustern, ein nächtlicher Datenbank-Dump in einen S3-kompatiblen Object Storage:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: postgres-dump
spec:
  schedule: "30 2 * * *"
  timeZone: "Europe/Berlin"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: dump
              image: postgres:alpine
              command: ["/bin/sh", "-c"]
              args:
                - pg_dump "$DATABASE_URL" | gzip > /backup/db.sql.gz && echo done
              envFrom:
                - secretRef:
                    name: postgres-dump-credentials
              volumeMounts:
                - name: backup
                  mountPath: /backup
          volumes:
            - name: backup
              emptyDir: {}

Der Kern ist schedule, ein klassischer Cron-Ausdruck mit fünf Feldern: Minute, Stunde, Tag im Monat, Monat, Wochentag. Ein paar Beispiele aus der Praxis:

schedule Bedeutung
*/5 * * * * alle fünf Minuten
0 * * * * jede volle Stunde
30 2 * * * täglich um 2:30 Uhr
0 3 * * 0 sonntags um 3:00 Uhr
0 6 1 * * am Ersten jedes Monats um 6:00 Uhr
@daily einmal täglich um Mitternacht

Ohne das Feld timeZone interpretiert Kubernetes den Zeitplan in der Zeitzone des Controllers, meist UTC. „2:30 Uhr" ist dann 3:30 oder 4:30 Uhr deutscher Zeit, je nach Sommer- oder Winterzeit. Mit timeZone: "Europe/Berlin" läuft der Job zur Uhrzeit, die du meinst. Ich setze das Feld inzwischen in jedem CronJob. Der typische Schaden dieses Fehlers: Ein Backup-Lauf landet unbemerkt mitten in der morgendlichen Lastspitze.

Zum Beispiel selbst: Der Dump landet hier zur Vereinfachung in einem emptyDir, in Produktion würde das Skript ihn per aws s3 cp oder rclone in den Object Storage schieben, mit Zugangsdaten aus einem Secret, nie aus dem Manifest. Was sonst noch zu einem vollständigen Backup gehört, vor allem etcd und Volumes, steht in Kubernetes Backup: etcd und Volumes sichern.

concurrencyPolicy, startingDeadlineSeconds und History: die Felder für den Betrieb

Die vier Felder unterhalb von schedule im Beispiel sind es, die einen CronJob von einer Spielerei zu etwas machen, das du in Produktion laufen lassen kannst.

concurrencyPolicy regelt, was passiert, wenn der nächste Zeitpunkt kommt und der vorherige Job noch läuft. Allow ist der Standard und lässt beide parallel laufen, für einen Backup-Lauf fatal: Zwei Dumps schreiben gleichzeitig in dieselbe Datei. Forbid überspringt den neuen Lauf, Replace beendet den alten und startet den neuen. Für fast alles, was ich betreibe, ist Forbid die richtige Wahl.

startingDeadlineSeconds beantwortet die Frage, was gilt, wenn ein Zeitpunkt verpasst wurde, etwa weil der Controller kurz nicht lief oder Forbid einen Lauf blockiert hat. Innerhalb der Frist holt Kubernetes den verpassten Start nach, danach lässt es ihn ausfallen. Ohne dieses Feld gibt es keine Frist, und ein besonderer Fall kommt hinzu: Zählt Kubernetes mehr als 100 verpasste Läufe, startet der CronJob gar nicht mehr und schreibt nur einen Fehler ins Log, etwa nach langem suspend oder einem mehrtägigen Ausfall bei einem Zeitplan im Minutentakt. Eine gesetzte Deadline verhindert das.

successfulJobsHistoryLimit und failedJobsHistoryLimit bestimmen, wie viele fertige Jobs samt Pods der CronJob aufhebt. Standard sind 3 erfolgreiche und 1 fehlgeschlagener. Ich drehe den zweiten Wert gern hoch, weil ein einzelner fehlgeschlagener Job nach dem nächsten Fehlschlag samt Logs schon wieder weg ist, und die bräuchte ich am Montagmorgen.

Dazu kommt suspend: true, mit dem du einen CronJob anhalten kannst, ohne ihn zu löschen: Kubernetes legt keine neuen Jobs an, bis du das Feld wieder auf false setzt, der saubere Weg für ein Wartungsfenster. Die ausführliche Beschreibung aller Felder findest du in der CronJob-Dokumentation von Kubernetes.

CronJob manuell starten, prüfen und aufräumen

Der Zeitplan ist die eine Hälfte des CronJobs, die andere: Du kannst mit derselben Vorlage jederzeit einen Job von Hand starten. Der Befehl dafür:

$ kubectl create job postgres-dump-manual --from=cronjob/postgres-dump
job.batch/postgres-dump-manual created

$ kubectl get jobs
NAME                    STATUS     COMPLETIONS   DURATION   AGE
postgres-dump-29284510  Complete   1/1           48s        7h
postgres-dump-manual    Running    0/1           5s         5s

Das nutze ich öfter, als gedacht: Vor einer Migration ziehe ich ein Backup außer der Reihe, nach einer Änderung am Manifest teste ich sofort, statt bis 2:30 Uhr zu warten. Für Prozesse, die bewusst manuell angestoßen werden sollen, ist ein CronJob mit suspend: true und manuellem Start eine ehrliche Lösung: Das Manifest liegt versioniert im Repository, die Vorlage ist geprüft, und der Start ist ein Befehl statt eines YAML-Schnipsels.

Zum Aufräumen gibt es zwei Mechanismen, die sich ergänzen. Die History-Limits des CronJobs gelten nur für Jobs, die er selbst angelegt hat. Der manuell erzeugte Job von oben fällt nicht darunter, er bleibt stehen, bis du ihn löschst oder sein ttlSecondsAfterFinished abläuft. Deshalb setze ich die TTL im jobTemplate, dann gilt sie für beide Wege. Fertige Jobs, die niemand aufräumt, sind ärgerlich: Sie füllen die Übersicht, und ein zweites kubectl apply desselben Job-Manifests schlägt fehl, weil der Name schon vergeben ist. CronJobs hängen an ihre Jobs einen Zeitstempel an, deshalb entstehen dort keine Namenskonflikte.

In meinen Clustern liegen CronJobs wie alles andere als Manifest in Git und werden per GitOps ausgerollt. Anfangs hakte es an einer Stelle: Ein manuell gestarteter Job steht nicht im Repository, und das GitOps-Werkzeug zeigt ihn als Abweichung an oder räumt ihn weg. Die Lösung war, die TTL kurz zu halten und solche Jobs vom Abgleich auszunehmen.

Häufige Fragen

Was ist der Unterschied zwischen Job und CronJob in Kubernetes?

Ein Job führt eine Aufgabe einmal aus und ist fertig, wenn die geforderte Zahl an Pods erfolgreich beendet ist. Ein CronJob legt nach einem Zeitplan immer wieder neue Jobs aus einer Vorlage an, er ist also kein eigener Ausführungsmechanismus, sondern ein Erzeuger von Jobs, die du mit denselben Befehlen prüfst.

Wie starte ich einen Kubernetes CronJob manuell?

Mit kubectl create job <name> --from=cronjob/<cronjob-name> erzeugst du aus der Vorlage des CronJobs sofort einen Job, unabhängig vom Zeitplan. Der Name muss im Namespace eindeutig sein. Er zählt nicht zur History des CronJobs, du solltest ihn also selbst löschen oder eine TTL im jobTemplate setzen.

Was bedeutet backoffLimit bei einem Kubernetes Job?

backoffLimit ist die Zahl der Fehlversuche, nach der Kubernetes den Job als fehlgeschlagen markiert und keine neuen Pods mehr startet. Der Standard ist 6, mit steigenden Wartezeiten zwischen den Versuchen, beginnend bei 10 Sekunden, bis maximal sechs Minuten. Ein Job, der endlos hängt, wird davon nicht erfasst; dafür brauchst du activeDeadlineSeconds.

Warum läuft mein CronJob nicht zur erwarteten Zeit?

Die häufigste Ursache ist die Zeitzone: Ohne das Feld timeZone gilt die Zeitzone des Controllers, meist UTC. Die zweithäufigste ist concurrencyPolicy Forbid mit einem noch laufenden Vorgänger, dann wird der Lauf übersprungen. Prüfe mit kubectl get cronjob die Spalten LAST SCHEDULE und SUSPEND, mit kubectl describe cronjob die Events. Steht dort ein Hinweis auf zu viele verpasste Läufe, setze startingDeadlineSeconds.

Wie viele alte Jobs behält ein CronJob?

Standardmäßig die letzten 3 erfolgreichen und den letzten 1 fehlgeschlagenen Job, gesteuert über successfulJobsHistoryLimit und failedJobsHistoryLimit. Ältere Jobs löscht der CronJob samt Pods und Logs. Willst du Fehler nachvollziehen, setze das Limit für fehlgeschlagene Jobs höher oder leite die Logs in ein zentrales Logging.

Wie du weitermachst

Jobs und CronJobs sind die Objekte, mit denen du in Kubernetes alles betreibst, was ein Ende hat. Das Job-Manifest baut auf dem Pod-Template aus Kubernetes Deployment: Rollout erklärt auf, und wie du Manifeste sauber schreibst, steht in Kubernetes YAML: Manifeste verstehen. Der wichtigste Anwendungsfall für CronJobs in meinen Clustern, das Backup, hat einen eigenen Beitrag: Kubernetes Backup: etcd und Volumes sichern.

Mein Vorschlag für heute: Nimm den Job von oben, setze das Image auf einen Befehl, der mit exit 1 scheitert, und schau mit kubectl get pods -w zu, wie die Pods mit wachsenden Abständen erscheinen, bis das backoffLimit erreicht ist. Dann packst du dasselbe Template in einen CronJob mit */1 * * * * und concurrencyPolicy: Forbid und beobachtest, was ein Lauf über eine Minute Dauer auslöst.

Ausführlich mit allen Beispielen, inklusive dem kompletten Queue-Worker-Aufbau mit RabbitMQ, steht das in Kapitel 5 meines Kubernetes-Praxisbuchs bei Rheinwerk.

Kevin Welter

Kevin Welter

Entwickler, IT-Architekt, Fachbuchautor (Kubernetes, Cloud-Infrastrukturen) und Speaker. Betreibt sein Business mit einer KI-Belegschaft aus acht KI-Mitarbeitern und zeigt Selbstständigen in seiner Community, wie sie ihren ersten KI-Mitarbeiter einstellen.

Mehr über KI-Mitarbeiter

Kubernetes von den Grundlagen bis zum produktionsreifen Cluster

Zum Buch