← Back to the section

When Kubernetes stops a container, it sends a SIGTERM signal. After that there are a few seconds to correctly finish processing requests. If you don't configure this explicitly, uvicorn interrupts all active connections immediately — the client gets a 502. Let's go through what needs to be done.

Why you need --timeout-graceful-shutdown

By default uvicorn doesn't know how much time it has to shut down. Without an explicit parameter it will stop the process right after receiving SIGTERM, without waiting for current requests to finish.

To fix this, we set a timeout:

# main.py
import uvicorn

if __name__ == "__main__":
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8080,
        timeout_graceful_shutdown=30,
    )

In Kubernetes it's usually passed via the CLI in the Dockerfile:

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080",
     "--timeout-graceful-shutdown", "30"]

After receiving SIGTERM, uvicorn stops accepting new connections but gives already-started requests 30 seconds to finish. Only then does the process stop.

Which value to choose. The range of 20–45 seconds is a reasonable balance:

  • less than 20 seconds: long requests are interrupted;
  • more than 45 seconds: a high risk of getting a forced shutdown from Kubernetes (SIGKILL) before the timeout expires, if the pod's terminationGracePeriodSeconds is 60 seconds;
  • 30 seconds suits most REST services (ordinary requests take less than a second, p99 is around 5 seconds).

If you have endpoints that run longer than 10 seconds, the problem can't be solved by increasing the timeout — you need to rework them into a 202 Accepted scheme with subsequent polling.

Lifespan: the correct entry point for shutdown

FastAPI provides a lifespan mechanism — a generator function that runs at application startup and shutdown. This is the only place to correctly toggle the readiness flag, stop Kafka clients and close database connections.

The minimal structure:

# app/state.py
from dataclasses import dataclass

@dataclass
class AppState:
    is_ready: bool = False

app_state = AppState()
# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.state import app_state

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(application: FastAPI):
    logger.info("startup: application ready")
    app_state.is_ready = True

    yield

    # shutdown
    logger.info("received SIGTERM, starting shutdown")
    app_state.is_ready = False

    await asyncio.sleep(0)  # let the event loop process pending callbacks


app = FastAPI(lifespan=lifespan)

The block before yield is startup. The block after yield is shutdown. It's important that app_state.is_ready = False comes first in the shutdown block: this toggle is exactly what tells Kubernetes to remove the pod from the load balancer.

Separate health endpoints

A FastAPI application needs two endpoints — they play different roles:

# app/routes/health.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.state import app_state

router = APIRouter()


@router.get("/health/live")
async def liveness():
    return {"status": "alive"}


@router.get("/health/ready")
async def readiness():
    if not app_state.is_ready:
        return JSONResponse(status_code=503, content={"status": "not_ready"})
    return {"status": "ready"}
  • /health/live — checks that the process is alive (the event loop hasn't hung). If it returns an error, Kubernetes restarts the pod.
  • /health/ready — checks that the application is ready to accept traffic. If it returns 503, Kubernetes removes the pod from the load balancer's endpoint list.

You can't merge them into one endpoint: on shutdown you need to return 503 only from /health/ready, while the process is still alive.

What the whole process looks like on SIGTERM

  1. Kubernetes sends SIGTERM, uvicorn invokes the shutdown block in lifespan.
  2. app_state.is_ready = False — the flag is toggled immediately.
  3. /health/ready starts returning 503.
  4. Kubernetes notices the readiness probe failing and removes the pod from endpoints (takes about 5–10 seconds).
  5. New traffic no longer arrives at this pod.
  6. In-flight requests are finished up to the timeout_graceful_shutdown value.

A full lifespan with real resources

A service with Kafka and SQLAlchemy:

# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine
from app.kafka import consumer, producer
from app.scheduler import scheduler
from app.state import app_state

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(application: FastAPI):
    await consumer.start()
    await producer.start()
    scheduler.start()
    app_state.is_ready = True
    logger.info("startup complete")

    yield

    logger.info("received SIGTERM, starting shutdown")
    app_state.is_ready = False

    scheduler.shutdown(wait=True)

    await consumer.stop()
    await producer.stop()

    await engine.dispose()
    logger.info("graceful shutdown complete")

The order here matters:

  1. is_ready = False — immediately, first.
  2. scheduler.shutdown(wait=True) — wait for the current scheduler iteration.
  3. consumer.stop() / producer.stop() — commit the offset, flush the buffer.
  4. engine.dispose() — close the connection pool last, after all tasks.

If you close the pool before stopping the scheduler, the scheduler will fail on its very next database access.

Common mistakes

No timeout_graceful_shutdown. uvicorn interrupts requests immediately on SIGTERM — the client gets a 502. Always set an explicit value.

timeout_graceful_shutdown=0. The same effect as without the parameter — immediate shutdown.

Readiness isn't toggled first. If you first close Kafka and then toggle the flag, the pod will keep receiving traffic for a few more seconds that it can no longer process correctly.

A module-level variable shutting_down: bool instead of the readiness flag. A health endpoint won't read such a variable — Kubernetes won't learn that shutdown has begun.

/health/live and /health/ready merged into one endpoint. Kubernetes won't be able to distinguish "the process has hung" from "the application is shutting down correctly."

engine.dispose() called before stopping background tasks. Tasks that access the database after the pool is closed will get a connection error.

In short

  • --timeout-graceful-shutdown 30 is mandatory — without it uvicorn interrupts requests immediately on SIGTERM.
  • The range of 20–45 seconds; 30 seconds suits most APIs.
  • The shutdown block in lifespan is the only correct place to toggle the readiness flag and stop resources.
  • The readiness flag is toggled first in the shutdown block, before anything else.
  • /health/live and /health/ready — must be separate endpoints with different semantics.
  • The order of closing in lifespan: flag → scheduler → Kafka → database connection pool.
  • Budgets and observability — how to compute the 60-second shutdown budget.
  • HTTP drain — what happens to in-flight requests.
  • Kafka shutdown — consumer.stop() and producer.stop() in detail.
  • Database and persistence — engine.dispose() and the order of closing the pool.
  • Kubernetes — preStop, terminationGracePeriodSeconds, probes.