RE:NODE

Workery gunicornu a uvicornu na malém serveru

Text: Tým RE:NODE
20 min čtení


Na malém serveru je správný počet workerů gunicornu obvykle jeden nebo dva, ne (2 x cores) + 1 z dokumentace. A pak záleží na nastaveních, která málokdo mění: timeout odpovídající tvému nejpomalejšímu poctivému požadavku, max_requests s jitterem, aby se odstavovaly workery, které prosakují paměť, preload_app jen tehdy, když tvůj kód přežije fork, a keepalive vhodný k proxy před aplikací. Uvicorn má nastavení méně, ale stejný tvar: --workers pro procesy, --timeout-keep-alive, --limit-max-requests. Tento článek je o konfiguraci a provozu. Kolik paměti worker zabere, je samostatná otázka, kterou řeší kolik RAM potřebuje aplikace v Pythonu.

Všechno je napsané pro kontejner s pevným limitem paměti a pevným podílem CPU - tak vypadá tarif pro Python za $4 až $7 - ale platí to pro jakýkoli malý VPS. Pokud ještě řešíš úplné základy, tedy jak aplikaci vůbec nasadit, začni u nasazení FastAPI nebo Flasku nebo nasazení Djanga do produkce a pak se vrať.

Co gunicorn a uvicorn ve skutečnosti spouštějí#

Gunicorn je správce procesů, který mimochodem mluví HTTP. Existuje jeden master proces, který se požadavků nikdy nedotkne. Poslouchá na socketu, vytváří workery, hlídá je a nahrazuje ty, které zemřely nebo se zasekly. Každý worker je plnohodnotný interpret Pythonu s vlastní kopií tvé aplikace, vlastními importovanými moduly, vlastními připojeními k databázi a vlastními cache. Mezi workery se sdílí jen naslouchající socket, proto počítadlo v proměnné na úrovni modulu počítá zvlášť v každém workeru a cache v paměti je ve skutečnosti N oddělených cache.

Uvicorn je server pro aplikace ASGI (FastAPI, Starlette, Django s asgi.py). Jeden proces uvicornu spouští jednu smyčku událostí a obsluhuje mnoho požadavků současně, pokud tvůj kód používá await a neblokuje. S --workers N se z něj stává malý supervizor podobný masteru gunicornu: spustí N procesů, každý s vlastní smyčkou, a znovu spustí každý, který skončí.

bind 0.0.0.0:8000forkforkProxyTLS, předává HTTPMaster gunicornufork, dohled, bez požadavkůWorker 1tvá aplikaceWorker 2tvá aplikaceDatabázejeden pool na workera
Rozložení procesů typického malého nasazení

Z toho plynou dva důsledky, na kterých stojí všechna rozhodnutí níže. Zaprvé, každý worker stojí plnou kopii aplikace v paměti, takže u tarifu s limitem paměti je počet workerů rozpočet, ne ladicí knoflík. Zadruhé, souběžnost uvnitř workeru závisí na jeho třídě: sync worker zpracovává přesně jeden požadavek najednou, vláknový jeden na každé vlákno, asynchronní tolik, kolik jeho smyčka událostí dokáže prokládat.

Výchozí příkaz gunicornu naslouchá na 127.0.0.1:8000 a spustí jednoho sync workera. Uvnitř kontejneru je tato adresa zvenčí nedostupná, takže první změna na jakémkoli hostingu je --bind 0.0.0.0:<port> s portem převzatým z prostředí. Na serveru RE:NODE pro Python je přidělený port vidět na záložce Startup; sesterské články pro něj používají SERVER_PORT, tak si před zkopírováním řádku ověř název na své záložce.

Jak zvolit počet workerů#

Vzorec (2 x cores) + 1 z dokumentace předpokládá jádra, která jsou tvá, a paměť, kterou jsi nepočítal. Na malém tarifu neplatí ani jedno. Podíl CPU je tvrdé omezení: tarif s půl jádrem dá celému stromu procesů půl jádra času, kolik workerů jich spustíš. Další workery aplikaci nezrychlí, jen rozdělí stejné půl jádra na více částí a znásobí účet za paměť.

