Graceful shutdown
Container orchestrators (Kubernetes, ECS, systemd, Docker) stop a service by sending SIGTERM and then waiting a grace period (Kubernetes defaults to 30 seconds via terminationGracePeriodSeconds) before sending SIGKILL. A well-behaved service uses that window to:
- Stop accepting new work.
- Drain in-flight work.
- Release distributed resources so other replicas do not wait for a lease to expire.
grelmicro stays library-shaped: the application owns the event loop and signal wiring. grelmicro gives you a clean way to translate a signal into shutdown and bounds how long draining takes.
Wire the signal to shutdown
Translate SIGTERM and SIGINT into a future, then race it against the workload. Leaving the Tasks context drains the background tasks:
import asyncio
import signal
from grelmicro.task import Tasks
# Keep shutdown_timeout at or below the Kubernetes
# terminationGracePeriodSeconds (default 30s) so draining finishes
# before the pod receives SIGKILL.
tasks = Tasks(shutdown_timeout=25)
@tasks.every(seconds=5)
async def heartbeat() -> None:
"""Run periodic work; finishes the current run before shutdown."""
def _request_stop(stop: asyncio.Future[None]) -> None:
"""Resolve the stop future the first time a signal arrives."""
if not stop.done():
stop.set_result(None)
async def main() -> None:
loop = asyncio.get_running_loop()
stop = loop.create_future()
# Translate the orchestrator's signals into a future, then race it
# against the workload. Leaving `async with tasks` drains the tasks.
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _request_stop, stop)
async with tasks:
await stop
if __name__ == "__main__":
asyncio.run(main())
When the host framework already owns the loop, let it do the wiring. Uvicorn and FastStream install their own signal handlers and exit the lifespan on SIGTERM, so a Tasks context entered inside the lifespan drains automatically. Add explicit handlers only for a plain asyncio.run or uvloop.run entry point.
Draining background tasks
Tasks(shutdown_timeout=...) controls the drain. On exit, Tasks sets a stop signal that every interval task observes:
- A task that is sleeping between runs wakes immediately and exits.
- A task that is mid-run finishes its current iteration, then exits before the next one. In-flight work is never interrupted.
- A task still running after
shutdown_timeoutseconds is force-cancelled.
shutdown_timeout defaults to 30.0, matching the Kubernetes grace period. Keep it at or below the pod terminationGracePeriodSeconds so draining finishes before SIGKILL. Set it to 0 to cancel immediately without draining.
The stop signal only bounds shutdown for tasks that are stuck mid-iteration. Cooperative tasks exit as soon as their current run completes, so a healthy service shuts down well within the window regardless of the timeout.
Releasing distributed resources
| Primitive | On graceful stop |
|---|---|
LeaderElection |
Breaks its loop after the current renew and releases the lock on the backend, so a standby replica takes over without waiting for the lease to expire. |
Lock, TaskLock |
Released when the holder leaves async with. A task force-cancelled while holding the lease does not release it explicitly: the lease expires on the backend after its TTL. Keep lease_duration short enough that the worst-case takeover delay is acceptable. |
RateLimiter, CircuitBreaker, HealthChecks |
Stateless across requests, so they have no shutdown obligation. |
The lease-on-cancel contract is the reason to prefer cooperative draining: a task that finishes its iteration releases its locks through async with, while a force-cancel falls back to TTL expiry.