A konténered nem lusta leállni – csak nem hallja, hogy szóltál neki

Tíz másodperc néma csend minden konténerleállításnál, aztán egy erőszakos kilövés. Ez nem lassú cleanup, hanem a Linux PID 1 különleges viselkedése — és egy gyors kernel-szintű teszttel pontosan megmérhető, mekkora a különbség.

Volt már, hogy deployoltál egy Node.js szolgáltatást, futtattál egy docker stop-ot, és a terminál egyszerűen csak állt? Nem hibaüzenet, nem log, csak néma várakozás, pontosan tíz másodpercig, aztán a konténer eltűnik, mintha mi sem történt volna. A legtöbb fejlesztő ilyenkor azt gondolja, hogy az alkalmazása lassan zár be – talán van egy nyitva felejtett adatbázis-kapcsolat, egy le nem futtatott cleanup hook. Pedig lehet, hogy az appod egyáltalán nem is kapta meg a leállási parancsot. Nem lassan reagál rá – soha nem is jutott el hozzá.

Ez a PID 1 problémája, és minden olyan Docker image-ben ott lapul, ahol a Node.js (vagy bármilyen más folyamat) közvetlenül, init nélkül fut a konténer élén. Ami különösen bosszantó: teljesen csendben történik, a health check zölden fut tovább, és amíg valaki nem méri meg direktben a leállási időt, senki nem veszi észre.

Diagram: fent egy piros sáv, ahol a szignál egy PID 1 dobozba ütközik, majd óra és kényszerleállás jel következik; lent egy zöld sáv, ahol a szignál egy init dobozon át simán eljut az alkalmazásdobozhoz, majd pipa jelzi a sikeres leállást

A PID 1 nem csak egy szám

Amikor a Linux kernel elindít egy új PID-namespace-et – pontosan ez történik minden Docker konténerben –, az adott namespace első processze megkapja az 1-es azonosítót, és a kernel különleges bánásmódban részesíti. A hagyományos Unix init folyamatoknak rendkívül nehéz lenne túlélniük, ha egy elszabadult alkalmazás véletlenül elküldene nekik egy kill -TERM 1-et – az egész rendszer (vagy jelen esetben az egész konténer) instabillá válna. Ezért a kernel a PID 1-re nem alkalmazza a szignálok alapértelmezett diszpozícióját. A pid_namespaces(7) man page erről elég egyértelműen fogalmaz: a namespace init processéhez csak azok a szignálok jutnak el, amelyekre saját maga explicit handlert regisztrált – kivétel a SIGKILL és a SIGSTOP, azokat a kernel mindig kézbesíti.

Ennek semmi köze a Dockerhez – ez tisztán kernel-szintű PID-namespace logika. A Docker csak annyit tesz, hogy amikor a Dockerfile CMD/ENTRYPOINT sora közvetlenül elindítja az alkalmazásodat, az lesz a konténer namespace-ének PID 1-e. Ha soha nem regisztráltál kézzel egy process.on('SIGTERM', ...) handlert, a Node.js processzed pontosan olyan immunis lesz a SIGTERM-re, mint egy rendes init rendszer – csak épp nem ez volt a szándékod.

Amit a saját gépemtől megkérdeztem

Ahelyett hogy elhinném ezt papíron, felépítettem a legegyszerűbb tesztet: egy node:22-alpine image, egy setInterval-lal életben tartott Node.js szkript, semmi extra.

FROM node:22-alpine
COPY app.js /app.js
CMD ["node", "/app.js"]

Elindítottam, majd egy docker kill -s TERM-mel (ez csak elküldi a szignált, nem követi automatikus SIGKILL-lel, szemben a docker stop-pal) rálőttem a SIGTERM-et, és öt másodpercig figyeltem:

$ docker kill -s TERM pid1_noinit
$ sleep 5 && docker inspect -f '{{.State.Running}}' pid1_noinit
true

Öt másodperc után a konténer még mindig fut. Nem lassan reagál – egyáltalán nem reagál. A kernel be sem juttatta hozzá a szignált, mert nem volt kire kézbesítenie.

Utána jött a valósághűbb teszt: docker stop, ami a dokumentáció szerint SIGTERM-et küld, majd egy grace period után SIGKILL-t – Linux konténereknél ez az alapértelmezett grace period pontosan 10 másodperc.

