Runbook «Авария» — единый печатный лист

Что это. Одна страница «первой минуты», собранная из девяти статей серии «Эксплуатация VPS / DevOps-база». Держите её на сервере (~/incident.md), в терминале или распечатанной рядом с рабочим местом. Она отвечает на вопрос «сайт/сервер не дышит — что делать и в каком порядке», не заставляя вспоминать восемь теорий. Для кого. Самописный VPS с WordPress: Ubuntu/Debian, nginx + PHP-FPM + MariaDB, один администратор. Не для. Полного сценария взлома — если по ходу увидите чужие процессы/подменённые файлы, переходите в «Шаг 6» ниже и в статью про реагирование.


Три золотых правила (нарушение любого стоит дороже самой аварии)

  1. Не перезагружайте машину первым шагом. Рестарт теряет состояние и улики и почти всегда маскирует причину. Идите по опроснику этажей (Шаг 2).
  2. Держите второй SSH-сеанс открытым, пока новый вход не подтверждён. Любая правка sshd/ufw/ядра без проверки нового входа = остаться за дверью.
  3. Меняйте один слой за раз и проверяйте симптом (curl 443) после каждого действия. «Переписали nginx + php.ini + ребут» не говорит, что помогло и что сломало.

Шаг 0 · Пульс машины (60 секунд)

Bash
# SSH заходит? (нет → консоль провайдера, дальше ничего не трогаем)
uptime; free -h; df -h /; df -i /; systemctl --failed --no-pager
# что слушает и открыто наружу
sudo ss -tulpn | grep -E 'LISTEN|UNCONN'
sudo ufw status numbered 2>/dev/null | head

Куда проваливаться по первому «не ок»:

УвиделиЭто значитИдите
df -h / ≈ 100% или df -i / ≈ 100%кончилось место/иноды → тихая смерть записиРазвилка A
free → available≈0, --failed с базой/phpOOM-killer кого-то сожралРазвилка B
systemctl --failed / юнит рестарт-штормсервис лежит или пересоздаётсяШаг 2, этаж
лишнее слушает (3306, :9100, чужой порт)периметр дыряв или чужой процессРазвилка E / 6
load↑ при пустом CPU (wa↑)тормозит диск/база, не процессорРазвилка A/B

Шаг 1 · Отвечает ли фронт (30 секунд)

Bash
curl -sSI --max-time 8 https://example.test/ | head -n 3
ОтветГде сломалосьИдите
502nginx жив, мёртв PHP-FPM или базаШаг 2 → этаж php/база, Развилка B/C
500PHP жив, WordPress падает внутри (плагин/тема/запрос)error-лог PHP + slowlog (Шаг 2, этаж php)
таймаут / «тишина»сокет/пул не отвечает или диск/память «полошили» всёРазвилка A/B
200/301фронт живой, жалоба не в «лежит»деградация — см. SLO/медленные запросы (Развилка C)

Шаг 2 · Опросник по этажам (идите сверху вниз, стоп на «не ок»)

Цепочка запроса: браузер → DNS → nginx → [сокет пула] → PHP-FPM → MariaDB → диск/память/CPU.

Bash
# Этаж nginx (фронт)
sudo systemctl is-active nginx; sudo nginx -t
sudo journalctl -u nginx -p err --since -15m --no-pager | tail

# Этаж сокет → php-fpm
ls -l /run/php/
sudo systemctl status php8.2-fpm --no-pager | head -n 6
sudo tail -n 30 /var/log/php8.2-fpm.log      # «reached pm.max_children»/«out of memory»
sudo tail -n 20 /var/log/php-fpm/example-slow.log   # что именно тормозит в коде

# Этаж база
sudo systemctl is-active mariadb; sudo mysqladmin ping
sudo mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'Max_used_connections'; SHOW VARIABLES LIKE 'max_connections';"
sudo mysql -e "SELECT ID,USER,TIME,STATE,LEFT(INFO,50) q FROM information_schema.PROCESSLIST WHERE COMMAND='Query' ORDER BY TIME DESC LIMIT 5;"

Как только слой «не ок» — это ваш подозреваемый корень; глубже не лезьте, идите в нужную развилку.


Развилки по типу аварии (четыре частых + атака)

Развилка A · «Кончился диск / иноды»

Bash
df -h /   # байты      |   df -i /   # иноды (свалка мелких файлов при 30% занято)
sudo du -xh --max-depth=1 / 2>/dev/null | sort -h | tail
sudo du -xh --max-depth=1 /var/log 2>/dev/null | sort -h | tail

