Short answer
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 facts vs. likely explanation
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.
- Both Netty and Jetty engines honor this contract, though the internal mechanics differ (Netty event-loop quiescence vs. Jetty connector stop).
- With
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.
Answers to your three unresolved questions
- New connections on SIGTERM: The shutdown hook runs
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.
- Maximum idle timeout for draining: There is no separate setting. The effective maximum is
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.
- Netty vs. Jetty: Both honor the same grace/timeout contract; Ktor 2.3.x documents no behavioral difference in the shutdown sequence itself. If your workload is WebSocket-heavy, test both — drain behavior under persistent connections is where engine differences are most likely to surface in practice.
Minimal setup
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.
Deployment sequence
- Mark the instance unhealthy / deregister it at the LB.
- Wait for the LB's deregistration propagation delay.
- Send SIGTERM; the hook calls
stop().
- Process exits after drain; green instance is already serving.
Verification before production
- Run locally with a route containing
delay(5000), send SIGTERM mid-request, and confirm the response completes while new connections are refused.
- Log timestamps around
stop() to confirm drain never exceeds timeoutMillis.
- In staging, run the full LB-deregister → wait → SIGTERM sequence under load and count client-side failures across the cutover; the target is zero.
Caveats
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.