Když se produkční proces zasekne, nechcete ho restartovat ani připojovat debugger. Stačí mu poslat SIGUSR1 a nechat ho vysypat stacky všech vláken i asyncio tasků do souboru.
Problém
Worker v kontejneru přestane brát práci. CPU na nule, nic se neděje, žádná chyba v logu. Deadlock? Zaseknuté await? Čeká se na lock, který nikdo nepustí?
Restartem problém zameteete pod koberec a stav zmizí. Připojit gdb nebo py-spy do produkčního kontejneru často nejde — chybí nástroje, práva, nebo prostě nechcete sahat na běžící proces zvenčí. A standardní logy vám neřeknou, kde přesně proces stojí.
Chcete jednu věc: přimět běžící proces, aby sám řekl, co právě dělá každé jeho vlákno — bez restartu, bez externího nástroje.
Řešení: signal handler na SIGUSR1
Unixové procesy umí reagovat na signály. SIGUSR1 (a SIGUSR2) jsou k tomu přímo určené — systém je nijak nevyužívá, jsou „k volnému použití". Zaregistrujeme si na něj handler, který vysype thread dump:
import signal
def init_stack_dumps():
signal.signal(signal.SIGUSR1, dump_stacks_sig_handle)
Tohle se zavolá jednou při startu aplikace. Od té chvíle stačí procesu poslat signál a on vyplivne dump.
Proč handler spouští nové vlákno
Signal handler v Pythonu má přísná omezení — běží mezi bajtkódy hlavního vlákna a neměl by dělat nic složitého. Proto handler sám jen odpálí daemon vlákno a hned se vrátí:
import threading
def dump_stacks_sig_handle(signal_number, frame):
t = threading.Thread(target=dump_stacks, daemon=True)
t.start()
Vlastní práci pak dělá dump_stacks() v klidu mimo handler. daemon=True zajistí, že dumpovací vlákno nebude bránit ukončení procesu.
Sběr stacků
Jádro je sys._current_frames() — vrátí aktuální frame každého běžícího vlákna. To projdeme a u každého vypíšeme formátovaný stack:
import sys, os, threading
def dump_stacks():
time.sleep(0.1)
pid = os.getpid()
filename = f"/tmp/python_thread_dump_{pid}.log"
with open(filename, "w") as f:
f.write(f"Thread dump for PID {pid}\n")
f.write(f"Parent PID {os.getppid()}\n")
f.write(f"Process breadcrumbs: {", ".join(app_context.process_breadcrumbs)}\n")
for thread_id, frame in sys._current_frames().items():
thread_name = threading.current_thread().name
f.write(f"\nThread {thread_id} ({thread_name}):\n")
f.writelines(format_stack(frame))
Dump jde do /tmp/python_thread_dump_<pid>.log — soubor pojmenovaný podle PID, takže při více procesech (gunicorn, multiprocessing) se dumpy nepřepíšou navzájem.
Asyncio tasky, ne jen vlákna
V async aplikaci sys._current_frames() nestačí — uvidíte jen event loop vlákno, ale ne jednotlivé pozastavené coroutiny. Ty visí jako Task objekty na loopu a mají vlastní stack:
try:
loop = asyncio.get_running_loop()
f.write("\n--- asyncio tasks ---\n")
for task in asyncio.all_tasks(loop):
f.write(f"Task: {task}\n")
if task.get_coro():
f.write(f"Coro: {task.get_coro()}\n")
if task.get_stack():
for frame in task.get_stack():
f.writelines(format_stack(frame))
except RuntimeError:
f.write("\nNo running asyncio loop detected.\n")
asyncio.all_tasks() vyjmenuje všechny živé tasky a task.get_stack() ukáže, na kterém await přesně každý stojí. Tohle je přesně to, co u zaseknuté async aplikace potřebujete vidět — který task čeká na čem. RuntimeError ošetřuje případ, kdy proces žádný loop neběží (čistě synchronní worker).
Vlastní kód zvýrazněný hvězdičkou
Stack zaseknutého procesu je dlouhý a většina řádků je šum z knihoven a stdlib. Trik: řádky, které vedou do vlastního kódu aplikace, dostanou prefix *:
def format_stack(frame):
stack = traceback.format_stack(frame)
formatted_stack = []
for line in stack:
if line.startswith(' File "' + app_root):
formatted_stack.append(f"* {line[:-1]}".replace("\n", "\n* ") + "\n")
else:
formatted_stack.append(f" {line[:-1]}".replace("\n", "\n ") + "\n")
return formatted_stack
app_root je kořen aplikace (odvozený z __file__). V dumpu pak stačí grepnout ^\* a vidíte jen své vlastní rámce — místo, kde se to ve vašem kódu zaseklo, bez prokousávání se stdlib.
K tomu se na začátku dumpu vypíšou breadcrumbs — krátká stopa posledních událostí, kterou si aplikace průběžně udržuje. Často hned napoví, co proces dělal těsně před zaseknutím.
Použití
Návod je ideální nechat rovnou v komentáři u zdrojáku. Přes Docker Compose to vypadá takhle:
# uklidit staré dumpy
docker compose exec service-process sh -c "rm /tmp/python_thread_dump_*"
# poslat signál všem python procesům v kontejneru
docker compose exec service-process pkill -SIGUSR1 -f python
# vytáhnout jen podstatné řádky — hlavičky a vlastní kód
docker compose exec service-process sh -c \
"grep -e '^Thread' -e '^\*' -e '^Process breadcrumbs' /tmp/python_thread_dump_*"
pkill -SIGUSR1 -f python zasáhne všechny matchnuté procesy najednou — každý si zapíše vlastní soubor podle PID, takže u multiprocess setupu dostanete kompletní obrázek jedním příkazem.
Drobnost: vnořené uvozovky v f-stringu
Řádek s breadcrumbs používá uvozovky uvnitř f-stringu i kolem něj:
f.write(f"Process breadcrumbs: {", ".join(app_context.process_breadcrumbs)}\n")
", ".join(...) se stejnými uvozovkami jako celý f-string by před Pythonem 3.12 byla syntaktická chyba. Od PEP 701 (Python 3.12) to projde — f-stringy už používají plnohodnotný tokenizer a vnořování uvozovek je povolené.
Obecnější pozorování
SIGUSR1/SIGUSR2jsou zdarma k dispozici pro vlastní ovládání běžícího procesu. Thread dump je nejtypičtější použití, ale stejně tak jde signálem přepnout log level nebo vyžádat metriky.- Signal handler musí být minimální — odpálit vlákno a vrátit se. Veškerou logiku dělejte mimo handler.
sys._current_frames()+asyncio.all_tasks()se doplňují. Jen vlákna vám u async aplikace ukážou prázdný event loop; teprve tasky řeknou, kde coroutiny stojí.- Soubor podle PID je nutnost u multiprocess služeb — jinak si procesy dumpy přepíšou.
- Prefix vlastního kódu mění nepoužitelně dlouhý dump v něco, co přečtete jedním grepem. Drobnost s velkým dopadem.