Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як обробляти сигнали в контейнерах? Що таке init process?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Всередині container твій головний процес це PID 1.** PID 1 має особливу семантику: ігнорує більшість сигналів за замовчуванням, поки явно не обробляєш, і відповідальний за reaping zombie-дітей. **App має trap SIGTERM** для graceful shutdown. Бери `--init` (або `tini`) для proper init-процесу. ```bash docker run --init myapp # tini стає PID 1, твій app це його дитина ``` ```js process.on('SIGTERM', () => server.close(() => process.exit(0))); ``` **Головне:** без signal-handling `docker stop` чекає 10с, тоді SIGKILL'ить app — zombie накопичуються, без graceful shutdown. `--init` розв'язує zombie-проблему; trap SIGTERM розв'язує graceful shutdown.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Signal-handling і init-процес** у container це одна з тем, що не важать, поки не починають важити, і тоді важать дуже багато. App, що ігнорують SIGTERM, втрачають дані на кожному deploy. App, що fork без init-процесу, лікають zombie, поки система не помре. ## Теорія ### TL;DR - Всередині container **PID 1 це твій app** (що поклав у `CMD`/`ENTRYPOINT`). - PID 1 має **особливу семантику, успадковану з Unix**: 1. Ігнорує більшість сигналів за замовчуванням, поки явно не trap. 2. Відповідальний за **reaping zombie**, дочірні процеси, що вийшли, але чий status не зібрано. - `docker stop` шле **SIGTERM** на PID 1, чекає 10с grace, тоді SIGKILL. - **Без trap SIGTERM** твій app SIGKILL після grace-періоду, без graceful shutdown, без flush, без clean-exit. - **Без proper init-процесу** (твого app або wrapper) zombie-діти накопичуються. Для app, що fork (Python `multiprocessing`, певні Node-патерни), це реальна проблема. - **Рішення:** trap-сигнали у коді AND/OR бери `tini` (`--init` флаг) як PID 1. ### Чому PID 1 особливий Linux PID 1 (init-процес, зазвичай `systemd` на host) має дві відповідальності: 1. **Signal-handling.** Більшість сигналів (SIGTERM, SIGINT, SIGUSR1) *ігноруються*, поки PID 1 явно не реєструє handler. Kernel робить це, щоб захистити init від випадкового вбивства. 2. **Reaping zombie.** Коли процес виходить, його батько має викликати `wait()`, щоб зібрати exit-status. Якщо батько не викликає, дитина стає *zombie* (видно у `ps` як `<defunct>`). Коли батько помирає, його orphaned-діти reparent'ять на PID 1, що очікувано їх reap'ить. У container твій app несподівано приведено у цю роль. Якщо app не виконує ці обов'язки, починаються сюрпризи. ### Signal-handling: SIGTERM Flow `docker stop` Docker: ``` t=0 daemon шле SIGTERM на PID 1 всередині container t=0..N PID 1 має: - закінчити in-flight роботу - flush state (DB, логи) - закрити з'єднання - чисто вийти t=N якщо ще running, daemon шле SIGKILL (дефолт N=10с) ``` Якщо app не реєструє SIGTERM-handler, kernel ігнорує сигнал. `docker stop` не бачить exit, чекає 10с, шле SIGKILL. Результат: брудний shutdown, exit 137. Фікс у коді: ```js // Node.js process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); server.close(() => { db.disconnect(); process.exit(0); }); }); ``` ```python # Python import signal, sys def handle_sigterm(signum, frame): print('SIGTERM received') cleanup() sys.exit(0) signal.signal(signal.SIGTERM, handle_sigterm) ``` ```go // Go func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) go func() { <-sigChan log.Println("SIGTERM received") // graceful cleanup os.Exit(0) }() server.ListenAndServe() } ``` ### Zombies і init-процес ```python # Python parent fork-ить дітей, але ніколи не wait import os for i in range(100): pid = os.fork() if pid == 0: # дитина робить роботу і виходить os._exit(0) # Parent ніколи не os.wait() → 100 zombie накопичується ``` `ps -ef` всередині container показує їх як `<defunct>`. Кожен zombie споживає PID-слот і трохи пам'яті. Довгоживучі app, що fork, можуть закінчити PID (ліміт ~32768). Реальний фікс у app: завжди reap. Але якщо app не можна виправити, init-процес може це робити. ### `tini` і `--init` ```bash docker run --init myapp ``` Флаг `--init` обгортає твій app у `tini`. `tini` стає PID 1; твій app стає PID 2 (і будь-які zombie, що він створює, reparent на tini, що їх reap'ить правильно). tini також forward сигнали правильно. Без `--init`: ``` PID 1: твій-app ``` З `--init`: ``` PID 1: /sbin/docker-init (tini) PID 2: твій-app ``` Відповідальності tini: - Forward SIGTERM, SIGINT тощо з PID 1 на твій app (PID 2). - Reap будь-яких zombie, що reparent на нього. - Вийти, коли app виходить, з тим самим exit-кодом. ### Shell vs exec форма знов Це найпоширеніша причина signal-проблем: ```dockerfile # НЕПРАВИЛЬНО: shell форма — sh стає PID 1, твій app PID 2 CMD nginx -g "daemon off;" # ПРАВИЛЬНО: exec форма — твій app Є PID 1 CMD ["nginx", "-g", "daemon off;"] ``` З shell-формою Docker обгортає команду у `/bin/sh -c '...'`. Тому `sh` це PID 1; nginx це PID 2. `docker stop` шле SIGTERM на `sh`, що його ігнорує (sh не forward сигнали дітям). Через 10с SIGKILL — і nginx ніколи не знав, що мав shutdown. **Завжди бери exec-форму** (`["prog", "arg"]`) для прод-CMD/ENTRYPOINT. ### Коли брати `tini` / `--init` Так: - Твій app fork-ить дітей і не reap їх (Python multiprocessing без proper join, Node child_process без exit-handling). - Використовуєш `bash`/`sh` як частину складного entrypoint-скрипта. - Бачиш `<defunct>`-процеси у `docker top` або `ps aux` всередині container. Ні (не треба): - Твій app це single-процес, що обробляє свої сигнали (більшість Go, Rust сервісів). - Entrypoint image вже init-like обгортка (деякі офіційні image, як docker-entrypoint.sh postgres, роблять exec в кінці, тож реальний бінар це PID 1). ### Shell entrypoint-скрипти: правильний шлях Багато image використовують shell-скрипт як entrypoint для setup: ```bash #!/bin/sh # docker-entrypoint.sh run-setup-tasks exec "$@" # ← критично: замінює sh реальною командою ``` `exec "$@"` замінює shell-процес реальною командою. Твій app стає PID 1 (після короткого існування shell); сигнали працюють; без shell-обгортки. Без `exec`: ```bash #!/bin/sh run-setup-tasks "$@" # spawn-ить дитину; sh лишається як PID 1 ``` Тепер `sh` це PID 1, ігнорує сигнали, і твій app не отримує SIGTERM. Баг. ### Типові помилки **Shell-форма CMD ламає docker stop** Згадано. Завжди exec-форма. **Відсутній `exec` у shell-entrypoint** ```bash # НЕПРАВИЛЬНО #!/bin/sh run-init /usr/bin/myapp # ПРАВИЛЬНО #!/bin/sh run-init exec /usr/bin/myapp ``` **Trap додано, але app реально не вимикається** ```python signal.signal(signal.SIGTERM, lambda s, f: print('SIGTERM')) # Логує сигнал, але не виходить. Container все одно SIGKILL у grace-період. ``` Handler має реально виходити (і завершити cleanup перед виходом). **Забути `--init` для fork-heavy app** OOM-like поведінка на довгоживучих container, багато `<defunct>` у `ps`. Додай `--init` або фіксь fork/wait логіку app. **Сприймати exit 137 як нормально** Exit 137 (SIGKILL) означає, що app НЕ shutdown gracefully — його force-killed. Якщо бачиш 137 на `docker stop`, твій signal-handling зламано. ### Real-world impact - **Container баз** (Postgres, MySQL): якщо SIGKILL, WAL/journal-replay при наступному старті. Іноді хвилини recovery-часу. - **Worker-container**: in-flight job loss або duplicate; downstream-impact залежить від idempotency. - **Web-сервери**: in-flight запити dropped; клієнти бачать 502/connection-reset. - **High-fork-rate container** (Python multiprocessing, паралельні test-runners): zombie-накопичення може крашнути container. ### Питання для поглиблення **Q:** Які сигнали шле docker stop? **A:** SIGTERM за замовчуванням. Override через `--stop-signal=SIGUSR1` (per-container) або `STOPSIGNAL` у Dockerfile. Корисно для app, що використовують SIGUSR1 для graceful shutdown. **Q:** Чому мій Node.js app виходить одразу на Ctrl+C, але не на docker stop? **A:** Ctrl+C шле SIGINT (не SIGTERM). Node має дефолтний SIGINT-handler, що виходить; не має дефолтного SIGTERM-handler. Додай `process.on('SIGTERM', ...)` явно. **Q:** Чи варто завжди брати `--init`? **A:** Додавання нешкідливе. Для fork-heavy або shell-heavy entrypoint бери. Для single-process app у exec-формі CMD не потрібно, але не шкодить. **Q:** Яка різниця між `tini`, `dumb-init` і `s6`? **A:** `tini` (використовується `--init`): мінімальний, signal-forward + zombie-reap. `dumb-init`: схоже на tini, трохи інші дефолти. `s6` і `runit`: повні process-supervisor (multi-process, restart on crash). Для single app tini достатньо; для multi-process container (anti-pattern, але іноді треба) s6. **Q:** (Senior) Як дебажити, чи app правильно обробляє SIGTERM? **A:** Шли SIGTERM і time-уй exit. `docker stop --time 30 mycontainer` з `time`, що його обгортає. Чистий app виходить за 1-2 секунди з кодом 0. Брудний app чекає 10-30с і виходить 137. Всередині логуй активність handler («received SIGTERM at...»), щоб підтвердити, що він спрацьовує. Для глибшого inspect: `strace -p <PID> -e trace=signal` ззовні (з `--cap-add SYS_PTRACE`) показує raw signal-delivery. ## Приклади ### Node.js graceful shutdown ```js const http = require('http'); const server = http.createServer((req, res) => { res.end('hello'); }); server.listen(3000); let shuttingDown = false; process.on('SIGTERM', () => { if (shuttingDown) return; shuttingDown = true; console.log('SIGTERM received, draining...'); server.close((err) => { if (err) console.error(err); console.log('Server closed; exiting'); process.exit(0); }); // Force-exit через 25с, якщо cleanup висне setTimeout(() => process.exit(1), 25000).unref(); }); ``` ```bash docker run -d --name web myapp docker stop web # 'SIGTERM received, draining...' # 'Server closed; exiting' # Exit код 0; зайняло ~1 секунду. ``` ### Entrypoint з exec ```bash #!/bin/sh # docker-entrypoint.sh set -e # Migrate DB якщо перший run if [ ! -f /var/lib/myapp/.initialized ]; then myapp migrate touch /var/lib/myapp/.initialized fi # Замінити shell реальною командою exec "$@" ``` ```dockerfile COPY docker-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["myapp", "server"] ``` `exec "$@"` це магічний рядок. Після init-задач shell замінюється на `myapp server`. Бінар myapp тепер PID 1 (або PID 2, якщо `--init`). Сигнали його дістають. ### Виявлення накопичення zombie ```bash # Всередині container $ ps -ef | grep defunct | wc -l 42 # 42 zombie. Або фіксь fork/wait логіку app, або додай --init. # Re-run з --init docker run --init myapp # Всередині: $ ps -ef | grep defunct # (жодного, tini їх reap'ив) ```Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.