Перейти к содержанию

Схемы данных

Кластер поднят. Три ноды, k3s, HA etcd — всё работает и ждет. Только пустой. Данных нет.

Первая мысль, которая приходит в голову в такой ситуации — взять то, что есть, перенести как есть, а потом разберёмся по месту: добавим полей, уберём лишних, подгоним под новые требования. Звучит разумно — и именно поэтому я не так быстро от этой идеи отказался.


В старой архитектуре данные хранятся в виде JSON-файлов: одна папка на тип сущности, один файл на каждый экземпляр. Пользователь — users/1.json. Устройство — devices/<uuid>.json. Маршрут — routes/entry_<entry-IP>_core_<core-IP>.json. И так далее.

Когда я это проектировал, уже держал в голове будущий переход в KV-хранилище — отсюда и выбор JSON как формата. Поэтому первый вариант миграции был именно таким: JSON-блоб целиком в один ключ.

/sigilgate/users/1  =  {"id": 1, "username": "<USERNAME>", "status": "active", ...}

Привезли файловую систему — положили в etcd. Разница только в том, что ключ теперь не имя файла, а путь.

Второй вариант: вынести первичные ключи из JSON-содержимого в путь и разбить каждый объект на отдельные поля. Путь становится идентификатором, значение — только данными.

/sigilgate/users/1/username  =  "<USERNAME>"
/sigilgate/users/1/status    =  "active"

У первого варианта есть своя логика. Объект всегда рядом — прочитал один ключ, получил всё. Схема простая, понятная, близкая к тому, что уже есть. Ничего не нужно придумывать заново.

Но у него есть неприятная особенность. Каждое обновление поля — это read-modify-write: прочитать объект, изменить нужное поле в памяти, записать обратно. В системе, где gRPC-пути ротируются каждый час, а Entry-поды при каждом клиентском подключении что-то ищут в базе — это не просто накладные расходы, это потенциальная гонка. И первичный ключ, кстати, продолжает дублироваться: "id": 1 в файле 1.json нигде не исчез — он просто переехал из имени файла в часть пути.

Второй вариант снимает эти проблемы. Обновление одного поля — один атомарный PUT, без чтения. Частичное чтение — читаешь ровно то, что нужно, не тащишь весь объект. Первичный ключ живёт в пути и нигде больше не дублируется. Но взамен — чтение целого объекта становится range-запросом, а создание объекта требует транзакции, чтобы не оставить в базе полусобранное состояние.

После разбора всех сущностей выбор стал очевидным. Самые частые операции в системе — обновление одного поля, поиск по одному ключу, проверка одного статуса. Читать объекты целиком — редкость. Они небольшие: range-запрос тут не проблема.


Но «какой вариант» оказался не самым интересным вопросом. Интереснее другой: а всё ли из того, что есть, вообще нужно тащить дальше?

Миграция — это не просто смена формата хранения. Это повод остановиться и спросить: «А нужно ли тащить в новую базу весь старый хлам?».

Я немного почистил данные перед переездом. Убрал поля, которые закладывались «на будущее», до которого дело так и не дошло. Убрал то, что в какой-то момент стало устаревшим, когда поменялась схема. Убрал аннотации и служебные артефакты, которые не несут функциональной нагрузки. Хлам есть в любой живой системе — временами надо перетряхивать и выбрасывать всё, что оказалось не нужным, чтобы легче было идти вперёд.


Параллельно — я принял более важное решение. Я разделил данные на несколько независимых слоёв.

Административный слой. Всё, что касается серверов как железа: hostname, локация, провайдер, технические характеристики, диагностика, сертификаты. Это зона ответственности Keyholder — инструмента оператора, о котором я уже писал. SigilGateApp этих данных не знает и знать не должен. Перед миграцией этот срез просто удалил из базы.

Коммуникационный слой. Часть пользователей хочет регистрироваться, получать техническую поддержку, быть в курсе новостей — для этого нужны контакты, имена, способы связи. Но это деанонимизирующая информация, и держать её рядом с операционными данными неправильно. Более того, для многих само членство в публичной группе — неприемлемый вариант. Поэтому всё, что связано с коммуникацией — переписка в поддержке, рассылки, оповещения — ушло в отдельное пространство имён /public/. Отдельный слой, который в будущем может переехать на отдельный кластер.

Операционный слой. Всё, что нужно для работы сети: ноды, маршруты, пользователи, устройства. Только обезличенные данные — никаких контактов, никаких явных идентификаторов. Telegram ID больше не хранится. Прямой связи между слоями в базе нет.


В результате данные поменялись довольно существенно.

Роли и домены перестали быть отдельными сущностями — они стали атрибутами нод. Вместо /sigilgate/roles/core/<ip> теперь /sigilgate/nodes/<ip>/role = core, а домен — просто поле той же ноды. Устройства переехали под пользователей: /sigilgate/users/<id>/devices/<uuid>/. Маршруты полностью пересмотрены: старая схема Entry-IP × Core-IP потеряла смысл — теперь это UUID устройства → Core-нода, прямой lookup, который Entry-под делает при каждом клиентском подключении.


После того как схема определилась, остальное было уже делом техники. Я написал два скрипта: один трансформирует и выгружает данные из старой базы в JSON-снапшот, второй загружает снапшот в etcd. В результате — 342 ключа, готовы к загрузке.

Попутно написал и backup-сервис. Схема та, что и планировалась изначально: etcd — основное хранилище, Git-репозиторий — резервная копия. Периодический dump из etcd в JSON, коммит в репозиторий.

У etcd есть собственный механизм снапшотов — и он тоже будет работать. Но Git-бэкап решает другую задачу. Снапшот etcd — это бинарный слепок всего кластера: идеально для полного восстановления после катастрофы, но бесполезно, если нужно понять, что изменилось три дня назад или откатить одну запись. Git-бэкап хранит данные в человекочитаемом текстовом формате — можно открыть diff и увидеть конкретное изменение, точечно восстановить нужную запись, не трогая остальное. Два инструмента — два разных сценария восстановления.

Загрузка и валидация — следующий шаг.