Ребут-цикл VPS: плитка, ядра и тихий userspace-фолбэк

Если инфраструктура работает, это ещё не значит, что она работает так, как ты задумал. В этот раз всё началось с одной жёлтой плитки на дашборде — а закончилось чисткой ядер, починкой сборки DKMS-модуля и поимкой тихого фолбэка, который всё это время ел скорость туннелей.
Контекст
Хозяйство: три внешних VPS (два в России, один в Финляндии), AmneziaWG-туннели для роутера MikroTik, Infra-as-Code на Ansible и наблюдаемость — экспортеры метрик, Grafana с плитками здоровья, алерты в Telegram. Финский VPS — egress Telegram-бота, поэтому трогаем его последним и проверяем отдельным списком.
Среди плиток есть «Нужен ребут»: textfile-проба на каждом хосте читает флаг
reboot-required и причину. Ноль — всем хорошо, единица — плитка жёлтая с
подписью «хост: причина».
Исходная проблема
Плитка снова жёлтая, причём на всех трёх VPS сразу. Первая мысль — «я же недавно уже ставил ядра, почему опять и вроде тот же пакет?». Разбор по apt-history расставил всё по местам:
27.10 06:22 unattended-upgrade → 6.12.113
03.11 06:33 unattended-upgrade → 6.12.115+deb13.1 ← «тот самый» прошлый раз
10.11 06:35 unattended-upgrade → 6.12.116 ← утро дня сессии
Дежавю обоснованное, но пакет не тот же, а новый релиз той же серии: Debian выпускает security-обновления ядер каждые одну-две недели, и unattended-upgrades (UU) ставит их сам по ночам. Плитка будет загораться регулярно — и это нормально.
Попутно нашлось: на одном хосте накопилось пять установленных ядер, каждое ~350–400 МБ, при диске 20 ГБ. Ставил их не человек, и чистить было некому.
Окно обслуживания
Порядок хостов — по риску: первыми два «дешёвых» туннельных VPS, финский
последним. Перед окном — pre-check: dkms status пуст (модуль не используется),
контейнеры с restart=always, туннели живут в userspace — ребут безопасен.
apt update/apt upgradeна всех трёх — пусто: UU утром уже всё поставил.apt autoremove— ноль к удалению. Вот тут и начинается интересное.
Грабля №1: autoremove не чистит ядра. Вообще
Ожидали, что autoremove после нового ядра уберёт старое. Реальность — «0 to
remove». Причина — защита в /etc/apt/apt.conf.d/01autoremove:
APT::VersionedKernelPackages {
"linux-.*";
};
Это предохранитель «не снести случайно все ядра», и он означает: autoremove
никогда не предложит версионируемый пакет ядра. Старые ядра чистятся либо самим
UU (если включить Remove-Unused-Kernel-Packages), либо точечным purge.
Грабля №2: cloud-ядра возвращают друг друга
На хосте с провайдерским cloud-ядром попытка удалить signed-пакет, а потом unsigned, провалилась хитро: после второго purge первый снова оказался установленным. Лечится только удалением всего семейства одной командой:
apt purge linux-image-<версия> linux-headers-<версия> \
linux-headers-<версия>-common
Итог чистки: −3 ядра на накопившем хосте (осталось running + latest, диск 5,3/20 ГБ), −1 на остальных. Ребут по одному, финский — в конце, с отдельной проверкой внешнего IP и egress бота. Плитка погасла на всех восьми хостах мониторинга — трёх VPS и домашнем хозяйстве.
WARNING про хедеры
После ребута в логах находится строчка, которую никто не читал до поры:
WARNING: No kernel headers were found, skipping module build.
To get the headers for the running kernel (6.12.116+deb13-amd64)
please install the linux-headers-6.12.116+deb13-amd64 package.
Это dkms-хук модуля AmneziaWG: при новом ядре он пытается собрать модуль и не
находит заголовков. Диагностика показала: хедеров не было ни на одном VPS,
dkms status везде — amneziawg/1.0.0: added, то есть ни одной сборки за всю
историю. На одном хосте не было даже gcc.
Точное версионное имя пакета завтра изменится, поэтому гарантию «всегда» даёт метапакет:
- name: Kernel headers meta + gcc for DKMS builds
ansible.builtin.apt:
name:
- "{{ 'linux-headers-cloud-amd64' if 'cloud' in ansible_kernel else 'linux-headers-amd64' }}"
- gcc
state: present
install_recommends: false
Важный нюанс, который поймал ревью: между headers-метапакетом и image-метапакетом нет зависимости — оба просто приезжают одним прогоном UU как независимые пакеты одного security-выпуска. Гарантия — эффект конфигурации UU, а не apt.
После установки хедеров модуль собрался сам — у dpkg есть триггер, который пересобирает зарегистрированные dkms-модули:
amneziawg/1.0.0, 6.12.116+deb13-amd64, x86_64: installed
Механика будущих ядер проверена живьём: новое ядро → UU ставит образ и хедеры → dkms-триггер собирает модуль.
Незакрытый хвост: userspace-фолбэк
Здесь уверенный вывод оказался ошибкой. Агент, помогавший в сессии, отчитался после фикса хедеров: «модуль собран и установлен, но контейнеры туннелей работают на userspace-реализации, модуль ядра не загружен и не нужен — рестарт контейнеров ничего не изменит».
Ошибка. Скрипт запуска контейнера зовёт awg-quick up, который сначала пробует
создать kernel-интерфейс — и только если модуля в ядре нет, тихо откатывается на
userspace-демон amneziawg-go. «В процессах виден amneziawg-go» — это не
проектное решение, а симптом застарелого фолбэка: модуль ни разу не был собран,
потому что хедеров не было.
Цепочку до конца продиктовал человек, который помнил, как система устроена на самом деле:
modprobe amneziawg # грузим модуль сейчас, а не на boot
docker restart amnezia-awg2 # контейнер перезапускается уже на kernel path
Плюс автозагрузка /etc/modules-load.d/amneziawg.conf и задача в Ansible-роль
«Load amneziawg module now»: конфиг автозагрузки на одном хосте уже существовал,
но читается он только при старте системы — а хост ребутился ещё до сборки модуля.
Два полушага не равны шагу.
Верификация: amneziawg-go исчез из процессов контейнеров, интерфейс awg0
стал kernel-точкой (link/none, POINTOPOINT), handshake всех туннелей свежий,
egress бота цел.
Бонус: TimeNotSynced
Финальный прогон verify-чеков хозяйства поймал новый алерт — рассинхрон времени на финском хосте. При этом время было верным! «Время правильное» и «NTP синхронизирован» (NTP — Network Time Protocol) — разные состояния: после рестарта timesyncd ни разу не зафиксировал успешную синхронизацию.
Сырой UDP-зонд на десять строк Python (в обход timesyncd) показал: Cloudflare и
Google отвечают за 1–23 мс, а адреса, которые раздаёт debian.pool.ntp.org
с этого egress, — стабильно молчат. Порт не блокируется — молчат именно адреса
пула. Пул по определению не гарантирует конкретные серверы, так что дефолт
«верить пулу» на VPS с капризным egress — хрупкий выбор.
[Time]
NTP=time.cloudflare.com time.google.com
После применения через ту же роль — synchronized: yes на всех трёх.
Как это делалось
Процесс — самая переносимая часть. Любое постоянное изменение идёт в порядке IaC: ресёрч (read-only) → проверка гипотезы → фиксация в git → dry-run с диффом → применение → verify-чеки. Перед коммитом — ревью четырьмя независимыми критиками (архитектура, реализация, безопасность, знания); одна из их рекомендаций была аргументированно отклонена — перенос задачи хедеров в «AWG-роль» не решал исходную проблему, потому что WARNING сыпался с хостов, куда эта роль не применяется.
Итог сессии по чекам хозяйства: 47/50 против 46/50 до начала.
Результаты
- Все VPS на 6.12.116, по два ядра на диске, плитка «Нужен ребут» зелёная.
- Хедеры едут с ядром автоматически, модуль собирается триггером.
- Туннели AmneziaWG на kernel-интерфейсе, userspace-демон больше не живёт в контейнерах.
- Время синхронизировано с фиксированными серверами.
Ограничения и открытые вопросы
- Авто-чистку старых ядер UU между хостами стоит выровнять: на финском UU сам удаляет старое ядро, на российских — копится. Конфиги разъехались исторически.
- На двух хостах живёт легаси-пакет
amneziawg-dkmsиз стороннего PPA — до-homelab эпоха; судьбу решить.
Выводы
Цикл «UU поставил ядро → ребут по окну → проверка плитки» можно сделать скучным, но скучность надо достроить: хедеры метапакетом, модуль сразу после сборки, NTP на фиксированные серверы. И главный урок дня: «работает на userspace» — не факт архитектуры, а диагноз, который надо перепроверять по первоисточнику.