DevOps & CI/CD Bug Report

Kubernetes CronJob silently stopped for nine days: Forbid policy and a hung Job

Our nightly invoice export stopped running and nobody noticed for nine days. One Job had hung on a network call with no timeout, and with concurrencyPolicy Forbid every later schedule was skipped. Deadlines on the Job, timeouts in code and an alert on last success fixed it.

Ryan Pham Ryan Pham @ryan_bugs · March 11, 2026 · 6 min read · 17 Views
Banner: Kubernetes CronJob blocked because a previous Job hung and the policy forbids overlap

Finance asked why the nightly invoice export file had not appeared in their bucket for over a week. The CronJob existed, was not suspended, and had a valid schedule:

$ kubectl -n billing get cronjob invoice-export
NAME             SCHEDULE    TIMEZONE         SUSPEND   ACTIVE   LAST SCHEDULE   AGE
invoice-export   0 2 * * *   Europe/Berlin    False     1        9d              311d

ACTIVE 1 and LAST SCHEDULE 9d. One Job was still running, nine days later.

Reproducing it

$ kubectl -n billing get jobs -l app=invoice-export
NAME                      STATUS    COMPLETIONS   DURATION   AGE
invoice-export-29021400   Running   0/1           9d         9d
$ kubectl -n billing logs job/invoice-export-29021400 | tail -3
2026-02-27T01:00:04Z export: fetched 18211 invoices
2026-02-27T01:00:05Z export: uploading to partner SFTP

The pod was alive, using no CPU, stuck after "uploading to partner SFTP". The CronJob spec had concurrencyPolicy: Forbid.

What we ruled out

  • Controller or scheduling problems. Other CronJobs in the cluster ran normally.
  • Timezone or schedule syntax. LAST SCHEDULE matched the expected time on the day it hung.
  • Resource limits. The pod was not throttled or OOM-killed; it was simply waiting.

Root cause

The export uploads a file to a partner's SFTP server. That night the partner's server accepted the TCP connection and then stopped responding. Our SFTP client had no timeout on the transfer, so the process waited forever. The Job had no activeDeadlineSeconds, so Kubernetes never killed it. With concurrencyPolicy: Forbid, the CronJob controller skips a scheduled run while a previous Job is still active. Each night it skipped, and because the skipped runs are not failures, nothing alerted.

What is happening under the hood

At each scheduled time, the CronJob controller checks the concurrency policy:

Policy Previous Job still running
Allow (default) Starts another Job alongside it
Forbid Skips this run
Replace Deletes the running Job and starts a new one

Forbid is the right choice for exports that must not overlap, and it turns any hung Job into a permanent stop. startingDeadlineSeconds controls how late a missed run may still start; it does not help when runs are skipped because of Forbid.

A Job has its own controls: activeDeadlineSeconds limits total runtime, after which pods are terminated and the Job is marked failed with reason DeadlineExceeded. backoffLimit limits retries. Without a deadline, a Job can run indefinitely.

Environment

Component Version / value
Kubernetes 1.33 (AKS)
CronJob concurrencyPolicy: Forbid, timeZone: Europe/Berlin
Job No activeDeadlineSeconds, backoffLimit: 2
App Python 3.12, paramiko SFTP client
Monitoring Alerts on Job failures only

Minimal reproduction

apiVersion: batch/v1
kind: CronJob
spec:
  schedule: "*/2 * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: hang
              image: busybox:1.37
              command: ["sh", "-c", "sleep infinity"]

After the first run, no further Jobs are created.

The fix

1. Timeouts in the code. The SFTP client now has connect, banner and per-operation timeouts, and the whole upload has an overall deadline:

import paramiko

transport = paramiko.Transport((host, 22))
transport.banner_timeout = 15
transport.connect(username=user, pkey=key)
transport.set_keepalive(15)
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.get_channel().settimeout(60)           # per read/write operation

2. A deadline on the Job. The export normally takes 3 minutes. We allow 30:

spec:
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 3600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      activeDeadlineSeconds: 1800
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never

If the code timeouts fail for some reason, Kubernetes kills the Job after 30 minutes, it is marked failed, and the next night's run can start. startingDeadlineSeconds lets a run that was delayed (for example by a control plane upgrade at 02:00) still start within the hour.

3. Alert on success, not just failure. kube-state-metrics exposes the last successful time for CronJobs. We alert when it is older than expected:

- alert: CronJobNotSucceeding
  expr: time() - kube_cronjob_status_last_successful_time{cronjob="invoice-export"} > 26 * 3600
  for: 15m
  labels: {severity: page}
  annotations:
    summary: "invoice-export has not succeeded in over 26 hours"

We also send a heartbeat to an external dead man's switch service at the end of a successful run, which catches the case where the whole cluster or monitoring is down.

Alternatives and trade-offs

  • concurrencyPolicy: Replace. A hung Job is killed by the next run. Good for idempotent tasks; risky for exports where a half-finished upload and a new upload could both reach the partner.
  • Allow with application-level locking. More control, more code.

How to test the fix

In staging, we pointed the export at a test SFTP server that accepts connections and never responds (a small socat listener). The upload failed after 60 seconds with a timeout, the Job failed, and the alert test fired after we shortened its window. With the code timeout disabled, activeDeadlineSeconds killed the Job at 30 minutes and the next scheduled run started normally.

Confirming in production

  • We deleted the hung Job; the next run exported and uploaded nine days of invoices in one file, which finance accepted after confirming with the partner.
  • Since then, two partner outages caused upload timeouts; both Jobs failed within two minutes, alerts fired, and retries the next night succeeded.

Regression guard

  • A Kyverno policy requires activeDeadlineSeconds on every Job template in CronJobs.
  • Every CronJob gets a last-success alert from a shared Helm template, with the expected interval as a value.
  • Code review checklist item: every network client call has a timeout.

Security implications

None directly. The export contains invoice data, and the delayed export meant finance was working without current data for nine days, a business continuity problem rather than a security one. The SFTP connection uses key authentication and host key pinning; we checked that the timeout changes did not weaken host key verification.

Prevention checklist

  • Put timeouts on every network call in batch code.
  • Set activeDeadlineSeconds on Jobs, especially with Forbid.
  • Alert on time since last success, not only on failures.
  • Add an external heartbeat for critical scheduled jobs.
  • Choose the concurrency policy deliberately per job.

References

Add a label

#kubernetes #monitoring #cronjob #batch-jobs #timeouts
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.