Introduction#
When Kubernetes terminates a pod, or a user presses Ctrl+C, the OS sends a signal to your process. How your application responds determines whether in-flight requests complete cleanly or get abruptly cut off. Graceful shutdown is a basic reliability requirement, but it requires explicit implementation.
Signal Basics#
Signals are asynchronous notifications sent to a process. Common signals:
| Signal | Number | Default Action | Common Use |
|---|---|---|---|
SIGTERM |
15 | Terminate | Polite shutdown request |
SIGINT |
2 | Terminate | Ctrl+C |
SIGKILL |
9 | Terminate (unblockable) | Force kill |
SIGHUP |
1 | Terminate | Reload config |
SIGQUIT |
3 | Core dump | Debug dump |
SIGUSR1/2 |
10/12 | Terminate | Application-defined |
SIGKILL and SIGSTOP cannot be caught, blocked, or ignored — they are handled by the kernel directly.
Kubernetes Shutdown Sequence#
Understanding signals in Kubernetes:
- Pod receives delete request
- Kubernetes sends
SIGTERMto PID 1 in the container - Kubernetes waits
terminationGracePeriodSeconds(default 30s) - If process still running: sends
SIGKILL
Your application must complete in-flight work and exit cleanly within the grace period.
Graceful Shutdown in Python (FastAPI)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import asyncio
import signal
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
import uvicorn
logger = logging.getLogger(__name__)
# Track active requests
active_requests = 0
shutdown_event = asyncio.Event()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Application starting")
yield
# Shutdown: wait for active requests to complete
logger.info("Shutdown initiated, waiting for active requests")
while active_requests > 0:
await asyncio.sleep(0.1)
logger.info("All requests complete, shutting down")
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def track_requests(request, call_next):
global active_requests
active_requests += 1
try:
response = await call_next(request)
return response
finally:
active_requests -= 1
@app.get("/health")
async def health():
if shutdown_event.is_set():
# Return 503 during shutdown so load balancer stops routing
from fastapi import Response
return Response(status_code=503, content="shutting down")
return {"status": "ok"}
def handle_sigterm(sig, frame):
logger.info("SIGTERM received")
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)
if __name__ == "__main__":
config = uvicorn.Config(app, host="0.0.0.0", port=8000)
server = uvicorn.Server(config)
server.run()
Graceful Shutdown in Go#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Simulate work
time.Sleep(2 * time.Second)
w.Write([]byte("OK"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start server in a goroutine
go func() {
log.Println("Server starting on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
log.Println("Shutdown signal received")
// Give in-flight requests 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Forced shutdown: %v", err)
}
log.Println("Server stopped gracefully")
}
http.Server.Shutdown stops accepting new connections and waits for active handlers to return.
Signal Handling in Containers: PID 1 Problem#
Shell scripts and some base images run your application as a subprocess of a shell. Shells do not forward signals to child processes by default.
1
2
3
4
5
# BAD: shell form — /bin/sh is PID 1, does not forward SIGTERM
CMD python app.py
# GOOD: exec form — python is PID 1, receives signals directly
CMD ["python", "app.py"]
If you need a shell wrapper for environment variable substitution:
1
2
3
4
#!/bin/bash
# Use exec to replace the shell with your process
# This makes your app PID 1 in the container
exec python app.py
Or use tini as a minimal init process:
1
2
3
4
FROM python:3.12-slim
RUN apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "app.py"]
tini properly forwards signals and reaps zombie processes.
SIGHUP for Config Reload#
Many servers reload configuration on SIGHUP without restarting.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import signal
import json
import logging
config = {}
def load_config():
global config
with open("/etc/app/config.json") as f:
config = json.load(f)
logging.info("Config reloaded")
def handle_sighup(sig, frame):
logging.info("SIGHUP received — reloading config")
load_config()
signal.signal(signal.SIGHUP, handle_sighup)
load_config()
1
2
3
4
5
6
# Reload config without restart
kill -HUP $(pgrep -f "python app.py")
# Or in Kubernetes: trigger pod restart by updating a ConfigMap annotation
kubectl patch deployment my-app -p \
'{"spec":{"template":{"metadata":{"annotations":{"restart-time":"'$(date +%s)'"}}}}}'
Kubernetes Lifecycle Hooks#
Kubernetes provides preStop hooks to add a delay before SIGTERM — useful when the load balancer takes time to deregister the pod.
1
2
3
4
5
6
7
8
9
10
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
# Sleep to allow load balancer to drain connections
# before SIGTERM is sent
command: ["/bin/sleep", "5"]
terminationGracePeriodSeconds: 60
Conclusion#
Graceful shutdown requires: catching SIGTERM, stopping acceptance of new requests, completing in-flight work, then exiting. In containers, ensure your process is PID 1 or use an init wrapper. The Kubernetes preStop hook gives you a window to deregister from load balancers before the signal arrives.