Поставка софта Многотенантность: изоляция, данные и стоимость обслуживания
0%

Многотенантность: изоляция, данные и стоимость обслуживания

Многотенантность: изоляция, данные и стоимость обслуживания

Компания продаёт систему учёта заявок. 380 организаций-клиентов, один экземпляр приложения, одна база. В понедельник приходит письмо от юриста крупного клиента: «Согласно нашей политике информационной безопасности, данные компании должны храниться в отдельной базе данных, к которой не имеют доступа другие ваши клиенты. Просим подтвердить выполнение этого требования до 1 числа следующего месяца».

Клиент платит 14 % годовой выручки. Отказать нельзя. Согласиться, не посчитав, — тоже: через полгода такое же письмо придёт от второго, третьего и десятого, и однажды окажется, что вы обслуживаете не один продукт, а сорок его копий с разными версиями схемы, разными окнами обновления и разными инцидентами.

Многотенантность — это не архитектурная мода и не «правильный способ писать SaaS». Это механизм разделения постоянных издержек между покупателями. Один релиз обслуживает всех. Одно дежурство обслуживает всех. Одна миграция обслуживает всех. Ровно поэтому подписка может стоить 30 USD в месяц, а не 30 000 — цена деления, а не цена разработки, определяет нижнюю границу прайс-листа (модели поставки).

Обратная сторона того же механизма: одна ошибка тоже обслуживает всех. И одна забытая строчка where tenant_id = ... обслуживает всех сразу.

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

Тенант — это не пользователь и не аккаунт

Тенант — это единица изоляции, единица тарификации и единица договора одновременно. Если эти три роли расходятся, боль начинается сразу и не заканчивается никогда.

  • Пользователь — человек с логином. Один человек может работать в трёх тенантах (подрядчик, обслуживающий три компании) и должен переключаться между ними без выхода из системы.
  • Аккаунт (учётная запись) — техническая сущность аутентификации. Она про «кто вы», а тенант — про «в чьих данных вы находитесь». Смешивать их — значит потом переписывать авторизацию (модели авторизации).
  • Организация в интерфейсе — то, что видит клиент. Она может не совпадать с тенантом: холдинг из пяти юрлиц может быть одним тенантом с пятью «пространствами» или пятью тенантами с общим биллингом.

Три вопроса, ответы на которые нужно зафиксировать до первой строчки кода, потому что менять их потом дорого:

  1. Кому выставляется счёт? Тенанту. Если счёт выставляется чему-то другому — например, «проекту», — то у вас два разных дерева, и в биллинге (глава 07) появится вечный источник расхождений.
  2. Что происходит при разделении и слиянии? Клиента купили, два тенанта надо слить в один. Или наоборот: филиал отделился, тенант надо разрезать. Операция «разрезать тенант» без сквозного tenant_id в каждой строке физически невыполнима: вы просто не сможете сказать, какие данные чьи.
  3. Может ли объект принадлежать двум тенантам? Правильный ответ по умолчанию — нет, никогда. Как только появляется «общий справочник, редактируемый двумя клиентами», модель изоляции ломается и её нельзя починить политиками на уровне базы.

Обратите внимание на две неочевидные детали этой схемы, каждая из которых экономит месяцы.

Первичный ключ везде составной: (tenant_id, id). Не потому, что так быстрее (хотя индексы, начинающиеся с tenant_id, действительно дают локальность, см. планы запросов), а потому, что внешний ключ тогда тоже составной: COMMENT (tenant_id, ticket_id) → TICKET (tenant_id, id). Такой ключ структурно запрещает комментарий одного тенанта на заявке другого. Не «мы проверяем в коде» — база физически не даст вставить такую строку. Это самая дешёвая изоляция из существующих: она стоит четырёх лишних символов в DDL.

У COMMENT есть свой tenant_id, хотя его можно было бы вывести через ticket_id. Денормализация здесь намеренная: без неё нельзя навесить политику доступа на таблицу комментариев, нельзя разрезать тенанта одним where, нельзя удалить тенанта, не обходя граф связей.

Арифметика: почему вообще один экземпляр на всех

Считаем на реальном порядке величин (середина 2026 года, средний по цене публичный облачный провайдер; конкретные цифры смотрите в своём счёте, здесь важен масштаб — подробнее про цену облака в отдельной главе трека DevOps).

Общий стенд на 380 тенантов: три сервера приложений по 60 USD, кластер СУБД с репликой — 600 USD, кэш — 80 USD, брокер — 60 USD, наблюдаемость — 250 USD. Итого около 1170 USD в месяц, то есть примерно 3 USD на тенанта плюс переменная часть (трафик, хранилище, вычисления под нагрузкой).

