Intro
Upgrading Kubernetes CronJobs or migrating them between clusters is rarely a single kubectl apply. Production-grade operations require a sequence that starts with observing the current state, documents the smallest reversible change, and ends with concrete verification commands where the expected output is known in advance. This article is a practical walkthrough of exactly that sequence.
You will find a full workflow covering version inventory, manifest changes from the deprecated batch/v1beta1 to the stable batch/v1 API, safe configuration, validation, failure modes with rollback, and an operations checklist. Each section includes real commands, example YAML snippets, expected outputs, and decision criteria, so you can adapt the steps to your own environment.
The intended audience is developers, DevOps engineers, SREs, and technical startup teams who run scheduled jobs in Kubernetes. The focus is on CronJobs specifically, but the surrounding concerns (cluster version, container registry, CI/CD pipelines) are included where they directly impact upgrade safety.
Before applying anything to a shared cluster, create a disposable namespace for your dry runs. This limits blast radius and lets you test the full migration path without touching production workloads.
kubectl create namespace cronjob-migration-test
kubectl config set-context --current --namespace=cronjob-migration-test
Now you have a safe playground to follow along with every example below.
Version and Environment Inventory
The first step in any CronJob upgrade or migration is to know exactly what you are running. The Kubernetes CronJob API has changed over time: batch/v1beta1 was the original API version, deprecated since Kubernetes 1.21, and removed in Kubernetes 1.25. The stable batch/v1 API has been available since Kubernetes 1.21. Your migration path depends heavily on which cluster version you are running and which CronJob objects you have.
Target a clean migration to batch/v1. Here is the phase table:
| Cluster Version | batch/v1beta1 Status | batch/v1 Status | Required Migration Action |
|---|---|---|---|
| < 1.21 | Available | Not available | Upgrade cluster first, then migrate CronJobs |
| 1.21 - 1.24 | Deprecated | Available | Migrate CronJobs to batch/v1 before cluster upgrade to 1.25 |
| >= 1.25 | Removed | Available | All CronJobs must be batch/v1 |
If you are on Kubernetes 1.24 or earlier, you can still read batch/v1beta1 CronJobs with the API server. On 1.25 onward, requests to the old endpoint fail with a 404. If your GitOps tooling or CI/CD pipeline still references the old API, those manifests will fail to apply after the cluster upgrade.
Check Cluster Version and API Availability
First, capture the cluster version. This determines whether the old API is still served.
kubectl version --short
# Example output on 1.24:
# Client Version: v1.24.10
# Server Version: v1.24.10
To see which API versions are available for CronJobs in your cluster, use kubectl api-versions.
kubectl api-versions | grep batch
# Example output on 1.24:
# batch/v1
# batch/v1beta1
# On 1.25+, only batch/v1 appears.
Do not assume availability just from the cluster version: an API can be disabled via feature gates or API server flags. The api-versions command is the ground truth.
List All Existing CronJobs and Their API Versions
Now enumerate every CronJob across all namespaces, including the API version stored in each object. Use kubectl get cronjobs with a wide output.
kubectl get cronjobs --all-namespaces -o wide
# Example output:
# NAMESPACE NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE CONTAINERS IMAGES
# default db-backup 0 2 * * * False 0 3m 12d backup postgres:15
The -o wide output does not show the API version. To capture it, use a JSON query.
kubectl get cronjobs --all-namespaces -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{" apiVersion="}{.apiVersion}{"\n"}{end}'
# Example output:
# default/db-backup apiVersion=batch/v1beta1
# default/reports apiVersion=batch/v1
Write this output to a file. It becomes your migration inventory:
kubectl get cronjobs --all-namespaces -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{" apiVersion="}{.apiVersion}{"\n"}{end}' > cronjob-inventory.txt
cat cronjob-inventory.txt
Assess Specific CronJob Health
Before modifying anything, verify that the existing CronJobs are actually functioning. Describe one to see its recent events.
kubectl describe cronjob db-backup -n default
# Look for Events section:
# Events:
# Type Reason Age From Message
# ---- ------ ---- ---- -------
# Normal SuccessfulCreate 3m cronjob-controller Created job db-backup-28463140
# Normal SawCompletedJob 2m cronjob-controller Saw completed job: db-backup-28463140, status: Complete
If there are no events, or the events show failures, fix the underlying issue before attempting an API migration. Migrating a broken CronJob only changes its API version, not its behavior.
Capture Current State for Rollback
Always have a way back. Store the current manifests in a version-controlled directory:
mkdir -p cronjob-backup/pre-migration
kubectl get cronjob db-backup -n default -o yaml > cronjob-backup/pre-migration/db-backup.yaml
If your CronJob was created via Helm or another tool, do not rely solely on the live object; also keep the original chart values or manifests. For manual rollback, applying the saved YAML is the fastest path.
Safe Configuration Path
Once you have the inventory and a backup, the next step is to define the target configuration. The primary goal is to migrate the CronJob manifest from batch/v1beta1 to batch/v1. The batch/v1 CronJob is structurally identical to the beta, so the change is usually limited to the apiVersion field. However, some clusters have additional validation or defaulting rules, so a dry-run apply is recommended.
Example: Migrating a CronJob Manifest
Here is a typical batch/v1beta1 CronJob:
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: reports
namespace: production
spec:
schedule: "0 1 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report-generator
image: registry.example.com/reports:1.4.2
command: ["/usr/local/bin/generate-report"]
env:
- name: REPORT_BUCKET
value: "s3://reports-archive"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
To migrate, change apiVersion to batch/v1.
apiVersion: batch/v1
kind: CronJob
metadata:
name: reports
namespace: production
spec:
schedule: "0 1 * * *"
# ... everything else remains the same
Use kubectl apply with --dry-run=client or --dry-run=server
Before applying to the live cluster, validate the manifest. --dry-run=client checks the YAML syntax and basic schema. --dry-run=server sends the request to the API server without persisting it, which is the most accurate validation you can get.
kubectl apply -f reports-cronjob.yaml --dry-run=server
# Expected output (if valid):
# cronjob.batch/reports created (server dry run)
# If the API version is removed, you get:
# error: unable to recognize "reports-cronjob.yaml": no matches for kind "CronJob" in version "batch/v1beta1"
If the server dry run succeeds, you are safe to apply for real. If it fails, inspect the error message and adjust the manifest accordingly. Never skip this step; it catches version mismatches, missing fields, and invalid values instantly.
Set concurrencyPolicy and History Limits Explicitly
A CronJob upgrade or migration is a good time to review the spec. Two fields often cause surprise in production:
concurrencyPolicy: Forbidprevents overlapping runs. Default isAllow, which can lead to duplicate jobs if one run overruns.successfulJobsHistoryLimitandfailedJobsHistoryLimitcontrol how many completed Job objects are kept. Defaults are 3 and 1, which are usually fine. Setting them too low removes useful audit history; too high clutters the cluster.
Example of a hardened CronJob spec:
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 300
jobTemplate:
spec:
backoffLimit: 4
activeDeadlineSeconds: 3600
startingDeadlineSeconds: 300 means if the CronJob controller misses the scheduled start by more than 5 minutes (e.g., controller was down), it will not run that iteration, preventing a burst of catch-up jobs. backoffLimit and activeDeadlineSeconds bound the job's own retries and total runtime.
Verify with a Local Test
To be extra careful, apply the migrated CronJob in a separate namespace first and manually trigger a job to see it work end to end.
kubectl create namespace cronjob-migration-test
kubectl apply -f reports-cronjob.yaml -n cronjob-migration-test
# Then create a test job from the CronJob using kubectl create job --from
kubectl create job manual-reports-test --from=cronjob/reports -n cronjob-migration-test
# Watch the job run
kubectl get jobs -n cronjob-migration-test
# NAME COMPLETIONS DURATION AGE
# manual-reports-test 1/1 42s 42s
If the test job succeeds, your CronJob configuration is valid under the new API version. If it fails, inspect logs and events to fix the issue before migrating the production CronJob.
Verification and Diagnostics
After applying the migrated CronJob, you need to verify that it is correctly configured and will run as expected. Verification is not just checking that the resource exists; it is confirming the schedule is interpreted correctly, the controller can create jobs, and those jobs complete successfully.
Verify the CronJob Object
First, confirm the CronJob is now using batch/v1.
kubectl get cronjob reports -n production -o jsonpath='{.apiVersion}{"\n"}'
# Expected: batch/v1
Then review the computed schedule. A common mistake is setting a schedule string that Kubernetes interprets differently than intended. The cronjob controller uses the standard cron format with five fields (minute hour day-of-month month day-of-week). Use kubectl describe to see the schedule and last schedule time.
kubectl describe cronjob reports -n production
# Output snippet:
# Schedule: 0 1 * * *
# Concurrency Policy: Forbid
# Suspend: False
# Last Schedule Time: 2025-03-12T01:00:00Z
If Suspend is True, the CronJob will never run. Also, check Last Schedule Time to see if the controller has been actively scheduling jobs. If the last schedule time is old and no jobs exist, investigate controller logs.
Watch Job Creation and Completion
Manually trigger a job from the CronJob to simulate the normal execution. This validates the job template without waiting for the next scheduled time.
kubectl create job reports-manual-test --from=cronjob/reports -n production
kubectl get jobs -n production
# NAME COMPLETIONS DURATION AGE
# reports-manual-test 1/1 35s 35s
If the job does not complete, inspect the pod logs and events.
kubectl logs jobs/reports-manual-test -n production
# If the pod is in a crash loop, get the previous logs:
kubectl logs jobs/reports-manual-test -n production --previous
Check CronJob Controller Health
The CronJob controller is part of the kube-controller-manager. If it is not running or is unhealthy, no CronJobs will be scheduled. Check its status by looking at the controller manager pods in the kube-system namespace.
kubectl get pods -n kube-system | grep controller-manager
# Expected output (varies by cluster):
# kube-controller-manager-control-plane 1/1 Running 0 42d
If the controller manager is in a crash loop, check its logs for errors related to CronJob sync.
kubectl logs -n kube-system kube-controller-manager-control-plane | grep -i cronjob
Validate Time Zones and Schedule Semantics
Kubernetes CronJobs do not support CRON_TZ. The schedule is always evaluated in the controller's time zone, which is usually UTC. If you need a job to run at a specific local time, convert the time to UTC yourself. For example, to run at 1:00 AM Eastern Standard Time (UTC-5), the schedule should be 0 6 * in UTC. Be explicit in your documentation to avoid confusion.
Failure Modes and Recovery
Even with careful planning, upgrades and migrations can fail. This section covers the most common failure modes for CronJob migration and provides concrete recovery steps.
Failure: API Version Removed Before Migration
If you try to apply a batch/v1beta1 CronJob on a Kubernetes 1.25+ cluster, the API server rejects it.
kubectl apply -f old-cronjob.yaml
# Error output:
# error: unable to recognize "old-cronjob.yaml": no matches for kind "CronJob" in version "batch/v1beta1"
Recovery: Edit the manifest to use apiVersion: batch/v1 and apply again. If you have many manifests, use a find-and-replace across your repository or a tool like kubectl convert (if still available) to convert in bulk. Do not attempt to use the old API by downgrading the cluster; that is not a supported path.
Failure: Job Template References Missing ConfigMap or Secret
The CronJob apply succeeds, but when a job is created, the pod fails with CreateContainerConfigError or InvalidImageName.
kubectl describe pod reports-manual-test-abcde -n production
# Events:
# Warning Failed 10s (x2 over 30s) kubelet Error: configmap "report-config" not found
Recovery: Recreate the missing ConfigMap or Secret in the target namespace. Ensure the CronJob's namespace matches where the config objects live. If you migrated a CronJob to a new cluster, you must migrate its dependencies (ConfigMaps, Secrets, ServiceAccounts, PersistentVolumeClaims, etc.) as well.
Failure: Incorrect ServiceAccount Permissions
After a cross-cluster migration, the ServiceAccount referenced in the CronJob may not exist or may lack RBAC permissions. The pod starts but the job fails with authorization errors.
kubectl logs jobs/reports-manual-test -n production
# Example error:
# Error from server (Forbidden): configmaps is forbidden: User "system:serviceaccount:production:reports-sa" cannot list resource "configmaps" in API group "" in the namespace "production"
Recovery: Create the ServiceAccount and necessary Role or ClusterRole bindings in the new cluster. Use kubectl auth can-i --list --as=system:serviceaccount:production:reports-sa -n production to audit permissions.
Failure: Schedule Evaluation Leads to Unexpected Runs
After migration, the CronJob runs at unexpected times. Common causes include time zone confusion or a schedule string that was previously set and now behaves differently due to controller upgrades.
Recovery: Review the schedule string in the manifest and compute the next few runs using a tool like crontab.guru or a local cron parser. If the schedule is incorrect, fix the manifest and apply again. If the schedule is correct but runs are still off, verify the controller's time zone and adjust the schedule accordingly.
Failure: Overlapping or Concurrency Issues
If concurrencyPolicy is set to Allow (default) and jobs take longer than the schedule interval, multiple jobs may run concurrently, causing resource contention or data corruption.
Recovery: Set concurrencyPolicy: Forbid to prevent overlapping runs. If you need to allow some overlap but not unlimited, use Replace to kill the existing job before starting a new one. Apply the change and monitor for the next scheduled run.
Rollback Procedure
If a migration causes unforeseen issues (e.g., a bug in the new image, or a job behaves differently under the new API), you need to roll back. Because the batch/v1 and batch/v1beta1 CronJob specs are identical in most fields, rolling back the API version is trivial: re-apply the saved backup manifest (which has the old apiVersion) if the cluster still supports it. For clusters that no longer serve the old API, you cannot roll back to batch/v1beta1; instead, you must fix the problem while staying on batch/v1.
Rollback steps:
- Suspend the CronJob immediately to stop new job creation.
kubectl patch cronjob reports -n production -p '{"spec":{"suspend":true}}'
- Revert to a previous manifest from your backup directory.
kubectl apply -f cronjob-backup/pre-migration/db-backup.yaml
- Verify the rollback by checking the API version and schedule.
kubectl get cronjob db-backup -n production -o jsonpath='{.apiVersion}{"\n"}'
- Unsuspend if the rollback is effective and you want to resume normal operation.
kubectl patch cronjob db-backup -n production -p '{"spec":{"suspend":false}}'
Always have a rollback plan written down before you start the migration. In the middle of an incident, you will not have time to remember the exact commands.
Operations Checklist
Use the following checklist to execute a CronJob upgrade/migration safely. Each item includes the command and expected outcome.
| Step | Action | Command / Criteria | Expected Output / Signal |
|---|---|---|---|
| 1 | Record cluster version | kubectl version --short | Server Version >= 1.21 for batch/v1 |
| 2 | Confirm API availability | kubectl api-versions | grep batch | Both batch/v1 and batch/v1beta1 on 1.21-1.24; only batch/v1 on 1.25+ |
| 3 | Inventory all CronJobs | kubectl get cronjobs --all-namespaces -o wide | List of all CronJobs with API versions |
| 4 | Backup existing CronJob manifests | kubectl get cronjob <name> -n <ns> -o yaml > backup.yaml | Saved YAML file for rollback |
| 5 | Update apiVersion to batch/v1 in manifest | Edit YAML | apiVersion: batch/v1 |
| 6 | Dry-run apply on target cluster | kubectl apply -f cronjob.yaml --dry-run=server | cronjob.batch/<name> created (server dry run) |
| 7 | Apply to production | kubectl apply -f cronjob.yaml | cronjob.batch/<name> configured |
| 8 | Verify API version | kubectl get cronjob <name> -o jsonpath='{.apiVersion}' | batch/v1 |
| 9 | Trigger a manual test job | kubectl create job <name>-test --from=cronjob/<name> | Job created and completes successfully |
| 10 | Check scheduled execution | Wait for next schedule; kubectl get jobs -n <ns> shows new job | Job runs at expected time |
| 11 | Monitor for failures | kubectl get jobs -n <ns> --watch | No failed jobs beyond backoffLimit |
| 12 | Document rollback path | Save rollback commands in runbook | Ready to execute if needed |
After completing the checklist, do a post-migration review: compare the new API version, schedule, concurrency policy, and resource limits against the pre-migration values to ensure nothing was unintentionally changed.
Conclusion
Kubernetes CronJob upgrades and migrations are a controlled, multi-step process, not a single command. The key to success is to observe before changing, backup before applying, dry-run before committing, and verify after the fact. The stable batch/v1 API has been available since Kubernetes 1.21, and migrating to it is straightforward if you follow the steps outlined: inventory your CronJobs, update the apiVersion, validate with server dry runs, test with a manual job, and have a rollback plan.
As a next step, pick one low-risk CronJob in a non-production namespace and go through the full process end to end. Record the current state, update the manifest, apply, trigger a test job, and verify the result. Then document the exact commands you used in a runbook for your team. Only after you have confidence in the process should you migrate production CronJobs.
A reliable upgrade workflow makes failure visible, protects sensitive values like secrets and credentials, limits changes to the intended resource, and defines recovery verification before an incident forces the decision. By following this guide, you can migrate your Kubernetes CronJobs safely and with confidence.