Postupuj od dvou limitů postupně.

  1. CPU. U aplikace, která je omezená procesorem, nic nepřinesou workery nad ceil(cpu share). U aplikace, která většinou čeká na databázi nebo cizí API, záleží méně na počtu workerů a více na počtu požadavků vyřizovaných současně na workera, a k tomu slouží vlákna a async.
  2. Paměť. Změř rezidentní velikost jednoho workeru po skutečném provozu, vynásob počtem workerů, přičti master (několik desítek MB) a nech volnou aspoň čtvrtinu limitu na špičky požadavků a vlastní růst interpretu.
bash
$ ps -o pid,rss,cmd -C gunicorn  PID   RSS CMD 4121 31204 gunicorn: master [app.main:app] 4128 96740 gunicorn: worker [app.main:app] 4129 97112 gunicorn: worker [app.main:app]

RSS je v kilobajtech. Spusť příkaz po několika stovkách požadavků, ne hned po startu - worker, který ještě neimportoval líně načítané moduly a nerozehřál cache, vypadá menší, než bude za hodinu.

Výchozí bod pro řadu Python za předpokladu zhruba 100 MB na workera, což je typické pro aplikaci ve FastAPI nebo Flasku s ORM a ovladačem databáze (tvá může mít dvojnásobek):

TarifPaměť / podíl CPUPočáteční konfigurace
Starter1 GB / 0.5 core1 worker, 4 vlákna nebo async
Standard2 GB / 1 core2 workery, po 2-4 vláknech
Advanced4 GB / 1.5 cores2-3 workery
Pro6 GB / 2 cores3-4 workery

Celý žebříček je na stránce tarifů pro Python, od $4 měsíčně. Všimni si, že u větších řádků je prvním limitem zřídka paměť - bývá jím podíl CPU. Za dvěma nebo třemi workery na jednociferném podílu jader už většinou kupuješ jen přepínání kontextu.

Existuje ještě jeden důvod dát přednost dvěma workerům před jedním, pokud na to máš: jediný worker je jediné místo, kde se může zaseknout. Narazí-li na pomalý požadavek, pauzu garbage collectoru nebo restart, je služba po celou tu dobu nedostupná. Dva workery znamenají, že druhý dál odpovídá, zatímco první se zotavuje. Na tarifu s 1 GB bývá takový kompromis nedostupný, a právě tehdy se vyplatí vlákna nebo async.

Třídy workerů: sync, gthread a uvicorn#

worker_class v gunicornu určuje, jak worker zpracovává požadavky, a po jejich počtu je to největší páka.

Třída workeruSouběžnost na workeraPro co
sync (výchozí)1 požadavekFlask nebo Django, náročné na CPU, hodně workerů
gthreadthreads požadavkůFlask nebo Django čekající na databázi
uvicorn_worker.UvicornWorkerSmyčka událostíFastAPI, Starlette, Django ASGI

`sync` je jednoduchý a předvídatelný a právě on nejvíc potřebuje před sebou proxy, která požadavky ukládá do vyrovnávací paměti: sync worker je obsazený tak dlouho, dokud klient dosílá svůj požadavek, takže pomalé spojení zablokuje celého workera. Za proxy, která si přečte celý požadavek, než ho přepošle, to problém není.

`gthread` dává každému workeru pool vláken. Čtyři vlákna v jednom procesu obsluhují čtyři požadavky, které všechny čekají na databázi, za paměť zhruba jednoho procesu, což je nejvýhodnější nastavení pro WSGI aplikaci závislou na I/O na malém tarifu:

bash
$ gunicorn wsgi:app --bind 0.0.0.0:8000 \    --workers 2 --worker-class gthread --threads 4

Pozor na aritmetiku dál po proudu: dva workery se čtyřmi vlákny mohou držet osm databázových spojení najednou. Velikost poolu a tarif databáze přizpůsob tomuto číslu, ne počtu workerů. Nastavení --threads nad 1 u výchozí třídy sync v současných verzích třídu přepne na gthread samo, ale když to napíšeš výslovně, nemusíš hádat.

Workery uvicornu pod gunicornem dávají dohled gunicornu spolu se smyčkou událostí uvicornu:

bash
$ gunicorn app.main:app -k uvicorn_worker.UvicornWorker \    --bind 0.0.0.0:8000 --workers 2

