Привет! Меня зовут Максим Иванков, я развиваю школы робототехники и программирования для детей уже 9 лет. В прошлой статье я рассказывал, как за одну ночь у меня удалили двадцать две виртуальные машины, и там был важный момент, который я обещал раскрыть отдельно. Прод тогда спас второй сервер, который стоял в другом месте и по большому счёту существовал на случай отключения электричества.
Сейчас у меня два физических сервера на списанном железе, на них суммарно пятьдесят шесть виртуальных машин, и на этом живёт всё: онлайн-школа, платформа с курсами, мессенджер, игровой движок, майнкрафт-серверы для учеников и десяток микросервисов между ними. При этом я не системный администратор и не девопс, а всё это выросло из задачи «надо где-то хостить свою онлайн-школу».
Статья про то, как это устроено технически: что за железо, как оно поделено, как устроена репликация между серверами, что происходит при падении одного из них, и главное — какие вещи я сделал неправильно и понял это уже только на практике.
Сразу оговорюсь, что это совсем не эталонная архитектура и не образец для копирования, а просто работающая система, собранная одним человеком, у которой есть достаточно очевидные слабые места, и часть из них я честно разберу в конце.
Что за железо
Основной сервер, дальше буду называть его S1, это двухпроцессорная машина на Intel Xeon E5-2667 v4: два сокета по 8 ядер, итого 16 ядер и 32 потока на 3.2 ГГц, плюс 256 ГБ DDR4 ECC шестнадцатью планками по 16 ГБ.
Второй сервер, он же S2, это IBM System x3850 X5, четырёхсокетная платформа под E7, и в ней стоят четыре Xeon E7-4870 по 10 ядер — то есть 40 ядер, 80 потоков на 2.4 ГГц и 512 ГБ памяти.
По цифрам S2 выглядит мощнее, но это обманчиво: E7-4870 это Westmere 2011 года, а E5-2667 v4 — Broadwell 2016-го. На однопоточных задачах, а веб-бэкенд это в основном они, S1 заметно быстрее при вдвое меньшем числе ядер. Зато у S2 больше памяти, и это делает его удобным для роли резерва, где надо разом поднять копии всех машин.
Дисковая подсистема хорошо показывает, что значит «на списанном железе». В томе три разных SSD: Micron на 240 ГБ, Apacer на 240 и Samsung 870 QVO на 1 ТБ. Разные производители, разный ресурс, разные скорости, и собраны они не в RAID, а в один LVM-том почти на 1.5 ТБ, поверх которого работает thin-пул.
И вот это первая вещь, которую я сделал неправильно, причём понял далеко не сразу.
Смерть любого из трёх дисков убивает весь пул целиком, причём QVO это QLC, у которого ресурс перезаписи заметно ниже, и он же самый большой в пуле. То есть самый ненадёжный диск несёт наибольшую долю данных.
Почему так вышло, понятно: диски докупались по мере роста, каждый раз брался тот, который был доступен и по деньгам подходил. Правильно было бы делать RAID-1 из пар или хотя бы разнести критичное на отдельный надёжный том. Это в списке долгов, и он честно висит непогашенным.
Почему вообще два сервера
Причина, по которой появился S2, достаточно прозаичная и совсем не про отказоустойчивость в высоком смысле. В том месте, где стоит S1, периодически отключают электричество, иногда на сутки, а бесперебойник держит минуты, генератора нет, и при отключении на сутки прод просто ложится.
То есть изначально второй сервер решал ровно одну задачу — чтобы было куда переехать, когда пропадёт свет. Поэтому он и стоит в другом городе: смысла ставить резерв в ту же электросеть никакого.
По иронии именно он и стал целью атаки в июне, и он же в итоге всё спас, только наоборот: прод переехал не на него, а обратно на первый. Этот сюжет я подробно разбирал в предыдущей статье, тут важно другое — сама идея держать вторую площадку оказалась правильной, даже если исходный сценарий сработал в обратную сторону.
Сейчас роль S2 шире. Это одновременно резервная площадка под переключение, хранилище WAL-архивов и дампов, и место, где живут вещи, которым на проде делать нечего: стейджинг, дашборд акселератора, dev-контур API.
Как поделены машины
Обе площадки крутятся на Proxmox VE, на S1 это версия 8.3, а на S2 уже 9.1 — так вышло, что S2 переставлялся после инцидента и в итоге поехал на свежей ветке.
На S1 сейчас 25 виртуалок и все они запущены, а на S2 их 31, из которых работают 25 — остальные погашены намеренно, это холодные копии под переключение.
Разделение примерно такое. Отдельная машина под реверс-прокси, отдельная под каждый фронтенд, отдельная под каждый микросервис бэкенда, отдельная под панель управления игровыми серверами и отдельная под сами игровые серверы, отдельные под мессенджер, CDN и вспомогательные штуки вроде импортёра файлов и хранилища бэкапов.
Микросервисов школы девять: пользователи, достижения, комментарии, истории, уведомления, помощь, песочница для кода, серверы и админка. У каждого своя виртуалка, свой PostgreSQL на своём порту и своя база. Вторая версия платформы устроена иначе — там всё в докере на одной машине, 10 баз и общий стек.
Тут стоит честно сказать про сам подход. Классическая рекомендация — не давать каждому сервису по целой виртуалке, а паковать их в контейнеры на общих хостах, но у меня получилось ровно наоборот, и причина достаточно простая: я строил это по ходу того, как изучал, и «одна железка — один сервис» было единственной моделью, которая помещалась в голову целиком.
У такого подхода есть неожиданный плюс, который я оценил уже позже — изоляция получается настоящая: упавший сервис не утаскивает соседей, обновление ядра на одной машине не трогает остальные, а при взломе одного контура остальные не оказываются автоматически скомпрометированы. Минус понятный — накладные расходы и то, что 25 машин надо обновлять по отдельности.
Память, которой физически нет
Забавная цифра: суммарно виртуалкам на S1 выдано около 400 ГБ памяти при физических 256, и это никого не смущает.
Оверкоммит примерно в полтора раза, и работает он потому, что машины не потребляют выданное одновременно — реально занято около 140 ГБ, а остальное это буферы и свободное.
Ситуация типичная и рабочая, но край у неё есть: если несколько тяжёлых машин разом займут обещанное, начнётся своп, а своп на гипервизоре — худшее, что может случиться с отзывчивостью. Спасает предсказуемый профиль нагрузки: майнкрафт-нода со своими 133 ГБ единственный настоящий тяжеловес, остальные сидят в пределах пары гигабайт.
Отдельно про KSM: на парке из двух десятков машин с одинаковой Ubuntu он теоретически должен экономить достаточно прилично.
А по факту ksmtuned активен, но pages_sharing показывает ноль, потому что порог включения не достигнут и до него мы не дотягиваем. Пока памяти хватает, это никого не беспокоит, но экономии, на которую можно было бы рассчитывать при росте, сейчас нет.
Сеть и то, как трафик попадает внутрь
Внешний адрес у S1 всего один, и это обычный домашний канал с PPPoE: перед сервером стоит роутер, за ним гипервизор с адресом в локальной сети, а виртуальные машины сидят на бридже.
Наружу торчит буквально несколько портов, а всё остальное закрыто. Веб-трафик приходит на реверс-прокси, который живёт отдельной виртуальной машиной, и уже он раскидывает запросы по внутренним адресам сервисов в зависимости от домена, а сертификаты Let’s Encrypt тоже на нём и выписываются автоматически.
Для доступа к машинам сделан проброс портов: у каждой виртуалки свой внешний порт для SSH. Работает, но схема плохая, потому что правильнее держать бастион и ходить через него, а не выставлять два десятка портов наружу. Это ещё один пункт в списке долгов.
Мелочь про фаервол Proxmox: каждой машине он создаёт свой мост, поэтому в brctl show вместо одного бриджа висит два десятка fwbr, и поначалу это пугает, хотя на деле норма.
Репликация: как данные попадают на второй сервер
Здесь было больше всего граблей, поэтому распишу подробнее.
Задача формулируется достаточно просто: при потере S1 данные не должны потеряться. Почасовые дампы решают её лишь частично, потому что в худшем случае теряется час работы пользователей, а для платформы, где дети проходят уроки и получают награды, это вполне ощутимо.
Правильное решение тут — потоковая репликация, когда WAL уезжает на вторую площадку непрерывно, и тогда потеря измеряется уже секундами.
Пока работает промежуточный вариант, archive-only: WAL всех баз непрерывно уезжает на S2 в архив, но живых реплик там нет. Решение осознанное — сначала закрыть дыру с потерей данных, тёплые реплики следующим этапом.
На S1 крутится 31 systemd-юнит с SSH-туннелями, а на S2 для каждой базы работает pg_receivewal, который тянет журнал через слот репликации.
Со старыми сервисами всё достаточно прямолинейно: база слушает порт на адресе виртуалки, и туннель бьёт прямо в неё. А вот со второй версией платформы пришлось повозиться.
Базы там живут в докере и порт на хост не публикуют, то есть слушают только внутри докер-сети, и снаружи, даже с самого гипервизора, до них уже не достучаться.
Первое, что попробовал — пробросить docker-адрес на хост через DNAT. Не заработало: докер ставит DROP на FORWARD, плюс асимметричная маршрутизация — пакет уходит одним путём, ответ возвращается другим. Правило откатил.
Рабочее решение оказалось двухзвенным. Первое звено поднимается на самой виртуалке с контейнерами — оттуда docker-адреса видны напрямую: скрипт спрашивает у докера текущий адрес контейнера и поднимает на него ssh -L. Второе звено с гипервизора отдаёт этот локальный порт на S2 обратным туннелем. Цепочка длинная, но каждое звено делает одну понятную вещь, и оба оформлены systemd-юнитами с зависимостью и Restart=always.
Зачем скрипт каждый раз заново спрашивает адрес контейнера: докер выдаёт их динамически, и после перезапуска контейнер вполне может получить другой. Захардкодить — значит получить молча сломанную репликацию при ближайшем рестарте, причём заметить это можно очень нескоро.
Что ещё оказалось важным на практике:
-
Слот создавать непосредственно перед запуском приёмника. Если слот есть, а приёмник не поднят, WAL копится на источнике и рано или поздно забивает диск. Неактивный слот опаснее отсутствующего.
-
pg_hbaперечитывается без рестарта. Достаточноpg_reload_conf(), ронять базу на проде ради одной строки не нужно. -
Коннект приходит не с того адреса, который ожидаешь. Из докер-сети он виден как приходящий с гейтвея, так что разрешать надо подсеть, а не адрес контейнера.
Сейчас на S2 почти 4 ГБ архивов по двадцати базам, из которых больше половины занимает мессенджер, потому что там самый активный поток записи, а остальные держатся в пределах десятков мегабайт.
Бэкапы
Параллельно с репликацией работает более простая и куда более понятная вещь — почасовые дампы.
Каждый час systemd-таймер запускает скрипт, который обходит все базы, снимает pg_dump -Fc и складывает локально. Держится при этом 24 копии на базу, то есть сутки истории, а в цифрах это под 500 файлов и 18 ГБ.
Важная деталь — Persistent=true: если сервер был выключен в момент прогона, таймер отработает сразу после включения, а не станет ждать следующего часа. Для машины, которую периодически обесточивают, это не мелочь.
После локального сохранения весь каталог зеркалируется на S2 обычным rsync, так что на втором сервере лежит ровно то же самое со свежестью в пределах часа.
И вот с этим зеркалом связана главная дыра во всей конструкции.
Географически тут всё в порядке: серверы стоят в разных городах, так что пожар или затопление одну площадку заберут, а вторая останется. Проблема в другом — обе копии живут под моим администрированием, на одинаковом стеке и с одними и теми же ключами доступа. Кривая команда, разошедшаяся по обеим площадкам, или скомпрометированный доступ забирают их одновременно, и я это уже проходил в июне.
Поэтому изначально схема задумывалась с выгрузкой в объектное хранилище, чтобы третья копия лежала вообще вне моего контура и её нельзя было снести моими же руками. До этой части просто не дошли руки, и получилось, что репликацию и почасовые дампы я довёл до конца, а последний рубеж остался на бумаге.
Причём стоит это копейки, потому что такой объём в любом объектном хранилище тянет на сотню рублей в месяц. Вопрос тут даже не в деньгах, а в том, что задача не горит ровно до того дня, когда она понадобится, и это ближайшее, что я собираюсь закрывать.
Что происходит при падении
Переключение между площадками у меня ручное, и это тоже осознанное решение.
Автоматический failover я пробовал, и он дважды ронял прод буквально на ровном месте — сеть моргала, скрипт решал, что основная площадка мертва, и начинал переезд. Восстанавливаться после ложного срабатывания в итоге дольше и больнее, чем переключить руками.
Механика такая. Домены живут в Cloudflare в режиме dns-only, и есть скрипт, который одной командой перекидывает 29 A-записей в семи зонах с одной площадки на другую. Три режима: показать состояние, показать план, применить. Лежит на обоих серверах — переключать придётся как раз тогда, когда основной недоступен.
В режиме dns-only записи переключаются практически мгновенно, без ожидания сброса кэша на прокси, а четыре записи закреплены за S2 намертво, и скрипт их вообще не трогает.
Но DNS — только часть работы. Записи направляют трафик, а дальше нужно, чтобы на S2 были подняты сами сервисы, настроен реверс-прокси под все домены и проброшены порты для того, что ходит не по HTTP — у меня это TURN мессенджера и порты майнкрафта.
Поэтому у меня написан пошаговый регламент: как убедиться, что площадка действительно умерла, а не кажется, в каком порядке переключать, что проверить после. Первый шаг там — именно проверка, потому что переключение при живом основном сервере создаёт расщепление, когда часть пользователей пишет в одну базу, а часть в другую.
Расщепление — это, к слову, совсем не теория: один раз я его словил, и разъезжающиеся данные потом пришлось сводить руками, что заняло несоизмеримо больше времени, чем сама авария.
Что запускается само
Отдельная тема, которую я целенаправленно проверял: переживает ли всё это перезагрузку. Для сервера, которому периодически выключают свет, вопрос не праздный.
Проверка показала, что да, переживает. Все виртуальные машины помечены на автозапуск, репликационные туннели оформлены юнитами с автоперезапуском и ожиданием сети, таймер бэкапов идёт с флагом наверстывания пропущенного, а таймеры аудита версий и проверки здоровья баз тоже включены.
Одна деталь сначала выглядела как поломка: у всех туннельных процессов PPID равен единице. На первый взгляд похоже на осиротевшие ручные запуски от отладки, а на деле это ровно то, как выглядит нормальный сервис. Чуть было не поубивал их как мусор, хорошо что сначала посмотрел.
Ещё был скрипт, который чинил реплики после миграции достаточно радикально: docker compose stop, rm -rf тома с данными и пересоздание с нуля. Как разовый инструмент он был вполне нужен, а как включённый сервис уже смертельно опасен, потому что при срабатывании снёс бы здоровую реплику. Сейчас он под systemctl mask и вызывается только руками.
Мониторинг
Тут у меня всё скромно, и я это признаю.
Раз в час проходит проверка здоровья каждой базы, раз в час же аудит версий сервисов на проде, плюс есть отдельный дашборд, который следит за дисками. Всё это шлёт уведомления, когда что то отклоняется от нормы.
Чего нет — так это нормальной системы метрик с историей, вроде связки Prometheus с Grafana. То есть я знаю, что сломалось прямо сейчас, но не могу посмотреть, как менялась нагрузка за месяц и с чего именно началась деградация, так что когда что то тормозит, разбираться приходится по факту, а не по графику.
Это следующее, что я собираюсь делать, и это, пожалуй, самый заметный пробел в текущей конструкции.
Чему всё это меня научило
Если собрать выводы, получится примерно такой список.
Разнородные диски в одном томе без избыточности — это мина замедленного действия. Работает такое годами, а потом умирает самый дешёвый диск и забирает всё с собой. Если начинаете так же — хотя бы разнесите критичные данные на отдельный надёжный том.
Две копии под одним администрированием — это полторы копии. Разные города спасают от пожара, но не от общей ошибки: у обеих площадок один хозяин, один стек и одни ключи. Пока копия не уехала туда, где её нельзя снести своей же командой, резерв закрыт не до конца.
Автоматика для переключения площадок опаснее, чем кажется. Стоимость ложного срабатывания в итоге выше стоимости пятнадцати минут ручной работы, по крайней мере на моих масштабах.
Динамические адреса нельзя записывать в конфиг. Всё, что докер выдаёт сам, надо спрашивать у него каждый раз, иначе получите молча сломанную репликацию после ближайшего перезапуска.
Неактивный слот репликации хуже отсутствующего. Он честно копит журнал на источнике и постепенно забивает диск, а вы об этом узнаёте ровно тогда, когда база встанет.
Состояние системы надо смотреть на самой системе, а не в своих записях про неё. Любая схема и любое описание отстают от реальности ровно с того момента, как их дописали, поэтому цифры для этой статьи я брал командами с работающих серверов.
И общий вывод, который для меня оказался главным. Инфраструктура, выросшая вместе с проектом, всегда несёт следы того, как она росла — от разнородных дисков до схемы «одна виртуалка на сервис». Часть этих решений неоптимальна, и я про них знаю. Но система, которую понимает один человек целиком, чинится за час, а система, собранная правильно, но непонятно, чинится днями. Пока я один — выбор в пользу первого.
Другие статьи серии
-
Через два месяца первого класса мы забрали сына на семейное обучение
-
Учителей физики стало вдвое меньше. ЕГЭ по ней сдают 37% от плана приёма в вузы
-
Проблему репетиторства сформулировали в 1984 году. Ответа до сих пор нет
-
Только 5% детей уходят из школы по здоровью. Кто и зачем уходит на самом деле
Спасибо, что дочитали. Если у вас похожая конструкция дома — интересно, как вы решали вопрос с внешней копией и переключением, потому что у меня оба эти места до конца не закрыты.
ссылка на оригинал статьи https://habr.com/ru/articles/1074564/