Zero-Downtime Service Reloads on a VPS: Connection Draining, SO_REUSEPORT and SIGQUIT Semantics

Restarting a web server or proxy on a busy VPS is not the same as reloading it. A naive systemctl restart tears the listening socket down and drops every connection in flight — a burst of 502s that lasts just long enough to be noticed, and long enough to lose writes on an API. This article covers the mechanisms that let you swap a binary or reload a config without severing established connections, and how to verify the drain actually worked.

Understand who owns the listening socket

The behaviour you get on restart depends entirely on whether the service master process survives. With nginx, SIGHUP tells the master to re-read configuration and start fresh worker processes while the old workers finish their current requests. The listening socket is inherited, so no connection is refused. With systemctl restart the whole process group dies, and every socket goes with it.

# graceful config reload - no dropped connections
nginx -s reload

# binary upgrade without losing the socket
systemctl reload nginx

The distinction matters most on a small, single-vCPU instance where the drain window has no spare core to hide behind: every millisecond a worker spends finishing is a millisecond the new workers are crowded.

Watch the drain, do not assume it

After a reload, old workers enter the “shutting down” state and linger until their keepalive connections time out or finish. You can observe this directly:

# old and new workers coexist during the drain
ps -eo pid,ppid,stat,cmd | grep '[n]ginx: worker'

# count ESTABLISHED connections per worker
ss -tnp | grep nginx | awk '{print $6}' | sort | uniq -c

A worker that refuses to exit usually means long-lived upstream connections or an idle keepalive client holding it open. Cap how long a worker is allowed to linger with worker_shutdown_timeout — without it, a slow client can pin an old worker (and its memory) far longer than you expect.

# in the http block
worker_shutdown_timeout 30s;
keepalive_timeout 15s;

SO_REUSEPORT for independent worker restarts

An alternative to the master/worker model is SO_REUSEPORT, which lets multiple independent processes bind the same port and have the kernel load-balance incoming connections across them. Each listener has its own accept queue, so you can retire one process while another keeps serving:

# verify the kernel allows it (it is a sysctl, default on modern kernels)
sysctl net.ipv4.ip_unprivileged_port_start
cat /proc/sys/net/core/somaxconn

The tradeoff is fairness: the kernel hashes connections to sockets, and a burst can land unevenly across processes. On a small VPS the master/worker reload model is usually simpler and more predictable. Reach for SO_REUSEPORT when you need genuinely independent processes — blue/green binary swaps, or a second binary listening during migration.

SIGQUIT, SIGTERM and graceful shutdown

Signals are not interchangeable. For nginx, SIGQUIT means graceful shutdown, SIGTERM means fast shutdown (immediate), and SIGHUP means reload. Sending the wrong one during a maintenance window is a common cause of “the reload dropped traffic”. Confirm the semantics for your own daemon before scripting it:

# graceful stop of the master: finish in-flight work, then exit
kill -QUIT $(cat /run/nginx.pid)

For application servers the pattern differs, but the principle is constant: there is usually a signal or a management endpoint that transitions the process to “stop accepting, finish current work”, and a harsher one that kills immediately. Use the former for deploys and the latter only for unresponsive processes.

Draining behind a load balancer

If your VPS sits behind a balancer, the drain must happen at two layers. Marking the backend unhealthy before reloading prevents new connections from arriving, and waiting for the balancer’s own detection interval before stopping means in-flight requests complete. A health check that only tests TCP lets connections reach a process mid-shutdown — use an application-level readiness endpoint that starts failing the moment a drain begins.

Prove it with a load generator

Do not trust the theory. Run continuous requests against the service while you reload it, and check for any non-2xx response. A simple loop with curl is enough to catch a dropped connection on a low-traffic site:

for i in $(seq 1 5000); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/health)
  [ "$code" = "200" ] || echo "FAIL $i: $code"
done

Run this in one terminal and trigger the reload in another. Any FAIL line is a real dropped request. After you have confirmed a clean drain, you can deploy with confidence on the same premium cloud solutions stack that keeps a predictable baseline. Graceful reloads are the rare optimisation that costs nothing but attention — and a well-behaved daemon on a properly configured virtual server will drain every connection before it exits.

Leave a Reply