Rolling Deployment in Microservices: Update Without Taking the Service Down
A rolling deployment replaces the running instances of a service with a new version a few at a time, instead of all at once. While it runs, some instances serve the old version and some serve the new one, and the service stays available the whole time. It is the default update strategy in Kubernetes and the most common way microservices are released.
Most teams use rolling deployments without thinking about them, until a release causes a burst of errors halfway through. This tutorial explains what happens during a rollout and how to make it truly zero-downtime.
1. Why Not Just Restart Everything?
Imagine a ticket-booking service for a concert venue running on four instances. The simplest way to release version 2 is to stop all four and start four new ones. That's called a recreate deployment. For the 30 to 60 seconds a Spring Boot app needs to start, every request fails. If the new version doesn't start at all, the service stays down until someone notices.
A rolling deployment avoids both problems. It starts one or two new instances, waits until they are ready, moves traffic to them, stops the same number of old ones, and repeats. If the new version can't become ready, the rollout stops with most old instances still serving customers.
2. Key Terms
- Instance (pod): one running copy of the service. In Kubernetes it's a pod.
- Replicas: how many instances you want in total, for example 4.
- Readiness probe: a check the platform runs to decide whether an instance may receive traffic. A new pod gets traffic only after it passes.
- maxSurge: how many extra pods may exist above the replica count during the rollout. With 4 replicas and
maxSurge: 1, there can be up to 5 pods. - maxUnavailable: how many of the desired pods may be not ready during the rollout. With 4 replicas and
maxUnavailable: 1, at least 3 must always be ready. - Graceful shutdown: letting an instance finish the requests it is already handling before it exits.
3. How a Rolling Deployment Works
In Kubernetes you trigger a rollout by changing the pod template of a Deployment, usually the image tag:
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # default 25%, rounded up
maxUnavailable: 0 # default 25%, rounded down
The Deployment controller then repeats one cycle for each batch of pods:
SIGTERM happen at the same time, not one after the other. The kubelet signals the container while the endpoint change is still spreading to every node's network rules, load balancers and ingress controllers. For a second or two, some clients may still send new requests to a pod that is already shutting down. That's why teams add a short preStop pause, for example sleep 5, so the pod keeps serving until everyone has stopped sending it traffic. Kubernetes waits up to terminationGracePeriodSeconds, 30 seconds by default, for the whole shutdown, and then kills the container.A rollout that never finishes isn't rolled back automatically. After progressDeadlineSeconds (600 seconds by default) Kubernetes marks it as failed, and you roll back with kubectl rollout undo.
4. Code Examples
These JDK-only Java 21 programs show what happens during a rollout.
4.1 Example 1: How maxSurge and maxUnavailable Shape a Rollout
This program follows the Deployment controller's rules step by step: remove old pods only while enough pods stay ready, and add new pods only while the total stays under the limit. New pods need one step to pass their readiness probe.
public class RollingUpdateDemo {
// Kubernetes rounds maxSurge up and maxUnavailable down when they are percentages
static int surgeFromPercent(int replicas, int percent) { return (int) Math.ceil(replicas * percent / 100.0); }
static int unavailableFromPercent(int replicas, int percent) { return replicas * percent / 100; }
static void rollOut(String title, int replicas, int maxSurge, int maxUnavailable) {
int minAvailable = replicas - maxUnavailable, maxTotal = replicas + maxSurge;
System.out.printf("%s (maxSurge=%d, maxUnavailable=%d): always >= %d ready, never > %d pods%n",
title, maxSurge, maxUnavailable, minAvailable, maxTotal);
int oldPods = replicas, newReady = 0, newStarting = 0;
for (int step = 1; newReady < replicas || oldPods > 0; step++) {
newReady += newStarting; // pods started last step pass their readiness probe now
newStarting = 0;
int removable = Math.max(0, oldPods + newReady - minAvailable);
int removed = Math.min(removable, oldPods);
oldPods -= removed; // old pods get SIGTERM and leave the load balancer
int room = maxTotal - (oldPods + newReady);
newStarting = Math.max(0, Math.min(room, replicas - newReady));
System.out.printf(" step %d: v1 ready=%d v2 ready=%d v2 starting=%d (stopped %d v1)%n",
step, oldPods, newReady, newStarting, removed);
}
}
public static void main(String[] args) {
rollOut("Defaults for 4 replicas, 25%/25%", 4, surgeFromPercent(4, 25), unavailableFromPercent(4, 25));
rollOut("Zero-downtime setting", 4, 1, 0);
}
}
Defaults for 4 replicas, 25%/25% (maxSurge=1, maxUnavailable=1): always >= 3 ready, never > 5 pods
step 1: v1 ready=3 v2 ready=0 v2 starting=2 (stopped 1 v1)
step 2: v1 ready=1 v2 ready=2 v2 starting=2 (stopped 2 v1)
step 3: v1 ready=0 v2 ready=4 v2 starting=0 (stopped 1 v1)
Zero-downtime setting (maxSurge=1, maxUnavailable=0): always >= 4 ready, never > 5 pods
step 1: v1 ready=4 v2 ready=0 v2 starting=1 (stopped 0 v1)
step 2: v1 ready=3 v2 ready=1 v2 starting=1 (stopped 1 v1)
step 3: v1 ready=2 v2 ready=2 v2 starting=1 (stopped 1 v1)
step 4: v1 ready=1 v2 ready=3 v2 starting=1 (stopped 1 v1)
step 5: v1 ready=0 v2 ready=4 v2 starting=0 (stopped 1 v1)
The defaults finish in three steps but allow capacity to drop to three ready pods. With maxUnavailable: 0, there are always four ready pods, at the cost of a slower rollout. If your service runs close to its capacity limit, losing a quarter of its pods during every release causes timeouts, so pick the setting on purpose.
4.2 Example 2: The Mixed-Version Trap
During a rolling deployment, old and new versions run at the same time and share the same database, cache and message topics. Here v2 renames a field, and orders created on one version are read by the other.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
public class MixedVersionsDemo {
// A shared store (database or cache) that every pod reads and writes, holding JSON-like maps
static final Map<String, Map<String, String>> store = new HashMap<>();
interface Pod { void save(String id, int cents); int read(String id); }
static Pod v1() {
return new Pod() {
public void save(String id, int cents) { store.put(id, Map.of("price", String.valueOf(cents / 100.0))); }
public int read(String id) { // throws if the field is missing
return (int) Math.round(Double.parseDouble(store.get(id).get("price")) * 100);
}
};
}
// v2, breaking change: renames "price" (euros) to "priceCents"
static Pod v2Breaking() {
return new Pod() {
public void save(String id, int cents) { store.put(id, Map.of("priceCents", String.valueOf(cents))); }
public int read(String id) { return Integer.parseInt(store.get(id).get("priceCents")); }
};
}
// v2, compatible ("expand" step): writes both fields, reads whichever exists
static Pod v2Compatible() {
return new Pod() {
public void save(String id, int cents) {
store.put(id, Map.of("priceCents", String.valueOf(cents), "price", String.valueOf(cents / 100.0)));
}
public int read(String id) {
Map<String, String> row = store.get(id);
return row.containsKey("priceCents") ? Integer.parseInt(row.get("priceCents"))
: (int) Math.round(Double.parseDouble(row.get("price")) * 100);
}
};
}
// Halfway through the rollout: 2 old pods and 2 new pods behind a load balancer
static int errorsDuringRollout(Pod newPod) {
store.clear();
List<Pod> pods = List.of(v1(), v1(), newPod, newPod);
Random balancer = new Random(42); // seeded, so every run picks the same pods
int errors = 0;
for (int order = 1; order <= 100; order++) {
String id = "ORD-" + order;
Pod writer = pods.get(balancer.nextInt(pods.size())); // "create order" lands on any pod
Pod reader = pods.get(balancer.nextInt(pods.size())); // "show order" may land on another
writer.save(id, 1999);
try {
if (reader.read(id) != 1999) errors++;
} catch (RuntimeException e) {
errors++; // missing field: NumberFormatException or NPE
}
}
return errors;
}
public static void main(String[] args) {
System.out.println("Breaking v2: " + errorsDuringRollout(v2Breaking()) + " of 100 orders failed to load");
System.out.println("Compatible v2: " + errorsDuringRollout(v2Compatible()) + " of 100 orders failed to load");
}
}
Breaking v2: 58 of 100 orders failed to load
Compatible v2: 0 of 100 orders failed to load
More than half the orders fail during the rollout, and every test passed, because each version is correct on its own. The fix is the expand and contract approach: first release a version that writes both the old and new fields and can read either one (expand). Only after every pod runs it, release a version that drops the old field (contract). The same rule applies to API responses, event formats and database columns.
4.3 Example 3: Graceful Shutdown
When a pod is told to stop, requests that are already running should finish. The JDK's HttpServer.stop(delay) stops accepting new connections and waits up to delay seconds for running requests.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
public class GracefulShutdownDemo {
// One "pod": a server whose /checkout request takes 800 ms
static int run(String title, int drainSeconds) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/checkout", exchange -> {
try { Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
byte[] body = "paid".getBytes();
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
HttpClient client = HttpClient.newHttpClient();
URI uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/checkout");
List<CompletableFuture<String>> inFlight = new ArrayList<>();
for (int i = 0; i < 3; i++) {
inFlight.add(client.sendAsync(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(e -> "failed"));
}
Thread.sleep(200); // the requests are now being processed
server.stop(drainSeconds); // "SIGTERM": stop accepting, then wait up to drainSeconds for running requests
int ok = 0;
for (var f : inFlight) if (f.join().equals("paid")) ok++;
System.out.println(title + ok + " of 3 in-flight checkouts completed");
return ok;
}
public static void main(String[] args) throws Exception {
run("Stop immediately: ", 0);
run("Drain for 5 s: ", 5);
}
}
Stop immediately: 0 of 3 in-flight checkouts completed
Drain for 5 s: 3 of 3 in-flight checkouts completed
Stopping immediately breaks three customers' checkouts in every pod that is replaced, every release. In Spring Boot, graceful shutdown is turned on by default since version 3.4 (server.shutdown=graceful), and spring.lifecycle.timeout-per-shutdown-phase, 30 seconds by default, limits how long it waits. Keep that limit below the pod's terminationGracePeriodSeconds, minus any preStop pause.
5. Rolling vs Recreate vs Blue-Green vs Canary
| Strategy | How it works | Downtime | Extra capacity needed | Rollback speed | Mixed versions live? |
|---|---|---|---|---|---|
| Recreate | Stop all old, start all new | Yes | None | Slow: redeploy | No |
| Rolling | Replace a few instances at a time | No, if done right | Small (maxSurge) | Medium: roll back the same way | Yes, during the rollout |
| Blue-green | Start a full new copy, switch all traffic at once | No | Double, briefly | Instant: switch back | Only for a moment |
| Canary | Send a small share of traffic to the new version first | No | Small | Fast: move traffic back | Yes, on purpose |
Rolling deployments are the cheapest zero-downtime option and the right default. Blue-green suits releases that must switch over all at once; canary suits risky changes you want to test on a few users first.
6. Common Mistakes and Pitfalls
1. No readiness probe. Without one, Kubernetes treats a pod as ready as soon as its container starts, while the JVM is still booting. Traffic reaches it too early and fails.
2. Breaking changes in one step. Renaming a field, removing an endpoint or changing an event format breaks the old pods still running, as Example 2 shows. Use expand and contract.
3. Ignoring the shutdown race. Without graceful shutdown and a short preStop pause, every release causes a small burst of connection errors that's easy to blame on "the network".
4. Database migrations that lock tables. A migration that runs when the first v2 pod starts, such as adding a column with a default to a huge table on an old database version, can block the v1 pods still serving traffic. Keep migrations small and backward-compatible.
5. Long-lived connections. WebSocket and gRPC streaming clients stay connected to old pods until those pods close the connection. Make the server close them during shutdown and make clients reconnect smoothly.
6. Too many replicas changing at once. A large maxSurge on a big Deployment can start dozens of pods together, which all open database connections and warm caches at the same time.
7. Best Practices
- Always configure readiness probes, and set
minReadySecondsif new pods sometimes fail shortly after becoming ready. - Choose maxSurge and maxUnavailable on purpose; use
maxUnavailable: 0for services that run close to their limits. - Make every release backward-compatible with the previous one, for APIs, events and the database schema.
- Enable graceful shutdown and add a short
preStoppause. - Watch the rollout:
kubectl rollout statuswaits for it to finish, and dashboards for error rate and latency should be open during a release. - Keep rollback cheap:
kubectl rollout undo deployment/ticketingreturns to the previous revision, which is possible because Kubernetes keeps old ReplicaSets (10 by default, set byrevisionHistoryLimit). - Automate the decision with tools like Argo Rollouts or Flagger when you need automatic rollback based on metrics.
8. Summary
- A rolling deployment replaces instances a few at a time, so the service stays up.
maxSurgelimits extra pods;maxUnavailablelimits how many may be not ready.- Readiness probes decide when new pods get traffic.
- Old and new versions run together, so every change must be backward-compatible.
- Graceful shutdown and a short
preStoppause prevent errors when old pods stop. - A stuck rollout is marked failed after the progress deadline, but you roll it back yourself.
Start by checking three things in your own Deployment: a readiness probe, graceful shutdown and a backward-compatible release. With those in place, a rolling deployment really does deliver zero-downtime releases.
Rolling Deployment: Interview Questions
Common interview questions on Rolling Deployment, from basic to scenario-based, with short answers.
Basic questions
Q. What is the default deployment strategy in Kubernetes, and what are its defaults?
Answer: RollingUpdate, with maxSurge and maxUnavailable both set to 25%. maxSurge is rounded up and maxUnavailable is rounded down, so for 4 replicas both are 1. They can't both be 0, because then the rollout could never make progress.
Q. How do you roll back a rolling deployment in Kubernetes?
Answer: Run kubectl rollout undo, optionally with a revision number from kubectl rollout history. Kubernetes scales the old ReplicaSet back up and the new one down, using the same rolling rules.
kubectl rollout undo deployment/ticketing --to-revision=7
Q. Can you pause a rolling deployment?
Answer: Yes. kubectl rollout pause stops the controller from replacing more pods, leaving the mix of versions as it is, and kubectl rollout resume continues. Pausing is useful for a quick manual check, or for making several changes to the Deployment that should go out as one rollout.
Intermediate questions
Q. What is minReadySeconds used for?
Answer: It makes Kubernetes wait the given number of seconds after a new pod becomes ready before counting it as available. This catches pods that pass their first readiness check and then crash or fail shortly after, before the rollout replaces more old pods. The default is 0.
Q. Does a PodDisruptionBudget protect a Deployment during a rolling update?
Answer: No. A PodDisruptionBudget limits voluntary disruptions that go through the eviction API, such as draining a node. A Deployment's rolling update is controlled by maxUnavailable and maxSurge instead. You usually want both: the strategy for releases and a PodDisruptionBudget for cluster maintenance.
Q. How does a rolling deployment work outside Kubernetes, for example on AWS ECS?
Answer: ECS services use minimumHealthyPercent and maximumPercent, which play the same role as maxUnavailable and maxSurge. The defaults for a replica service are 100% and 200%, so ECS starts new tasks first and stops old ones only after the new tasks are healthy behind the load balancer.
Q. How do you deploy a database schema change with rolling deployments?
Answer: Split it into backward-compatible steps across releases. First add the new column or table and write to both old and new structures; then move reads to the new structure; finally remove the old one in a later release. Every release must work with the schema used by the release before it.
Q. How should a Java service handle SIGTERM?
Answer: The JVM runs its shutdown hooks when it receives SIGTERM. Frameworks use this to stop accepting requests, finish running ones, stop message consumers after their current message, and close connection pools. In plain Java you can register your own hook.
Runtime.getRuntime().addShutdownHook(Thread.ofPlatform().unstarted(() -> server.stop(20)));
Q. Why might a rollout get stuck, and how do you find out why?
Answer: Common causes are new pods failing their readiness probe, crashing on start (CrashLoopBackOff), a wrong image tag (ImagePullBackOff), or no cluster capacity for the extra pods. Check kubectl rollout status, kubectl describe deployment for the Progressing condition, and the events and logs of the new pods.
Tricky and scenario questions
Q. Scenario: Every release causes about 20 seconds of 502 errors at the ingress, even though readiness probes are set. What is wrong?
Answer: Most likely the old pods stop too quickly: they exit on SIGTERM while the ingress still routes requests to them. Enable graceful shutdown, add a preStop pause of a few seconds, and make sure terminationGracePeriodSeconds is longer than both together. If the errors happen when new pods start, check that the readiness probe really reflects that the app is ready.
Q. Scenario: A rollout finished successfully, but error rates doubled an hour later. Why didn't the rolling deployment catch it?
Answer: A rolling deployment only checks that pods become ready; it doesn't check business metrics. Problems that appear under real traffic, such as slow queries or a memory leak, show up after the rollout. Use a canary release or a tool like Argo Rollouts that analyses error rates and latency before continuing, and roll back quickly when alerts fire.
Q. Scenario: You must change the format of an event published to Kafka. How do you release it with rolling deployments?
Answer: Producers and consumers are updated at different times, so both formats will exist on the topic. First release consumers that understand both formats, then release producers that write the new one, and remove support for the old format only after its events are no longer being consumed or replayed. Contract tests help confirm each step is compatible.
Q. When would you choose a recreate deployment instead?
Answer: When two versions must never run at the same time, for example a single-instance job that holds an exclusive lock, or a change that can't be made backward-compatible. You accept a short outage in return for never having mixed versions.