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(interval=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. At the deadline, Tasks cancels each task still running once. A task cancelled in its body still runs the release in __aexit__ to completion. A task cancelled while it already awaits the release, on a slow backend for example, stops the release, and the lease expires on the backend after its TTL. A second cancel, or an anyio cancel scope, stops the release the same way. Keep lease_duration short enough that the worst-case takeover delay is acceptable. A TaskLock that exits before min_hold_duration keeps its claim until then, on purpose. |
RateLimiter, CircuitBreaker, HealthChecks |
Stateless across requests, so they have no shutdown obligation. |
That is the reason to prefer cooperative draining. A task that finishes its iteration releases its locks through async with, while a cancel that lands during the release leaves the lease to expire by TTL.