RE:NODE

Hosting aplikací13 min čtení

Řízené vypnutí a skutečné health checky

Jak SIGTERM, vypouštění spojení a health endpoint, který kontroluje správné věci, změní restart z výpadku na něco, čeho si nikdo nevšimne.

8 čtenářů

Restart zahazuje požadavky z jediného důvodu: tvůj proces zemřel uprostřed jejich vyřizování. Řešení jsou dva malé kousky kódu. První zachytí SIGTERM, přestane přijímat nová spojení, nechá doběhnout ta rozpracovaná, zavře databázový pool a skončí se stavem nula. Druhý je endpoint, který říká, jestli je proces připravený na provoz - a hlavně takový, který nezaměňuje „databáze má chvilku potíže“ za „restartuj mě okamžitě“. Obojí zabere odpoledne. Dohromady je to rozdíl mezi deployem, kterého si nikdo nevšimne, a deployem, který se objeví v tvém nástroji na sledování chyb.

Co ve skutečnosti zastaví tvůj proces#

Všechno, co program zastaví, mu pošle jedno ze dvou, a právě ten rozdíl je celé téma.

SignálLze zachytitKdo ho posílá
SIGTERM (15)Anodocker stop, tlačítko Stop v panelu, systemctl stop, kill bez přepínače
SIGINT (2)AnoCtrl-C v terminálu
SIGHUP (1)AnoZavření terminálu; často přepoužitý jako „načti znovu konfiguraci“
SIGKILL (9)Nekill -9, vypršení časového limitu zastavení, OOM killer v jádru

SIGTERM je žádost. SIGKILL žádost není - jádro proces odstraní a žádný tvůj kód se nespustí. Každý rozumný mechanismus vypnutí je závod mezi nimi: něco pošle SIGTERM, počká pevný počet sekund a pak pošle SIGKILL. Výchozí lhůta Dockeru je 10 sekund. Kubernetes používá 30. Ať je před tvou aplikací cokoli, zjisti si to číslo, protože tvoje vypnutí se do něj musí vejít, jinak je jen dekorace.

Dvě zastavení vynechávají zdvořilou verzi úplně. Tlačítko Kill posílá SIGKILL schválně - na to je a je to správná věc, když se proces zasekne. A zastavení kvůli nedostatku paměti nedá vůbec nic: na RE:NODE dosažení limitu paměti zastaví kontejner a čistě ho nahodí znovu, místo aby nechalo stroj swapovat, což je lepší pro všechny ostatní na nodu a fatální pro to, co tvůj proces držel v paměti. Pokud na ztrátě rozpracované práce záleží, odpovědí není chytřejší obsluha vypnutí, ale nedojít paměť - obvyklé příčiny pokrývá vysvětlení limitů paměti v Node.

Problém PID 1: signál, který nikdy nedorazí#

Než napíšeš jakoukoli obsluhu, ověř, že signál vůbec dorazí k tvému kódu. Je to nejčastější důvod, proč správná funkce vypnutí nikdy neběží.

Pokud je tvůj startovací příkaz npm start, pak SIGTERM dostane npm a tvoje aplikace je jeho potomek. Chování npm se signály se napříč verzemi a platformami lišilo a selhání je tiché: zastavení vyprší, všechno se zabije a tvoje obsluha se nikdy nezavolá. Totéž platí pro shellový wrapper:

bash
#!/bin/sh# Wrong: the shell stays as PID 1 and the signal stops herenode dist/server.js# Right: the shell replaces itself with node, which becomes PID 1exec node dist/server.js

Pravidla, která z toho plynou:

  • Udělej ze startovacího příkazu samotný runtime: node dist/server.js, python -m gunicorn .... Ne npm start, ne yarn dev, ne skript, který zapomene na exec.
  • Pokud musíš použít wrapper, dej exec na poslední řádek.
  • Proces, který je v kontejneru skutečně PID 1, zdědí i povinnost sklízet osiřelé potomky. Node a Python to nedělají, a proto existují --init a tini. Záleží na tom, pokud tvá aplikace spouští podprocesy; jinak to ignoruj.

