Kubernetes¶
servicewright's shutdown ordering was designed against how Kubernetes actually terminates pods. This page is the configuration that matches it.
What happens on a rollout¶
When the kubelet terminates a pod, two things happen concurrently:
- the endpoint is removed from Services (eventually — it propagates asynchronously);
SIGTERMis sent to your process.
That race is why a service must keep serving after it has been told to stop. servicewright does:
readiness flips to false first, so the endpoint controller starts removing you, then every
listener stays open for drain_delay_seconds, and only then does the drain begin. The delay is
the part you configure: it has to cover the time your cluster takes to stop routing to a
terminating pod, and at its default of 0.0 the listener closes in the same tick readiness goes
red.
sequenceDiagram
participant K as kubelet
participant EC as Endpoint controller
participant P as Pod
participant LB as Load balancer
K->>P: SIGTERM
K-->>EC: pod marked Terminating
P->>P: readiness = false
P->>P: wait drain_delay_seconds, still accepting
EC->>LB: endpoint removed
Note over P,LB: the pod is still accepting —<br/>this overlap is the point,<br/>and drain_delay_seconds is its length
P->>P: drain(grace): finish in-flight work
P->>P: stop(), pre_shutdown hooks
P->>P: app scope closes, pools disposed
P-->>K: exit 0
Probes¶
livenessProbe:
httpGet:
path: /system/health/livez
port: 8000
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /system/health/readyz
port: 8000
periodSeconds: 5
failureThreshold: 2
Liveness is independent of your dependencies — it only says the event loop is alive. Pointing it
at readyz would restart every pod in the fleet during a database blip instead of just routing
around them. See Health checks.
A startupProbe is usually unnecessary: readiness stays false through warmup, so a slow start is
already handled. Add one if your warmup can exceed the liveness failure budget.
gRPC¶
The standard health service is registered automatically and is driven by the same registry as the HTTP probes.
Litestar¶
The Litestar adapter uses shorter paths: /system/livez and /system/readyz.
Termination grace period¶
Get this arithmetic right or the kubelet will SIGKILL you mid-drain:
terminationGracePeriodSeconds > drain_delay_seconds + drain_grace_seconds + cleanup_timeout_seconds + slack
spec = AppSpec(
service_name="orders",
create_container=build_container,
drain_delay_seconds=5.0,
drain_grace_seconds=30.0,
cleanup_timeout_seconds=10.0,
)
Set drain_delay_seconds to your propagation lag
The usual preStop: sleep 5 hack exists to keep a pod serving while endpoint removal
propagates. Flipping readiness does not do that by itself: the endpoint controller, kube-proxy
and your ingress each learn about it asynchronously, and with drain_delay_seconds=0.0 the
listener closes in the same tick readiness goes red, so whatever is still routed to the pod is
refused. drain_delay_seconds is that sleep moved into the process, where readyz keeps
answering 503 for the whole of it. A few seconds covers most clusters; to measure yours, send
SIGTERM to one pod under load and count refused connections.
Exit codes¶
| Situation | Exit code | What Kubernetes does |
|---|---|---|
| Graceful stop | 0 |
Deployment: nothing. Job: succeeded. |
| Essential entrypoint crashed | non-zero | restarts per restartPolicy |
| Startup failed (warmup, bind) | non-zero | CrashLoopBackOff, which is correct |
Second SIGTERM |
128 + signum |
immediate |
A crash never looks like a clean stop, which is what makes restartPolicy and Job retries behave.
Deployment¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-service
spec:
replicas: 3
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: /system/metrics
spec:
terminationGracePeriodSeconds: 60
containers:
- name: api
image: registry.example.com/orders-service:1.4.0
ports:
- containerPort: 8000
env:
- name: LOGGING__LEVEL
value: INFO
- name: TRACING__COLLECTOR_URL
value: http://otel-collector:4317
livenessProbe:
httpGet: { path: /system/health/livez, port: 8000 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /system/health/readyz, port: 8000 }
periodSeconds: 5
With maxUnavailable: 0 and correct readiness behaviour, a rolling update drops no requests.
Batch jobs¶
An OneShotEntrypoint is a Kubernetes Job. It runs its
function once, returns, and the whole service shuts down.
apiVersion: batch/v1
kind: Job
metadata:
name: orders-migration
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/orders-service:1.4.0
command: ["python", "-m", "orders.jobs.migrate"]
The function raising means a non-zero exit, so backoffLimit retries do what you expect. You
still get warmup, observability and ordered cleanup — the same code path as the API.
Scaling workers separately¶
Same image, same AppSpec, different entrypoint lists:
# api.py
Service(build_spec(), entrypoints=[http])
# worker.py
Service(build_spec(), entrypoints=[cron, consumer])
containers:
- name: api
command: ["python", "-m", "orders.api"]
---
containers:
- name: worker
command: ["python", "-m", "orders.worker"]
Two Deployments with independent replica counts, resource requests and HPAs. Warmup, health, observability and shutdown stay identical because they come from the same spec.
Workers have no HTTP port
A pure worker cannot serve /readyz. Two options:
- use an
execprobe, or rely on liveness only and let the process crash on failure; - set
settings.metrics.enabled = Trueso the standalone metrics server gives the pod a port to scrape.
Logging¶
Ship JSON to stdout and let your collector do the rest:
Every line carries request_id (or job_id / run_id) automatically, so a single grep spans the
API request and the work it triggered. See Request context.