Potřebuješ k tomu balíček uvicorn-worker. Starší uvicorn.workers.UvicornWorker, který byl součástí uvicornu, je označený jako zastaralý a ve verzi, kterou máš připnutou, už nemusí existovat, takže než zkopíruješ řádek ze starého návodu, zkontroluj pip show uvicorn. Případně se gunicornu můžeš vyhnout úplně:

bash
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000} --workers 2

Co zvolit? Vlastní --workers uvicornu znamená o jednu závislost méně a pro malou aplikaci to stačí. Gunicorn přidává vyzrálou sadu provozních funkcí: max_requests s jitterem, timeouty na workera, které zabijí zaseknutý proces, háčky post_fork, dávno zavedený protokol signálů a jemnější řízení logů. Uvicorn v posledních vydáních ztrátu stahuje (viz oddíl o reloadu), ale pokud potřebuješ jistotu, že zaseknutý worker bude zabit, je gunicorn jako supervizor zatím bezpečnější.

Pravidlo pro async workery: nikdy neblokuj smyčku událostí. Synchronní volání databáze nebo requests.get uvnitř handleru async def zastaví všechny ostatní požadavky v tom procesu, dokud se nevrátí. Zablokovaná smyčka nevyhodí chybu. Projeví se jako zpoždění a pod gunicornem nakonec jako WORKER TIMEOUT, protože smyčka zároveň posílá masteru heartbeat workeru.

Timeouty, které zabíjejí požadavky#

timeout v gunicornu (výchozí 30 sekund) se chápe špatně nejčastěji. Není to timeout požadavku. Je to nejdelší doba, po kterou smí worker mlčet, než masteru oznámí, že žije. U sync workerů heartbeat probíhá mezi požadavky, takže se chová jako limit na požadavek: požadavek trvající 31 sekund stojí workera život. Řádek v logu se nedá splést:

code
[2026-10-17 09:14:02 +0000] [4121] [CRITICAL] WORKER TIMEOUT (pid:4128)[2026-10-17 09:14:03 +0000] [4128] [INFO] Worker exiting (pid: 4128)[2026-10-17 09:14:03 +0000] [4140] [INFO] Booting worker with pid: 4140

U gthread a async workerů pochází heartbeat z jiného místa, takže pomalý požadavek sám timeout nespustí; spustí ho zablokované hlavní vlákno nebo smyčka událostí. Tento rozdíl je důležitý při ladění: WORKER TIMEOUT u async workeru znamená, že smyčku něco zmrazilo, ne že jeden požadavek trval dlouho.

Které timeouty hrají roli a komu patří:

NastaveníVýchozíCo omezuje
gunicorn timeout30Mlčení workeru, než je zabit
gunicorn graceful_timeout30Jak dlouho smí končící worker dokončovat požadavky
gunicorn keepalive2Nečinnost spojení keep-alive v sekundách
uvicorn --timeout-keep-alive5Nečinnost spojení keep-alive v sekundách
uvicorn --timeout-graceful-shutdownžádnýČekání na spojení při vypnutí

Zvednout timeout na 120 kvůli jednomu pomalému reportu je častá špatná odpověď. Dovolí to také každému skutečně zaseknutému workeru stát dvě minuty nečinně, a to na tarifu, kde jsou jen dva. Lepší možnosti v tomto pořadí:

  • Zrychli pomalou věc: index, menší dotaz, cache.
  • Vyndej ji z požadavku: vrať 202 Accepted, práci udělej v úloze na pozadí a výsledek pak zjišťuj dotazem.
  • Pokud musí být opravdu synchronní, zvedni timeout a vedle napiš komentář proč, a nech vlákna nebo workera navíc, aby jeden dlouhý požadavek nestáhl celou aplikaci.

Uvicorn nemá timeout požadavku vůbec. Když visí cizí volání, visí i handler, takže nastav timeouty na každém odchozím klientovi (httpx.AsyncClient(timeout=10), statement_timeout v databázi) a nečekej, že tě zachrání server. Proxy před aplikací má také vlastní timeout čtení, obvykle delší než výchozí u gunicornu; pokud se proxy vzdá první, klient uvidí 504, zatímco worker je zaneprázdněný ještě několik sekund.

Nakonec worker_tmp_dir. Gunicorn zapisuje soubory heartbeatu do dočasného adresáře a v kontejnerech, kde leží na pomalém nebo překryvném souborovém systému, se tyto zápisy mohou zablokovat a způsobit falešné timeouty. Zdokumentované řešení je adresář v paměti:

