Ktor 2.3.0 graceful shutdown: enabling zero‑downtime migration of a small application
0 reputation · 15 Jun 2020, 16:08 UTC
0 reputation · 15 Jun 2020, 16:08 UTC
To perform a blue‑green deployment of a small Ktor 2.3.0 application without exposing clients to 503 errors, relying on the built‑in graceful shutdown mechanism that stops accepting new requests while draining in‑flight traffic.
The application must be started with start(wait = false) so that a shutdown hook can invoke server.stop(). The load balancer must stop routing traffic to the instance before the SIGTERM is sent, otherwise new requests may be rejected. Stateful objects kept in application scope will be lost unless persisted externally.
35275 reputation · 15 Jun 2020, 19:30 UTC
Yes, the pattern you describe works: start with start(wait = false), register a JVM shutdown hook that calls server.stop(gracePeriodMillis, timeoutMillis), and deregister from the load balancer before SIGTERM. On shutdown, Ktor's engines stop accepting new connections first, then drain in-flight requests up to the grace period, then force-close whatever remains at the timeout. There is no separate per-engine idle-drain knob in 2.3.x — the timeoutMillis you pass is the ceiling.
Confirmed (well-established Ktor 2.3.x behavior):
stop(gracePeriodMillis, timeoutMillis) is the engine-level contract: stop accepting, wait for in-flight work, then close.start(wait = false) you own the lifecycle, which is the standard containerized pattern.Likely, but version/engine-sensitive: the precise moment acceptors stop relative to the first drain step, and how connections sitting in the OS backlog are treated. Ktor does not document a guarantee finer than "no new connections once shutdown begins." Anything routed to the instance between LB deregistration and SIGTERM can still be refused — which is why ordering matters, not because Ktor is misbehaving.
stop(), whose first effect is to stop the acceptors. The gap you cannot close at the Ktor level is the window before SIGTERM — hence LB-first sequencing.timeoutMillis; after it, connections are closed regardless of state. Long-polling, SSE, and WebSocket connections will not finish on their own and will be cut at that point.fun main() {
val server = embeddedServer(Netty, port = 8080) {
configureRouting()
}.start(wait = false)
Runtime.getRuntime().addShutdownHook(Thread {
server.stop(gracePeriodMillis = 5_000, timeoutMillis = 15_000)
})
}
Size gracePeriodMillis to your longest normal request, and keep timeoutMillis comfortably below your orchestrator's kill window (e.g., Kubernetes terminationGracePeriodSeconds), or the pod gets SIGKILLed mid-drain and you recreate the 503s you were avoiding.
stop().delay(5000), send SIGTERM mid-request, and confirm the response completes while new connections are refused.stop() to confirm drain never exceeds timeoutMillis.This reflects Ktor 2.3.x; Ktor 3.x changed parts of the server lifecycle, so re-verify if you upgrade. Application-scoped state is lost on shutdown — persist sessions externally (e.g., Redis) before migrating. One diagnostic detail that would change this advice: if your app serves SSE or WebSockets, say so — the grace-period sizing and engine choice recommendations shift significantly for persistent connections.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.