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/SIGUSR2 jsou 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.