What maxSurge, maxUnavailable and minReadySeconds control
maxSurge and maxUnavailable sit under spec.strategy.rollingUpdate and cap how far the pod count may drift from spec.replicas during a rollout. maxSurge is how many pods may exist above the desired count, so it sets your temporary extra capacity and cost. maxUnavailable is how many pods may be missing below it, so it sets how much serving capacity you are willing to lose. Both accept an integer or a percentage of desired replicas. Both default to 25%. They cannot both be zero, because the controller would then be unable to add or remove any pod.
minReadySeconds is a top-level spec field and defaults to 0. A new pod counts as available only after it has stayed Ready for that many seconds with no container crashing. With the default, a pod counts the moment its readiness probe first passes. progressDeadlineSeconds, also under spec, sets how long the controller waits for progress before it marks the rollout as failed.
Percentages resolve differently for each field. Surge rounds up and unavailable rounds down, so the rounding always favours keeping capacity.
| Replicas | maxSurge (25%, ceil) | maxUnavailable (25%, floor) | Max total pods | Min available |
|---|---|---|---|---|
| 3 | 1 | 0 | 4 | 3 |
| 4 | 1 | 1 | 5 | 3 |
| 10 | 3 | 2 | 13 | 8 |
| 200 | 50 | 50 | 250 | 150 |
Illustrative calculation from the defaults.
Work these numbers out for your own replica count. At 3 replicas the defaults allow no unavailable pods, so the rollout moves one surge pod at a time. At 200 replicas the same 25% starts 50 pods at once and lets 50 go missing. That can saturate node capacity, image pulls or database connections.
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-api
spec:
replicas: 10
# A new pod must stay Ready for 15s with no container crashing
# before it counts as available (default: 0).
minReadySeconds: 15
# Mark the rollout as failed if it makes no progress for 10 minutes
# (default: 600).
progressDeadlineSeconds: 600
selector:
matchLabels:
app: checkout-api
strategy:
type: RollingUpdate
rollingUpdate:
# Up to 25% extra pods above replicas; rounds up: ceil(2.5) = 3 -> max 13 pods
maxSurge: 25%
# Up to 25% of pods missing; rounds down: floor(2.5) = 2 -> min 8 available
maxUnavailable: 25%
template:
metadata:
labels:
app: checkout-api
spec:
containers:
- name: app
image: registry.example.com/checkout-api:1.4.2
ports:
- containerPort: 8080
# Readiness gates when the pod counts as Ready; minReadySeconds starts after this passes.
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
A Deployment at 10 replicas that sets maxSurge, maxUnavailable, minReadySeconds and progressDeadlineSeconds, with comments showing how the 25% defaults resolve to 3 surge pods and 2 unavailable pods.
How a rolling update progresses batch by batch
Each rollout creates a new ReplicaSet for the updated pod template. The Deployment controller then adjusts two replica counts, new up and old down, while holding two invariants:
- Total pods never exceed
replicas + maxSurge. - Available pods never drop below
replicas - maxUnavailable.
A pod counts as available only after it has been Ready continuously for minReadySeconds. If readiness drops during that window, the timer restarts. The controller removes old pods only as new pods become available, so availability, not pod creation, sets the pace.
Illustrative rollout: 4 replicas, maxSurge 1, maxUnavailable 0
The limits here are 5 total pods and 4 available pods.
| Step | Old | New (available) | What gates the next step |
|---|---|---|---|
| 0 | 4 | 0 | Surge room: create 1 new pod |
| 1 | 4 | 1 (0) | New pod must be Ready for minReadySeconds |
| 2 | 3 | 1 (1) | Old pod removed, surge room reopens |
| 3 | 3 | 2 (1) | Wait again |
| … | … | … | Repeat until done |
| End | 0 | 4 (4) | Old ReplicaSet scaled to 0 |
When a new pod fails
Suppose the pod created at step 1 crashes, or fails readiness, before minReadySeconds elapses. It never becomes available. Removing an old pod would leave 3 available, which breaks the limit of 4. Adding another new pod would make 6 total, which breaks the limit of 5. The rollout stalls with all four old pods still serving. Section 5 covers how to detect this state and roll back.
Old and new versions run together
At every intermediate step, both versions receive traffic. Schema changes, message formats, API contracts and cache entries must be readable by both versions. If they are not, a rolling update is the wrong strategy for that change.
+-----------------------------------------------+
| Deployment controller |
| total pods <= replicas + maxSurge = 5 |
| avail pods >= replicas - maxUnavailable = 4 |
+-----------------------------------------------+
| scale old down | scale new up
| (only after a new pod | (only while
| becomes available) | total < 5)
v v
+---------------------------+ +---------------------------+
| old ReplicaSet (v1) | | new ReplicaSet (v2) |
| 4 -> 3 -> 2 -> 1 -> 0 | | 0 -> 1 -> 2 -> 3 -> 4 |
+---------------------------+ +---------------------------+
| |
| v
| +---------------------------+
| | new pod Ready? |
| | no -> never available; |
| | rollout stalls |
| | yes -> stays Ready for |
| | minReadySeconds, |
| | then available |
| +---------------------------+
| |
v v
+-----------------------------------------------------------+
| Ready v1 and v2 pods serve traffic side by side |
| schemas, messages, APIs, caches must work for both |
+-----------------------------------------------------------+
With 4 replicas, maxSurge 1 and maxUnavailable 0, the Deployment controller adds one v2 pod at a time and removes a v1 pod only after that v2 pod has stayed Ready for minReadySeconds, so a failing pod stalls the rollout while v1 and v2 serve traffic together.
Choosing a profile: who each setting suits and when to use it
Pick a profile based on spare capacity and how much exposure to a bad version you can tolerate.
| Profile | Settings | Preconditions | Fits | Risks |
|---|---|---|---|---|
| Zero-downtime, full capacity | maxUnavailable: 0, maxSurge: 1 or more |
Spare CPU and memory for the surge pods, plus quota headroom | Latency-sensitive APIs that run near their replica floor | If a surge pod stays Pending, the rollout stalls indefinitely. progressDeadlineSeconds only marks it failed and does not roll back. |
| Capacity-constrained | maxSurge: 0, maxUnavailable: 1 or more |
Remaining replicas can absorb the load during each step | Fixed-size clusters, tight quotas, large or GPU pods | Capacity drops during the rollout, so a traffic spike hits fewer pods |
| Fast rollout | Higher values for both, such as 25–50% | Plenty of headroom and strong automated checks | Stateless workers, internal services, teams with fast rollback | Exposes a larger share of traffic to a bad version sooner |
A higher maxSurge raises peak resource requests. On a full cluster, this can make the cluster autoscaler add nodes. A new node takes time to join, which slows the rollout, and you pay for it until it scales back down. Setting both values to 0 is rejected because the controller would have no way to make progress.
Headroom checklist
- Node allocatable: Run
kubectl describe node <name>and compare Allocatable with Allocated resources. The free amount must fit at least one surge pod's requests on a node the pod can schedule to. - ResourceQuota: Run
kubectl describe resourcequota -n <namespace>. Surge pods count against the quota, so Used plus the surge requests must stay under Hard. - Cluster autoscaler: Confirm the node group's maximum size allows growth. Also confirm that the time to add a node fits within
progressDeadlineSeconds.
#!/usr/bin/env bash
# Pre-rollout headroom check. Placeholder namespace and deployment names.
set -euo pipefail
NS=payments
DEPLOY=checkout-api
# 1. Current strategy and the per-pod requests each surge pod will need.
kubectl get deployment "$DEPLOY" -n "$NS" \
-o jsonpath='{.spec.strategy.rollingUpdate}{"\n"}{.spec.template.spec.containers[*].resources.requests}{"\n"}'
# 2. Allocatable capacity per node (total schedulable, not free).
kubectl get nodes \
-o custom-columns=NAME:.metadata.name,CPU:.status.allocatable.cpu,MEMORY:.status.allocatable.memory
# 3. Requests already committed on each node. Free = Allocatable - Requests.
kubectl describe nodes | grep -A 8 'Allocated resources'
# 4. Quota: Used + surge requests must stay under Hard.
kubectl describe resourcequota -n "$NS"
# 5. Cluster autoscaler status, if it publishes its default status ConfigMap.
kubectl get configmap cluster-autoscaler-status -n kube-system \
-o jsonpath='{.data.status}' 2>/dev/null || echo 'no autoscaler status ConfigMap found'
# During the rollout: bound the wait so a stalled rollout
# (for example, maxUnavailable: 0 with a Pending surge pod) does not hang the pipeline.
kubectl rollout status deployment/"$DEPLOY" -n "$NS" --timeout=10m || {
# Show Pending pods and their scheduling events, then roll back explicitly.
# progressDeadlineSeconds alone does not roll back.
kubectl get pods -n "$NS" --field-selector=status.phase=Pending
kubectl get events -n "$NS" --field-selector=reason=FailedScheduling
kubectl rollout undo deployment/"$DEPLOY" -n "$NS"
exit 1
}
A shell script that checks node allocatable capacity, committed requests, ResourceQuota usage and cluster autoscaler status before a rollout, then puts a timeout on the rollout and rolls back if it stalls.
Readiness probes and minReadySeconds: setting values from data
minReadySeconds only means something if the readiness probe is honest. The controller counts a new Pod as available once it has been Ready for that long without any container crashing. If the probe returns 200 the moment the process binds a port, the wait adds delay and catches nothing.
Probe checklist
- Readiness reflects serving ability, not liveness. It checks loaded config, open local resources and warm critical caches. Liveness only answers "should this container be restarted?"
- Timing matches observed behavior. Set
initialDelaySecondsnear the fastest real startup. Pair aperiodSecondsof 5–10 with afailureThresholdof 3 so one slow response doesn't eject the Pod. - Slow starters use a
startupProbe. It holds off liveness and readiness until startup completes, so you don't need a large initial delay that also slows fast starts. - No shared downstream checks. If every Pod's readiness depends on the same database, one database blip marks the whole fleet unready and drops all traffic.
- Not over-strict. A tight
timeoutSecondsduring GC pauses or a 100% success requirement causes flapping and rollouts that stall atprogressDeadlineSeconds.
Deriving minReadySeconds
Base the value on how long it takes a bad Pod to fail. If cache warmup or connection-pool exhaustion appears about 40 seconds after a Pod starts taking traffic, a value of 60 gives a margin.
The cost grows linearly with the number of batches. Illustrative estimate: 10 batches × 60 s adds roughly 10 minutes to every deploy.
minReadySeconds also can't detect wrong responses or a rising error rate in a Pod that stays Ready. Catching those requires metrics-based analysis outside the Deployment controller.
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-api
spec:
replicas: 10
# Observed time-to-failure ~40s after taking traffic; 60s adds margin
minReadySeconds: 60
# Must exceed the full rollout time, which minReadySeconds lengthens
progressDeadlineSeconds: 1200
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels: {app: checkout-api}
template:
metadata:
labels: {app: checkout-api}
spec:
containers:
- name: app
image: registry.example.com/checkout-api:1.4.2
ports: [{containerPort: 8080}]
startupProbe: # slow cache load: allow up to 30 x 5s = 150s
httpGet: {path: /healthz/startup, port: 8080}
periodSeconds: 5
failureThreshold: 30
readinessProbe: # config loaded, local pool open, cache warm; no shared DB check
httpGet: {path: /healthz/ready, port: 8080}
periodSeconds: 5
timeoutSeconds: 2 # tolerate short GC pauses
failureThreshold: 3
livenessProbe: # only: is the process wedged and needs a restart?
httpGet: {path: /healthz/live, port: 8080}
periodSeconds: 10
failureThreshold: 3
A Deployment that pairs a startupProbe, a serving-focused readinessProbe and a separate livenessProbe with a minReadySeconds value derived from observed time-to-failure.
Detecting failed rollouts and rolling back
Kubernetes has one built-in way to decide a rollout is stuck: progressDeadlineSeconds (600 by default). If the controller sees no progress within that window, such as a new Pod becoming available or an old one scaling down, it sets the Deployment's Progressing condition to status: False with reason ProgressDeadlineExceeded. A Pod that keeps failing its readiness probe or sits in CrashLoopBackOff produces this state.
That condition is the only consequence. The controller does not revert anything. It keeps the new ReplicaSet at whatever scale it reached and leaves the old Pods that are still running in place. Your maxUnavailable setting determines how much capacity you lose while it stays in that state.
In CI
Run kubectl rollout status deployment/<name> after applying the manifest. It blocks until the rollout completes. It exits non-zero if the progress deadline is exceeded. You can also add --timeout so a pipeline doesn't wait longer than its own budget. Gate the next step on that exit code, and on failure run kubectl rollout undo deployment/<name> explicitly. Add --to-revision=<n> to target a known-good revision rather than just the previous one.
Inspecting state
kubectl rollout history deployment/<name>lists revisions. Add--revision=<n>to see that revision's Pod template.kubectl describe deployment <name>shows the conditions, the old and new ReplicaSets, and recent events.kubectl get deployment <name> -o jsonpath='{.status.conditions}'gives machine-readable output for scripts.
The dangerous case is a team that believes rollback is automatic. A failed deploy is then reported as "done," and a mix of old and broken Pods serves traffic at reduced capacity until someone notices, often during the next incident.
Coordinating with PDBs, HPA and graceful shutdown
-
Don't expect a PodDisruptionBudget to throttle the rollout. The Deployment controller deletes old Pods directly and never calls the Eviction API, so a PDB has no effect on
maxUnavailable. PDBs do apply to node drains, which use eviction. If a cluster upgrade drains nodes while your rollout is in progress, the PDB counts the not-yet-ready new Pods as unhealthy. A drain can then block until the rollout settles. Set the PDB with that overlap in mind. -
Account for the HPA changing the denominator. Percentages for
maxSurgeandmaxUnavailableare computed against the current desired replica count.maxSurgerounds up andmaxUnavailablerounds down. When the HPA scales mid-rollout, the controller spreads the change proportionally across the old and new ReplicaSets, so batch sizes shift. A scale-down at 4 replicas can roundmaxUnavailable: 25%to zero. -
Drain old Pods before the process exits. Removing a Pod from Service endpoints and sending SIGTERM happen concurrently. A short
preStopsleep keeps the process serving while load balancers and kube-proxy stop routing to it. After that, the app's SIGTERM handler finishes in-flight requests.terminationGracePeriodSecondsmust cover both phases, because the grace period starts beforepreStopruns. When it expires, the kubelet sends SIGKILL. The example below uses a 5-secondpreStopsleep with a 30-second grace period. -
Keep changes compatible in both directions. Old and new versions run side by side during the rollout and again after a rollback. Schema migrations must be additive first and destructive later. API fields must be tolerated when they are missing. Producers and consumers must both handle old and new message formats.
When to avoid rolling updates, alternatives, and impact on cost and engineering time
When old and new versions cannot run side by side, switch the Deployment to Recreate. Kubernetes then terminates all old Pods before it creates any new ones, so downtime is guaranteed. Use it in these cases:
- Singleton consumers. A queue consumer or scheduler that must never run twice.
- Incompatible changes. Schema or protocol changes that cannot be made additive.
- ReadWriteOnce volumes. The volume is attached to one node, so a surged Pod on another node cannot mount it.
| Approach | Built from | Downtime | Gating signal |
|---|---|---|---|
| RollingUpdate | One Deployment | None if probes are accurate | Readiness, minReadySeconds |
| Recreate | One Deployment | Yes | None |
| Blue-green | Two Deployments, Service selector switch | None | Manual or scripted check |
| Canary / progressive delivery | Multiple Deployments, weighted routing | None | Error-rate and latency metrics |
Choose canary or progressive delivery when a Pod can pass its readiness probe and still serve bad responses.
Cost and time
Surge Pods, and any nodes the autoscaler adds to fit them, are billed capacity. A longer minReadySeconds makes every deploy take longer.
Illustrative estimate (assumed numbers, not a benchmark):
- Assumed inputs: 20 replicas,
maxSurgeof 5,maxUnavailableof 0, 30 s to become ready,minReadySecondsof 30. - Batches = 20 ÷ 5 = 4.
- Rollout time ≈ batches × (ready time +
minReadySeconds) = 4 × 60 s = 4 minutes. - Extra capacity ≈ 5 Pods running for those 4 minutes.
These costs are small compared with what deliberate settings prevent: rollouts stuck at a quota limit, hours of incident triage, and manual rollbacks done under pressure.
Pre-deploy checklist
- Compute the resolved integer values of
maxSurgeandmaxUnavailable. - Confirm the cluster has headroom for the surge Pods.
- Validate that readiness probes report what you think they report.
- Set
minReadySecondsfrom measured data. - Set
progressDeadlineSeconds. - Alert on rollout failure.
- Test rollout and rollback in staging at production replica counts.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to kubernetes rolling update maxsurge, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.