Минимальный работающий стенд на одного тенанта: маленький контейнер приложения, самая дешёвая управляемая база, доля балансировщика — 70–120 USD в месяц, и это при полной незагруженности. Умножаем на 380: около 36 000 USD в месяц против 1170. Тридцатикратная разница — и это только счёт от провайдера.

Настоящая разница в другом. Инфраструктурный счёт — самая маленькая и самая честная часть цены. Дальше идёт то, что не видно в консоли биллинга:

Операция Пул (один экземпляр) Силос (экземпляр на тенанта, 380 штук)
Релиз одна выкладка 380 выкладок, из них 3–8 упадут
Миграция схемы одна, 40 секунд 380 запусков, частичные отказы, окно в несколько часов
Диагностика инцидента один набор дашбордов «а на каком стенде это было?»
Обновление зависимости с CVE один прогон конвейера флот с разбросом версий
Резервное копирование и проверка восстановления один регламент 380 регламентов или один, но с 380 проверками
Дежурство один список алертов алерты умножаются на число стендов
Новый тенант строка в таблице, секунды развёртывание, минуты-часы, иногда вручную

Ключевая мысль всей главы: стоимость обслуживания растёт не с числом серверов, а с числом уникальных состояний, которые вам нужно держать в голове и в конвейере. Сто одинаковых стендов дешевле десяти разных. Это то же наблюдение, что лежит в основе платформенного подхода к окружениям и стоимости платформы.

Спектр изоляции: пять ступеней, а не два варианта

Разговор «мультитенантно или нет» почти всегда бесполезен, потому что вариантов не два. Граница между общим и своим ездит по слоям, и на каждом слое её можно поставить отдельно.

Спектр изоляции тенантов: семь слоёв инфраструктуры и пять моделей многотенантности

Разберём каждую ступень по трём вопросам: что получает покупатель, во что это обходится поставщику и какое обязательство создаёт.

Ступень 1. Пул: общая база, tenant_id в каждой строке

Покупателю: самая низкая цена, мгновенное подключение, все функции сразу, обновления без окон.

Поставщику: самая дешёвая эксплуатация (3 USD на тенанта в примере выше) и самая дорогая инженерная дисциплина. Каждый запрос обязан быть ограничен тенантом; проверка не может быть «на совести разработчика», её нужно вынести в инвариант — политики уровня строк, обёртка репозитория, тест, перебирающий все ручки.

Обязательство: вы обещаете логическую изоляцию, а не физическую. Это нормальное, честное и продаваемое обещание — при условии, что вы можете его предъявить: описание модели, отчёт о тестировании, журналы доступа. Если клиент спрашивает «а где гарантия», ответ «у нас код правильный» не работает; ответ «вот политика в СУБД, вот тест, который её проверяет, вот отчёт пентеста» — работает.

Ступень 2. Схема на тенанта в общей базе

Покупателю: почти ничего сверх первой ступени. Это ступень «для галочки в анкете безопасности».

Поставщику: миграции умножаются на число схем, а планировщик СУБД начинает страдать: несколько тысяч схем по полсотни таблиц дают сотни тысяч объектов в системном каталоге, и это заметно бьёт по времени соединения и по автовакууму в PostgreSQL. Реальный потолок — сотни, редко тысячи схем, дальше нужно резать на кластеры.

Обязательство: «данные разделены на уровне схемы». Учтите: с точки зрения атакующего, получившего SQL-инъекцию под общей ролью, разница между схемой и её отсутствием невелика. Продавать это как «полную изоляцию» некорректно.

Ступень 3. База на тенанта в общем кластере

Покупателю: отдельная резервная копия, отдельное восстановление на точку во времени, возможность выгрузить «свою базу» целиком, отдельные права доступа.

Поставщику: заметный шаг вверх. Пул соединений становится проблемой (соединения нельзя переиспользовать между базами так же свободно), появляется задача маршрутизации «тенант → база», а миграции превращаются в флотовую операцию. Зато восстановление одного тенанта после того, как он сам себе испортил данные, становится тривиальной операцией, — а это, по опыту, самая частая просьба.

Обязательство: отдельная точка восстановления и отдельная выгрузка. Обычно этого достаточно для 90 % корпоративных требований, включая то письмо от юриста в начале главы.

Ступень 4. Свой экземпляр приложения и своя СУБД

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

Поставщику: дорого — и главным образом не деньгами, а разбросом. Как только у тенанта появляется «своё окно обновления», у вас появляется флот с разными версиями, и каждый инцидент начинается с вопроса «на какой версии». Это прямо конфликтует с моделью непрерывной поставки (безопасные релизы).

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

Ступень 5. Силос: свой облачный аккаунт, своя сеть

Покупателю: отдельная юрисдикция, отдельные ключи шифрования (вплоть до ключей, которыми управляет сам клиент), сетевая изоляция, отдельный отчёт аудитора, иногда развёртывание в аккаунте самого клиента.

Поставщику: это уже не совсем SaaS. По сути вы поставляете управляемую установку, и экономика ближе к self-hosted. Каждый новый тенант требует автоматизированного развёртывания «с нуля», иначе через десять клиентов вы утонете. Требование «сначала полная автоматизация развёртывания, потом первый silo-клиент» — не перфекционизм, а условие выживания.

Обязательство: максимальное. Здесь клиент вправе требовать доказательств почти на всё, и почти все требования проверяемы.

Квадрант «не бывает» — важный: физической изоляции по цене логической не существует. Каждый раз, когда отдел продаж обещает «отдельный контур» без изменения цены, кто-то в инженерной команде оплачивает это своим временем.

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

Модель данных и инвариант, который нельзя обойти

Проверка в контроллере — не изоляция. Изоляция — это свойство хранилища, которое сохраняется, даже если разработчик написал самый ленивый код. В PostgreSQL такой механизм называется row-level security и выглядит так:

-- Роль приложения. Важно: НЕ владелец таблиц и НЕ суперпользователь.
create role app_runtime login;

create table ticket (
    tenant_id uuid not null,
    id        uuid not null,
    subject   text not null,
    primary key (tenant_id, id)
);

-- Включаем политики. FORCE обязателен: без него владелец таблицы
-- (а под ним часто ходят миграции и, по недосмотру, само приложение)
-- политики просто не увидит и прочитает всё.
alter table ticket enable row level security;
alter table ticket force row level security;

create policy tenant_isolation on ticket
    using      (tenant_id = current_setting('app.tenant_id', true)::uuid)
    with check (tenant_id = current_setting('app.tenant_id', true)::uuid);

grant select, insert, update, delete on ticket to app_runtime;

Три детали, каждая из которых была чьим-то инцидентом.

force row level security. Без него владелец таблицы обходит все политики. Приложение, подключающееся под ролью-владельцем (типовая ситуация, когда миграции и рантайм используют одни и те же учётные данные), получает полный доступ ко всем тенантам, а тесты при этом зелёные — потому что тесты тоже ходят под владельцем.

with check, а не только using. using фильтрует чтение, with check запрещает записать строку с чужим tenant_id. Без второй половины изоляция односторонняя: прочитать чужое нельзя, а подложить чужому — можно.

Третий аргумент true в current_setting. Без него отсутствие переменной вызовет ошибку. С ним — вернёт NULL, сравнение даст NULL, политика не пропустит ни одной строки. Второй вариант безопаснее: «ничего не видно» лучше, чем «упало пятьсот с трассировкой стека», и гораздо лучше, чем «видно всё».

Ловушка пула соединений

Дальше начинается место, где ломаются почти все. Контекст тенанта живёт в переменной сессии, а сессия в пуле переиспользуется.

Лечится это тремя решениями сразу, и ни одного из них недостаточно поодиночке:

  1. SET LOCAL внутри явной транзакции вместо SET. SET LOCAL откатывается по завершении транзакции, поэтому «протечь» в следующий запрос физически не может.
  2. Режим пула на уровне транзакций (в pgbouncer это pool_mode = transaction). В режиме сессий соединение закрепляется за клиентом надолго и переменные живут дольше запроса; в транзакционном режиме SET без LOCAL вообще не работает предсказуемо — и это хорошо, потому что ошибка становится видимой сразу, а не через полгода.
  3. Единственная точка входа в базу. Один модуль, который открывает транзакцию, выставляет контекст и отдаёт наружу уже готовое соединение. Всё остальное приложение не имеет доступа к «сырому» пулу. Это архитектурное ограничение, а не соглашение — соглашения не переживают найма пятого разработчика.

Тест, который ловит то, что не ловит ревью

Инвариант должен проверяться автоматически, иначе он превращается в абзац документации. Два теста стоят своих денег и пишутся за день.

Первый — структурный. Он перебирает каталог базы и падает, если кто-то создал таблицу с tenant_id и забыл политику:

-- В CI: этот запрос обязан вернуть ноль строк.
select c.relname as table_without_isolation
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and exists (
      select 1 from pg_attribute a
      where a.attrelid = c.oid and a.attname = 'tenant_id' and a.attnum > 0
  )
  and (not c.relrowsecurity or not c.relforcerowsecurity);

Второй — поведенческий. Он проходит по всем зарегистрированным маршрутам HTTP-API и обращается к чужим объектам от лица соседа:

import pytest

# Два тенанта заранее наполнены одинаковым набором объектов.
# Для каждого маршрута знаем, какой объект в него подставить.
@pytest.mark.parametrize("method,path_template", ROUTES)
def test_cross_tenant_access_is_impossible(client, method, path_template, seeded):
    """Тенант A пытается достать объект тенанта B по прямой ссылке."""
    path = path_template.format(**seeded.tenant_b.ids)
    response = client.request(method, path, headers=seeded.tenant_a.auth)

    # 404, а не 403: существование чужого объекта — тоже утечка.
    # По коду ответа не должно быть видно, есть такой идентификатор или нет.
    assert response.status_code == 404, (
        f"{method} {path}: тенант A получил {response.status_code} "
        f"на объект тенанта B"
    )

Параметризация по списку маршрутов — принципиальный момент. Тест, написанный руками для десяти ручек, устареет на одиннадцатой; тест, берущий маршруты из самого приложения, автоматически покрывает всё новое и падает ровно в тот день, когда кто-то добавил ручку без проверки. Про то, как встроить такие проверки в конвейер, — тесты в CI; про то, как думать об этом как об угрозе, — моделирование угроз.

Шесть мест, где теряется tenant_id

Проверка на входе есть почти у всех. Утечки происходят там, где обращение к данным уже не выглядит как HTTP-запрос.

Шесть типовых мест утечки контекста тенанта: кэш, фоновые задачи, пул соединений, объектное хранилище, поиск, отчёты

Кэш. Ключ вида ticket:{id} вместо t:{tenant}:ticket:{id}. Ошибка одноразовая, а последствия живут ровно столько, сколько TTL. Правило: ключ кэша собирается той же функцией, что и запрос к базе, и тенант в ней — обязательный аргумент, а не опциональный. Про кэши как таковые — кэширование.

Фоновые задачи. Контекст тенанта лежал в переменной потока или в «текущем запросе»; задача поставлена в очередь и выполняется через десять минут — контекста уже нет, и код берёт то, что найдёт. Особенно коварно при повторных попытках: первая попытка отработала правильно, третья — под другим тенантом. Правило: tenant_id — обязательное поле полезной нагрузки задачи, а исполнитель начинает работу с выставления контекста и падает, если поля нет. Про доставку и повторы — идемпотентность.

Пул соединений. Разобран выше.

Объектное хранилище. У бакета нет политик уровня строк. Ключ uploads/{uuid}.pdf угадать нельзя, но можно получить: он попадает в письма, в логи, в экспорт, в ссылки, которыми делятся. Правило: префикс ключа включает тенанта, доступ выдаётся только временными подписанными ссылками с коротким сроком, а проверка прав происходит в момент выдачи ссылки, а не в момент скачивания. Подробнее — объектные хранилища.

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

Отчёты, выгрузки, миграции и режим поддержки. Всё это ходит под ролью, которая обходит политики, — и это неизбежно, потому что миграции обязаны видеть все строки. Проблема не в самом обходе, а в его невидимости. Правило: отдельная роль, отдельное подключение, обязательный журнал «кто, когда, чей тенант, зачем» и уведомление клиента о входе поддержки в его данные. Многие корпоративные договоры этого прямо требуют; проверить формулировку — вопрос к юристу, а инженерное следствие простое: журнал должен быть с самого начала, задним числом его не собрать.

Шумный сосед

Второй класс проблем не про безопасность, а про справедливость. Один тенант выгружает миллион строк отчёта — у остальных 379 растут задержки.

Шум распространяется не там, где его ждут. Полезно знать список ресурсов, которые в общем экземпляре делятся и потому конкурируют:

  • процессорное время и память приложения — самое очевидное и самое редко критичное;
  • дисковые операции и буферный кэш СУБД: один тяжёлый запрос вытесняет из кэша горячие страницы всех остальных, и деградация продолжается ещё долго после того, как запрос закончился;
  • блокировки и длинные транзакции: у PostgreSQL долгая транзакция одного тенанта тормозит автовакуум по всей базе;
  • слоты пула соединений — самый частый реальный виновник: 100 соединений, один тенант занял 80;
  • очереди фоновых задач: 200 000 задач одного тенанта задерживают всех остальных на часы, если очередь одна;
  • квоты внешних поставщиков (почта, платежи, модели): лимит один на всех, исчерпывает его один;
  • кардинальность метрик и объём журналов — шум по тенанту превращается в счёт за наблюдаемость.

Механизмы защиты выстраиваются по возрастанию цены:

Механизм Что даёт Чего стоит
Лимит запросов на тенанта защита от простого перебора нужно решить, что делать с легитимным всплеском
Ограничение параллелизма на тенанта (не более N одновременных тяжёлых операций) защита пула и СУБД требует учёта «тяжести» операций
Таймауты запросов к СУБД обрубает патологию обрывает и легитимные длинные отчёты
Отдельная очередь или взвешенное обслуживание справедливость фоновых задач больше движущихся частей
Вынос тяжёлых операций в отдельный пул реплик отчёты не мешают работе ещё один экземпляр, отставание реплики
Ячейка (отдельный набор ресурсов) полная изоляция производительности стоимость ступени 4

Правило распределения усилий: изоляция производительности покупается либо квотами, либо ячейками. Квоты дешевле и почти всегда достаточны; ячейки надёжнее и почти всегда преждевременны. Начинать надо с ограничения параллелизма — это одна строка семафора и 80 % эффекта. Подробный разбор поведения перегруженной системы — деградация и паттерны устойчивости.

Важная деталь про планирование ёмкости: распределение нагрузки по тенантам почти всегда сильно скошено. Типичная картина — 5 % тенантов дают больше половины запросов. Это значит, что средний тенант не существует как объект планирования: считать надо по крупнейшим (ёмкость). И это же означает, что переход крупнейшего тенанта на отдельную ячейку часто улучшает жизнь всех остальных сильнее, чем любая оптимизация.

Ячейки и переезд тенанта

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

Ячейки решают сразу четыре разные задачи, и в этом их ценность:

  • радиус отказа: сломанная ячейка забирает с собой не всех, а 1/N клиентов;
  • поэтапный релиз: новая версия выкладывается по ячейкам, и это естественная канарейка на реальном трафике, а не на проценте запросов;
  • шум: крупный тенант выносится в свою ячейку без изменения кода;
  • регион данных: требование «данные в конкретной стране» превращается в атрибут строки в реестре тенантов, а не в форк системы (ограничения юрисдикций).

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

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

Три свойства делают этот протокол пригодным для эксплуатации:

  1. Режим только для чтения вместо простоя. Пользователи тенанта видят баннер «идёт обслуживание, изменения временно недоступны» вместо ошибки. Окно измеряется минутами, а не часами, потому что основная масса данных уже скопирована.
  2. Откат до переключения бесплатен. Пока запись в реестре не изменена, отмена стоит ровно ничего: удалили копию, сняли режим чтения. Все дорогие решения приняты после того, как сверка сошлась.
  3. Карантин вместо немедленного удаления. Данные в исходной ячейке живут ещё несколько дней. Это единственная защита от «переехали, а через сутки выяснилось, что часть вложений не скопировалась».

Отдельно: копирование удобнее делать через журнал изменений СУБД, а не через выборки — так вы получаете догоняющую репликацию, а не бесконечную гонку. Механика та же, что в репликации и шардировании и партиционировании.

Миграции по флоту: где заканчивается «одна выкладка»

Как только тенантов больше одной базы, миграция схемы перестаёт быть шагом конвейера и становится распределённой операцией со своими отказами.

Арифметика: 380 баз, миграция по 8 секунд каждая, при последовательном выполнении — 51 минута. При параллелизме 10 — пять минут. При доле отказов 2 % — восемь баз останутся в частично применённом состоянии, и вам нужен ответ на вопрос «что делать дальше», написанный заранее.

Три правила, которые делают эту операцию выполнимой.

Первое: только совместимые миграции, схема expand/contract. Сначала выкладывается изменение схемы, совместимое со старым кодом (добавили колонку, не убрали старую). Потом код. Потом — отдельным релизом, через дни или недели — удаление старого. Иначе флот, в котором часть баз мигрировала, а часть нет, гарантированно сломается: код обязан уметь работать с обеими версиями схемы. Это не «хорошая практика», это единственный способ пережить частичный отказ.

Второе: состояние миграции — данные, а не вывод скрипта. Прогон должен быть возобновляемым: упало на 214-й базе — перезапустили и продолжили с 215-й, а не с первой.

import asyncio, logging

async def migrate_fleet(tenants, migration_id, concurrency=10):
    """Прогон миграции по флоту баз с фиксацией состояния и ограничением параллелизма."""
    sem = asyncio.Semaphore(concurrency)
    failed = []

    async def one(tenant):
        async with sem:
            # Состояние хранится в управляющей базе, а не в памяти процесса.
            if await control.is_applied(tenant.id, migration_id):
                return
            try:
                async with tenant.db.transaction():          # DDL в транзакции,
                    await tenant.db.execute(MIGRATIONS[migration_id])  # чтобы не было полусостояний
                await control.mark_applied(tenant.id, migration_id)
            except Exception as exc:                          # не роняем весь прогон
                logging.exception("миграция %s провалена у тенанта %s", migration_id, tenant.id)
                await control.mark_failed(tenant.id, migration_id, str(exc))
                failed.append((tenant.id, exc))

    await asyncio.gather(*(one(t) for t in tenants))

    # Прогон считается успешным только если флот однороден.
    if failed:
        raise FleetMigrationIncomplete(migration_id, failed)
    return len(tenants)

Третье: разброс версий схемы — метрика, а не мелочь. Дашборд «сколько баз на какой версии» должен существовать и быть на виду. Здоровое состояние — один столбик. Два столбика в течение часа — норма. Два столбика в течение недели — вы уже поддерживаете два продукта и просто пока об этом не знаете. Подробнее про то, как жить с несколькими версиями сразу, — обновления и версии; про механику самих конвейеров — непрерывная поставка.

Кастомизация под тенанта: конфиг — да, код — нет

Самый быстрый способ уничтожить экономику многотенантного продукта — согласиться на «маленькую доработку для важного клиента». Через два года «маленьких доработок» будет сорок, и релиз перестанет выкладываться.

Работающая иерархия по возрастанию цены:

  1. Настройки — значения, которые тенант меняет сам: логотип, поля формы, рабочие часы, шаблоны писем. Цена — ноль. Сюда нужно уметь загонять максимум требований.
  2. Флаги функций на тенанта — включение готового поведения. Цена — реальная, но управляемая: каждый флаг удваивает число комбинаций для тестирования, поэтому у флага должен быть срок жизни и владелец.
  3. Точки расширения — вебхуки, правила, скрипты в песочнице, внешние обработчики. Цена высокая (нужна изоляция исполнения, лимиты, версионирование контракта), но зато конечная и одинаковая для всех клиентов.
  4. Отдельный код для тенанта — конец экономики. Если пришлось, это должно быть оформлено как отдельный платный продукт с отдельной ценой поддержки, а не как «ветка для клиента».

Простое правило для входящих требований: если функциональность просят три и больше клиента — это продуктовая задача, её надо делать в общем коде и продавать всем (приоритизация). Если один — это платная услуга по внедрению, точка расширения или отказ. Ответ «сделаем бесплатно, чтобы не потерять клиента» — это скидка, размер которой вы не посчитали.

Жизненный цикл тенанта — это и есть биллинг

В многотенантном продукте технический жизненный цикл тенанта и коммерческий — один и тот же объект. Все переходы должны быть явными состояниями, а не следствием сочетания четырёх булевых полей.

Инженерные обязательства прячутся именно в переходах, и каждое из них стоит денег:

  • provisioning. Сколько занимает создание тенанта? В пуле — миллисекунды. В силосе — минуты или часы, и это значит, что регистрация не может быть синхронной, нужен экран «готовим ваш стенд» и отдельная обработка отказов.
  • trial. Пробный тенант потребляет ресурсы и почти никогда не платит. Отсюда: отдельные лимиты (меньше фоновых задач, меньше хранилища, меньше вызовов внешних поставщиков), защита от массовой регистрации и — обязательно — понимание, сколько стоит один пробный тенант. Если пробный период стоит вам 4 USD, а конверсия 3 %, то привлечение одного платящего клиента через пробный период стоит 133 USD только в инфраструктуре (юнит-экономика). Механика пробных периодов и оплаты — глава про биллинг.
  • suspended. Данные ещё есть, доступа уже нет. Сколько они хранятся и кто платит за хранение — вопрос, на который должен быть письменный ответ.
  • offboardingpurged. Здесь три технические задачи, которые почти всегда обнаруживаются в последний момент: полная выгрузка данных в машиночитаемом виде, фактическое удаление из всех систем (включая индексы, кэши, витрины, очереди, журналы) и честный ответ про резервные копии. Из резервной копии выборочно удалить одного тенанта нельзя — там нет такой операции. Честная формулировка звучит как «данные исчезнут из резервных копий в течение N дней в силу ротации»; проверять, устраивает ли это ваши обязательства, нужно с юристом, а не с архитектором.

Учёт потребления по тенантам — предпосылка, а не отчётность. Если вы не умеете сказать, сколько запросов, гигабайт и вычислительных секунд израсходовал конкретный тенант, вы не можете ввести потребительское ценообразование, не можете обосновать корпоративный тариф и не можете понять, какой клиент убыточен. Счётчики нужно ставить до того, как понадобится тарификация, а не после (ценообразование).

Что обещает договор и во что это превращается в коде

Корпоративная продажа приносит требования, каждое из которых имеет точную инженерную цену. Полезно держать эту таблицу перед глазами до того, как обещание попало в договор.

Обещание в договоре Инженерное следствие Минимальная ступень Порядок цены
«Данные хранятся в стране X» реестр тенантов с регионом, ячейки по регионам, запрет кросс-региональных вызовов, проверка для резервных копий и журналов 4–5 новый регион = новый стенд целиком
«Отдельная база данных» маршрутизация, флотовые миграции, отдельные копии 3 ощутимо, но управляемо
«Шифрование ключами клиента» внешнее хранилище ключей, ротация, отзыв ключа = потеря доступа к данным как штатный сценарий 4–5 высокая, включая поддержку (криптография на практике)
«Журнал доступа персонала» сквозной аудит-лог, неизменяемое хранилище, выдача клиенту 1 низкая, если сделано сразу; высокая задним числом
«Своё окно обновления» флот версий, поддержка старых схем, отдельная канарейка 4 самая недооценённая строка
«SLA 99,9 % именно для нас» измерение доступности по тенантам, отдельные SLI, отчёт 1 средняя (SLI и SLO)
«Право на аудит и пентест» стенд для проверки, регламент, время инженеров 1 несколько человеко-недель в год
«Удаление данных за N дней» каскад по всем хранилищам, срок ротации копий, акт 1 низкая при проектировании, высокая при переделке

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

Полезно приходить к юристу с конкретными вопросами, а не с «посмотрите договор». Пять вопросов, которые обычно решают дело:

  1. Кто по этому договору является оператором данных, а кто — обработчиком, и какие обязанности отсюда следуют для нас?
  2. Что именно означает формулировка «хранение на территории» применительно к резервным копиям, журналам и системам наблюдаемости — они тоже под неё попадают?
  3. Какие сроки на выгрузку и удаление данных мы принимаем и есть ли исключения для резервных копий и требований по хранению отчётности?
  4. Обязаны ли мы уведомлять клиента о доступе нашего персонала к его данным и в какой форме?
  5. Что мы обязаны сделать и в какой срок при инциденте, затронувшем данные этого клиента?

Живой пример договорных документов можно посмотреть в разделе /legal этого портала: оферта, политика обработки персональных данных и условия возврата — это ровно тот слой, который в SaaS превращается в требования к коду. Подробный разбор ограничений — в главе про юрисдикции и персональные данные и в треке безопасности (приватность и соответствие требованиям).

Наблюдаемость по тенантам

Многотенантность ломает стандартный подход к мониторингу одним простым фактом: средний показатель по системе не описывает опыт ни одного клиента. Общая доступность 99,95 % может означать «у всех всё хорошо» и «у трёх тенантов из 380 всё лежало сутки» — цифра одна и та же, а разговоры разные.

Практический набор:

  • tenant_id — обязательный атрибут журналов и трассировок. Стоит почти ничего, окупается в первом же инциденте, когда вопрос звучит как «это у всех или только у них?».
  • tenant_id — не метка метрик. 380 тенантов на 20 метрик на 10 значений других меток — это десятки тысяч временных рядов, и счёт за наблюдаемость обгонит счёт за приложение (мониторинг и кардинальность). Компромисс: метки только для крупных тенантов из явного списка, остальные — в корзину other, а разбор по конкретному тенанту — через журналы и трассировки (наблюдаемость распределённых систем).
  • Отдельный SLO для тенантов с индивидуальным договором. Если продали 99,9 % конкретному клиенту, измерять надо конкретного клиента.
  • Отчёт «худшие тенанты по задержке» вместо общей персентили. Хвост распределения почти всегда сосредоточен у нескольких клиентов, и это диагноз, а не шум.
  • Учёт потребления на тенанта — та же телеметрия, что нужна биллингу. Стройте один поток данных, а не два: расхождение между «биллинговым» и «инженерным» учётом — источник вечных споров.

Как выбрать ступень

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

Правило по умолчанию для нового продукта: начинайте с пула, оставляя дверь открытой. Шесть решений стоят почти ничего сегодня и экономят квартал завтра:

  1. tenant_id в каждой таблице с первого дня, даже если тенант пока один.
  2. Составные первичные и внешние ключи (tenant_id, id).
  3. Единая точка доступа к базе, выставляющая контекст тенанта; политики уровня строк включены сразу.
  4. Никаких запросов, охватывающих несколько тенантов, в прикладном коде — они допустимы только в явно выделенном административном модуле.
  5. Реестр тенантов с полем «где живёт» — даже если значение пока одно на всех.
  6. Учёт потребления по тенантам с первого дня.

Обратное направление важно понимать заранее: перейти из пула в силос сложно, но возможно; вернуться из силоса в пул — почти всегда отдельный проект на квартал. Причина в том, что в силосе идентификаторы разъезжаются (в каждой базе свои последовательности), появляются локальные особенности конфигурации, и обратное слияние требует сквозного переназначения ключей. Не отдавайте эту дверь бесплатно.

Типичные ошибки

  • Считать, что проверка в контроллере — это изоляция. Инвариант должен жить в хранилище, а тест — перебирать все маршруты автоматически.
  • Включить политики уровня строк и ходить под владельцем таблиц. Без force row level security политики не применяются, а тесты остаются зелёными.
  • SET вместо SET LOCAL при пуле соединений. Тихая утечка, которая не оставляет следов в журналах.
  • Отсутствие tenant_id в полезной нагрузке фоновых задач. Работает до первой повторной попытки.
  • Обещать «отдельную базу», имея в виду отдельную схему. Расхождение вскроется на аудите, и это будет разговор не про архитектуру.
  • Соглашаться на индивидуальное окно обновления без оговорки о максимальном отставании версии. Через два года вы бесплатно поддерживаете релиз трёхлетней давности.
  • Строить ячейки без операции переезда тенанта. Тогда ячейки не решают ни одной из задач, ради которых их делали.
  • Считать удельную стоимость только по счёту облака. Основная цена силоса — в миграциях, дежурстве и разбросе версий, а не в виртуальных машинах.
  • Ставить tenant_id меткой на все метрики. Счёт за наблюдаемость обгоняет счёт за приложение.
  • Делать «маленькую доработку для важного клиента» в общем коде. Это не доработка, это форк с отложенной оплатой.
  • Проектировать удаление тенанта в момент, когда первый клиент попросил удалиться. Каскад по всем хранилищам и вопрос про резервные копии всплывут именно тогда, когда времени нет.
  • Продавать корпоративные обязательства по массовой цене. Убыток растёт пропорционально успеху продаж.

Мини-итог

Многотенантность — это способ разделить постоянные издержки между покупателями, и всё остальное в этой теме следует из данного факта. Один релиз, одно дежурство и одна миграция обслуживают всех — поэтому подписка стоит десятки, а не десятки тысяч. Ровно поэтому же одна ошибка обслуживает всех.

Изоляция — не выключатель, а лестница из пяти ступеней: общая строка с политиками, своя схема, своя база, свой экземпляр, свой облачный аккаунт. Каждая ступень даёт покупателю конкретное проверяемое обещание, стоит поставщику конкретных денег и создаёт конкретное обязательство. Ступень выбирают под обещание, а не под важность клиента, и каждая ступень выше первой должна быть отдельной строкой прайс-листа.

Технический фундамент нижней ступени — инвариант в хранилище, а не проверка в коде: составные ключи (tenant_id, id), политики уровня строк с force и with check, SET LOCAL внутри транзакции, единственная точка доступа к базе и два автоматических теста — структурный по каталогу и поведенческий по всем маршрутам. Утечки при этом происходят не в SQL, а в пяти других местах: кэш, фоновые задачи, объектное хранилище, поисковый индекс, отчёты и режим поддержки.

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

И последнее. Жизненный цикл тенанта — это и есть биллинг: от provisioning до purged каждый переход имеет цену и обязательство. Учёт потребления по тенантам ставится до того, как понадобится тарификация. А где начинается право — юрисдикция, обработка персональных данных, формулировки договора — там начинается работа юриста; задача инженера в том, чтобы обещание было исполнимым и проверяемым, и чтобы вопросы юристу были заданы конкретные.

Что дальше

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

На этом портале есть живой пример: развёрнутый для читателей инстанс it-tools распространяется под GPL-3.0, и это накладывает вполне конкретные обязанности на того, кто его разместил. Следующая глава — про то, почему лицензия является инженерным ограничением того же класса, что схема базы данных: её нельзя «обойти позже», и она определяет, что вы имеете право поставить клиенту.

Лицензии как инженерное ограничение: копилефт и разрешительные

Источники

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов