<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Cronjob · XB Field Notes</title><link>https://www.xbcatm.com/tags/cronjob/</link><description>Illustrated field notes on HTTP, DNS, TLS, Caddy, SSH, releases, and recovery.</description><generator>Hugo</generator><language>en-us</language><atom:link href="https://www.xbcatm.com/tags/cronjob/index.xml" rel="self" type="application/rss+xml"/><item><title>Kubernetes CronJobs need a history policy</title><link>https://www.xbcatm.com/posts/kubernetes-cronjob-cleanup-hooks/</link><pubDate>Fri, 25 Feb 2028 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/posts/kubernetes-cronjob-cleanup-hooks/</guid><description>A CronJob spawns a Job per schedule and the controller keeps a default history, so old pods collect unless you trim them.</description><content:encoded>&lt;p&gt;A CronJob does not run your work; it creates a Job, and the Job creates a pod. When the pod finishes, neither the Job nor the pod disappears. The pod is kept on purpose so you can still read its logs after the fact, and the Job is kept so you can still read its status.&lt;/p&gt;
&lt;p&gt;That default is right for one run and wrong for ten thousand. Every finished Job is an object in etcd, a row in every list response, and a watch event for every controller that cares about Jobs. Two separate mechanisms bound the pile, and they are independent: count-based history limits applied by the CronJob controller, and age-based TTL applied by the TTL controller.&lt;/p&gt;
&lt;h2 id="a-finished-job-is-not-free"&gt;A finished Job is not free&lt;/h2&gt;
&lt;p&gt;A completed Job keeps its pod, and a pod keeps its container logs for as long as the pod exists. Retention is the only thing between you and a namespace full of tombstones. Thirty days of an hourly schedule is 720 runs: 720 Jobs and 720 pods, each with a name, a status, an owner reference and a line in every &lt;code&gt;kubectl get jobs&lt;/code&gt;. A finished Job is not inert either: its status conditions stay in the API for every watcher to re-read. The cost per object is trivial, but it is linear in time, so it stays invisible until it is a problem.&lt;/p&gt;
&lt;p&gt;The visibility cost arrives before the resource cost. Once the namespace holds hundreds of finished Jobs, &lt;code&gt;kubectl get jobs&lt;/code&gt; no longer fits on a screen and a genuine failure is one more line among the successes. API server memory pressure is real but gradual; the degradation of your ability to debug is immediate, and it is what makes people reach for a cleanup policy. The arithmetic compounds quietly: an hourly job accumulates a year of tombstones, and a per-minute job gets there in a week.&lt;/p&gt;
&lt;h2 id="two-mechanisms-two-controllers"&gt;Two mechanisms, two controllers&lt;/h2&gt;
&lt;p&gt;The CronJob controller trims by count. &lt;code&gt;successfulJobsHistoryLimit&lt;/code&gt; defaults to 3 and &lt;code&gt;failedJobsHistoryLimit&lt;/code&gt; defaults to 1, and setting either to 0 keeps none of that class. The trim is per class and oldest-first, so a chronically failing job keeps only its newest failure. Neither limit is enforced by the API; an absent policy is a valid policy, and a leaking one.&lt;/p&gt;
&lt;p&gt;The TTL controller trims by age. &lt;code&gt;ttlSecondsAfterFinished&lt;/code&gt; lives on the Job, and the controller deletes the Job that many seconds after it finishes, cascading to its pods. &lt;code&gt;0&lt;/code&gt; makes the Job eligible immediately after it finishes; unset means the TTL controller never touches it. The feature is stable since v1.23 and reads the Job rather than the CronJob, which matters below. The two do not coordinate: whichever fires first wins, so a TTL shorter than your history window silently overrides the count you configured.&lt;/p&gt;
&lt;h2 id="owner-references-decide-who-cleans-up"&gt;Owner references decide who cleans up&lt;/h2&gt;
&lt;p&gt;Jobs created by a CronJob carry an owner reference to that CronJob, so deleting the CronJob cascades to its Jobs and their pods. Cascading deletion is why deleting a CronJob looks like it cleaned everything up: the Jobs and pods go with it, and the history limits never enter into it. The count-based trim, however, is performed by the CronJob controller, which needs the CronJob to exist in order to know the limits. &lt;code&gt;kubectl delete cronjob --cascade=orphan&lt;/code&gt; therefore leaves Jobs with a dangling owner reference and nothing left to trim them, while TTL still applies because it is evaluated per Job.&lt;/p&gt;
&lt;p&gt;For Jobs you create directly rather than through a CronJob, the default deletion policy is &lt;code&gt;orphanDependents&lt;/code&gt;: deleting the Job can leave its pods behind. The control plane garbage collects them eventually, but the docs warn that lingering pods can degrade a cluster and recommend &lt;code&gt;ttlSecondsAfterFinished&lt;/code&gt; for exactly that reason. A cleanup policy is a retention decision, not a hygiene preference.&lt;/p&gt;
&lt;h2 id="when-it-breaks"&gt;When it breaks&lt;/h2&gt;
&lt;p&gt;Setting both history limits to 0 to &amp;ldquo;keep the namespace clean&amp;rdquo; deletes every Job the moment it finishes, and the pod and its logs go with it. The one failure you needed to read is gone, with no recovery path. Keep a few successes and enough failures to cover a weekend; evidence you cannot read is not evidence.&lt;/p&gt;
&lt;p&gt;TTL only counts from completion, so a Job that never finishes never ages out. A pod stuck in &lt;code&gt;ImagePullBackOff&lt;/code&gt; leaves its Job active forever: not a success, not a failure, so neither history limit counts it. Give the job template an &lt;code&gt;activeDeadlineSeconds&lt;/code&gt; so a stuck run fails, and let TTL collect it.&lt;/p&gt;
&lt;h2 id="check-it-quickly"&gt;Check it quickly&lt;/h2&gt;
&lt;p&gt;Compare what is configured against what is actually accumulating:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;kubectl get cronjob hourly-rollup -o jsonpath=&amp;#39;{.spec.successfulJobsHistoryLimit} {.spec.failedJobsHistoryLimit} {.spec.jobTemplate.spec.ttlSecondsAfterFinished}{&amp;#34;\n&amp;#34;}&amp;#39;
kubectl get jobs --sort-by=.metadata.creationTimestamp --no-headers | wc -l
kubectl get pods --field-selector=status.phase=Succeeded --no-headers | wc -l
kubectl get jobs -o jsonpath=&amp;#39;{range .items[*]}{.metadata.name} ttl={.spec.ttlSecondsAfterFinished} owner={.metadata.ownerReferences[0].name}{&amp;#34;\n&amp;#34;}{end}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two empty values on the first line mean nothing bounds retention. A steady Job count near the sum of the two limits means the CronJob controller is trimming; a count that grows with the clock means it is not. In the last command, a Job with an empty &lt;code&gt;ttl&lt;/code&gt; and an empty &lt;code&gt;owner&lt;/code&gt; is the orphan case, and neither controller will ever remove it.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/job/"&gt;Kubernetes Job&lt;/a&gt;, &amp;ldquo;Clean up finished jobs automatically&amp;rdquo; and the TTL mechanism for finished Jobs, checked 2026-09-16.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/"&gt;Kubernetes CronJob&lt;/a&gt;, default history limits and the job template, checked 2026-09-16.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/"&gt;Kubernetes garbage collection&lt;/a&gt;, owner references and cascading deletion, checked 2026-09-16.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A CronJob turns a schedule into a pod</title><link>https://www.xbcatm.com/posts/k8s-cronjob-schedules-batch-work/</link><pubDate>Fri, 17 Dec 2027 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/posts/k8s-cronjob-schedules-batch-work/</guid><description>Kubernetes CronJobs create a short-lived pod on a schedule, giving batch jobs a home inside the cluster with retries and history bounds.</description><content:encoded>&lt;p&gt;A CronJob is a Job template plus a cron expression. Every time the expression matches, the controller creates a new Job, and that Job runs a pod to completion. Nothing stays resident between runs: a batch job inherits the cluster&amp;rsquo;s retry and scheduling machinery without a daemon kept alive to trigger it.&lt;/p&gt;
&lt;p&gt;The tension is that a schedule is only approximately honoured: Kubernetes promises a Job &amp;ldquo;approximately once per execution time&amp;rdquo;, covering both failure directions — two Jobs for one tick, or none at all. Anything you schedule has to be safe to run twice, because the guarantee is at-least-once, not exactly-once.&lt;/p&gt;
&lt;h2 id="a-schedule-makes-a-job"&gt;A schedule makes a job&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;schedule&lt;/code&gt; takes the familiar five fields — minute, hour, day of month, month, day of week — with Vixie-cron ranges and steps, so &lt;code&gt;*/10 * * * *&lt;/code&gt; fires every ten minutes and &lt;code&gt;*/15 9-17 * * mon-fri&lt;/code&gt; stays inside business hours. &lt;code&gt;?&lt;/code&gt; means &lt;code&gt;*&lt;/code&gt;, and &lt;code&gt;@daily&lt;/code&gt; and &lt;code&gt;@hourly&lt;/code&gt; expand to their equivalents. A CronJob with no &lt;code&gt;timeZone&lt;/code&gt; follows the controller manager&amp;rsquo;s clock, so moving the control plane changes when it runs. A missing field is a validation error, one of the few CronJob mistakes that fails loudly.&lt;/p&gt;
&lt;p&gt;Each match produces a separate Job named after the CronJob, which is why the CronJob name is capped at 52 characters: the controller appends eleven characters and the Job name must still fit in 63. A CronJob never runs the work itself; it only creates Jobs, which own the pods. Since Kubernetes v1.32 the Job also carries a &lt;code&gt;batch.kubernetes.io/cronjob-scheduled-timestamp&lt;/code&gt; annotation holding the time it was meant to run, the only way to tell a late run from an on-time one after a controller restart.&lt;/p&gt;
&lt;h2 id="concurrency-is-a-policy-not-a-guess"&gt;Concurrency is a policy, not a guess&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;concurrencyPolicy&lt;/code&gt; decides what happens when a tick arrives while the previous Job is still running. &lt;code&gt;Allow&lt;/code&gt;, the default, starts a second copy. &lt;code&gt;Forbid&lt;/code&gt; skips the tick. &lt;code&gt;Replace&lt;/code&gt; cancels the running Job and starts a fresh one. The policy applies only to Jobs from the same CronJob, so two CronJobs never block each other.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Replace&lt;/code&gt; is the dangerous one: if the Job reliably outruns its interval, every tick kills the run that was about to finish and the work never completes. &lt;code&gt;Forbid&lt;/code&gt; is usually what people mean by &amp;ldquo;don&amp;rsquo;t overlap&amp;rdquo;, with two caveats — skipped runs are lost, and once the running Job finishes, &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; is still evaluated, so a long-overdue run may start anyway.&lt;/p&gt;
&lt;h2 id="missed-runs-have-a-budget-of-a-hundred"&gt;Missed runs have a budget of a hundred&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;startingDeadlineSeconds&lt;/code&gt; bounds how late a missed run is still worth starting; past that deadline the run is skipped and counted as failed, and skipping one occurrence does not stop the schedule, because the next matching time still fires. Left unset, missed runs have no deadline of their own, but they are still subject to the controller&amp;rsquo;s hard cap. It counts the schedules missed since the last scheduled time, and above one hundred it starts nothing and logs &lt;code&gt;too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The documented example is concrete. A per-minute CronJob whose controller is down from 08:29 to 10:21 has missed 112 schedules, so nothing catches up on recovery. Add &lt;code&gt;startingDeadlineSeconds: 200&lt;/code&gt; and the controller instead counts only the schedules missed inside the last 200 seconds — three of them — and the Job does start. The same field hides a trap: values below ten seconds are unreliable, because the controller only re-evaluates every ten seconds. &lt;code&gt;suspend: true&lt;/code&gt; parks a CronJob without touching Jobs already running, but suspended executions count as missed, so unsuspending without a deadline fires the whole backlog at once.&lt;/p&gt;
&lt;h2 id="when-it-breaks"&gt;When it breaks&lt;/h2&gt;
&lt;p&gt;The classic failure is a Job that overlaps itself. A twenty-five minute report on a ten-minute schedule, with the default &lt;code&gt;Allow&lt;/code&gt;, peaks at three concurrent copies reading the same input and writing the same output. It never presents as an error: duplicated rows, doubled API spend, or a lock timeout in one copy out of three. Either move to &lt;code&gt;Forbid&lt;/code&gt; and accept skipped runs, or make the Job idempotent with a lease so the late copy exits early.&lt;/p&gt;
&lt;p&gt;The second failure is silent catch-up loss. A control-plane outage longer than a hundred schedule intervals means the whole backlog is discarded on recovery, and the only trace is a line in the controller manager&amp;rsquo;s log. That is right for a per-minute poll and wrong for a nightly backup, which is what &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; is for: setting it to the real tolerance — eight hours for a twice-daily backup — both bounds how stale a run may be and keeps the hundred-interval cap permanently out of reach.&lt;/p&gt;
&lt;h2 id="check-it-quickly"&gt;Check it quickly&lt;/h2&gt;
&lt;p&gt;Read the schedule, the policy and the last firing time together:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;kubectl get cronjob nightly-report -o jsonpath=&amp;#39;{.spec.schedule} {.spec.concurrencyPolicy} {.spec.startingDeadlineSeconds}{&amp;#34;\n&amp;#34;}&amp;#39;
kubectl get cronjob nightly-report -o jsonpath=&amp;#39;{.status.lastScheduleTime}{&amp;#34;\n&amp;#34;}&amp;#39;
kubectl get jobs --sort-by=.metadata.creationTimestamp -o custom-columns=NAME:.metadata.name,START:.status.startTime,SUCCEEDED:.status.succeeded
kubectl get events --field-selector involvedObject.kind=CronJob --sort-by=.lastTimestamp | tail -5
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;lastScheduleTime&lt;/code&gt; should sit within one schedule interval of now; a gap of hours means the controller was down or the expression never matched. Overlapping &lt;code&gt;START&lt;/code&gt; values with &lt;code&gt;SUCCEEDED&lt;/code&gt; empty mean &lt;code&gt;Allow&lt;/code&gt; plus a Job that outruns its interval, and a &lt;code&gt;too many missed start times&lt;/code&gt; event names the missed-run rule directly.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/"&gt;Kubernetes CronJob&lt;/a&gt;, schedule syntax, concurrency policy, the 100-missed-schedule rule and history limits, checked 2026-09-16.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/reference/kubernetes-api/batch/cron-job-v1/"&gt;CronJob v1 API reference&lt;/a&gt;, field-level definition of the CronJob spec, checked 2026-09-16.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>