Як обробляти сигнали в контейнерах? Що таке init process?
Signal-handling і init-процес у container це одна з тем, що не важать, поки не починають важити, і тоді важать дуже багато. App, що ігнорують SIGTERM, втрачають дані на кожному deploy. App, що fork без init-процесу, лікають zombie, поки система не помре.
Теорія
TL;DR
- Всередині container PID 1 це твій app (що поклав у
CMD/ENTRYPOINT). - PID 1 має особливу семантику, успадковану з Unix:
- Ігнорує більшість сигналів за замовчуванням, поки явно не trap.
- Відповідальний за 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) має дві відповідальності:
- Signal-handling. Більшість сигналів (SIGTERM, SIGINT, SIGUSR1) ігноруються, поки PID 1 явно не реєструє handler. Kernel робить це, щоб захистити init від випадкового вбивства.
- 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.
Фікс у коді:
// Node.js
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
db.disconnect();
process.exit(0);
});
});# Python
import signal, sys
def handle_sigterm(signum, frame):
print('SIGTERM received')
cleanup()
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)// 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 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
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-проблем:
# НЕПРАВИЛЬНО: 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:
#!/bin/sh
# docker-entrypoint.sh
run-setup-tasks
exec "$@" # ← критично: замінює sh реальною командоюexec "$@" замінює shell-процес реальною командою. Твій app стає PID 1 (після короткого існування shell); сигнали працюють; без shell-обгортки.
Без exec:
#!/bin/sh
run-setup-tasks
"$@" # spawn-ить дитину; sh лишається як PID 1Тепер sh це PID 1, ігнорує сигнали, і твій app не отримує SIGTERM. Баг.
Типові помилки
Shell-форма CMD ламає docker stop
Згадано. Завжди exec-форма.
Відсутній exec у shell-entrypoint
# НЕПРАВИЛЬНО
#!/bin/sh
run-init
/usr/bin/myapp
# ПРАВИЛЬНО
#!/bin/sh
run-init
exec /usr/bin/myappTrap додано, але app реально не вимикається
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
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();
});docker run -d --name web myapp
docker stop web
# 'SIGTERM received, draining...'
# 'Server closed; exiting'
# Exit код 0; зайняло ~1 секунду.Entrypoint з exec
#!/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 "$@"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
# Всередині 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'ив)Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.
Коментарі
Ще немає коментарів