Основні висновки
execзамінює bash процес на ваш застосунок, роблячи його PID 1- Без
execсигнали Docker не доходять до вашого застосунку - PID 1 має особливу поведінку в Linux і Docker
- Зомбі-процеси можуть заблокувати систему без правильного init процесу
- Практика: завжди використовуйте
execв entrypoint скриптах
Проблема: Коли Docker контейнер не вмирає
Уявіть ситуацію: ви запускаєте docker stop mycontainer, чекаєте… і контейнер все ще працює. Через 10 секунд Docker втрачає терпіння і вбиває контейнер за допомогою SIGKILL. Дані втрачаються.
Причина? Ваш застосунок ніколи не отримав сигнал SIGTERM для graceful shutdown.
Типовий антипаттерн
|
1 2 3 4 |
#!/bin/bash # Цей скрипт стає PID 1 node src/app.js |
Що відбувається:
- Bash скрипт отримує PID 1
- Node.js застосунок отримує інший PID (наприклад, PID 7)
docker stopнадсилаєSIGTERMдо PID 1 (bash)- Bash не передає сигнал до дочірніх процесів
- Node.js ніколи не дізнається про shutdown
- Docker чекає 10 секунд і вбиває всіх
SIGKILL
PID 1: Особливий процес з особливими правилами
Чому PID 1 особливий?
PID 1 в Linux має спеціальну поведінку: він ігнорує будь-які сигнали, якщо для них не встановлено явного обробника. Це зроблено навмисно – ви не хочете випадково вбити init процес, оскільки це має серйозні наслідки для всієї системи.
Відмінності PID 1:
- Ігнорує сигнали без явних обробників
- Не можна вбити стандартними сигналами
- Відповідальний за reaping зомбі-процесів
- Повинен adoptувати orphaned процеси (взяти опікунство над цими процесами)
Демонстрація проблеми
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# myapp.py import time import signal import sys def signal_handler(signum, frame): print(f"Received signal {signum}") sys.exit(0) signal.signal(signal.SIGTERM, signal_handler) while True: print("App is running...") time.sleep(1) |
Як PID 1:
|
1 2 3 |
$ docker run myapp python myapp.py # docker stop myapp -> Не працює! Чекає 10 секунд, потім SIGKILL |
Як звичайний процес:
|
1 2 3 |
$ python myapp.py & $ kill -TERM $! -> Працює відразу! |
Shell Form vs Exec Form: Критична відмінність
Shell Form (Небезпечний)
|
1 2 3 |
ENTRYPOINT python app.py CMD python app.py |
Команда виконується через /bin/sh -c, створюючи додатковий shell процес:
|
1 2 3 |
PID 1: /bin/sh -c "python app.py" PID 7: python app.py |
Exec Form (Правильний)
|
1 2 3 |
ENTRYPOINT ["python", "app.py"] CMD ["python", "app.py"] |
Команда виконується безпосередньо як PID 1 без shell:
|
1 2 |
PID 1: python app.py |
Зомбі-процеси: Тихий вбивця ресурсів
Що таке зомбі-процес?
Зомбі-процес – це процес, який завершився, але його батьківський процес не викликав waitpid() для отримання його статусу виходу. Процес “мертвий”, але запис про нього залишається в системній таблиці процесів.
Реальний приклад атаки через зомбі
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Небезпечний код з реального проекту func startProcess() *exec.Cmd { cmd := exec.Command("npm", "run", "start") cmd.Start() // ❌ Не чекаємо завершення! return cmd } func createManyProcesses() { for i := 0; i < 10000; i++ { startProcess() // Створюємо зомбі } } |
У реальному проекті Stormkit зомбі-процеси призвели до вичерпання пам’яті сервера та Redis помилок.
Чому це небезпечно?
- Вичерпання PID: Система може закінчити PID’и
- Споживання пам’яті: Кожен зомбі займає місце в kernel
- Маскування атак: Інструменти моніторингу можуть плутати зомбі з живими процесами
- DoS атаки: Злумисник може створити тисячі зомбі
exec: Магічне рішення
Як працює exec в bash
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/bin/bash echo "Process tree before exec:" ps -ef # Без exec - два процеси node app.js & wait echo "---" # Із exec - один процес exec node app.js # Цей код ніколи не виконається! echo "This will never print" |
Що відбувається під капотом
Без exec:
|
1 2 |
bash (PID 1) → node (PID 7) |
З exec:
|
1 2 |
bash (PID 1) → exec → node (PID 1) ✓ |
Команда exec замінює поточний процес на новий, зберігаючи той же PID.
Практичні рішення та Best Practices
1. Використовуйте exec в entrypoint скриптах
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#!/bin/bash # startrv.sh # Ініціалізація setup_environment() check_dependencies() create_directories() # ✅ Правильно: exec замінює bash на Node.js exec node src/app.js # ❌ Неправильно: bash залишається PID 1 # node src/app.js |
2. Обробляйте сигнали правильно
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/bin/bash # Встановлюємо обробники сигналів trap 'handle_shutdown' SIGTERM SIGINT handle_shutdown() { echo "Graceful shutdown..." # Зупиняємо дочірні процеси kill -TERM $child_pid wait $child_pid exit 0 } # Запускаємо застосунок exec node app.js |
3. Використовуйте Init системи для складних випадків
|
1 2 3 4 5 6 |
# Tini - легка init система FROM node:18 RUN apt-get update && apt-get install -y tini ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["node", "app.js"] |
4. Docker –init флаг
|
1 2 3 |
# Docker автоматично додає tini docker run --init myapp |
Вразливості та Сценарії атак
1. PID Exhaustion Attack
Сценарій: Зловмисник знаходить endpoint, який створює процеси без reaping
|
1 2 3 4 5 6 7 |
// Вразливий код app.post('/run-task', (req, res) => { const child = spawn('task-runner', [req.body.params]); // ❌ Не очищуємо дочірні процеси! res.json({status: 'started'}); }); |
Атака:
|
1 2 3 4 5 |
# Створюємо 32768 зомбі (максимум PID за замовчуванням) for i in {1..32768}; do curl -X POST /run-task -d '{"params":"evil"}' & done |
Результат: Система не може створювати нові процеси
2. Container Escape через Signal Handling
Проблема: Неправильна обробка сигналів може дозволити вихід за межі контейнера
|
1 2 3 4 5 6 |
#!/bin/bash # Небезпечний entrypoint trap 'exec $DEBUG_CMD' SIGUSR1 # ❌ Виконуємо довільні команди! exec myapp |
Атака:
|
1 2 3 |
docker exec mycontainer kill -USR1 1 # Якщо DEBUG_CMD="/bin/bash", отримуємо shell з правами PID 1 |
3. Resource Exhaustion
Зомбі-процеси займають запис у таблиці процесів ядра, що може привести до вичерпання системних ресурсів:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Створюємо зомбі-бомбу import os import time def create_zombie_bomb(): for i in range(10000): pid = os.fork() if pid == 0: # Child os._exit(0) # Швидко вмираємо # Parent не чекає - створюємо зомбі! time.sleep(3600) # Тримаємо зомбі живими |
Інструменти діагностики та моніторингу
1. Перевірка PID процесу
|
1 2 3 4 5 6 7 |
# Всередині контейнера ps aux | head -5 # PID 1 повинен бути ваш застосунок, не bash! # З хоста docker exec container_name ps aux |
2. Моніторинг зомбі-процесів
|
1 2 3 4 5 6 7 8 9 |
# Пошук зомбі ps aux | awk '$8=="Z" {print}' # Кількість зомбі ps aux | awk '$8=="Z"' | wc -l # Детальна інформація cat /proc/*/stat | awk '$3=="Z" {print}' |
3. Тестування signal handling
|
1 2 3 4 5 6 7 8 9 |
# Тест graceful shutdown docker run -d --name test-app myapp docker stop test-app # Має зупинитися швидко, не через 10 секунд! # Тест з timeout timeout 5 docker stop test-app # Якщо повертає код 124 - проблема з сигналами |
4. Process tree аналіз
|
1 2 3 4 5 6 7 8 9 |
# Структура процесів docker exec container_name pstree -p # Правильно: # myapp(1) # Неправильно: # bash(1)---myapp(7) |
Реальні кейси з практики
Кейс 1: Node.js застосунок у production
Проблема: NPM не є process manager і не передає сигнали до застосунку
|
1 2 3 4 5 6 |
# ❌ Неправильно CMD ["npm", "start"] # ✅ Правильно CMD ["node", "src/app.js"] |
Кейс 2: Kubernetes Pod Termination
У Kubernetes під час видалення pod’а надсилається SIGTERM сигнал кожному контейнеру. Якщо процес не обробляє цей сигнал правильно, він може ігнорувати його.
Симптоми:
- Pod’и “висять” під час deployment
- Тривалий час завершення
- Corrupted дані
Рішення:
|
1 2 3 4 5 6 7 8 9 10 11 |
# kubernetes-deployment.yaml spec: containers: - name: app image: myapp lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"] terminationGracePeriodSeconds: 30 |
Кейс 3: Multi-stage Docker Build
|
1 2 3 4 5 6 7 8 9 10 |
# ❌ Копіює bash разом з застосунком FROM ubuntu COPY entrypoint.sh / ENTRYPOINT ["/entrypoint.sh"] # ✅ Використовує exec form + distroless FROM gcr.io/distroless/nodejs COPY app.js / ENTRYPOINT ["node", "/app.js"] |
Checklist: Аудит вашого Docker setup
✅ Dockerfile Review
- [ ] Використовується exec form для ENTRYPOINT/CMD
- [ ] Немає
npm startв production образах - [ ] Shell скрипти використовують
exec - [ ] Встановлено правильні signal handlers
✅ Runtime Tests
- [ ]
docker stopзаймає < 2 секунд - [ ] PID 1 – це ваш застосунок, не bash
- [ ] Немає зомбі-процесів після навантаження
- [ ] Graceful shutdown зберігає дані
✅ Security Checks
- [ ] Немає виконання довільних команд в signal handlers
- [ ] Логи не містять “killed” повідомлень
- [ ] Моніторинг показує стабільну кількість процесів
- [ ] Init система налаштована для multi-process контейнерів
Висновки та рекомендації
Основні принципи
- Завжди використовуйте
execв shell entrypoint скриптах - Уникайте shell form в Dockerfile інструкціях
- Обробляйте SIGTERM у ваших застосунках
- Використовуйте init системи для складних сценаріїв
- Тестуйте signal handling на всіх етапах розробки
Інструменти для впровадження
- Tini: Мінімальна init система для контейнерів
- dumb-init: Альтернатива від Yelp
- Docker –init: Вбудоване рішення
- s6-overlay: Повноцінна init система з supervision
Заключна думка
Правильна обробка сигналів не є менш важливою коли ваш застосунок працює всередині Docker контейнера. Різниця між exec node app.js та node app.js здається мінімальною, але вона критично важлива для стабільності, безпеки та правильного функціонування ваших контейнеризованих застосунків.
Не дозволяйте простій помилці у одному рядку коду зруйнувати надійність вашого production середовища. Використовуйте exec – ваші контейнери скажуть вам спасибі! 🐳
Додаткові ресурси
- Docker Best Practices Guide
- Linux Process Management
- Tini GitHub Repository
- Kubernetes Container Lifecycle
Стаття написана на основі досліджень та аналізу real-world кейсів з production середовищ.


