← Инженерные заметки

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

Неоновая иллюстрация: два VPS (в России и Финляндии), соединённые VPN-туннелем
по UDP, роутер и облако интернета, два ноутбука с замерами iperf3 и хмурый
кот-админ за терминалом рядом со стопкой книг по MikroTik и сетям

Если инфраструктура работает, это ещё не значит, что она работает так, как ты задумал. В этот раз всё началось с одной жёлтой плитки на дашборде — а закончилось чисткой ядер, починкой сборки 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» — не факт архитектуры, а диагноз, который надо перепроверять по первоисточнику.

Источники

Обсудить в Telegram

Расскажите о задаче

Опишите задачу или проблему в двух словах. Отвечу в Telegram и честно скажу, чем могу быть полезен.