Module 2: Local & Container Deployment
4. Health Checks and Dependencies
Overview
In this capsule you'll implement health checks that verify each service's real readiness and dependency ordering that ensures your containers start in the right order. By the end, your Docker Compose won't accept traffic until ALL services are truly operational — not just "running."
Context: A "running" service isn't the same as a "ready" service. Redis may be starting but not accepting connections. Your API may be importing libraries but not responding to requests. Health checks verify real readiness; dependency ordering ensures the API doesn't try to connect to Redis before Redis is ready.
Health Checks: More than "the process exists"
The problem without health checks
# Without health checks, Compose only verifies that the process started
docker compose up -d
# ✔ cache-1 Started # But does it accept connections?
# ✔ api-1 Started # But does /health respond 200?
# The API tries to connect to Redis, which is still initializing
# → ConnectionRefusedError
# → The API crashes or returns 500s during the first few seconds
Health check anatomy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s # How often to check
timeout: 10s # Maximum wait time per check
retries: 3 # Attempts before declaring unhealthy
start_period: 10s # Initial grace (doesn't count as a failure)
Timeline of a health check:
0s ─────────── start_period (10s) ──────────── 10s
│
10s ── check ── 40s ── check ── 70s ── check ── 100s
│ │ │
pass? ─── YES → healthy NO → retry (up to 3)
│
3 failures → unhealthy
Health checks per service
services:
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
# Redis responds PONG when it's ready to accept commands
api:
build: ./api
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s # More grace: the API needs to import libraries
# The /health endpoint verifies that the API AND Redis are operational
vectordb:
image: qdrant/qdrant:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
interval: 15s
timeout: 5s
retries: 3
Health endpoint in the API
# api/main.py — Health check that verifies dependencies
@app.get("/health")
def health():
checks = {"api": "up"}
overall = "healthy"
# Check Redis
try:
cache.ping()
checks["redis"] = "up"
except Exception:
checks["redis"] = "down"
overall = "degraded"
status_code = 200 if overall == "healthy" else 503
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=status_code,
content={"status": overall, "services": checks}
)
Readiness vs Liveness: Two Types of Health Check
In production systems, a single health check isn't enough. There are two different questions you need to answer:
- Liveness: "Is the process alive?" → If not, restart it.
- Readiness: "Can the service accept traffic?" → If not, don't send it requests.
The difference in practice
Scenario: Your API is importing a large ML model (30 seconds)
Liveness: ✅ The process is alive (it would respond to a basic ping)
Readiness: ❌ It's not ready to accept requests (the model hasn't loaded)
Correct action: Do NOT restart (it's alive), do NOT send traffic (it's not ready)
Implement both in FastAPI
# api/main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
import redis
import time
app_ready = False
startup_time = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global app_ready, startup_time
startup_time = time.time()
# Simulate heavy load: import model, warm cache, etc.
await initialize_services()
app_ready = True
yield
app_ready = False
app = FastAPI(lifespan=lifespan)
@app.get("/health/live")
def liveness():
"""Is the process alive? Basic check, no external dependencies."""
return {
"status": "alive",
"uptime_seconds": round(time.time() - startup_time, 1),
}
@app.get("/health/ready")
def readiness():
"""Can it accept traffic? Verifies critical dependencies."""
from fastapi.responses import JSONResponse
if not app_ready:
return JSONResponse(
status_code=503,
content={"status": "not_ready", "reason": "still initializing"},
)
checks = {}
all_healthy = True
try:
cache.ping()
checks["redis"] = "up"
except Exception:
checks["redis"] = "down"
all_healthy = False
try:
import urllib.request
urllib.request.urlopen("http://vectordb:6333/healthz", timeout=2)
checks["vectordb"] = "up"
except Exception:
checks["vectordb"] = "down"
all_healthy = False
status_code = 200 if all_healthy else 503
status = "ready" if all_healthy else "degraded"
return JSONResponse(
status_code=status_code,
content={"status": status, "checks": checks},
)
Health checks in Docker Compose: map to readiness
services:
api:
build: ./api
healthcheck:
# Use the readiness endpoint, not the liveness one
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
interval: 15s
timeout: 10s
retries: 3
start_period: 30s
Docker Compose has only one health check (it doesn't distinguish liveness/readiness like Kubernetes). Use the readiness endpoint — it's the one that matters for dependency ordering.
Health Check Strategies for AI Services
AI services have unique health check needs. A normal API only needs to verify "can I respond over HTTP?" AI services need to verify loaded models, LLM connectivity, and vector store states.
Verify LLM connectivity
# api/health.py
from openai import OpenAI
import time
def check_llm_connectivity(client: OpenAI, timeout: float = 5.0) -> dict:
"""
Verifies that the OpenAI API responds.
Doesn't send a full prompt — uses a minimal request.
"""
start = time.time()
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "1"}],
max_tokens=1,
)
latency_ms = round((time.time() - start) * 1000)
return {
"status": "up",
"latency_ms": latency_ms,
"model": "gpt-4o-mini",
}
except Exception as e:
return {"status": "down", "error": type(e).__name__}
Verify vector store readiness
# api/health.py
import urllib.request
import json
def check_vector_store(qdrant_url: str = "http://vectordb:6333") -> dict:
"""Verifies that Qdrant responds and has collections."""
try:
response = urllib.request.urlopen(
f"{qdrant_url}/collections", timeout=3
)
data = json.loads(response.read())
collections = [c["name"] for c in data.get("result", {}).get("collections", [])]
return {
"status": "up",
"collections": collections,
"collection_count": len(collections),
}
except Exception as e:
return {"status": "down", "error": str(e)}
Complete health endpoint for an AI app
# api/main.py
from health import check_llm_connectivity, check_vector_store
from config import settings
@app.get("/health/detailed")
def health_detailed():
"""Complete health check with latencies and the state of each dependency."""
checks = {}
# Redis
import time
start = time.time()
try:
cache.ping()
checks["redis"] = {
"status": "up",
"latency_ms": round((time.time() - start) * 1000),
}
except Exception as e:
checks["redis"] = {"status": "down", "error": str(e)}
# LLM (only in readiness, not in every basic health check)
checks["llm"] = check_llm_connectivity(openai_client)
# Vector Store
checks["vectordb"] = check_vector_store()
# Overall status
statuses = [c["status"] for c in checks.values()]
if all(s == "up" for s in statuses):
overall = "healthy"
elif checks["redis"]["status"] == "up":
overall = "degraded"
else:
overall = "unhealthy"
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=200 if overall == "healthy" else 503,
content={"status": overall, "checks": checks},
)
An important detail: the health check Docker Compose uses (/health/ready) must be fast (< 2 seconds). The /health/detailed endpoint with LLM latencies is for monitoring, not for the Compose health check.
Dependency Ordering
depends_on with a condition
services:
api:
depends_on:
cache:
condition: service_healthy # Wait for cache to be HEALTHY
vectordb:
condition: service_healthy
cache:
healthcheck:
test: ["CMD", "redis-cli", "ping"]
vectordb:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
Startup order:
1. cache + vectordb start in parallel
2. Compose waits for both to report "healthy"
3. api starts ONLY when cache AND vectordb are healthy
4. If cache fails the healthcheck 3 times → api does NOT start
depends_on without a condition (insufficient)
# ❌ INSUFFICIENT: only waits for the container to start, not to be ready
services:
api:
depends_on:
- cache # Only verifies that cache "started", not that it's healthy
Complex Scenario: Multi-Service AI Pipeline
In real AI apps, services form dependency chains. A typical RAG pipeline has 3 layers:
# docker-compose.yml — RAG pipeline with dependency layers
services:
# Layer 1: Infrastructure (no dependencies, start in parallel)
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
vectordb:
image: qdrant/qdrant:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Layer 2: Worker that depends on infra
embeddings-worker:
build: ./workers/embeddings
depends_on:
vectordb:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"]
interval: 15s
timeout: 10s
retries: 3
start_period: 20s
# Layer 3: Main API (depends on everything)
api:
build: ./api
depends_on:
cache:
condition: service_healthy
vectordb:
condition: service_healthy
embeddings-worker:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
interval: 15s
timeout: 10s
retries: 3
start_period: 30s
Resulting startup order:
Layer 1 (parallel): cache ──────┐
vectordb ───┘
│ both healthy
▼
Layer 2: embeddings-worker
│ healthy
▼
Layer 3: api
Restart policies
services:
api:
restart: unless-stopped
# Options:
# no — never restarts
# always — always restarts (even on exit 0)
# on-failure — restarts only if exit != 0
# unless-stopped — restarts except if you stop it
Graceful Shutdown and Signal Handling
When you run docker compose down, Docker sends SIGTERM to each container. If the process doesn't terminate in 10 seconds (default), it sends SIGKILL. For AI apps, those 10 seconds may not be enough — a request to the LLM can take 30 seconds.
The problem
docker compose down
→ SIGTERM to all containers
→ API is processing an LLM request (takes 15s)
→ 10 seconds... Docker sends SIGKILL
→ Request cut off, user gets an error, possible cache corruption
Configure the stop timeout
services:
api:
build: ./api
stop_grace_period: 30s # Wait 30s before SIGKILL
restart: unless-stopped
Handle SIGTERM in Python
# api/main.py
import signal
import asyncio
from fastapi import FastAPI
from contextlib import asynccontextmanager
shutdown_event = asyncio.Event()
def handle_sigterm(signum, frame):
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
cache.close()
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def check_shutdown(request, call_next):
if shutdown_event.is_set():
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"error": "Server is shutting down"},
)
return await call_next(request)
The pattern: register a handler for SIGTERM that sets a flag. A middleware rejects new requests when the flag is active. The lifespan closes connections at the end.
Shutdown ordering in Compose
docker compose down stops in reverse order of depends_on: first the API (stops receiving traffic), then cache (the API no longer needs it). If you need a specific order: docker compose stop api && docker compose stop cache.
Complete Pattern: Ordering + Health + Restart
services:
cache:
image: redis:7-alpine
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
api:
build: ./api
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://cache:6379
depends_on:
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
Troubleshooting
Problem 1: "The container stays in 'health: starting' forever"
# Check the healthcheck logs
docker inspect --format='{{json .State.Health}}' module-02-api-1 | python -m json.tool
# The start_period may be too short
# If your app takes 20s to start, start_period must be >20s
Problem 2: "curl not found in the container"
# Add curl to the Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
Or use an alternative without curl:
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
Problem 3: "api starts before Redis is ready"
Verify that depends_on uses condition: service_healthy (not just depends_on: - cache), and that cache has a healthcheck defined.
Problem 4: "The health check passes but the service doesn't work well"
# Your health check is too simple. If it only verifies that the process exists,
# it doesn't detect problems like: exhausted connection pool, full memory, deadlock.
# Solution: the health check must test the real functionality
# ❌ Only verifies that the port responds
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
# ✅ Verifies that the dependencies work too
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
# Check the latest health check results:
docker inspect --format='{{range .State.Health.Log}}{{.Output}}{{end}}' module-02-api-1
Problem 5: "Container restarts in a loop (restart loop)"
# The container fails, restarts, fails again, infinitely
docker compose logs api --tail 50
# Look for the error causing the crash
# Common causes:
# 1. Missing environment variable → the app crashes on startup
# 2. Unreachable dependency → the app crashes on connect
# 3. Port already in use inside the container
# Diagnosis:
docker compose ps
# NAME STATUS PORTS
# api-1 restarting (3 seconds)
# To debug without automatic restart:
docker compose run --rm api bash
# Inside the container, run the command manually:
python -c "from config import settings; print(settings)"
Hands-On Exercises
Exercise 1: Multi-service health check
Implement a /health/detailed endpoint that verifies Redis, the LLM model (a test call), and returns response times.
See solution
import time
@app.get("/health/detailed")
def health_detailed():
checks = {}
# Redis check
start = time.time()
try:
cache.ping()
checks["redis"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000)}
except Exception as e:
checks["redis"] = {"status": "down", "error": str(e)}
# LLM check
start = time.time()
try:
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "ping"}],
max_tokens=5
)
checks["llm"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000)}
except Exception as e:
checks["llm"] = {"status": "down", "error": str(e)}
overall = "healthy" if all(c["status"] == "up" for c in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
Exercise 2: Graceful degradation
Modify the API so it works (without cache) if Redis goes down, instead of returning 500.
See solution
@app.post("/ask")
def ask(request: AskRequest):
# Try cache, but don't fail if Redis is down
cached = None
try:
if request.use_cache:
cached = cache.get(cache_key)
except redis.ConnectionError:
pass # Cache unavailable, proceed without it
if cached:
return AskResponse(answer=json.loads(cached)["answer"], cached=True)
# LLM call (always works if Redis is down)
response = client.chat.completions.create(...)
# Try to cache, but don't fail if Redis is down
try:
cache.setex(cache_key, settings.cache_ttl, json.dumps({...}))
except redis.ConnectionError:
pass # Can't cache, but response still works
return AskResponse(answer=answer, cached=False, tokens_used=tokens)
Exercise 3: Separate readiness and liveness
Implement two separate endpoints: /health/live (only verifies that the process runs) and /health/ready (verifies Redis + vector store). Configure Docker Compose to use the readiness endpoint.
See solution
# api/main.py
import time
startup_time = time.time()
app_ready = False
@app.get("/health/live")
def liveness():
return {
"status": "alive",
"uptime_seconds": round(time.time() - startup_time, 1),
}
@app.get("/health/ready")
def readiness():
from fastapi.responses import JSONResponse
if not app_ready:
return JSONResponse(
status_code=503,
content={"status": "initializing"},
)
checks = {}
try:
cache.ping()
checks["redis"] = "up"
except Exception:
checks["redis"] = "down"
try:
import urllib.request
urllib.request.urlopen("http://vectordb:6333/healthz", timeout=2)
checks["vectordb"] = "up"
except Exception:
checks["vectordb"] = "down"
all_up = all(v == "up" for v in checks.values())
return JSONResponse(
status_code=200 if all_up else 503,
content={"status": "ready" if all_up else "degraded", "checks": checks},
)
# docker-compose.yml
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
interval: 15s
timeout: 10s
retries: 3
start_period: 30s
The practical difference: if Redis goes down temporarily, /health/live still responds 200 (don't restart the container), but /health/ready responds 503 (don't send new traffic).
Exercise 4: Graceful shutdown with stop_grace_period
Configure your API to handle SIGTERM correctly: stop accepting new requests, finish in-flight requests, close connections, and then die cleanly. Configure a stop_grace_period of 30 seconds.
See solution
# api/main.py
import signal
import asyncio
from fastapi import FastAPI
from contextlib import asynccontextmanager
shutdown_event = asyncio.Event()
def handle_sigterm(signum, frame):
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
# Cleanup
cache.close()
await asyncio.sleep(0.5)
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def reject_during_shutdown(request, call_next):
if shutdown_event.is_set():
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"error": "Shutting down, try another instance"},
)
return await call_next(request)
# docker-compose.yml
services:
api:
build: ./api
stop_grace_period: 30s
restart: unless-stopped
# Test graceful shutdown
docker compose down
# Watch the logs: you'll see "SIGTERM received" and then cleanup
docker compose logs api --tail 20
Summary
- Health checks verify that a service is truly operational, not just that the process exists.
- Readiness vs liveness: readiness verifies dependencies (can I accept traffic?), liveness verifies the process (am I alive?). Docker Compose uses only one — choose readiness.
depends_onwithcondition: service_healthyensures correct ordering.- start_period gives initial grace so the service starts before failures are counted.
- Restart policies (
unless-stopped) keep services running through crashes. - The
/healthendpoint must verify dependencies (Redis, vector store), not just the API. - Graceful degradation: if a dependency fails, the app degrades functionality instead of crashing.
- Graceful shutdown: handle SIGTERM, use
stop_grace_period, close connections cleanly.
Additional Resources
- Docker Compose Healthcheck — Official reference
- Docker Container Health — Healthcheck in the Dockerfile
- Health Check Patterns — Microsoft — Health monitoring patterns
- Graceful Degradation Patterns — Degradation patterns
- Docker Compose depends_on — Dependency ordering
- FastAPI Lifespan Events — Startup and shutdown in FastAPI
- Docker Stop Grace Period — Configure the shutdown timeout
- Python Signal Handling — Handle SIGTERM in Python