Handling SIGTERM in Asyncio Services¶
The container is killed 30 seconds into every rollout and the logs show nothing after "starting". No shutdown message, no drain, no cleanup — the process simply stops existing. The usual cause is not a bug in the shutdown logic but a signal that never reached it: a signal.signal() handler that cannot safely touch the loop, a shell wrapper that swallowed SIGTERM, or a Python process running as PID 1 in a container, where the kernel's default-action protection means an unhandled SIGTERM is silently discarded and the process runs until SIGKILL.
This guide installs signal handling that actually works: loop.add_signal_handler() for the Unix path, the call_soon_threadsafe bridge for Windows, escalation when a second signal arrives, PID 1 and entrypoint pitfalls in containers, and a test that proves the handler runs before you find out during a deploy.
Prerequisites¶
- Python 3.11+; standard library only.
- A way to send signals to the process — a shell for local testing,
kubectl delete podordocker stopfor the container path. - The graceful shutdown and signal handling overview for the full shutdown sequence this handler starts.
1. Install the handler on the loop¶
loop.add_signal_handler() routes the signal through asyncio's self-pipe, so your callback runs as an ordinary loop callback with the loop in a consistent state.
import asyncio
import signal
shutdown = asyncio.Event()
async def main() -> None:
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown.set)
await serve_until(shutdown)
asyncio.run(main())
The handler must be installed inside the running loop — get_running_loop() fails otherwise, and a handler registered against a different loop never fires. Note that the callback is a plain function, not a coroutine: to start async work from it, use loop.create_task() (and keep a reference to the task). Verify: run the service, send kill -TERM <pid> from another shell, and confirm the shutdown path logs before the process exits.
2. Escalate on a second signal¶
Operators expect a second Ctrl-C to mean "now". Without escalation, they reach for SIGKILL and you lose all cleanup.
import asyncio
import logging
import signal
log = logging.getLogger("shutdown")
class SignalState:
def __init__(self) -> None:
self.event = asyncio.Event()
self.forced = False
def handle(self, sig: signal.Signals) -> None:
if self.event.is_set():
log.warning("second %s — cancelling everything now", sig.name)
self.forced = True
for task in asyncio.all_tasks():
task.cancel()
return
log.info("%s — graceful shutdown started", sig.name)
self.event.set()
def install(state: SignalState) -> None:
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, state.handle, sig)
Passing sig as an argument (rather than a lambda per signal) keeps the log accurate about which signal arrived, which matters when diagnosing whether an orchestrator or a human stopped the process. Verify: press Ctrl-C twice; the first should start the drain, the second should terminate within a second or two.
3. Handle the Windows path¶
add_signal_handler() raises NotImplementedError on Windows. The portable fallback marshals the signal onto the loop by hand.
import asyncio
import signal
import sys
def install_portable(state: SignalState) -> None:
loop = asyncio.get_running_loop()
signals = (signal.SIGTERM, signal.SIGINT)
if sys.platform == "win32":
for sig in signals:
signal.signal( # runs in the interpreter's signal context
sig,
lambda s, _frame: loop.call_soon_threadsafe(state.handle, signal.Signals(s)),
)
else:
for sig in signals:
loop.add_signal_handler(sig, state.handle, sig)
call_soon_threadsafe() is the only asyncio API safe to call from a signal.signal() handler; it appends to the loop's callback queue and wakes the loop through its self-pipe. Do not touch events, queues or tasks directly in that handler. Note also that Windows delivers CTRL_C_EVENT/CTRL_BREAK_EVENT rather than Unix signals for console interrupts, so a service that must stop cleanly under Windows service management usually needs a platform-specific path anyway. Verify: on Windows, Ctrl-C in a console should run the same shutdown path as SIGTERM on Linux.
4. Make sure the signal reaches Python in a container¶
Three container-level problems stop the handler from ever running, and all three look identical from inside.
# WRONG: the shell is PID 1, and `sh -c` does not forward SIGTERM to the child
CMD python -m myservice
# RIGHT: exec form — python is PID 1 and receives the signal directly
CMD ["python", "-m", "myservice"]
# PID 1 has no default signal disposition: an unhandled SIGTERM is DISCARDED.
# Installing a handler is what makes SIGTERM meaningful for PID 1 at all.
The shell form (CMD python ...) runs /bin/sh -c, which becomes PID 1 and does not forward signals to its child, so SIGTERM reaches the shell and never the interpreter. The exec form fixes that. Separately, the kernel does not apply default actions to PID 1, so a process without an explicit handler ignores SIGTERM entirely and only dies at SIGKILL — the exact "nothing in the logs, killed after the grace period" symptom. If you need reaping of child processes as well, use an init shim such as tini or docker run --init. Verify: docker stop (which sends SIGTERM, then SIGKILL after 10 s) should produce your shutdown logs and exit well before the kill.
5. Turn the signal into the shutdown sequence¶
The handler's only job is to start the sequence; the sequence itself is ordinary async code.
import asyncio
import contextlib
async def main() -> None:
state = SignalState()
install_portable(state)
ready = Readiness() # your probe endpoint reads this
async with asyncio.TaskGroup() as tg:
server = tg.create_task(serve(), name="server")
workers = [tg.create_task(worker(i), name=f"worker.{i}") for i in range(8)]
await state.event.wait() # ---- signal arrives here ----
ready.set_not_ready() # 1. stop the load balancer
server.cancel() # 2. stop accepting
with contextlib.suppress(asyncio.CancelledError):
await server
if not state.forced:
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(15): # 3. bounded drain
await drain()
for task in workers: # 4. cancel the remainder
task.cancel()
await close_resources() # 5. release externals
Keeping the sequence in main() rather than the handler is what makes it testable and bounded — a callback cannot await, so any handler that "does the shutdown" is either doing it wrong or spawning an untracked task. Verify: the log shows readiness cleared, then the drain, then cancellation, in that order, on every signal.
6. Test it without deploying¶
Signals are testable in-process, which is the difference between knowing the handler works and hoping.
import asyncio
import os
import signal
async def test_sigterm_triggers_shutdown() -> None:
state = SignalState()
install_portable(state)
assert not state.event.is_set()
os.kill(os.getpid(), signal.SIGTERM) # deliver to ourselves
await asyncio.sleep(0) # let the loop run the callback
assert state.event.is_set(), "handler did not run"
os.kill(os.getpid(), signal.SIGTERM) # escalation path
await asyncio.sleep(0)
assert state.forced
asyncio.run(test_sigterm_triggers_shutdown())
The single await asyncio.sleep(0) is the point: it yields to the loop so the queued signal callback runs, which is exactly the mechanism that makes loop signal handlers safe. Add a second test that runs the full sequence with a stubbed drain and asserts the phase order from the log. Verify: the test fails if you swap add_signal_handler for a signal.signal() handler that mutates the event directly — proof it is testing the plumbing, not just the flag.
Verification¶
- The handler runs.
kill -TERMproduces a shutdown log line within milliseconds, locally and in the container. - Escalation works. A second signal shortens shutdown to about a second, with cancellation logged.
- The container exits early.
docker stop/ pod deletion completes well inside the grace period, with exit code 0 rather than 137.
Pitfalls and edge cases¶
signal.signal()touching loop state. It runs between bytecodes and can observe half-mutated structures. Onlycall_soon_threadsafe()is safe from there.- PID 1 with no handler. The kernel discards
SIGTERMfor PID 1 without an explicit handler; the process survives untilSIGKILL. - Shell-form
CMD./bin/sh -cbecomes PID 1 and does not forward signals. Use the exec form or an init shim. - Installing handlers before the loop runs.
get_running_loop()requires a running loop; handlers bound to a different loop never fire. - Doing the work in the handler. A callback cannot await; spawning an untracked task from it means shutdown can be garbage collected mid-flight.
- Forgetting
SIGINTin production. Some orchestrators and process supervisors sendSIGINT; handle both. - Signals in a non-main thread. Only the main thread receives signals; a loop running in a worker thread must be signalled indirectly through
call_soon_threadsafe. SIGKILL/SIGSTOPare not catchable. Nothing you write runs for those — which is why the graceful path must fit inside the grace period.
Frequently Asked Questions¶
Why is loop.add_signal_handler better than signal.signal in asyncio?
add_signal_handler routes the signal through asyncio's self-pipe, so the callback runs as a normal loop callback with the loop in a consistent state and full access to tasks, events and create_task. signal.signal handlers run between arbitrary bytecodes, possibly mid-mutation of loop internals, where the only safe asyncio call is loop.call_soon_threadsafe.
Why does my containerised Python service ignore SIGTERM?
Two common causes. The Dockerfile uses the shell form of CMD, so /bin/sh is PID 1 and does not forward the signal to Python — switch to the exec form. Or Python is PID 1 with no handler installed, in which case the kernel discards SIGTERM entirely because default signal dispositions do not apply to PID 1; installing an explicit handler makes it meaningful.
How do I handle SIGTERM on Windows where add_signal_handler is unavailable?
Fall back to signal.signal() with a handler that does nothing but call loop.call_soon_threadsafe() to schedule your real callback on the loop. Be aware that Windows consoles deliver CTRL_C_EVENT and CTRL_BREAK_EVENT rather than Unix signals, and that services managed by the Windows service control manager need a platform-specific stop path.
Should a second SIGTERM force an immediate exit?
Yes, and it should be documented. The first signal starts the graceful sequence; the second cancels every task immediately. Operators expect a repeated interrupt to mean "stop now", and providing that escalation keeps them from sending SIGKILL, which gives you no cleanup at all.
Related¶
- Graceful Shutdown & Signal Handling — the parent overview: the full shutdown sequence and its budget.
- Draining in-flight requests before shutdown — what the handler triggers, and how to bound it.
- Resilience, Cancellation & Error Handling — up to the overview for cancellation semantics.