bash
$ gunicorn app.main:app --worker-tmp-dir /dev/shm

Preload, háčky a past forku#

Ve výchozím stavu každý worker importuje tvou aplikaci sám, po forku. S --preload (nebo preload_app = True) ji master importuje jednou, před vytvořením workerů. Plynou z toho dvě věci.

Co získáš. Chyby při startu se objeví hned v masteru, místo aby každý worker donekonečna selhával při startu. Workery také startují rychleji a stránky s importovaným kódem se mohou mezi procesy sdílet metodou copy-on-write, takže celková paměť je nižší než u N nezávislých importů.

S čím nepočítat. Počítání referencí v Pythonu zapisuje do objektů při každém použití, čímž špiní stránky a časem je přestane sdílet. Úspora je skutečná, ale částečná: čekej, že se zmenšuje, jak workery stárnou. Než se rozhodneš, jestli se komplikace vyplatí, změř rezidentní paměť s --preload i bez něj.

Co za to zaplatíš. Všechno, co vznikne při importu, vznikne jednou a zdědí to každý worker. U konfigurace je to neškodné, u spojení katastrofální:

  • Databázový engine nebo spojení otevřené při importu pak sdílí několik procesů přes tentýž socket. Dotazy se prolínají a dostaneš chyby, které se objevují jen pod zátěží a jen někdy.
  • Vlákna spuštěná při importu existují jen v masteru. Workery je nedostanou.
  • Semínka náhodných čísel a další stav procesu se kopírují, takže se workery chovají stejně tam, kde jsi čekal různě.

Řešení je vytvářet spojení až po forku, v háčku. Konfigurační soubor gunicornu je obyčejný Python, takže háčky jsou funkce:

gunicorn.conf.py
def post_fork(server, worker):    # runs in each worker after the fork, before it serves anything    from app.db import engine    engine.dispose(close=False)

engine.dispose(close=False) je způsob SQLAlchemy 2.x, jak zahodit pool zděděný od masteru, aniž by se zavřela spojení, která master stále vlastní. U jakékoli jiné klientské knihovny je postup stejný: zahoď, co vytvořil master, a nech každého workera otevřít vlastní. Pokud tvá aplikace otevírá spojení líně při prvním použití, jak to dělá většina ORM, je preload obvykle bezpečný tak, jak je.

Preload má ještě jednu souvislost, na kterou lidé při nasazování narážejí a kterou rozebírají následující oddíly: s --preload signál HUP znovu načte konfiguraci, ale neimportuje znovu kód, protože kód byl načten v masteru a master se nerestartoval.

Keepalive a proxy před aplikací#

HTTP keep-alive umožňuje klientovi použít jedno spojení TCP pro mnoho požadavků. U malé aplikace záleží na spojení mezi proxy a tvým serverem. Tarify RE:NODE pro aplikace a webhosting obsahují slot reverzní proxy, který ukončuje TLS a předává prosté HTTP na tvůj port, přičemž skutečnou adresu klienta posílá v X-Forwarded-For. Obecný obrázek najdeš v článku co dělá reverzní proxy.

Dvě nastavení:

  • `keepalive` v gunicornu, výchozí 2 sekundy, platí jen pro třídy workerů gthread a async. Worker sync zavře spojení po každé odpovědi, protože si nemůže dovolit čekat na nečinné spojení, když je jediný, kdo obsluhuje provoz.
  • `--timeout-keep-alive` v uvicornu, výchozí 5 sekund.

Klasická chyba je souběh. Proxy drží nečinné spojení k tvé aplikaci a pošle po něm požadavek ve stejném okamžiku, kdy tvůj server usoudí, že bylo nečinné dost dlouho, a zavře ho. Proxy vidí reset a vrátí 502 na požadavek, který tvůj kód nikdy nedostal. Děje se to nárazově, častěji při nízkém provozu než při vysokém a v logu aplikace nic není. Pravidlo zní: keep-alive timeout aplikace musí být delší než timeout nečinnosti proxy směrem k aplikaci. Zjisti, jak dlouho tvá proxy drží nečinná spojení směrem k aplikaci; hodnotu pro slot proxy tento článek sdělit nemůže, takže měř. Pokud vidíš občasné 502 při čistém logu aplikace, zkus jako experiment zvýšit keepalive zhruba na 10 a sleduj, jestli přestanou.

