Skip to main content

Як обробляти сигнали в контейнерах? Що таке init process?

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'ив)

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Коментарі

Ще немає коментарів