Это выглядело как архитектурная проблема, а оказалось проблемой порядка шагов в деплое.
Выкладка шла по обычному сценарию: скрипт собирает образы, поднимает контейнеры, следом гоняет тесты. И вот в этот момент тесты открывают векторную базу - ту самую, которую в эту же секунду уже держит открытой только что стартовавший сервис. Дальше как повезет: иногда падает тест, иногда - не тест.
Я смотрела на симптом и видела «архитектуру»: два процесса делят одно хранилище напрямую - значит, хранилище надо выносить. Правильный (и дорогой) ответ из учебника. И, как выяснилось, ответ не на тот вопрос.
Ниже - разбор: как все было устроено, почему «вынести базу в отдельный сервис» оказалось оверкиллом, что мы поменяли вместо этого и как отличить случай, где хватает смены порядка операций, от случая, где без отдельного сервиса действительно не обойтись.
Ситуация: embedded-база и два процесса
Система - два контейнера:
- веб-сервис, который отвечает на запросы
- бот с планировщиком: он и общается с людьми, и по расписанию выполняет фоновые задачи.
Оба работают с одной векторной базой знаний.
Важно не то, что база «векторная», а то, как она подключена.
Она работает во встроенном режиме (embedded). То есть база - не отдельная программа, к которой ходят по сети, а каталог с файлами на диске. Библиотека внутри процесса открывает этот каталог и работает с ним напрямую - как приложение с файлами. Никакого сервера посередине нет.
И да: оба процесса открывают один и тот же каталог. В любом архитектурном обзоре это подсвечивают красным.
flowchart LR
P1["Процесс: веб-сервис"] --> D[("Каталог с файлами базы")]
P2["Процесс: бот"] --> D
P3["Тесты при выкладке"] -.->|"третий, посторонний"| D
N["Сетевого сервиса базы нет"] -.- DПочему напрашивалось «правильное» архитектурное решение
Учебник отвечает без запинки: поднимите базу отдельным сервисом и ходите в нее по сети. Один владелец данных, остальные - клиенты, конкурентный доступ разруливает сама база.
Возразить нечего. Но у «правильного решения» был ценник:
- отдельная работа: переписать подключение, перенести данные, убедиться, что поиск отвечает так же
- отдельный контейнер: его надо собирать, обновлять, наблюдать
- отдельная точка отказа: лег сервис базы - легли оба процесса разом (хотя раньше они могли падать независимо).
И вот здесь стоит задать себе неприятный вопрос: какую проблему я закрываю этими тремя пунктами?
Ответ оказался таким же неприятным: гипотетическую.
Два живых процесса, читающих одну базу, теоретически могли столкнуться - но на практике не сталкивались.
А ломалось другое: во время деплоя тесты открывали базу поверх живого процесса. Не «два сервиса конкурируют под нагрузкой», а «во время выкладки в каталог заходит третий, посторонний». Сетевой сервис, конечно, вылечил бы и это - просто как побочный эффект, ценой всего списка выше.
flowchart TB
subgraph WAS["Было"]
W1["Поднять сервис и бота"] --> W2["Прогнать тесты"] --> W3["Тесты открывают базу<br/>поверх живых процессов"]
end
subgraph NOW["Стало"]
N1["Тесты в одноразовом<br/>контейнере"] --> N2["Контейнер погашен"] --> N3["Поднять сервис и бота"]
end
WAS ==> NOWЧто сделали вместо этого
Мы поменяли порядок шагов в скрипте выкладки.
Было: поднять рабочие процессы, потом запустить тесты. Тесты стартовали, когда сервис и бот уже держали базу открытой.
Стало: тесты гоняются в отдельном одноразовом контейнере до подъема рабочих процессов. Контейнер живет ровно столько, сколько идут тесты, и умирает. Если тесты не прошли - рабочие процессы просто не поднимаются.
Результат: базу больше никто не открывает поверх живого процесса. Не потому что мы запретили это «механизмом», а потому что момент, в который это было возможно, больше не наступает.
Embedded-режим остался как был. Мы не трогали подключение - ни строчки. Изменился только деплой.
И мне кажется важным это фиксировать словами: симптом выглядел как «архитектура хранилища», а причина сидела в «порядке шагов деплоя».
Когда так можно - и когда нельзя
Чтобы этот прием не превратился во вредный совет, нужна честная оговорка.
Это работает, потому что:
- нагрузка небольшая - процессы не толкаются в базе постоянно
- записи редкие: переиндексация идет по расписанию, а не непрерывно.
Читать одновременно обычно не страшно. Опасно - когда кто-то пишет, пока другой читает или пишет.
Как только запись становится интенсивной и параллельной, фокус перестает работать. Никакой порядок шагов в скрипте не спасет от двух процессов, которые одновременно перестраивают индекс: они делят каталог на диске, и договориться им негде.
Тогда сетевой сервис становится не «правильным по учебнику», а обязательным - потому что нужен один владелец данных, который сериализует записи.
Признаки, что вы еще в безопасной зоне:
- писатель ровно один, пишет по расписанию, и вы это расписание знаете
- все остальные только читают
- вы можете назвать поименно каждый процесс, который открывает каталог.
Признаки, что пора выносить базу в отдельный сервис:
- писателей больше одного или запись запускается событием от пользователя
- вы собираетесь масштабировать процессы копиями (вторая копия сервиса = второй держатель каталога)
- вы уже ловили битый индекс и лечили его переиндексацией.
Дешевое операционное решение - это не «навсегда». Это «пока выполняются условия». И эти условия стоит прямо записать рядом со скриптом деплоя, чтобы через год кто-нибудь (возможно, вы сами) понимал, на чем держится конструкция.
Отдельный урок: документация описывала то, чего не было
Пока я копалась, всплыло забавное: в описании системы одно время значился отдельный контейнер базы. Его никогда не существовало. Не «был и убрали, а в документации забыли поправить» - его не было изначально.
Вывод скучный, но полезный: проверять надо код и список реально запущенных процессов, а не описание. Описание - это чья-то версия намерений. Оно не участвует в деплое, ничего не открывает и ничего не блокирует.
Применить у себя
Что проверить на своей системе (практически)
По порядку:
- Убедитесь, какой режим подключения: в коде клиента смотрите, что передают - путь к каталогу на диске или хост+порт. Путь = embedded.
- Выпишите всех, кто открывает каталог. Не по документации - по списку контейнеров и volume-mount'ов. Одна и та же папка, примонтированная в два контейнера, - это два держателя.
- Не забудьте «гостей»: тесты, миграции, разовые скрипты обслуживания, ручные запуски «посмотреть, что там в базе». Они часто ломают систему внезапно - просто потому что никто не считает их частью архитектуры.
- Прочитайте деплой-скрипт сверху вниз и отметьте моменты, когда одновременно живы два (или больше) потребителя базы. Обычно это узкое окно в несколько секунд - и именно там все происходит.
- Отдельно ответьте на вопрос «кто и когда пишет». Один писатель по расписанию - у вас есть право на дешевое решение. Несколько писателей - возвращайтесь к пунктам про отдельный сервис.
Как понять, что получилось
Критерий в одной фразе:
в каждый момент времени каталог базы открыт ровно теми процессами, что и в обычной работе, и во время выкладки нет ни одной секунды, когда их становится больше.
Проверять так:
- прогоните выкладку несколько раз подряд (если раньше падало «иногда», один успешный прогон ничего не доказывает)
- убедитесь, что провал тестов больше не оставляет систему в странном состоянии: тесты не прошли, рабочие процессы не поднялись, работает старая версия
- проверьте, что одноразовый контейнер с тестами действительно исчезает после прогона, а не остается висеть и держать каталог
- через месяц вернитесь и перечитайте условия «когда так можно». Если хотя бы одно перестало выполняться - вы снова в архитектурной задаче, и теперь она настоящая.
И последнее: дешевое решение - не повод не понимать дорогое. Я по-прежнему знаю, как вынести базу в отдельный сервис, и знаю, при каком условии это придется сделать. Разница лишь в том, что делать это сейчас означало бы заплатить за проблему, которой у нас нет.