Чистим без удаления открытых дескрипторов:

Bash
sudo journalctl --vacuum-size=100M
sudo rm -f /var/log/*.gz /var/log/*/*.[0-9]   # ротированные номера/архивы, НЕ текущие .log
# текущий открытый лог — притереть на месте, а не rm:
: > /var/log/nginx/access.log
  • /boot 100% (apt не ставит) → удалить старые ядра: apt-get -s autoremove --purge симуляцией, затем аккуратно (текущее + одно запасное не трогать).
  • иноды (свалка) → чистить старые сессии: find /var/lib/php/sessions -type f -mtime +1 -delete.
  • ⛔ Никогда rm у файла, который демон держит открытым — место не освободится до сигнала; используйте : > и/или USR1/reload.

Развилка B · «Сервер подзавис и один процесс сожрал память» (OOM)

Bash
sudo dmesg -T | grep -iE 'out of memory|oom-kill|killed process' | tail
ps -o rss=,comm= -C php-fpm8.2 | awk '{s+=$1} END{printf "php-fpm суммарно: %.0f МБ\n", s/1024}'
free -h

Killed process ... (mariadbd) / (php-fpm) — ядро прирезало, база «сама не падала». Порядок подъёма и быстрая помощь:

Bash
sudo systemctl start mariadb && sleep 2 && sudo mysqladmin ping   # сначала база
sudo systemctl reload php8.2-fpm                                  # потом php
# временно притормозить аппетит пула, пока пересчитываете:
#   pm.max_children ↓ в /etc/php/8.2/fpm/pool.d/www.conf, затем reload fpm

Корень — «сумма хотелок > физическая память»: pm.max_children × RSS + innodb_buffer_pool_size + Opcache > RAM. Считать одним бюджетом; на 1 ГБ VPS обязателен swap.

Развилка C · «Слишком много соединений / висящий запрос»

Bash
# зависший долгий запрос найти и снять (НЕ рестарт сервиса)
sudo mysql -e "SELECT ID,TIME,STATE,LEFT(INFO,50) FROM information_schema.PROCESSLIST WHERE COMMAND='Query' ORDER BY TIME DESC LIMIT 5;"
sudo mysql -e "KILL <Id>;"      # только прочитав Info — не обрывать чужой незавершённый импорт
  • ERROR 1040: Too many connections → не рестарт базы, а связка max_connections ↔ суммарные дети всех пулов ↔ wait_timeout (закрывать уснувшие).
  • ss -s / ss -tan | awk '{print $1}' | sort | uniq -c → много SYN-RECV = SYN-flood (периметр: ufw limit/fail2ban/WAF); много TIME_WAIT = норма/keepalive; забитый nf_conntrack_count→_max = поднять лимит/снизить шум.
  • «502 из пустого пула» при нормальной памяти → reached pm.max_children: либо снять висящие (выше), либо реально много параллельных. ⚠️ поднимать max_children «чтобы не было 502» — получить OOM: потолок 502 и потолок памяти — одна задача.

Развилка D · «Поправил/обновил — не поднялось» (быстрый откат по слоям)

Bash
# проверить синтаксис ДО reload и после падения
sudo nginx -t; sudo php-fpm8.2 -t; sudo sshd -t   # если правили ssh — ДО рестарта!
sudo systemd-analyze verify /etc/systemd/system/<юнит>.service
sudo journalctl -u <юнит> -p err --since -10m --no-pager | tail   # первая строка = причина