Ověř si to, nepředpokládej. Zaloguj v obsluze jeden řádek, zastav server z panelu a hledej ten řádek v konzoli. Na RE:NODE je výstup konzole nefiltrovaný, takže řádek o vypnutí je opravdu vidět, ne oříznutý - co dalšího tam stojí za vypsání, pokrývá čtení konzole.

Vypouštění HTTP serveru v Node#

Tvar je vždy stejný: přestaň brát novou práci, dokonči starou, uvolni zdroje, skonči. V Node je záludnost v keep-alive spojeních, která zůstávají otevřená po odpovědi a způsobují, že server.close() nikdy nezavolá callback.

shutdown.js
const server = app.listen(process.env.PORT || 3000);let shuttingDown = false;async function shutdown(signal) {  if (shuttingDown) return;  shuttingDown = true;  console.log(`${signal} received, draining`);  // 1. Fail readiness first, so anything checking stops sending traffic.  app.locals.ready = false;  // 2. Give the checker one interval to notice before we close the door.  await new Promise((resolve) => setTimeout(resolve, 5000));  // 3. Stop accepting connections; the callback fires when the last one ends.  server.close(async () => {    await pool.end();          // database    await worker?.close();     // job queue    console.log("drained, exiting");    process.exit(0);  });  // 4. Idle keep-alive sockets would hold us open forever. Node 18.2+.  server.closeIdleConnections();  // 5. A hard deadline, shorter than whatever will send SIGKILL.  setTimeout(() => {    console.error("drain timed out, forcing exit");    process.exit(1);  }, 20_000).unref();}process.on("SIGTERM", () => shutdown("SIGTERM"));process.on("SIGINT", () => shutdown("SIGINT"));

Kroky, které lidé vynechávají, jsou 2 a 4.

Krok 2 existuje proto, že health checky se dotazují, ne že by se posílaly. Pokud load balancer kontroluje každých pět sekund a ty zavřeš okamžitě, bude na tebe směrovat požadavky až pět sekund poté, co jsi přestal naslouchat, a každý z nich skončí jako 502. Nejdřív shodit readiness a pak počkat jeden interval dotazování změní tyto požadavky v takové, které se nikdy neodeslaly. Když nic externího nekontroluje - jeden proces za jednou proxy - můžeš to zkrátit, ale nikdy ne na nulu.

Krok 4 je past keep-alive. Prohlížeč drží nečinné HTTP spojení otevřené pro opětovné použití; server.keepAliveTimeout v Node je ve výchozím stavu pět sekund, ale klient, který posílá další požadavky, může socket držet donekonečna. server.closeIdleConnections() zavře ta, která právě nic nedělají, a ta uprostřed požadavku nechá být. Node také začne na existujících spojeních označovat odpovědi hlavičkou Connection: close, jakmile se server zavírá, což klientům říká, ať se připojí jinam.

Pevný termín v kroku 5 musí být kratší než lhůta platformy, jinak jsi SIGKILL jen posunul později. .unref() brání časovači držet event loop otevřený, když vše ostatní skončilo.

Spojení WebSocket nekončí samo, takže na ně bude server.close() čekat věčně. Zavři je výslovně s kódem zavření (1001, „going away“), aby klienti věděli, že se mají znovu připojit, a na straně klienta znovupřipojení rozlož náhodným zpožděním - jinak se všichni uživatelé připojí znovu ve stejné desetině sekundy. Bouři znovupřipojení podrobněji rozebírá WebSockets za reverzní proxy.

Python: gunicorn, uvicorn a další#

Gunicorn už celý vzor implementuje; práce je většinou v tom, vědět, který signál co dělá.

SignálÚčinek
TERMŘízené vypnutí: workery dokončí aktuální požadavky, nejdéle do --graceful-timeout
QUITRychlé vypnutí: workery se zastaví hned
HUPNačte znovu konfiguraci a restartuje workery jeden po druhém
USR2Spustí nového mastera vedle starého

--graceful-timeout je ve výchozím stavu 30 sekund a --timeout (jak dlouho může worker mlčet, než ho master zabije) je také 30. Pokud je lhůta tvé platformy 10 sekund, 30 u gunicornu nic neznamená - sniž ji, aby se vešla:

bash
$ gunicorn app.wsgi:application \    --bind 0.0.0.0:8000 --workers 3 \    --timeout 30 --graceful-timeout 8 --max-requests 1000 --max-requests-jitter 100

--max-requests recykluje worker po tomto počtu požadavků, což je hrubá, ale účinná odpověď na pomalý únik paměti. Jitter zabraňuje tomu, aby se všechny workery recyklovaly najednou.

Pro ASGI si uvicorn SIGTERM obsluhuje sám a podporuje --timeout-graceful-shutdown. Ve FastAPI patří úklid do lifespan handleru:

python
from contextlib import asynccontextmanager@asynccontextmanagerasync def lifespan(app):    pool = await create_pool()    app.state.pool = pool    yield                      # the application runs here    await pool.close()         # runs on shutdown, before the process exits

Cokoli po yield běží během vypnutí. Pokud pořád používáš @app.on_event("shutdown"), funguje to, ale nový kód patří do lifespan.

Zastavení workeru uprostřed úlohy#

HTTP požadavek trvá milisekundy. Úloha na pozadí může trvat minuty a platí stejná lhůta 10 sekund, takže „dokončit aktuální úlohu“ často není možné. Odpovědí není delší timeout, ale učinit přerušení bezpečným.

  • Potvrzuj pozdě. Celery task_acks_late=True a obdoby znamenají, že úloha se označí za hotovou až po dokončení. Zabij worker v půlce a úloha se vrátí do fronty, místo aby zmizela.
  • Dělej úlohy idempotentní. Pokud pozdní potvrzení úlohu doručí znovu, někdy poběží dvakrát. Strhnout peníze z karty dvakrát není hypotetický scénář.
  • Nejdřív přestaň odebírat. Při SIGTERM řekni workeru, ať nebere nové úlohy, a nech aktuální doběhnout v rámci lhůty. Celery tomu říká warm shutdown a druhý SIGTERM z něj udělá cold shutdown. await worker.close() v BullMQ čeká na aktivní úlohy; předání true vynutí ukončení.
  • Předvybírej méně. Worker, který sebral padesát úloh, které nezačal, drží během restartu padesát úloh jako rukojmí. worker_prefetch_multiplier = 1 v Celery, malá souběžnost jinde.
  • Ukládej průběh dlouhé práce. Úloha, která zpracovává 50 000 řádků, by měla zaznamenávat postup, aby restart pokračoval, ne začínal znovu.

Samotné možnosti front, včetně toho, co dělat, když nemáš Redis, probírá úlohy na pozadí na malém serveru.

Liveness, readiness a proč jejich míchání způsobuje výpadky#

Dvě otázky, dvě různé odpovědi, a jejich zaměňování je způsob, jak se pětisekundový škytnutí databáze změní ve dvacetiminutový výpadek.

  • Liveness: je tento proces rozbitý nenávratně? Selhání znamená „restartuj mě“. Mělo by kontrolovat skoro nic - že se event loop točí a HTTP server odpovídá. Žádná databáze, žádná cache, žádné API třetí strany.
  • Readiness: má tento proces právě teď dostávat provoz? Selhání znamená „obejdi mě na chvíli“. Tady se závislosti kontrolovat smějí, protože start bez připojení k databázi je reálný důvod neposílat požadavky.

Dej databázi do liveness kontroly a postavil jsi zesilovač. Databáze škytne, každá instance selže v liveness, každá instance se restartuje, všechny se připojí najednou znovu a databáze, která byla chvíli pomalá, je teď skutečně mimo provoz. Restart mezitím zničil cache a connection pooly, které by jí pomohly se zotavit.

Stojí za to znát i třetí druh, pokud tvá aplikace startuje dlouho: startup check, který před začátkem liveness povolí dlouhou lhůtu. JVM nebo velká aplikace v Pythonu, které potřebují 60 sekund na start, by jinak zabila liveness kontrola s timeoutem 10 sekund, pořád dokola, ve smyčce, která vypadá přesně jako pád. Pokud se služba nekonečně restartuje a logy ukazují pokaždé čistý start, je to většinou důvod - stejný tvar problému jako herní server, který se pořád restartuje.

Health endpoint, který má smysl#

javascript
// Liveness: no dependencies. If this cannot answer, the process is stuck.app.get("/healthz", (req, res) => res.status(200).json({ status: "ok" }));// Readiness: cheap dependency checks, short timeouts, cached briefly.let cached = { at: 0, body: null, code: 503 };app.get("/readyz", async (req, res) => {  if (Date.now() - cached.at < 2000) return res.status(cached.code).json(cached.body);  const checks = {};  try {    await Promise.race([      pool.query("SELECT 1"),      new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 1500)),    ]);    checks.database = "ok";  } catch (error) {    checks.database = `failed: ${error.message}`;  }  const ok = app.locals.ready && Object.values(checks).every((v) => v === "ok");  cached = {    at: Date.now(),    code: ok ? 200 : 503,    body: { status: ok ? "ok" : "degraded", checks, version: process.env.GIT_SHA },  };  if (!ok) res.set("Retry-After", "5");  res.status(cached.code).json(cached.body);});

Detaily, které z něj dělají užitečnou věc, ne ozdobu:

  • `SELECT 1`, ne skutečný dotaz. Testuješ, že spojení funguje, ne že schéma je správné.
  • Timeout na každé kontrole. Health endpoint, který visí, je horší než ten, který selže, protože o výsledku rozhoduje vlastní timeout kontrolujícího a ten bývá mnohem delší.
  • Výsledek na sekundu dvě cachuj. Monitor, proxy a deploy skript, které se všechny dotazují každých pět sekund, jsou překvapivě velká zátěž, pokud každý dotaz otevírá spojení.
  • `503`, nikdy `500`. 503 Service Unavailable s Retry-After je správné „teď ne, zkus to znovu“; 500 znamená, že se něco vyhodilo, a monitory s tím zacházejí jinak.
  • Vracej verzi. SHA commitu v těle z endpointu dělá kontrolu deploye: vidíš, který build skutečně odpovídá.
  • Žádná tajemství v těle. Tyto endpointy končí bez autentizace. Connection stringy, názvy hostů a stack trace tam nepatří.

Health checky také generují obrovské množství šumu v logu - jeden řádek každých pár sekund, navždy, který topí řádky, na kterých ti záleží. Na RE:NODE konzole skládá šum webserveru za počet, který lze vypnout, což je přesně tento problém. Pokud si logování děláš sám, vylouč health cesty z access logu místo čtení přes ně. Zbytek té úvahy je v článku logy, které stojí za uchování.

Kdo je spotřebovává? Na malém nasazení méně, než lidé předpokládají. Open-source nginx umí jen pasivní health checking - max_fails a fail_timeout označí upstream za špatný po neúspěšných požadavcích - a aktivní dotazování je komerční funkce. Takže na jednom stroji jsou užiteční spotřebitelé tvůj deploy skript (počkej na /readyz, než přepneš provoz), tvůj monitoring dostupnosti a ty. I to stojí za to: na co alarmovat, což rozhodně není každá neúspěšná kontrola, popisuje monitoring, který ti něco řekne.

Otestuj to dřív, než to budeš potřebovat#

Nic z toho nelze předpokládat, že funguje. Důkaz zabere dvacet minut:

  1. Spusť aplikaci a dej na ni stálou zátěž - autocannon -c 20 -d 30 http://localhost:3000/ nebo hey, cokoli, co hlásí odpovědi mimo 2xx.
  2. V polovině pošli skutečný signál: kill -TERM $(pgrep -f "node dist/server.js") nebo stiskni Stop v panelu.
  3. Sleduj konzoli. Měl bys vidět řádek své obsluhy, pak řádek o vypouštění, pak ukončení procesu.
  4. Přečti souhrn zátěžového nástroje. Nula neúspěšných požadavků je hranice úspěchu. Pár jich znamená špatné pořadí vypouštění; stovky znamenají, že signál nikdy nedorazil.
  5. Změř čas. Pokud vypouštění trvá déle než lhůta, zkrať pevný termín, dokud se nevejde.

Pak to schválně rozbij: zastav databázi a ověř, že /readyz vrací 503, zatímco /healthz pořád vrací 200. Pokud selžou oba, tvá liveness kontrola dělá příliš mnoho a postavil jsi zesilovač popsaný výše.

Týdenní naplánovaný restart je dobrý nátlak na vše výše. Pokud je cesta vypnutí správná, restart je neviditelný; pokud ne, zjistíš to v čase, který si určíš sám, ne během incidentu. Na RE:NODE záložka Schedules spouští výraz cron proti seřazeným úkolům - akce napájení, záloha, příkaz konzole - takže restart v pondělí ve 4:00 se nastaví za minutu. Kdy se vyplatí a kdy je to jen způsob, jak ignorovat únik paměti, rozebírají restartovací plány, které pomáhají.

Jedno varování ke smyčkám restartů. Hlídač pádů na RE:NODE každé dvě minuty kontroluje, jestli se uptime nevrátil zpět; tři restarty za hodinu dají na stránku serveru varování a otevřou ticket, šest server pozastaví. Restarty, které jsi vyžádal, se nepočítají. Je to záchranná síť proti procesu, který nemůže nastartovat, ale znamená to i to, že špatně nastavený health check, který zabíjí zdravou aplikaci, ji nakonec úplně zastaví.

FAQ#

Jaký je rozdíl mezi SIGTERM a SIGKILL?

SIGTERM žádá proces, aby skončil, a lze ho zachytit, takže se spustí tvůj úklidový kód. SIGKILL nelze zachytit, zablokovat ani obsloužit - jádro proces okamžitě odstraní. Všechno, co zastavuje kontejner, nejdřív pošle SIGTERM a po vypršení časového limitu SIGKILL.

Jak dlouho má řízené vypnutí trvat?

Kratší než lhůta, která tě stejně zabije - typicky 10 až 30 sekund. Nastav explicitní pevný termín pár sekund uvnitř ní a po jeho vypršení vynuť ukončení, aby zaseknutý požadavek neproměnil restart v zabití.

Proč se moje obsluha vypnutí nikdy nespustí?

Skoro vždy proto, že signál dostal wrapper. Pokud je startovací příkaz npm start nebo shellový skript bez exec, tvá aplikace je podproces a SIGTERM nikdy neuvidí. Spouštěj runtime přímo.

Má health check dotazovat databázi?

V readiness ano, s krátkým timeoutem. V liveness ne. Liveness kontrola závislá na databázi promění krátký problém s databází v restart všech instancí najednou, čímž problém zhorší.

Jaký stavový kód má vracet nezdravý endpoint?

503 Service Unavailable s hlavičkou Retry-After. 500 si nech pro skutečné chyby a 200 vracej jen tehdy, když je proces opravdu připravený obsluhovat.

Pomáhají health checky na jednom serveru?

Ano, i když jinak. Není kam provoz obejít, takže readiness hlavně říká deploy skriptu, kdy nový proces běží, a dává monitoringu něco přesného, na co alarmovat. Důležitější je část s vypouštěním: právě ta dělá restarty zadarmo.


Komentáře

Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.

0/2000