00:00

Rolling Deployment in Microservices: Update Without Taking the Service Down

Last updated:

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:

deployment.yaml
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:

One cycle of a rolling deploymentA new v2 pod is started. The platform waits until its readiness probe passes, then adds it to the Service. One v1 pod is removed from the Service and sent SIGTERM at the same time. The v1 pod finishes its in-flight requests and exits. If not all pods run v2 yet, the cycle repeats; otherwise the rollout is complete. Start a v2 pod JVM boots, warms up Readiness passes? probe checked every period not yet: wait Add v2 to Service it starts receiving traffic Remove a v1 pod leaves Service + gets SIGTERM v1 drains and exits finishes in-flight requests All pods on v2? compare with replicas Rollout complete yes yes no: next batch
With maxUnavailable: 0, each cycle adds a ready v2 pod before an old v1 pod is removed, so serving capacity never drops.
What happens internally: removing a pod from the Service and sending it 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.

RollingUpdateDemo.java
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);
    }
}
Output
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.

MixedVersionsDemo.java
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");
    }
}
Output
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.

GracefulShutdownDemo.java
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);
    }
}
Output
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

StrategyHow it worksDowntimeExtra capacity neededRollback speedMixed versions live?
RecreateStop all old, start all newYesNoneSlow: redeployNo
RollingReplace a few instances at a timeNo, if done rightSmall (maxSurge)Medium: roll back the same wayYes, during the rollout
Blue-greenStart a full new copy, switch all traffic at onceNoDouble, brieflyInstant: switch backOnly for a moment
CanarySend a small share of traffic to the new version firstNoSmallFast: move traffic backYes, 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 minReadySeconds if new pods sometimes fail shortly after becoming ready.
  • Choose maxSurge and maxUnavailable on purpose; use maxUnavailable: 0 for 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 preStop pause.
  • Watch the rollout: kubectl rollout status waits for it to finish, and dashboards for error rate and latency should be open during a release.
  • Keep rollback cheap: kubectl rollout undo deployment/ticketing returns to the previous revision, which is possible because Kubernetes keeps old ReplicaSets (10 by default, set by revisionHistoryLimit).
  • 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.
  • maxSurge limits extra pods; maxUnavailable limits 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 preStop pause 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.