Откатывать ровно подозрительный слой:

  • nginx/php .d-конфиг → вернуть .bak/drop-in → -t → reload (не restart фронта).
  • sshd → не трогать основной файл, убрать битый sshd_config.d/99-битый.conf; живой второй сеанс не закрывать.
  • systemd-юнит → systemctl revert <юнит> (сброс override к пакетному).
  • /etc/* вообще → etckeeper/git в /etc (git checkout HEAD~1 -- файл).
  • пакет → apt-get install pkg=<версия> --allow-downgrades + reload сервиса.
  • ядро грузится криво → GRUB из консоли провайдера выбрать старую запись; держать запасное ядро.
  • виновник — ночное авто-обновление → grep по /var/log/apt/history.log и /var/log/unattended-upgrades/.

Развилка E · «Это не сбой» → точка ветвления на атаку

Если на этажах видны незнакомые пользователи/процессы, подменённые файлы .php в загрузках, исходящий трафик-флуд, пустой/срезанный auth.log, чужой IP входа, новые systemd-юниты → не «чиним и перезапускаем» (затрёте следы и вернёте атакующего). Переходите в Шаг 6 и статью «Реагирование на инциденты: что делать, если сайт взломали». Отличить «OOM из-за кода» от «OOM из-за майнера» — тоже там.


Шаг 3 · Снять закупорку (не «свалить всё»)

KILL <Id> висящего · reload где можно вместо restart · вернуть конфиг из .bak/git · откатить пакет/ядро по необходимости. После каждого действия — curl -sSI https://example.test/ | head (не чиним вслепую; проверяем, что симптом ушёл).

Шаг 4 · Зафиксировать (1 минута, критично)

echo "$(date -u +%FT%TZ) нашёл=<этаж+признак> сделал=<что> откатил=<если>" >> ~/incident-log

Плюс контекст: «после обновления» → apt history; «в 3 ночи» → unattended log; OOM → вывод dmesg -T. Без записи «через неделя машина странно сконфигурирована и никто не помнит почему».

Шаг 5 · Не допустить завтра

Каждый инцидент должен оставить за собой предохранитель — см. «Чек-лист предотвращения» ниже по статье, из которой выросла развилка (диск/иноды → logrotate + df -i алерт; OOM → пересчёт пула + warn на available; соединения → max_connections/wait_timeout + PROCESSLIST-проба; конфиг → etckeeper/бэкап-конфига + -t-привычка; «узнали от пользователя» → порог+heartbeat).

Шаг 6 · Если похоже на взлом (замена сценария)

Изолировать (закрыть наружу доступ, отозвать скомпрометированные ключи/сессии) → зафиксировать (копии логов, снимок подозрительных файлов, снимок процесса) → потом чинить; единственный надёжный путь «вернуть чистое» — восстановление из заведомо чистого бэкапа (файлы из архива, база из проверенного дампа). Дальше — сценарий реагирования (см. ссылку в конце).


Чек-лист предотвращения — по девяти статьям серии

(держать «уже настроенным», чтобы развилки срабатывали редко, а инцидент не стал «узнали от пользователя»)

1 · Анатомия VPS (вход, права, swap, часы)

  • не закрывали root-сессию, пока не проверили новый вход в отдельном окне (запасная дверь до подтверждения)
  • `sshd -t` перед `systemctl restart ssh` (иначе опечатка в конфиге = на морозе)
  • права `~/.ssh` 700, `authorized_keys` 600; публичный ключ не обрезан; passphrase на приватном
  • swap-файл прописан в `/etc/fstab` (иначе исчезнет после ребута → первая же ООМ-авария)
  • один часовой пояс на всю систему (UTC в ОС + местный в cron = задачи не в тот час)
  • бэкап приватного ключа в менеджере паролей; права на файлы, созданные root через sudo, под владельца

2 · systemd (юниты, enable, journal)

  • `ExecStart` абсолютным путём (из `command -v`), без shell-конструкций (`cd`/`&&`/`|`)
  • `daemon-reload` после правки файла; `enable` + `[Install]/WantedBy` для автостарта; `After=` не равен зависимости (нужен `Wants=`/`Requires=`)
  • `WorkingDirectory` задан; `User=` не забыт (иначе root-owned артефакты, которые PHP не прочтёт)
  • `Restart=on-failure` (не `always`, иначе не останавливается по `stop`); `RestartSec`/`StartLimitBurst` + потолок `MemoryMax` против рестарт-шторма
  • правки через `systemctl edit` (drop-in), не в `/lib/…` (затрёт `apt upgrade`)

3 · Nginx (фронт, TLS, лимиты тела)

  • `root` совпадает с реальным путём сайта; `try_files … ?$args` (иначе `?preview`/`?p=` теряются)
  • `fastcgi_pass` на unix-сокет (не TCP-порт); `SCRIPT_FILENAME` абсолютный (иначе 502 «Primary script unknown»)
  • `cgi.fix_pathinfo=0` + `try_files … =404` (против combo-исполнения из uploads); `^~ /wp-content/uploads/` + deny PHP там
  • TLS от Let’s Encrypt с автопродлением (certbot-таймер); порт 80 открыт для продления
  • `client_max_body_size` согласован с PHP-лимитами (`> post_max_size > upload_max_filesize`)

4 · PHP-FPM (пул, память, Opcache, slowlog)

  • `pm.max_children` посчитан по `free`/RSS, суммарно по всем пулам (+ Opcache-сегмент в бюджет)
  • `pm.max_requests` задан (пересоздавать детей, не давая утечке разбухать); режим `static`/`dynamic`/`ondemand` по задаче
  • правки в `…/fpm/php.ini` (не `…/cli/php.ini`); `php_admin_value` там, где плагину нельзя менять лимит
  • `opcache.validate_timestamps` осознанно (=0 требует сброса кэша после деплоя)
  • `slowlog` на существующий путь с правами пользователя пула; `listen.mode 0660` + верный owner/group (не 0666)

5 · MariaDB (bind, InnoDB, бэкап, PROCESSLIST)

  • не `DB_USER=’root’` в wp-config; пользователь под один сайт; `bind-address` сокетом, 3306 наружу закрыт
  • `innodb_buffer_pool_size` посчитан с учётом PHP-детей на той же машине (не «по учебнику 70%»)
  • `mysqldump —single-transaction —routines —triggers —events` (без блокировки и не теряя триггеры)
  • бэкап на другом диске/хосте, не рядом с базой; проверка восстановления прогонялась реально
  • «БД висит» → `SHOW PROCESSLIST`/`KILL <Id>`, а не рестарт сервиса

6 · Firewall / SSH-харднинг (периметр)

  • `ufw allow 22` до `ufw enable`; 80/443 открыты (80 нужен для продления certbot); исходящий трафик не закрыт (`apt`/NTP/DNS)
  • fail2ban на актуальные фильтры (invalid-user, а не «Failed password» при выключенном пароле); `ignoreip` на свои IP/монитор
  • overrides в `.local`/`.d`, не в `jail.conf`/`sshd_config` напрямую (затрёт обновление); один источник правил (не ufw+Docker+nft вразнобой)
  • штатный аудит `ss -tulpn`; нестандартный SSH-порт — шумоподавление, не «защита»

7 · Обновления (кандидат, unattended, откат)

  • помните `apt upgrade` ≠ применено (демоны на старом коде до перезапуска) — `needrestart`/`/var/run/reboot-required`
  • unattended только `-security`; `Automatic-Reboot` либо с окном+мониторингом, либо выключен
  • `hold` на ядре/PHP с планом вручную закрыть CVE (не навсегда без плана); мажорная PHP ставится рядом (второй сокет = путь отката)
  • `reload` где достаточно (nginx/php-fpm), не `restart`; дамп базы перед мажорным апгрейдом БД
  • `apt-get -s autoremove` симуляцией (не удалить нужное ядро); staging не открыт наружу с боевым дампом

8 · Мониторинг ОС (пять метрик, logrotate, алерты)

  • читают `available`, не `free`; видят высокий `wa`= диск/база, а не «мало CPU»; смотрят `df -i` рядом с `df -h`
  • `logrotate` для nginx/PHP slowlog + лимит journal (`SystemMaxUse`); `postrotate` с `kill -USR1` nginx / reload fpm
  • алерты двух типов: threshold с `for`-гистерезисом (не орёт на касание) + heartbeat/dead man’s switch (ловит «монитор упал вместе с сервером»)
  • приёмник heartbeat и Prometheus — не на той же машине; порог привязан к действию и SLO; будит ночью только critical (остальное warning/дашборд)

9 · Диагностика (этот лист)

  • идёте по опроснику этажей, а не «ребут первым шагом»
  • при «база/php внезапно умер» читают `dmesg -T`/`journalctl -k`; при «500 при свободном диске» — `df -i`
  • `KILL` только прочитав `Info`; откатывают один слой, правят конфиг с `-t` и вторым SSH-сеансом
  • при признаках взлома меняют сценарий (фиксация→изоляция→чинить), фиксируют «что делали», ставят предохранитель (шаг 5)

Конвенции листа

  • Простым языком. Любая аббревиатура при первом употреблении объясняется в статьях серии; здесь предполагается, что вы их уже читали.
  • Ubuntu/Debian. Имена пакетов/юнитов/сокетов/логов (php8.2-fpm vs 8.3, mariadb vs mysql, ssh vs sshd) и поведение apt-postinst условны — сверяйте фактические через systemctl list-units, php -v, mariadb --version, ls /run/php/, logrotate -d.
  • Домены/IP/цифры условные: example.test (зарезервированный тест-домен, наружу не резолвится), 203.0.113.x (TEST-NET), staging.test; пороги (85%/70%/10m) — ориентиры, калибруйте под бюджет своей машины.
  • Правило всей серии в аварии: приёмник алертов/сам монитор/консоль отката — не на той же машине, что сторожите; второй SSH-сеанс жив, пока не подтверждён новый вход; боевой не трогаем, пока не проверили на копии.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.