Reference
Auto-Uncordon CronJob¶
Technical reference for the automated node uncordon system that resolves stuck nodes after K3S upgrades.
Overview¶
The Auto-Uncordon CronJob is a cluster infrastructure component that automatically detects and uncordons nodes left in SchedulingDisabled state after K3S system-upgrade-controller completes upgrades.
Purpose: Mitigate a known race condition in the K3S system-upgrade-controller where controller pod restarts cause it to lose track of which nodes need uncordoning.
Deployed: Since 2025-11-09 as part of core infrastructure
Location: kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
Component Specifications¶
CronJob¶
Property |
Value |
Description |
|---|---|---|
Name |
|
CronJob resource name |
Namespace |
|
System namespace for cluster management tools |
Schedule |
|
Every 5 minutes |
Concurrency Policy |
|
Prevents overlapping job executions |
Active Deadline |
|
Jobs timeout after 2 minutes |
Backoff Limit |
|
Retry failed jobs up to 2 times |
Success History |
|
Keep last 3 successful jobs for logs |
Failure History |
|
Keep last 5 failed jobs for debugging |
Container¶
Property |
Value |
Description |
|---|---|---|
Image |
|
Alpine Linux with kubectl and jq |
Command |
|
Execute detection script |
CPU Request |
|
Minimal CPU requirement |
CPU Limit |
|
Maximum CPU allowed |
Memory Request |
|
Minimal memory requirement |
Memory Limit |
|
Maximum memory allowed |
Environment Variables¶
Variable |
Default |
Description |
|---|---|---|
DRY_RUN |
|
Set to |
NAMESPACE |
|
K3S upgrade controller namespace (hardcoded in script) |
RBAC Permissions¶
ServiceAccount¶
apiVersion: v1
kind: ServiceAccount
metadata:
name: auto-uncordon
namespace: kube-system
ClusterRole Permissions¶
API Group |
Resources |
Verbs |
Purpose |
|---|---|---|---|
|
|
|
Read node status and uncordon nodes |
|
|
|
Read K3S upgrade plan target versions |
|
|
|
Check for active upgrade jobs |
Security principle: Minimal permissions required for operation (least privilege).
Detection Algorithm¶
Safety Checks¶
The script performs 5 safety checks before uncordoning any node. ALL checks must pass:
1. Node is Cordoned¶
spec.unschedulable == true
Rationale: Only uncordon nodes that are actually cordoned. Prevents interference with schedulable nodes.
2. Node is Ready¶
status.conditions[type=="Ready"].status == "True"
Rationale: Ensure node is healthy before allowing pod scheduling. Prevents scheduling to unhealthy nodes.
3. Version Matches Target¶
# Node version (normalized)
status.nodeInfo.kubeletVersion (e.g., v1.31.13+k3s1)
# Matches upgrade plan version (normalized)
status.latestVersion from Plan resource (e.g., v1.31.13-k3s1)
Version normalization: K3S uses + in kubelet version but - in plan version. Both are equivalent:
v1.31.13+k3s1→ normalize to →v1.31.13-k3s1
Rationale: Ensure upgrade completed successfully. Prevents uncordoning during active upgrades.
4. No Active Upgrade Jobs¶
# Check for jobs in system-upgrade namespace
# matching node name with status.active > 0
kubectl get jobs -n system-upgrade
Rationale: Don’t interfere with in-progress upgrades. Prevents race conditions.
5. Upgrade Plan Complete¶
# Check plan status conditions
status.conditions[type=="Complete"].status == "True"
Rationale: Verify upgrade cycle finished. Prevents premature uncordoning.
Node Type Detection¶
The script determines whether a node is control-plane or agent:
# Check for control-plane label
metadata.labels["node-role.kubernetes.io/control-plane"]
# If present:
# - Use k3s-server plan latestVersion
# - Node type: "control-plane"
# If absent:
# - Use k3s-agent plan latestVersion
# - Node type: "agent"
Execution Flow¶
1. Fetch upgrade plan target versions
- k3s-agent plan → AGENT_TARGET_VERSION
- k3s-server plan → SERVER_TARGET_VERSION
2. Check plan completion status
- If neither plan shows Complete=True → Exit (no action needed)
3. Get all cordoned nodes
- Query: spec.unschedulable == true
- If none found → Exit (no action needed)
4. For each cordoned node:
a. Check node Ready status
b. Determine node type (control-plane vs agent)
c. Get target version for node type
d. Normalize and compare versions
e. Check for active upgrade jobs
f. If all checks pass → Uncordon node
5. Log summary
- Count of nodes uncordoned
- Success/failure for each node
Log Format¶
Successful Run (No Stuck Nodes)¶
[2025-11-09 12:37:00 UTC] Starting stuck node detection...
[2025-11-09 12:37:04 UTC] Target versions - Agent: v1.31.13-k3s1, Server: v1.31.13-k3s1
[2025-11-09 12:37:07 UTC] Upgrade plans Complete status - Agent: True, Server: True
[2025-11-09 12:37:08 UTC] INFO: No cordoned nodes found. Nothing to do.
Successful Uncordon¶
[2025-11-09 12:40:03 UTC] Found cordoned nodes: kup6s-agent-cax31-fsn1-yim
[2025-11-09 12:40:03 UTC] Checking node: kup6s-agent-cax31-fsn1-yim
[2025-11-09 12:40:05 UTC] ✅ Node is Ready
[2025-11-09 12:40:06 UTC] Node type: agent, target version: v1.31.13-k3s1
[2025-11-09 12:40:08 UTC] ✅ Version matches target: v1.31.13+k3s1
[2025-11-09 12:40:10 UTC] ✅ No active upgrade jobs
[2025-11-09 12:40:10 UTC] ✅ ALL CHECKS PASSED - Ready to uncordon
[2025-11-09 12:40:10 UTC] 🔧 Uncordoning node kup6s-agent-cax31-fsn1-yim
node/kup6s-agent-cax31-fsn1-yim uncordoned
[2025-11-09 12:40:11 UTC] ✅ Successfully uncordoned kup6s-agent-cax31-fsn1-yim
[2025-11-09 12:40:11 UTC] Complete. Uncordoned 1 node(s).
Skipped Due to Safety Check¶
[2025-11-09 12:38:03 UTC] Checking node: kup6s-agent-cax31-fsn1-yim
[2025-11-09 12:38:05 UTC] ✅ Node is Ready
[2025-11-09 12:38:06 UTC] Node type: agent, target version: v1.31.13-k3s1
[2025-11-09 12:38:08 UTC] ❌ SKIP: Version mismatch (current: v1.31.12+k3s1, target: v1.31.13-k3s1)
[2025-11-09 12:38:08 UTC] Complete. Uncordoned 0 node(s).
Monitoring and Observability¶
Check CronJob Status¶
# Verify CronJob exists and is active
kubectl get cronjob auto-uncordon-stuck-nodes -n kube-system
# Check if CronJob is suspended
kubectl get cronjob auto-uncordon-stuck-nodes -n kube-system \
-o jsonpath='{.spec.suspend}'
# Should return: false
View Recent Jobs¶
# List recent job executions
kubectl get jobs -n kube-system \
-l app.kubernetes.io/name=auto-uncordon \
--sort-by=.metadata.creationTimestamp | tail -10
# Get job completion status
kubectl get jobs -n kube-system \
-l app.kubernetes.io/name=auto-uncordon \
-o custom-columns=NAME:.metadata.name,COMPLETIONS:.status.completions,DURATION:.status.completionTime
Access Logs¶
# View logs from most recent job
kubectl logs -n kube-system \
-l app.kubernetes.io/name=auto-uncordon \
--tail=100
# Follow logs in real-time (wait for next scheduled run)
kubectl logs -n kube-system \
-l app.kubernetes.io/name=auto-uncordon \
--tail=100 -f
# View logs from specific job
kubectl logs -n kube-system job/auto-uncordon-stuck-nodes-28923456
Metrics (Future Enhancement)¶
Currently no Prometheus metrics exported. Monitoring relies on:
Job completion status (visible in
kubectl get jobs)Log output (visible in
kubectl logs)Manual node status checks (
kubectl get nodes)
Potential metrics (not implemented):
auto_uncordon_nodes_detected_total- Count of stuck nodes detectedauto_uncordon_nodes_uncordoned_total- Count of nodes successfully uncordonedauto_uncordon_nodes_skipped_total- Count of nodes skipped by safety checksauto_uncordon_last_run_timestamp- Timestamp of last successful run
Configuration Options¶
Enable/Disable Dry-Run Mode¶
Dry-run mode: Logs what would be done without actually uncordoning nodes.
# Enable dry-run (logs only, no uncordoning)
kubectl set env cronjob/auto-uncordon-stuck-nodes -n kube-system \
DRY_RUN=true
# Disable dry-run (resume automatic uncordoning)
kubectl set env cronjob/auto-uncordon-stuck-nodes -n kube-system \
DRY_RUN=false
Suspend/Resume CronJob¶
Temporary disable (e.g., during cluster maintenance):
# Suspend CronJob (stop all scheduled runs)
kubectl patch cronjob auto-uncordon-stuck-nodes -n kube-system \
-p '{"spec":{"suspend":true}}'
# Resume CronJob
kubectl patch cronjob auto-uncordon-stuck-nodes -n kube-system \
-p '{"spec":{"suspend":false}}'
Adjust Schedule¶
Change run frequency (requires manifest edit):
# Edit CronJob directly (temporary change, lost on reapply)
kubectl edit cronjob auto-uncordon-stuck-nodes -n kube-system
# Update spec.schedule: "*/5 * * * *"
# Permanent change: Edit manifest and reapply
# kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
Schedule examples:
*/5 * * * *- Every 5 minutes (default)*/10 * * * *- Every 10 minutes0 * * * *- Every hour at minute 00 0 * * *- Daily at midnight
Deployment¶
Initial Deployment¶
Deployed automatically as part of infrastructure provisioning:
cd kube-hetzner
bash scripts/apply-and-configure-longhorn.sh
Manifest location: kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
Manual Deployment¶
kubectl apply -f kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
Note: For OpenTofu deployments, the .tpl file contains $$ for variable escaping. For direct kubectl apply, replace $$ with $.
Verification¶
# Check all components deployed
kubectl get sa,clusterrole,clusterrolebinding,configmap,cronjob \
-n kube-system -l app.kubernetes.io/name=auto-uncordon
# Expected output:
# serviceaccount/auto-uncordon
# clusterrole.rbac.authorization.k8s.io/auto-uncordon
# clusterrolebinding.rbac.authorization.k8s.io/auto-uncordon
# configmap/auto-uncordon-script
# cronjob.batch/auto-uncordon-stuck-nodes
Upgrade¶
# Re-apply manifest to update
kubectl apply -f kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
# Delete existing ConfigMap to force script update
kubectl delete configmap auto-uncordon-script -n kube-system
kubectl apply -f kube-hetzner/extra-manifests/85-auto-uncordon-cronjob.yaml.tpl
Troubleshooting¶
See Troubleshoot Nodes Stuck After Upgrade for detailed troubleshooting guide.
Quick checks:
# 1. Verify CronJob is running
kubectl get cronjob auto-uncordon-stuck-nodes -n kube-system
# 2. Check recent job status
kubectl get jobs -n kube-system -l app.kubernetes.io/name=auto-uncordon \
--sort-by=.metadata.creationTimestamp | tail -5
# 3. View recent logs
kubectl logs -n kube-system -l app.kubernetes.io/name=auto-uncordon --tail=100
# 4. Check RBAC permissions exist
kubectl get clusterrole auto-uncordon
kubectl get clusterrolebinding auto-uncordon
Security Considerations¶
Minimal Permissions¶
The auto-uncordon ServiceAccount has only the permissions required for operation:
✅ Can read node status
✅ Can patch nodes (uncordon operation)
✅ Can read upgrade plans
✅ Can list jobs
❌ Cannot delete or modify other resources
❌ Cannot access secrets or sensitive data
No Manual Cordon Interference¶
The CronJob respects manually cordoned nodes during maintenance:
Only uncordons nodes matching K3S upgrade target version
Skips nodes with active upgrade jobs
Won’t uncordon nodes that are truly unhealthy (not Ready)
Safe to manually cordon for maintenance - auto-uncordon won’t interfere.
Image Provenance¶
Container image: alpine/k8s:1.31.3
Official Alpine Kubernetes tools image
Contains kubectl, helm, and jq
Minimal attack surface (Alpine Linux base)
Version pinned for reproducibility
Implementation History¶
Date |
Event |
Description |
|---|---|---|
2025-11-09 |
Initial deployment |
CronJob deployed to resolve recurring stuck node issues |
2025-11-09 |
First successful uncordon |
Node |
2025-11-09 |
Documentation created |
How-to guide and reference documentation added |