
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Deploying Python applications without dropping requests requires more than just a restart command; it demands explicit handling of termination signals and active status reporting. Graceful shutdown and health checks in Python are the two mechanisms that bridge the gap between your application code and infrastructure orchestrators like Kubernetes or systemd. Without them, rolling updates cause 502 errors and liveness probes kill healthy but busy pods. This guide covers the exact signal handling patterns and HTTP probe implementations needed for production-grade reliability.
How Do You Handle SIGTERM for Graceful Shutdown in Python?
When Kubernetes terminates a pod or systemd stops a service, it sends a SIGTERM signal. The default Python behavior is to raise KeyboardInterrupt or exit immediately, severing active connections and corrupting in-flight transactions. To achieve graceful shutdown and health checks in Python, you must intercept this signal and initiate a controlled drain sequence. This is distinct from SIGINT (Ctrl+C), which is typically reserved for interactive debugging.
In practice, your shutdown handler must perform three actions atomically: stop accepting new connections, wait for existing requests to complete (with a timeout), and flush any buffered logs or metrics. For async frameworks like FastAPI or Starlette, this integrates directly with the ASGI lifespan protocol. For synchronous WSGI apps like Flask or Django, you rely on the production server (Gunicorn/uWSGI) to manage worker draining while your app handles cleanup hooks.
Async Lifespan Pattern (FastAPI/Starlette)
Modern Python web frameworks provide a structured lifecycle hook. Instead of raw signal handlers, use the ASGI lifespan context manager. This ensures your shutdown logic runs within the same event loop as your requests, avoiding thread-safety issues.
from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncio
import logging
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Initialize DB pools, cache connections
logger.info("Application startup complete")
yield
# Shutdown: Triggered by SIGTERM
logger.info("Shutdown initiated, draining active requests...")
# Wait for background tasks with timeout
try:
await asyncio.wait_for(drain_active_tasks(), timeout=25.0)
except asyncio.TimeoutError:
logger.error("Drain timed out, forcing exit")
logger.info("Cleanup complete, exiting gracefully")
app = FastAPI(lifespan=lifespan) Synchronous Worker Management (Gunicorn)
For WSGI applications, do not handle SIGTERM in your application code. Gunicorn intercepts SIGTERM at the master process level and coordinates worker shutdown. Your responsibility is defining a worker_exit hook or using the --graceful-timeout flag. A common mistake is setting this timeout lower than your longest expected request duration. If your database queries take 30 seconds but graceful timeout is 20 seconds, requests will still be severed. Align this value with your load balancer's idle timeout and your Kubernetes resource limits.
What Should a Python Health Check Endpoint Actually Verify?
A health check is not a ping test. Returning {"status": "ok"} unconditionally creates false positives where the load balancer routes traffic to a pod that has lost its database connection or exhausted its connection pool. Effective health checks for graceful shutdown and health checks in Python must validate the critical path dependencies that your service cannot function without.
Distinguish between liveness and readiness. Liveness answers "Is the process deadlocked?" and should be cheap (e.g., checking if the event loop is responsive). Readiness answers "Can this instance serve traffic right now?" and must verify downstream connectivity. During shutdown, readiness must fail immediately to remove the pod from service endpoints, while liveness remains true until the process actually exits.
- Liveness Probe: Returns 200 if the main thread/event loop is responsive. No external calls. Used to detect deadlocks.
- Readiness Probe: Returns 200 only if DB, cache, and message queue connections are valid. Returns 503 during shutdown drain.
- Startup Probe: Handles slow initialization (model loading, cache warming). Prevents premature liveness kills.
Implementing a Dependency-Aware Readiness Check
This implementation checks PostgreSQL connectivity and Redis availability with strict timeouts. It also respects the shutdown flag to prevent new traffic routing during termination.
from fastapi import Response, status
import asyncpg
import redis.asyncio as redis
import time
# Global state managed by lifespan
is_shutting_down = False
@app.get("/healthz/ready")
async def readiness_check():
if is_shutting_down:
return Response(
content='{"status": "shutting_down"}',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
media_type="application/json"
)
checks = {}
overall_ok = True
# Check PostgreSQL with 2s timeout
try:
conn = await db_pool.acquire(timeout=2.0)
await conn.fetchval("SELECT 1")
db_pool.release(conn)
checks["database"] = "ok"
except Exception as e:
checks["database"] = f"error: {str(e)}"
overall_ok = False
# Check Redis with 1s timeout
try:
await redis_client.ping()
checks["cache"] = "ok"
except Exception as e:
checks["cache"] = f"error: {str(e)}"
overall_ok = False
response_code = 200 if overall_ok else 503
return Response(
content=json.dumps({"status": "ok" if overall_ok else "degraded", "checks": checks}),
status_code=response_code,
media_type="application/json"
) For teams adopting observability standards, expose these health states as metrics rather than just HTTP responses. Refer to Prometheus metrics monitoring fundamentals to learn how to export app_health_status gauges that alert on dependency failures before users notice.
How Do You Configure Kubernetes Probes for Python Apps?
Kubernetes probe configuration determines whether your graceful shutdown and health checks in Python actually work in production. Misconfigured probes are the leading cause of "mysterious" 502 errors during deploys. The key parameters are initialDelaySeconds, periodSeconds, timeoutSeconds, and failureThreshold. These must be tuned to your application's actual startup time and dependency latency, not copied from generic templates.
Critical Timing Relationships
Your terminationGracePeriodSeconds (default 30s) must exceed the sum of your pre-stop hook duration plus your application's maximum drain time. If your app needs 25 seconds to finish requests and your pre-stop sleep is 5 seconds, set grace period to at least 35 seconds. Otherwise, Kubernetes sends SIGKILL before graceful shutdown completes.
| Parameter | Recommended Value | Rationale |
|---|---|---|
initialDelaySeconds (Liveness) | 0 (use Startup Probe instead) | Startup probe handles variable init times; liveness delay masks crashes |
timeoutSeconds (Readiness) | 2–3s | Prevents cascading delays when DB is slow; fail fast over hang |
periodSeconds (Readiness) | 5–10s | Balances detection speed vs. API overhead; align with LB health interval |
failureThreshold (Liveness) | 3 | Tolerates transient GC pauses without restarting; avoids flapping |
terminationGracePeriodSeconds | Max request time + 10s buffer | Ensures SIGTERM drain completes before SIGKILL |
Always add a preStop hook with a 3–5 second sleep. This compensates for the eventual consistency lag between Kubernetes marking a pod as terminating and the kube-proxy/IPVS rules updating across all nodes. Without this sleep, new requests may still route to the pod even after readiness fails. See blue-green and canary deploys on Kubernetes for advanced traffic shifting strategies that complement probe tuning.
Why Does My Python App Still Drop Requests During Shutdown?
Even with correct signal handlers and probes, request drops occur due to race conditions between layers. The most frequent culprit in Python environments is the mismatch between the ASGI server's internal connection tracking and your application's shutdown flag. Uvicorn, for example, may stop accepting new TCP connections upon SIGTERM, but in-flight requests on existing connections continue. If your app sets the shutdown flag and returns 503 on readiness before those connections fully drain, the load balancer removes the pod while requests are still processing.
Common Failure Modes
- Missing Pre-Stop Sleep: Endpoint removal propagates asynchronously. Requests already in flight from the LB hit a pod that has already failed readiness.
- Aggressive Timeouts: Setting
asyncio.wait_fordrain timeout below actual P99 latency forces premature exit. - Background Task Leakage: Celery workers or asyncio tasks not registered with the drain tracker continue running after shutdown begins, getting killed mid-operation.
- Connection Pool Exhaustion: Health checks themselves consume DB connections during high-load shutdown, causing legitimate requests to timeout waiting for a connection.
Debugging Shutdown Issues
Add structured logging at every shutdown phase. Log the count of active requests when SIGTERM arrives, the elapsed drain time, and any forced terminations. Correlate these logs with structured logging best practices to trace individual request IDs through the shutdown window. In Kubernetes, use kubectl get events and pod termination logs to distinguish between graceful exits (exit code 0) and SIGKILL terminations (exit code 137). Exit code 137 always indicates your grace period was insufficient or your drain logic hung.
Conclusion
Implementing graceful shutdown and health checks in Python is a non-negotiable requirement for production systems in 2026. The combination of ASGI lifespan handlers, dependency-aware readiness endpoints, and correctly tuned Kubernetes probes transforms chaotic deploys into predictable operations. Start by auditing your current signal handling: if you cannot describe exactly what happens between SIGTERM and process exit, you have a reliability gap. Test your shutdown behavior under load using tools like k6 or Locust before trusting it in production. When your infrastructure respects your application's lifecycle boundaries, zero-downtime deployment becomes routine rather than aspirational. For architecture review or implementation support, reach out to discuss your specific deployment challenges.