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.

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.