Příliš vysoká hodnota má svou cenu: každé nečinné spojení zabírá místo. U workeru gthread drží otevřené spojení vlákno. Se čtyřmi vlákny a štědrým keep-alive mohou čtyři nečinná spojení nechat workera bez volné ruky pro pátého klienta. Drž hodnotu skromnou, 5 až 10 sekund, a počet vláken nad počtem spojení z proxy, který očekáváš.

Druhá polovina je důvěra v předávané hlavičky. Oba servery věří X-Forwarded-For a X-Forwarded-Proto jen z důvěryhodných adres: gunicorn přes forwarded_allow_ips (proměnná prostředí FORWARDED_ALLOW_IPS), uvicorn přes --forwarded-allow-ips. Obojí má výchozí 127.0.0.1, což neodpovídá proxy připojující se z jiné adresy. Výsledek je, že každý požadavek vypadá, jako by přišel z proxy a jako prosté HTTP, takže se URL přesměrování skládají s http:// a v logu vidíš jednoho návštěvníka. Hodnota * to opraví a je přijatelná, jen když se na port aplikace nedá dostat jinak než přes proxy. Pokud si nejsi jistý, že tomu tak je, dej do seznamu adresu proxy a podívej se, jakou adresu protějšku tvůj server loguje.

max-requests a pomalé úniky paměti#

max_requests říká gunicornu, aby workera po obsloužení daného počtu požadavků odstavil a nahradil novým. Je to hrubý, ale účinný nástroj proti pomalému úniku paměti v tvém kódu nebo v závislosti, kterou neovládáš, i proti postupné fragmentaci, kvůli které dlouho běžící procesy Pythonu časem rostou. Vždy ho kombinuj s jitterem:

gunicorn.conf.py
max_requests = 1000max_requests_jitter = 100

Jitter přidá k limitu každého workeru náhodné číslo od 0 do 100, aby na limit nenarazily všechny při tomtéž požadavku. Bez něj se na tarifu se dvěma workery mohou oba recyklovat během okamžiku po sobě a vznikne okno, ve kterém nemá kdo obsluhovat. Nahrazovaný worker nejdřív dokončí rozdělaný požadavek a teprve pak skončí, takže uživatelé nevidí chybu; v nejhorším zaznamenají o něco pomalejší odpověď, než nový worker nastartuje.

Číslo vol podle vlastního grafu paměti, ne podle blogu. Sleduj graf paměti na záložce Console po celý den. Pokud rezidentní velikost workeru stabilně roste s počtem požadavků, nastav max_requests tak, aby byl worker odstaven dávno předtím, než dosáhne místa, kde tarifu dojde rezerva. Pokud je paměť rovná, max_requests nepotřebuješ a recyklace jen zahazuje vyhřáté cache a odvedenou práci při importu.

Uvicorn má totéž jako --limit-max-requests N: proces skončí po N požadavcích. Pod dohledem gunicornu se vytvoří znovu. S vlastními --workers uvicornu ho novější vydání také obnoví, starší ne, a po každé recyklaci by ve službě zbylo méně procesů. Než se na to spolehneš, zkontroluj připnuté vydání a pokud to nemůžeš potvrdit, dej přednost gunicornu.

max_requests únik skrývá, neopravuje ho. Pokud po něm sáhneš, zapiš si proč a příčinu najdi, až bude čas. Paměťovou stránku věci rozvádí článek limity paměti v Nodu o tom, jak to vypadá v Node, a úniky paměti herních serverů popisuje postup, jak rozlišit únik od běžného růstu.

Reload bez ztráty požadavků#

Master gunicornu reaguje na signály a to je celé jeho provozní rozhraní:

SignálÚčinek
HUPZnovu načte konfiguraci, spustí nové workery, staré hladce ukončí
TERMHladké vypnutí, čeká až graceful_timeout
INT, QUITOkamžité vypnutí
TTIN, TTOUPřidá nebo ubere jednoho workera
USR1Znovu otevře logovací soubory (po rotaci)
USR2Spustí nový master vedle starého

HUP je signál pro nasazování. Noví workery nastartují s novým kódem i konfigurací dřív, než se starým řekne, aby skončili, a staří dokončí, co dělají, takže rozdělané požadavky doběhnou. Na vlastním stroji:

bash
$ gunicorn app.main:app -c gunicorn.conf.py --pid /run/myapp.pid$ kill -HUP $(cat /run/myapp.pid)

Pod systemd, což je rozumný způsob provozu na VDS, vystav tuto akci jako reload:

/etc/systemd/system/myapp.service
[Service]User=appWorkingDirectory=/srv/myappExecStart=/srv/myapp/.venv/bin/gunicorn app.main:app -c gunicorn.conf.pyExecReload=/bin/kill -s HUP $MAINPIDKillMode=mixedTimeoutStopSec=40

systemctl reload myapp pak pošle HUP. TimeoutStopSec by měl být větší než graceful_timeout, jinak systemd proces zabije, zatímco workery ještě dokončují. Než se na to spolehneš, je třeba znát několik výhrad:

  • S `preload_app` signál `HUP` nový kód nenačte. Kód žije v masteru. Chceš-li při preloadu nasadit nový kód, restartuj, nebo použij předání přes USR2 (spustit nový master, pak poslat WINCH a QUIT starému), což je natolik spletité, že většina malých nasazení volí restart.
  • Rozbité nové vydání může službu shodit. Pokud se nový kód nenaimportuje, noví workery nenastartují (Worker failed to boot), zatímco staří se vypnou. Před odesláním signálu spusť python -c "import app.main" nebo gunicorn --check-config.
  • Nové závislosti je třeba nainstalovat předem. HUP nespouští pip. Viz requirements a virtualenvy v Pythonu.
  • Změny schématu databáze jsou tvoje starost. Staří a noví workery běží několik sekund vedle sebe, takže migrace, která rozbije starý kód, ho rozbije.

Uvicorn v režimu s více procesy získal podobné chování: novější vydání restartují workery jednoho po druhém na SIGHUP a přidávají nebo ubírají workery na SIGTTIN a SIGTTOU. Stejně jako u jiných podrobností uvicornu si to ověř v poznámkách k vydání verze, kterou máš připnutou.

Na hostingu s panelem je situace jiná. Tlačítko Restart v panelu je úplné zastavení a spuštění procesu serveru, což je čistý, jednoduchý a bezpečný způsob nasazení, a přeruší spojení na těch několik sekund, které aplikace potřebuje k nastartování. Konzole panelu zapisuje do procesu, neposílá mu signály, takže HUP odtud nečekej; možnost poslat signál jsme nepotvrdili. Pro nasazení bez výpadku na malém tarifu si přečti nasazení bez výpadku na malém serveru a o tom, co končící proces dluží svým klientům, hladké vypnutí a health checky. Pokud je zapnuté nasazení z Gitu, push server restartuje, takže platí stejná mezera při startu.

Kompletní konfigurace pro malý tarif#

Nastavení dej do gunicorn.conf.py v pracovním adresáři aplikace, kde ho gunicorn hledá ve výchozím stavu, aby startovací příkaz zůstal krátký a nastavení procházelo review v gitu jako všechno ostatní. Tato konfigurace sedí na aplikaci ve FastAPI na tarifu Standard (2 GB, 1 core):

gunicorn.conf.py
import osbind = f"0.0.0.0:{os.environ.get('SERVER_PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))worker_class = "uvicorn_worker.UvicornWorker"timeout = 30graceful_timeout = 30keepalive = 10max_requests = 1000max_requests_jitter = 100worker_tmp_dir = "/dev/shm"forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")accesslog = "-"errorlog = "-"loglevel = "info"capture_output = True

Startovací příkaz je pak jen gunicorn app.main:app -c gunicorn.conf.py. Logy jdou na standardní výstup a chybový výstup, odkud je čte záložka Console; není co rotovat. Pro aplikaci ve Flasku nebo Djangu na stejném tarifu dej worker_class = "gthread", přidej threads = 4 a vynech řádek s uvicornem.

Soubor zkontroluj, než server spustíš, a také když nestartuje:

bash
$ gunicorn app.main:app -c gunicorn.conf.py --check-config$ gunicorn app.main:app -c gunicorn.conf.py --print-config | grep -E "^(workers|timeout)"

První příkaz zachytí překlepy v názvech nastavení a chyby importu; druhý vypíše, co gunicorn skutečně vyhodnotil po uplatnění proměnných prostředí a příkazové řádky. Přepínače z příkazové řádky přebíjejí soubor, a tak se dostaneš k ladění nastavení, které „je přece v konfiguraci“ a neúčinkuje.

Řešení potíží#

`WORKER TIMEOUT` se opakuje. Worker mlčel déle než timeout. U sync workerů najdi pomalý požadavek (loguj dobu požadavku přes %(D)s v access_log_format). U async workerů hledej blokující volání uvnitř async def. Pokud se to děje jen při startu, aplikace se importuje déle než timeout; pomůže preload i zvýšení timeout jen pro start.

`Worker (pid:N) was sent SIGKILL! Perhaps out of memory?` Něco zabilo workera bez účasti gunicornu a na tarifu s limitem paměti je prvním podezřelým limit paměti. Po dosažení limitu jádro procesy zastaví a kontejner se čistě restartuje, místo aby swapoval. Řešení je méně workerů, méně vláken nebo menší aplikace, přesně v tomto pořadí. Opakované restarty se zaznamenávají: tři za hodinu znamenají varování na stránce serveru a tiket, šest pozastavení serveru.

`Worker failed to boot` a master skončí. Import selhal. Stejný import spusť ručně interpretem z venv a přečti si traceback. Bez --preload se to objeví jednou na workera, což je hlučné, ale neškodné.

Občasné 502 z proxy při čistém logu aplikace. Souběh keep-alive popsaný výše. Zvedni keepalive nebo --timeout-keep-alive a sleduj.

Všechno pomalé při 100 % CPU a spoustě volné paměti. Brzdí tě CPU. Víc workerů to zhorší. Profiluj aplikaci, cachuj nebo přejdi na tarif s větším podílem CPU. Server na 100 % CPU je pomalý, ale nikdy není kvůli tomu pozastaven.

Chyby databáze jen pod zátěží. Buď byla spojení otevřena před forkem (--preload), nebo součin workerů, vláken a velikosti poolu přesahuje, co databáze dovolí. Spočítej to: workery krát vlákna krát velikost poolu.

Přesměrování míří na `http://`. Server nevěří X-Forwarded-Proto. Nastav forwarded_allow_ips, jak je popsáno výše, a v Djangu SECURE_PROXY_SSL_HEADER.

FAQ#

Kolik workerů gunicornu spustit na tarifu s 1 GB?

Jednoho, s několika vlákny nebo třídou async, pokud měření neukáže, že tvá aplikace zabírá celkem pod zhruba 200 MB na workera. Druhý worker dává odolnost proti jednomu zaseknutí, ale při 1 GB a 0.5 core často stojí víc paměti a sporů o CPU, než ušetří.

Stačí uvicorn s --workers, nebo potřebuju gunicorn?

Pro malou aplikaci stačí samotný uvicorn a má o jednu závislost méně. Gunicorn zvol, když chceš silnější dohled: timeout, který zabije zaseknutého workera, max_requests s jitterem, háčky a dávno zavedené rozhraní signálů.

Mám zapnout preload?

Jen pokud je start náročný nebo chceš vidět chyby importu v masteru, a teprve poté, co ses ujistil, že nic při importu neotevírá spojení ani nespouští vlákno. Změř paměť s ním i bez něj; úspora z copy-on-write je v Pythonu menší, než lidé čekají.

Jaké max_requests je rozumné?

Takové, které workera odstaví dřív, než jeho paměť dosáhne tvé rezervy. Tisíc s jitterem sto je běžný výchozí bod. Pokud je tvůj graf paměti rovný, nepotřebuješ ho vůbec.

Proč gunicorn zabije požadavek, který trvá 31 sekund?

Protože timeout je ve výchozím stavu 30 a sync worker, který do té doby neodpověděl, se považuje za zaseknutého. Než limit zvedneš, požadavek zrychli nebo ho přesuň do úlohy na pozadí, a zvedej jen na tak dlouho, jak je to nutné.

Dá se gunicorn na hostingu s panelem reloadovat bez výpadku?

Ne signálem, který bychom potvrdili. Restart je čisté zastavení a spuštění s mezerou několika sekund. Pokud mezeru nesnesete, provozuj dvě instance za něčím, co umí jednu vyřadit z rotace, nebo přejdi na VDS, kde máš systemd ve vlastních rukou a můžeš poslat HUP.

Pomohlo vám to?