28.4. Graceful Shutdown
Table of Contents
Why Graceful Shutdown Matters
When a backend process stops, it can do so in two ways:
- Hard (abrupt) shutdown: the process is killed immediately. Ongoing requests are cut off, in‑flight database writes may be interrupted, and clients see errors or timeouts.
- Graceful shutdown: the process stops cooperatively. It stops accepting new work, completes or cancels current work safely, cleans up resources, and then exits.
In production systems you almost always want graceful shutdown. It is important for:
- User experience: active requests finish instead of failing in the middle of an action.
- Data integrity: you avoid half-finished writes and inconsistent state.
- System stability: resources like DB connections and file handles are released.
- Zero‑downtime deployment: you can upgrade or restart services without breaking traffic.
A production backend must always implement a graceful shutdown procedure that:
- Stops accepting new work.
- Waits for in‑flight work to complete or times it out.
- Closes connections and external resources safely.
- Exits with a clear, predictable state.
Throughout this chapter we will look at patterns and concrete examples that you can adapt to your stack, with a focus on Python and async backends.
Signals and Process Lifecycle
On Unix-like systems (Linux, macOS) processes receive signals that tell them to stop or change behavior.
Common termination signals:
| Signal | Typical source | Meaning in our context |
|---|---|---|
SIGINT | Ctrl+C in a terminal | Interrupt, usually used in development |
SIGTERM | systemd, Kubernetes, Docker stop | Please terminate, used in production |
SIGKILL | Kernel, kill -9 | Forced kill, cannot be caught |
For graceful shutdown, we care about catchable signals, mainly SIGTERM and sometimes SIGINT.
High-level graceful shutdown flow:
- Process receives
SIGTERM. - A signal handler sets a flag, triggers cleanup logic, or stops servers.
- Servers stop accepting new connections.
- Ongoing requests get a chance to finish.
- Timeouts and resource cleanup run.
- Process exits with status code 0 (success).
If your process ignores SIGTERM, orchestration systems may escalate to SIGKILL, which gives you no chance to shut down gracefully.
Basic Pattern: Stop Accepting New Work
The first step of a graceful shutdown is to stop taking on new work.
For a web server, that means:
- Stop accepting new connections.
- Stop routing new requests to workers.
- Optionally respond to new connections with a quick error during the drain period.
A common approach is to:
- On shutdown signal, close the listening socket or mark the server as shutting down.
- Allow existing connections to continue and finish.
In higher-level frameworks (like Uvicorn + FastAPI) this is often handled by the server. Your responsibility is to:
- Integrate with the server’s shutdown hooks.
- Make sure your own background and long-running work respects shutdown signals.
Tracking In‑Flight Requests
To avoid killing ongoing work, you need a way to know when it is safe to exit. A simple pattern is to track how many requests are currently running.
In pseudocode:
in_flight_requests = 0
shutting_down = False
def before_request():
global in_flight_requests
if shutting_down:
raise RejectNewRequests()
in_flight_requests += 1
def after_request():
global in_flight_requests
in_flight_requests -= 1
def handle_shutdown():
global shutting_down
shutting_down = True
wait_until(in_flight_requests == 0 or timeout_reached)
In an async Python service, you might do something similar with an asyncio.Lock or asyncio.Semaphore.
This pattern makes it possible to:
- Immediately reject new requests while
- Allowing current requests to finish.
Timeouts and Maximum Shutdown Duration
You cannot wait forever. There will always be a request that hangs, a database that is slow, or a bug that delays completion.
So you usually combine:
- Request timeouts: every request must finish within a maximum duration.
- Shutdown timeout: the process will wait up to a limit, then forcefully stop any remaining tasks.
Typical shutdown timeout values are 10 to 60 seconds, depending on your workload.
Always define a maximum shutdown timeout, for example:
$$ T_{\text{shutdown\_max}} = 30\ \text{seconds} $$
After $T_{\text{shutdown\_max}}$ expires, the process must exit even if work is still pending, otherwise deployments and scaling operations can hang.
In Kubernetes, for example, the terminationGracePeriodSeconds setting defines how long the platform will wait after sending SIGTERM before sending SIGKILL.
Graceful Shutdown in Async Python
Many modern backends use asyncio and async frameworks. In async systems, graceful shutdown is mostly about:
- Cancelling tasks in a controlled way.
- Awaiting their completion.
- Distinguishing between "soft cancellation" and "forceful stop".
A typical pattern:
import asyncio
import signal
shutdown_event = asyncio.Event()
def _signal_handler():
shutdown_event.set()
async def main():
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, _signal_handler)
loop.add_signal_handler(signal.SIGINT, _signal_handler)
# Start background tasks
worker_task = asyncio.create_task(background_worker())
# Wait for shutdown signal
await shutdown_event.wait()
# Begin graceful shutdown
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
# Clean up resources here (DB, Redis, etc.)
asyncio.run(main())Key ideas:
- A signal sets some shared state (
shutdown_event). - Background workers check for cancellation and exit.
- Tasks are cancelled and awaited so that any
finallyblocks run.
Coordinating with Web Servers (Uvicorn / Gunicorn / Nginx)
Graceful shutdown is not just your application. It is the whole chain:
- Load balancer / reverse proxy (Nginx, HAProxy, cloud LB).
- Application server (Gunicorn, Uvicorn workers).
- Application code (FastAPI, Django, etc.).
- Background workers (Celery, RQ, custom asyncio workers).
Each layer has a role.
Typical production flow:
- Load balancer marks the instance as draining so it does not receive new connections.
- Load balancer keeps existing connections alive for a grace period.
- System sends
SIGTERMto the app server process. - App server:
- Stops accepting new connections.
- Lets current requests finish.
- Signals workers to shut down.
- Your app:
- Releases resources.
- Stops background tasks.
You need to ensure your application hooks run, for example:
- In FastAPI, use lifespan events or custom startup/shutdown events.
- In Django, use signals or WSGI/ASGI server hooks.
- For custom workers, add explicit signal handling as shown above.
Handling Database Connections and Pools
Databases maintain connections that are expensive to create. You usually use a connection pool.
During graceful shutdown, you must:
- Stop creating new DB connections.
- Let ongoing transactions finish or roll back.
- Close connections in the pool cleanly.
If you use an ORM, it likely provides a method such as:
- SQLAlchemy:
engine.dispose()orsession.close_all(). - An async engine:
engine.dispose()withawait.
Example pattern (pseudocode):
async def on_shutdown():
# Stop accepting new requests, wait for ongoing ones ...
# Close DB connection pool
await async_engine.dispose()If you do not close the pool, the OS will eventually free the resources when the process exits, but some drivers or libraries may not flush buffered data or metrics correctly.
Also ensure that long-running transactions are avoided in your design, because they make graceful shutdown harder. Use shorter transactions and commit frequently.
Shutting Down Background Workers and Queues
Web requests are not the only form of work. You may have:
- Message queue consumers (e.g., listening to RabbitMQ, Kafka).
- Periodic job schedulers.
- Custom background loops (pollers, event listeners).
For each of these:
- On shutdown signal, ask the worker to stop fetching new jobs.
- Let the current job finish (or interrupt it in a safe way).
- Acknowledge or requeue jobs appropriately.
- Close connections to the broker.
Example pattern with an async worker loop:
async def worker(shutdown_event):
while not shutdown_event.is_set():
job = await queue.get()
try:
await handle_job(job)
finally:
queue.task_done()
When shutdown_event is set:
- The loop stops after the current job.
- You can also implement a drain timeout to avoid a job running forever.
In job systems like Celery, built‑in signals and commands handle much of this, but you still need to design your tasks to be idempotent and able to resume if interrupted.
Draining Traffic Behind a Load Balancer
In a production environment, your backend is usually behind a load balancer or ingress controller.
You typically combine two mechanisms:
- Instance draining at the load balancer:
- Stop routing new requests to a particular instance.
- Keep existing connections alive for a configured delay.
- Process-level graceful shutdown:
- The instance still serves the connections it has.
- It waits for those requests to finish.
Typical Kubernetes scenario:
- Pod is marked for termination.
- Kubernetes sends
SIGTERMto containers. - At the same time, the pod is removed from the service endpoints, so the service stops sending new requests.
- Pod has up to
terminationGracePeriodSecondsto finish ongoing work and exit.
Similar concepts exist in cloud load balancers, like ALB/NLB in AWS, where you configure:
- Connection draining or deregistration delay.
- Health checks that determine when instances are ready or not.
Coordinating these settings with your app’s shutdown timeout is important. If the load balancer stops routing traffic too late, you may still receive new requests already in your shutdown phase.
Handling Long‑Running and Streaming Requests
Some endpoints may:
- Stream data for a long time (e.g. Server‑Sent Events, WebSockets).
- Perform long calculations.
- Process very large uploads or downloads.
For such endpoints you must decide:
- Allow them to complete even if shutdown is slow, or
- Cut them off at a configured limit and tell the client to retry or resume.
Options include:
- Imposing maximum request duration on the server side.
- Implementing resumable operations or idempotent retries:
- For uploads, use chunked uploads or presigned URLs.
- For long jobs, offload to background workers and return a job ID, then the worker can continue independently of the web server lifecycle.
It is usually a bad idea to keep a process alive indefinitely just because one client has kept a streaming connection open. Use a reasonable time limit for such connections.
Observability During Shutdown
To understand and trust your shutdown behavior, you should:
- Log the start and end of shutdown:
- Received
SIGTERM - Number of in‑flight requests
- Time spent draining
- Log errors during cleanup:
- Problems closing DB pools
- Exceptions in background tasks
- Export metrics:
- Count of active requests.
- Number of forced cancellations.
- Shutdown duration.
This helps you answer questions like:
- How often do we hit the shutdown timeout limit?
- Are there frequent tasks that ignore cancellation?
- Is our termination grace period misconfigured?
A Typical Graceful Shutdown Checklist
Use this as a practical reference when implementing graceful shutdown in a backend service.
Graceful Shutdown Checklist
- Signal handling
- Handle
SIGTERM(and optionallySIGINT). - Trigger a shutdown event instead of exiting immediately.
- Stop accepting new work
- Stop accepting new web requests.
- Stop fetching new jobs from message queues.
- Track in‑flight work
- Maintain a count or registry of active requests / jobs.
- Wait for them to finish, subject to a maximum shutdown timeout.
- Timeouts
- Set per‑request or per‑job timeouts.
- Set a global shutdown timeout $T_{\text{shutdown\_max}}$.
- Release resources
- Close DB connections and pools.
- Close Redis, message broker, file handles, and external client connections.
- Cooperate with load balancers
- Ensure instance draining is configured.
- Coordinate LB grace period with your shutdown timeout.
- Logs and metrics
- Log shutdown steps and durations.
- Expose metrics related to shutdown success / failures.
- Test it
- Simulate
SIGTERMin staging or local environment. - Verify that:
- No new requests are accepted.
- Ongoing requests finish successfully.
- The process exits within the configured time.
By following these patterns, you can make your backend behave predictably under restarts, deployments, and failures, which is essential for reliable production systems.
Views: 8
KAHIBARO