
- Runbook Overview
- Preparation Checklist
- Production Upgrade Procedure
- Rollback Procedure
- Troubleshooting: Real Failure Scenarios During a Multi-Cluster Upgrade
- Post-Upgrade Observation Period
- Postmortem Template
- Automation: Upgrading from a CI/CD Pipeline
- References
Runbook Overview
This document is a step-by-step procedure (runbook) that a team operating two or more Kubernetes clusters should follow when carrying out a version upgrade. It is written so that judgement calls during an incident are kept to a minimum and the work follows a procedure decided in advance.
Who This Applies To
- Multi-region or multi-environment (dev/staging/prod) cluster operations
- Environments mixing managed services (EKS, GKE, AKS) with self-managed clusters (kubeadm, kOps)
- Target upgrade version: v1.32 → v1.33 (or a similar minor version upgrade)
Version Skew Policy Summary
Kubernetes does not allow skipping a minor version. Upgrades must be done sequentially.
| Component | Allowed skew | Example (when the CP is v1.33) |
|---|---|---|
| kubelet | CP -2 | v1.31, v1.32, v1.33 allowed |
| kube-proxy | Same minor as the CP | only v1.33 allowed |
| kubectl | CP +/-1 | v1.32, v1.33, v1.34 allowed |
| etcd | Specific version pairs | Check the release notes |
Preparation Checklist
Complete the items below in order, starting at D-7 (one week before the upgrade).
D-7: Analyze the Changes
#!/bin/bash
# d7-changelog-review.sh
# Collect the changes in the target upgrade version
TARGET_VERSION="v1.33"
echo "=== ${TARGET_VERSION} change check ==="
echo ""
echo "[1] Deprecated API check"
echo " - Check the Removed/Deprecated sections in the release notes"
echo " - Whether an Endpoints API → EndpointSlice migration is needed"
echo " - flowcontrol.apiserver.k8s.io/v1beta3 → v1 transition"
echo ""
echo "[2] Feature Gate change check"
echo " - InPlacePodVerticalScaling: Beta (enabled by default)"
echo " - UserNamespacesSupport: Stable"
echo " - SidecarContainers: Stable"
echo " - NFTablesProxyMode: Stable"
echo ""
echo "[3] Compatibility check for the addons in use"
# Check the compatibility matrix of each addon
ADDONS=(
"calico"
"cilium"
"ingress-nginx"
"cert-manager"
"external-dns"
"prometheus-operator"
"argocd"
)
for addon in "${ADDONS[@]}"; do
echo " - $addon: [ ] compatibility check done"
done
D-5: Upgrade the Staging Cluster
Always upgrade staging before production. If there is no staging cluster, run the upgrade on the dev cluster first.
# List the clusters in the multi-cluster environment
kubectl config get-contexts
# Switch to the staging cluster context
kubectl config use-context staging-cluster
# Run the staging upgrade (managed service example: EKS)
aws eks update-cluster-version \
--name staging-cluster \
--kubernetes-version 1.33
# Monitor the upgrade status
aws eks describe-update \
--name staging-cluster \
--update-id <update-id>
D-3: Staging Verification Complete
After observing staging for at least 48 hours, check the items below.
- [ ] All nodes Ready
- [ ] All system Pods Running
- [ ] Application Pods behaving normally
- [ ] Ingress/Service traffic normal
- [ ] CronJob succeeded at least once
- [ ] HPA/VPA behaving normally
- [ ] Monitoring metric collection normal
- [ ] Log pipeline normal
- [ ] No deprecated API warnings
- [ ] Performance test passed (response time, throughput)
D-1: Final Preparation for the Production Upgrade
#!/bin/bash
# d1-final-prep.sh
echo "=== D-1 final preparation ==="
# 1. Change announcement
echo "[1] Change announcement sent: [ ]"
echo " - Internal team Slack announcement"
echo " - External status page update"
echo " - Maintenance window shared"
# 2. On-call owners
echo "[2] On-call owners:"
echo " - Primary: ___________"
echo " - Secondary: ___________"
echo " - Escalation path: Primary → Secondary → Tech Lead"
# 3. Rollback preparation
echo "[3] Rollback readiness:"
echo " - [ ] etcd snapshot created"
echo " - [ ] Previous version binaries kept"
echo " - [ ] Rollback procedure document reviewed"
echo " - [ ] Estimated rollback time: ___ minutes"
# 4. Maintenance window
echo "[4] Work window:"
echo " - Start: ___:___ (KST)"
echo " - Expected end: ___:___ (KST)"
echo " - Hard limit: ___:___ (KST)"
Production Upgrade Procedure
Phase 1: Declare the Start of the Upgrade
# Announce the start of work through the Slack webhook
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d '{
"text": ":wrench: [MAINTENANCE] Kubernetes upgrade starting\nCluster: prod-cluster-01\nTarget version: v1.33\nOperator: @oncall\nExpected duration: 2 hours"
}'
Phase 2: Sequential Multi-Cluster Upgrade
In a multi-cluster environment, upgrade sequentially starting from the cluster with the smallest share of traffic.
Upgrade order:
1. canary-cluster (5% of traffic) ← upgrade first, observe for 1 hour
2. prod-cluster-02 (30% of traffic) ← after canary is stable
3. prod-cluster-01 (65% of traffic) ← last
Upgrade Commands per Managed Service
EKS:
# Upgrade the Control Plane
aws eks update-cluster-version \
--name prod-cluster-01 \
--kubernetes-version 1.33
# Wait for the upgrade to finish
aws eks wait cluster-active --name prod-cluster-01
# Upgrade the Managed Node Group
aws eks update-nodegroup-version \
--cluster-name prod-cluster-01 \
--nodegroup-name workers-general \
--kubernetes-version 1.33
# Upgrade the addons
for ADDON in vpc-cni coredns kube-proxy; do
LATEST=$(aws eks describe-addon-versions \
--addon-name $ADDON \
--kubernetes-version 1.33 \
--query 'addons[0].addonVersions[0].addonVersion' \
--output text)
aws eks update-addon \
--cluster-name prod-cluster-01 \
--addon-name $ADDON \
--addon-version $LATEST
done
GKE:
# Upgrade the Control Plane
gcloud container clusters upgrade prod-cluster-01 \
--master \
--cluster-version 1.33.0-gke.100 \
--zone asia-northeast3-a
# Upgrade the Node Pool
gcloud container clusters upgrade prod-cluster-01 \
--node-pool workers-general \
--cluster-version 1.33.0-gke.100 \
--zone asia-northeast3-a
AKS:
# Check the versions available for upgrade
az aks get-upgrades \
--resource-group myResourceGroup \
--name prod-cluster-01 \
--output table
# Upgrade the Control Plane and Node Pool together
az aks upgrade \
--resource-group myResourceGroup \
--name prod-cluster-01 \
--kubernetes-version 1.33.0
Phase 3: Per-Cluster Health Check
Always run this after each cluster upgrade completes.
#!/bin/bash
# cluster-health-check.sh <cluster-context>
CONTEXT=$1
echo "=== Health Check: $CONTEXT ==="
kubectl --context "$CONTEXT" get nodes -o wide
# Check the version of every node
echo ""
echo "Node versions:"
kubectl --context "$CONTEXT" get nodes \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.nodeInfo.kubeletVersion}{"\n"}{end}'
# System Pod status
echo ""
echo "System pods:"
FAILING=$(kubectl --context "$CONTEXT" -n kube-system get pods --no-headers | \
grep -v "Running\|Completed" | wc -l)
echo "Failing system pods: $FAILING"
# Production workload status
echo ""
echo "Production workloads:"
kubectl --context "$CONTEXT" -n production get deployments -o wide
# Check for Pending Pods
echo ""
echo "Pending pods:"
kubectl --context "$CONTEXT" get pods -A --field-selector=status.phase=Pending
# Check events (last 10 minutes)
echo ""
echo "Recent warnings:"
kubectl --context "$CONTEXT" get events -A --sort-by='.lastTimestamp' \
--field-selector type=Warning | tail -20
Phase 4: Traffic Shifting and Observation
How you control traffic across clusters depends on your infrastructure.
# Example of AWS Route53 weight-based routing
# Shift traffic to the canary cluster gradually
# Step 1: 5% of traffic to canary
# prod-cluster-01: weight 65
# prod-cluster-02: weight 30
# canary-cluster: weight 5
# Step 2: confirm canary is stable, then wait 30 minutes
# Step 3: 20% of traffic to canary
# prod-cluster-01: weight 50
# prod-cluster-02: weight 30
# canary-cluster: weight 20
# Update the Route53 weights
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "api.example.com",
"Type": "A",
"SetIdentifier": "canary",
"Weight": 20,
"AliasTarget": {
"HostedZoneId": "Z9876543210",
"DNSName": "canary-alb.ap-northeast-2.elb.amazonaws.com",
"EvaluateTargetHealth": true
}
}
}]
}'
Rollback Procedure
If a problem appears after the upgrade, decide whether to roll back using the criteria below.
Rollback Decision Criteria
| Severity | Symptoms | Response |
|---|---|---|
| P1 - Critical | API Server unresponsive, large-scale Pod CrashLoop, risk of data loss | Roll back immediately |
| P2 - High | Failure of a specific workload, error rate up by 10% or more, no metric collection | Roll back if the cause is not found within 30 minutes |
| P3 - Medium | Minor functional failure, more warning-level errors, performance drop within 10% | Analyze the cause and try a hotfix first |
| P4 - Low | Log warnings only, no impact on behavior | Keep monitoring, handle at the next maintenance |
Rolling Back a Managed Service
Managed services often do not support downgrading the Control Plane. Instead, replace the node group with one on the previous version.
# EKS: create a node group on the previous version
aws eks create-nodegroup \
--cluster-name prod-cluster-01 \
--nodegroup-name workers-rollback \
--kubernetes-version 1.32 \
--node-role arn:aws:iam::123456789:role/eks-node-role \
--subnets subnet-abc subnet-def \
--instance-types m6i.xlarge \
--scaling-config minSize=3,maxSize=10,desiredSize=5
# Move the Pods from the old node group to the new one
kubectl cordon -l eks.amazonaws.com/nodegroup=workers-general
kubectl drain -l eks.amazonaws.com/nodegroup=workers-general \
--ignore-daemonsets --delete-emptydir-data
# Delete the old node group
aws eks delete-nodegroup \
--cluster-name prod-cluster-01 \
--nodegroup-name workers-general
Multi-Cluster Traffic Rollback
Remove traffic from the cluster where the problem occurred.
# Set the traffic weight of the affected cluster to 0
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "api.example.com",
"Type": "A",
"SetIdentifier": "canary",
"Weight": 0,
"AliasTarget": {
"HostedZoneId": "Z9876543210",
"DNSName": "canary-alb.ap-northeast-2.elb.amazonaws.com",
"EvaluateTargetHealth": true
}
}
}]
}'
Troubleshooting: Real Failure Scenarios During a Multi-Cluster Upgrade
Scenario 1: drain Fails on a PDB Violation During a Node Group Upgrade
$ kubectl drain node-xyz --ignore-daemonsets
error: Cannot evict pod as it would violate the pod's disruption budget.
Cause: the PDB's minAvailable equals the current number of Running Pods, so not even one can be evicted.
Fix:
# Check the PDB status
kubectl get pdb -A -o wide
# Example output:
# NAMESPACE NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
# production web-pdb 3 N/A 0
# ↑ 0 means drain is impossible
# Option 1: raise the replica count first to create headroom
kubectl -n production scale deployment web --replicas=4
# Retry drain after 1-2 minutes
# Option 2: relax the PDB temporarily (caution: availability drops)
kubectl -n production patch pdb web-pdb \
--type=merge -p '{"spec":{"minAvailable":2}}'
# Restore it after the drain finishes
kubectl -n production patch pdb web-pdb \
--type=merge -p '{"spec":{"minAvailable":3}}'
Scenario 2: Addon Incompatibility After the Upgrade
$ kubectl -n kube-system logs ingress-nginx-controller-xyz
Error: the server could not find the requested resource (get ingresses.networking.k8s.io)
Cause: the Ingress Controller version is not compatible with the new Kubernetes API version.
Fix:
# Check compatible addon versions and upgrade
helm repo update
# Check the compatible ingress-nginx versions
helm search repo ingress-nginx/ingress-nginx --versions | head -10
# Upgrade to a compatible version
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
--namespace kube-system \
--version 4.12.0 \
--reuse-values
Scenario 3: Service Mesh Failure from a Version Mismatch Between Clusters
# Cluster A: v1.33, cluster B: v1.32
# Some traffic routing fails in the Istio multi-cluster setup
$ istioctl proxy-status
NAME CLUSTER CDS LDS EDS RDS
web-v1-xyz.production cluster-a SYNCED SYNCED SYNCED SYNCED
web-v1-abc.production cluster-b STALE STALE STALE STALE
Cause: you need to confirm that the Istio version supports the Kubernetes versions on both clusters.
Fix:
# Check Istio compatibility
istioctl version
# Standardize on an Istio version supported by both clusters
# Istio 1.24+ supports Kubernetes v1.31~v1.33
istioctl upgrade --set revision=1-24-0
Scenario 4: Wrong EKS Addon Upgrade Order
$ aws eks update-addon --addon-name vpc-cni ...
An error occurred (InvalidParameterException): Addon version v1.19.0 is not
compatible with cluster version 1.33. Available versions: v1.19.2, v1.20.0
Fix:
# Query the latest compatible version
aws eks describe-addon-versions \
--addon-name vpc-cni \
--kubernetes-version 1.33 \
--query 'addons[0].addonVersions[:5].{version:addonVersion,default:compatibilities[0].defaultVersion}' \
--output table
# Update to a compatible version
aws eks update-addon \
--cluster-name prod-cluster-01 \
--addon-name vpc-cni \
--addon-version v1.20.0 \
--resolve-conflicts OVERWRITE
Post-Upgrade Observation Period
What to Observe and for How Long
| Observation item | Check interval | Normal baseline | Response when abnormal |
|---|---|---|---|
| API Server error rate | 5 minutes | < 0.1% | Check logs + escalate |
| Pod restart count | 15 minutes | No increase over pre-upgrade | Check the restarted Pod logs |
| Node status | 10 minutes | All Ready | Investigate NotReady nodes |
| Service response time (p99) | 5 minutes | Within 10% of pre-upgrade | Profiling |
| etcd latency | 5 minutes | < 100ms | Check etcd disk I/O |
| Deprecated API warnings | 1 hour | 0 | Fix the code of that component |
Observation Period Definition
0-2 hours after the upgrade: intensive observation (check every 5 minutes)
2-24 hours after the upgrade: normal observation (every 30 minutes)
24-72 hours after the upgrade: alert-driven observation (respond to anomaly alerts)
72 hours onward: return to normal operations, write the postmortem
Postmortem Template
Write the postmortem within 72 hours of finishing the upgrade. Write it whether or not there was an incident, and feed it into the next upgrade.
# Cluster Upgrade Postmortem
## Basic Information
- Target cluster: \_\_\_
- Previous version: v1.32.x → current version: v1.33.x
- Work window: YYYY-MM-DD HH:MM ~ HH:MM (total \_\_\_ hours)
- Operator: \_\_\_
## Progress Summary
- [ ] Staging upgrade: no problems / problems occurred (details: \_\_\_)
- [ ] Production upgrade: no problems / problems occurred (details: \_\_\_)
- [ ] Rollback executed: Yes / No
## Issues Found
| Issue | Severity | Resolution | Time taken |
| ------ | -------- | ---------- | ---------- |
| \_\_\_ | P1~P4 | \_\_\_ | \_\_\_ min |
## Improvements (to apply at the next upgrade)
1. ***
2. ***
3. ***
## Documents That Need Updating
- [ ] This runbook
- [ ] On-call guide
- [ ] Monitoring dashboards
Automation: Upgrading from a CI/CD Pipeline
An example of automating the recurring multi-cluster upgrade with GitHub Actions.
# .github/workflows/cluster-upgrade.yml
name: Kubernetes Cluster Upgrade
on:
workflow_dispatch:
inputs:
target_version:
description: 'Target Kubernetes version'
required: true
default: '1.33'
cluster:
description: 'Cluster to upgrade'
required: true
type: choice
options:
- canary-cluster
- prod-cluster-02
- prod-cluster-01
dry_run:
description: 'Dry run mode'
required: true
type: boolean
default: true
jobs:
pre-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-2
- name: Pre-upgrade checks
run: |
aws eks describe-cluster --name ${{ inputs.cluster }} \
--query 'cluster.{version:version,status:status}' \
--output table
# Check the current version
CURRENT=$(aws eks describe-cluster --name ${{ inputs.cluster }} \
--query 'cluster.version' --output text)
echo "Current version: $CURRENT"
echo "Target version: ${{ inputs.target_version }}"
# Check addon compatibility
for ADDON in vpc-cni coredns kube-proxy; do
echo "Checking $ADDON compatibility..."
aws eks describe-addon-versions \
--addon-name $ADDON \
--kubernetes-version ${{ inputs.target_version }} \
--query 'addons[0].addonVersions[0].addonVersion' \
--output text
done
upgrade:
needs: pre-check
runs-on: ubuntu-latest
if: ${{ !inputs.dry_run }}
environment: production # manual approval required
steps:
- name: Upgrade Control Plane
run: |
aws eks update-cluster-version \
--name ${{ inputs.cluster }} \
--kubernetes-version ${{ inputs.target_version }}
echo "Waiting for control plane upgrade..."
aws eks wait cluster-active --name ${{ inputs.cluster }}
- name: Upgrade Node Groups
run: |
NODEGROUPS=$(aws eks list-nodegroups \
--cluster-name ${{ inputs.cluster }} \
--query 'nodegroups[]' --output text)
for NG in $NODEGROUPS; do
echo "Upgrading node group: $NG"
aws eks update-nodegroup-version \
--cluster-name ${{ inputs.cluster }} \
--nodegroup-name $NG \
--kubernetes-version ${{ inputs.target_version }}
done
- name: Upgrade Addons
run: |
for ADDON in vpc-cni coredns kube-proxy; do
LATEST=$(aws eks describe-addon-versions \
--addon-name $ADDON \
--kubernetes-version ${{ inputs.target_version }} \
--query 'addons[0].addonVersions[0].addonVersion' \
--output text)
aws eks update-addon \
--cluster-name ${{ inputs.cluster }} \
--addon-name $ADDON \
--addon-version $LATEST \
--resolve-conflicts OVERWRITE
done
post-verify:
needs: upgrade
runs-on: ubuntu-latest
steps:
- name: Health Check
run: |
aws eks update-kubeconfig --name ${{ inputs.cluster }}
echo "Node status:"
kubectl get nodes -o wide
echo "System pods:"
kubectl -n kube-system get pods
echo "Failing pods:"
kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded
- name: Notify
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H 'Content-type: application/json' \
-d "{
\"text\": \":white_check_mark: Cluster upgrade complete\nCluster: ${{ inputs.cluster }}\nVersion: ${{ inputs.target_version }}\"
}"
Quiz
Q1. Why is the canary cluster upgraded first in a multi-cluster upgrade?
Answer: ||Upgrading the cluster with the smallest share of traffic first keeps the blast radius
small if something goes wrong. The remaining clusters follow once canary has proven stable.||
Q2. What does a kubelet version skew of CP -2 mean in practice?
Answer: ||Even after the Control Plane is upgraded to v1.33, Worker nodes are compatible down to
v1.31, so you can upgrade the Worker nodes gradually instead of all at once.||
Q3. Why does drain fail when a PDB reports ALLOWED DISRUPTIONS of 0, and how do you fix it?
Answer: ||The PDB minAvailable equals the current number of Running Pods, so there is no room to
evict even one. Raise the replica count first to create headroom, or relax the PDB temporarily.||
Q4. Why observe for at least 48 hours after a staging upgrade?
Answer: ||Problems that do not surface immediately - CronJobs, periodic batch processing, certificate
renewal - need at least one or two days of observation. Some failures appear only within a shift in the traffic pattern (day/night).||
Q5. Why is rolling back the Control Plane hard on a managed service (EKS/GKE/AKS)?
Answer: ||Managed services do not support downgrading the Control Plane. Instead you create a node
group on the previous version and shift traffic to it, or build a new cluster and migrate.||
Q6. Why write a postmortem whether or not there was an incident?
Answer: ||Recording the improvements you found during the upgrade, the steps that took longer than
expected, and the gaps between the document and the actual procedure is what lets you improve the
runbook for the next upgrade. Even with no incident, it is a chance to improve the process itself.||
Q7. Why require manual approval on the production environment in GitHub Actions?
Answer: ||Even in an automated pipeline, a production upgrade needs a final sign-off from a person.
Someone reviews the pre-check results and judges whether to proceed given the state of the service at that moment.||