$ time docker stop pid1_noinit
# 10.143s valós idő
$ docker inspect -f 'ExitCode={{.State.ExitCode}}' pid1_noinit
ExitCode=137

137-es exit kód, ami 128+9 – tehát végül a SIGKILL (szignál 9) ölte meg a folyamatot. Pontosan tíz másodpercet várt a Docker daemon, mielőtt feladta és lekapcsolta az áramot. Ez az a néma csend a bevezetőből – nem az appod lassú, hanem soha nem is próbált leállni, mert soha nem is tudott róla.

Két javítás, két radikálisan eltérő sebesség

Kétféleképpen lehet ebből kikeveredni. Az egyik: adj a konténernek egy valódi init processzt, ami PID 1-ként fut, saját magára regisztrál handlert minden fontos szignálra, és utána továbbküldi a valódi alkalmazásnak, ami már nem PID 1, tehát rá a normál szignál-szabályok vonatkoznak. A Docker ezt beépítve kínálja a --init flaggel Docker 1.13 óta – a docker-init bináris a háttérben a tini egy beépített változata.

$ docker run -d --init pid1test
$ docker exec <container> ps aux
PID   USER     COMMAND
1     root     /sbin/docker-init -- docker-entrypoint.sh node /app.js
7     root     node /app.js

Ugyanaz a teszt, ugyanaz a docker stop:

$ time docker stop pid1_init
# 0.122s valós idő
$ docker inspect -f 'ExitCode={{.State.ExitCode}}' pid1_init
ExitCode=143

143 (128+15, SIGTERM) – vagyis a Node.js processz tényleg megkapta a szignált, és a saját alapértelmezett viselkedése szerint azonnal leállt. 122 milliszekundum a korábbi 10143 helyett.

A másik megoldás nem is Dockerre vonatkozik, hanem a saját kódodra: ha explicit módon regisztrálsz egy handlert a SIGTERM-re, a kernel onnantól hajlandó kézbesíteni neked, mert van kire kézbesítenie.

process.on('SIGTERM', () => {
  console.log('got SIGTERM, exiting gracefully');
  process.exit(0);
});

Ugyanazzal a docker stop-pal, --init nélkül:

$ time docker stop pid1_handler
# 0.128s valós idő
$ docker inspect -f 'ExitCode={{.State.ExitCode}}' pid1_handler
ExitCode=0

0-s exit kód, 128 milliszekundum – gyakorlatilag ugyanolyan gyors, mint a --init-es verzió, csak itt te magad kezelted a leállást ahelyett, hogy egy külső init processz megvédett volna a PID 1 speciális helyzetétől.

Ez a két megoldás gyakorlatban két különböző problémát old meg. A --init (vagy Dockerfile-ba épített tini/dumb-init) azt garantálja, hogy bármilyen szignál eljut az alkalmazásodhoz, és emellett a zombi processzek reap-elését is elvégzi – ha az appod maga spawnol gyerekfolyamatokat, ez külön ajándék. A saját process.on('SIGTERM') viszont csak azt oldja meg, amire explicit felkészültél – ha az appod egy másik szignált kap, arra megint nem lesz kezelő, mert a kernel csak azt kézbesíti, amire regisztráltál.

Az npm start ennél is érdekesebb sztori

Rengeteg Node.js image CMD ["npm", "start"]-tal indul a CMD ["node", "index.js"] helyett – kényelmes, mert nem kell duplikálni a package.json scripts.start mezőjét. A hivatalos Node.js Docker Best Practices dokumentum kifejezetten óva int ettől: az exec-forma használata "causes exit signals such as SIGTERM and SIGINT to be received by the Node.js process instead of npm swallowing them" – vagyis node index.js esetén direktben a Node.js processz lesz a PID 1, míg npm start esetén az npm a PID 1, a valódi Node.js processz csak egy gyerek alatta.

Kíváncsi voltam, mennyire "nyeli le" ma az npm a szignált – ez a dokumentum megfogalmazása alapján régi tapasztalatnak tűnik, de az npm CLI azóta sokat változott. Lefuttattam ugyanazt a tesztet CMD ["npm", "start"]-tal, process.on('SIGTERM') handler nélkül az alkalmazásban:

$ time docker stop pid1_npm
# 0.684s valós idő
$ docker logs pid1_npm
npm error signal SIGTERM
npm error command sh -c node app.js
$ docker inspect -f 'ExitCode={{.State.ExitCode}}' pid1_npm
ExitCode=1

A jelenlegi npm (10.9.8, a Node 22-es image-hez csomagolva) tehát nem nyeli le a szignált – ténylegesen továbbküldi a gyerek Node processznek, ami 684 milliszekundum alatt leállt, jóval a teljes 10 másodperces grace period előtt. De az npm ezt utólag "command failed" hibaként jelenti, és 1-es exit kóddal zár, ahelyett hogy a valódi 143-as (SIGTERM okozta) kódot adná tovább. Ha az orchesztrátorod vagy a monitoringod exit kód alapján különbözteti meg a rendesen leállt, a SIGKILL-lel megölt (137) és az összeomlott eseteket, az npm start ezt az információt egyszerűen eldobja, és mindent 1-es hibakódra fordít – még akkor is, ha a leállás technikailag rendben lezajlott.

Amikor viszont az alkalmazás maga is kezeli a SIGTERM-et, ez a probléma eltűnik:

$ time docker stop pid1_npm_handler
# 0.186s valós idő
$ docker inspect -f 'ExitCode={{.State.ExitCode}}' pid1_npm_handler
ExitCode=0

Tiszta 0-s kód, mert ekkor az npm nem egy szignál miatt megszakadt gyerekfolyamatot lát, hanem egy simán, process.exit(0)-val lezáruló scriptet. A tanulság: az npm start gyakorlati kára ma inkább az exit kód elmaszkolása, nem a szignál teljes elnyelése – ami azért árnyaltabb, mint amit a legtöbb erről szóló írás ismételget.

Ha Compose-ban vagy más orchesztrátoron futtatsz

Ugyanez az --init Docker Compose-ban init: true attribútumként érhető el szolgáltatás-szinten, ugyanazzal a hatással – a dokumentáció szó szerint úgy fogalmaz, hogy egy init processzt futtat PID 1-ként, ami továbbítja a szignálokat és reap-eli a zombikat. Ha Kubernetesen vagy más orchesztrátoron futtatsz, a helyzet nem jobb: a container runtime (containerd, CRI-O) ugyanúgy közvetlenül a te processzedet indítja PID 1-ként, docker --init-hez hasonló beépített megoldás nélkül – a felelősség vagy a saját image-edre hárul (tini vagy dumb-init becsomagolva), vagy a saját szignálkezelő kódodra.

Számít ez egyáltalán?

Rövid életű batch job vagy build konténer esetén gyakorlatilag nem – ha úgyis SIGKILL-t kap, vagy simán lefut és kilép, a PID 1 szignál-immunitása sosem kerül tesztre. De minden hosszan futó service-nél, ami rolling deploy-nál, autoscalingnél vagy egy egyszerű docker compose down-nál rendszeresen kap leállási parancsot, ez a különbség nagyon is valódi: nyitva maradt adatbázis-tranzakciók, félbeszakadt HTTP kérések, WebSocket kapcsolatok, amikre soha nem futott le semmilyen cleanup, csak egy nyers SIGKILL zárta be őket egy rossz pillanatban.

A saját tapasztalatom szerint mindkét megoldást egyszerre érdemes bevetni, nem az egyiket a másik helyett. A --init (vagy Compose init: true) egy sornyi konfigurációba kerül, gyakorlatilag nulla hátránnyal jár, és megvéd olyan szignáloktól is, amikre nem gondoltál explicit – plusz reap-eli a zombikat, ami előbb-utóbb minden gyerekfolyamatokat spawnoló appnak jól jön. De ettől még a saját SIGTERM handlered nem lesz felesleges: a --init csak eljuttatja hozzád a szignált, a tényleges graceful shutdown logikát – nyitott kapcsolatok lezárása, in-flight kérések kivárása – attól függetlenül neked kell megírnod. A --init a kézbesítést oldja meg, nem a leállás minőségét.

Ha valamit összesűrítve elvinnél magaddal: a konténered nem azért nem áll le tíz másodpercig, mert lusta vagy elfoglalt – hanem mert PID 1-ként a Linux kernel szemében gyakorlatilag süket a világra, amíg te magad meg nem tanítod hallani.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük