Scheduled Job Failed¶
Runbook for the Grafana alert Scheduled Job Failed (k8s-cronjob-failed, group k8s-job-alerts).
What the alert means¶
A Kubernetes Job in the heezy namespace retried until it hit its backoffLimit and gave up. None
of the CronJobs set backoffLimit, so the default of 6 applies: the pod failed six times in a row
before the Job was marked Failed. That run of the schedule produced nothing. The next run fires on
schedule and is unaffected, so a single alert is usually a transient dependency blip, not a broken
job.
The alert fires 5 minutes after the failure is observed and clears once the Job ages past 2 hours. It does not clear because anything was fixed. Treat the resolve notification as "stop looking at the clock", not "resolved".
Reading the alert¶
The notification carries two labels:
| Label | Example | Meaning |
|---|---|---|
cronjob |
heezy-finance-sync |
The schedule that broke. This is what you act on. |
job_name |
heezy-finance-sync-29773320 |
The single Job object. The suffix is a unix-minute stamp, not a version. |
Get the logs¶
Do not use kubectl logs. The alert has a 5 minute for clause and Kubernetes garbage collects
the failed pod well before that, so by the time the message reaches Discord the pod is gone and
kubectl logs -n heezy job/<name> returns error: timed out waiting for the condition.
The logs are in Loki, which promtail ships from every pod in the namespace. Retention is 3 days.
Grafana → Explore → Loki datasource:
Drop the Job suffix to see every run of a schedule:
If the failure is older than 3 days, the logs are gone everywhere. Skip to Confirm current health and decide whether it is still happening.
Confirm current health¶
The Job object survives the pod. failedJobsHistoryLimit is 3 on every CronJob and no
ttlSecondsAfterFinished is set, so up to three failed Jobs per schedule sit in the namespace
indefinitely. They are history, not active problems.
Compare the AGE column against the schedule. If the newest Job for that CronJob is Complete, the
schedule has recovered on its own and there is nothing to do beyond noting the cause.
The schedules¶
| CronJob | Schedule (UTC) | Runs | Depends on |
|---|---|---|---|
heezy-finance-sync |
0 * * * * |
amazon_orders.py --hours 2 |
Gmail API, Ollama on big-boi, Postgres |
heezy-statement-scanner |
30 * * * * |
scan_statements.py |
nfs-heezy-ingest PVC, Postgres |
heezy-budget-alerts |
0 14 * * * |
budget_alerts.py |
Postgres, Discord webhook |
heezy-mailbot-amazon-forward |
*/5 * * * * |
app.jobs.amazon_forward --live |
Gmail API |
heezy-mailbot-receipt-ingest |
*/15 * * * * |
app.jobs.receipt_ingest --live |
Gmail API, receipts app |
heezy-mailbot-triage |
0 * * * * |
app.jobs.triage --live --hours 2 |
Gmail API |
ecr-credentials-refresh |
0 */6 * * * |
rewrites the ecr-credentials secret |
AWS ECR, in-cluster RBAC |
plex-config-backup |
0 4 * * * |
tars Plex config to NFS | plex-config PVC, NFS |
uptime-kuma-backup |
0 3 * * * |
copies kuma.db to NFS |
uptime-kuma-data PVC, NFS |
Details: Mailbot, Data Sources, Backups.
Common causes¶
| Symptom in the logs | Cause | Fix |
|---|---|---|
Fatal: missing Gmail env credentials |
The ExternalSecret did not project GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN |
Check the ExternalSecret synced, then the OpenBao path |
invalid_grant |
Gmail refresh token revoked or expired | Rotate the token, see Mailbot |
Connection refused to 192.168.1.21:11434 |
Ollama down on big-boi | Restart Ollama. heezy-finance-sync cannot parse order emails without it |
Connection refused to 192.168.1.21:5432 |
Postgres down or connection limit reached | Check big-boi |
ImagePullBackOff on the pod, no application logs |
ECR token expired | Check ecr-credentials-refresh ran in the last 6h. Its own failure cascades into every other job on the heezy-finance and heezy-mailbot images |
| Nothing in Loki at all | Pod never started | kubectl describe job -n heezy <job_name> and read the events |
An ecr-credentials-refresh failure is the one that fans out. Check it first when several
unrelated schedules alert at once.
Rerun a schedule by hand¶
CronJobs are declarative in heezy-k8s, so trigger a one-off Job from the CronJob rather than
editing anything:
Delete the manual Job when you are done reading its logs. It does not count against
failedJobsHistoryLimit and will otherwise linger.
Clearing stale failed Jobs¶
Old Failed Job objects are cosmetic but they clutter kubectl get jobs and keep
kube_job_status_failed at 1. The alert expression already ignores anything older than 2 hours, so
there is no need to clean them up to silence anything.
Deleting them is destructive and irreversible. State the exact Jobs first, then:
How the alert is built¶
The rule lives in ansible-heezy/roles/lgtm/templates/grafana-alerting.yml.j2 and is deployed by
the lgtm playbook. See Monitoring.
max by (cronjob, job_name) (
(kube_job_status_failed{namespace="heezy"} > 0)
* on (job_name) group_left (cronjob)
label_replace(kube_job_owner{namespace="heezy", owner_kind="CronJob"},
"cronjob", "$1", "owner_name", "(.*)")
* on (job_name) group_left ()
(kube_job_status_start_time{namespace="heezy"} > bool time() - 7200)
)
Three things are load bearing:
kube_job_ownerjoin.kube_job_status_failedonly carriesjob_name, which is stamped with a unix minute and does not identify the schedule. The join addscronjob.> bool, notand. The 2 hour window exists so a stale failed Job does not alert forever. It used to be written asand on(job_name) (kube_job_status_start_time > time() - 7200), which removed the series once the job aged out. Grafana then resolved the instance asMissingSeriesorNoDataand re-rendered the annotations against an empty label set, so every resolve notification readCronJob [no value] in namespace heezy exhausted its backoff limit. Multiplying by a bool keeps the series present at value 0, and the resolve carries its labels.> 0before the joins. Without it every Job in the namespace produces an instance, including the successful ones, and Grafana accumulates dozens of Normal instances.
[no value] in an old notification
Discord messages sent before 2026-08-14 show CronJob [no value]. That is the pre-fix resolve
behaviour described above, not a live problem. The job_name was never in that message, so
those alerts cannot be traced back to a schedule after the fact.