Многотенантность: изоляция, данные и стоимость обслуживания
Компания продаёт систему учёта заявок. 380 организаций-клиентов, один экземпляр приложения, одна база. В понедельник приходит письмо от юриста крупного клиента: «Согласно нашей политике информационной безопасности, данные компании должны храниться в отдельной базе данных, к которой не имеют доступа другие ваши клиенты. Просим подтвердить выполнение этого требования до 1 числа следующего месяца».
Клиент платит 14 % годовой выручки. Отказать нельзя. Согласиться, не посчитав, — тоже: через полгода такое же письмо придёт от второго, третьего и десятого, и однажды окажется, что вы обслуживаете не один продукт, а сорок его копий с разными версиями схемы, разными окнами обновления и разными инцидентами.
Многотенантность — это не архитектурная мода и не «правильный способ писать SaaS». Это механизм разделения постоянных издержек между покупателями. Один релиз обслуживает всех. Одно дежурство обслуживает всех. Одна миграция обслуживает всех. Ровно поэтому подписка может стоить 30 USD в месяц, а не 30 000 — цена деления, а не цена разработки, определяет нижнюю границу прайс-листа (модели поставки).
Обратная сторона того же механизма: одна ошибка тоже обслуживает всех. И одна забытая строчка where tenant_id = ... обслуживает всех сразу.
Эта глава — про то, где именно проходит граница между «общим» и «своим», сколько стоит подвинуть её на одну ступень вверх, и какие обязательства каждая ступень создаёт. Технической базой служит предыдущая глава про облачные уровни: многотенантность живёт ровно там, где кончается ваша ответственность за инфраструктуру и начинается ответственность за чужие данные.
Тенант — это не пользователь и не аккаунт
Тенант — это единица изоляции, единица тарификации и единица договора одновременно. Если эти три роли расходятся, боль начинается сразу и не заканчивается никогда.
- Пользователь — человек с логином. Один человек может работать в трёх тенантах (подрядчик, обслуживающий три компании) и должен переключаться между ними без выхода из системы.
- Аккаунт (учётная запись) — техническая сущность аутентификации. Она про «кто вы», а тенант — про «в чьих данных вы находитесь». Смешивать их — значит потом переписывать авторизацию (модели авторизации).
- Организация в интерфейсе — то, что видит клиент. Она может не совпадать с тенантом: холдинг из пяти юрлиц может быть одним тенантом с пятью «пространствами» или пятью тенантами с общим биллингом.
Три вопроса, ответы на которые нужно зафиксировать до первой строчки кода, потому что менять их потом дорого:
- Кому выставляется счёт? Тенанту. Если счёт выставляется чему-то другому — например, «проекту», — то у вас два разных дерева, и в биллинге (глава 07) появится вечный источник расхождений.
- Что происходит при разделении и слиянии? Клиента купили, два тенанта надо слить в один. Или наоборот: филиал отделился, тенант надо разрезать. Операция «разрезать тенант» без сквозного
tenant_idв каждой строке физически невыполнима: вы просто не сможете сказать, какие данные чьи. - Может ли объект принадлежать двум тенантам? Правильный ответ по умолчанию — нет, никогда. Как только появляется «общий справочник, редактируемый двумя клиентами», модель изоляции ломается и её нельзя починить политиками на уровне базы.
Обратите внимание на две неочевидные детали этой схемы, каждая из которых экономит месяцы.
Первичный ключ везде составной: (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, политика не пропустит ни одной строки. Второй вариант безопаснее: «ничего не видно» лучше, чем «упало пятьсот с трассировкой стека», и гораздо лучше, чем «видно всё».
Ловушка пула соединений
Дальше начинается место, где ломаются почти все. Контекст тенанта живёт в переменной сессии, а сессия в пуле переиспользуется.
или упал между SET и запросом B->>D: select * from ticket D-->>B: заявки тенанта A Note over B: утечка без единой ошибки в журнале
Лечится это тремя решениями сразу, и ни одного из них недостаточно поодиночке:
SET LOCALвнутри явной транзакции вместоSET.SET LOCALоткатывается по завершении транзакции, поэтому «протечь» в следующий запрос физически не может.- Режим пула на уровне транзакций (в pgbouncer это
pool_mode = transaction). В режиме сессий соединение закрепляется за клиентом надолго и переменные живут дольше запроса; в транзакционном режимеSETбезLOCALвообще не работает предсказуемо — и это хорошо, потому что ошибка становится видимой сразу, а не через полгода. - Единственная точка входа в базу. Один модуль, который открывает транзакцию, выставляет контекст и отдаёт наружу уже готовое соединение. Всё остальное приложение не имеет доступа к «сырому» пулу. Это архитектурное ограничение, а не соглашение — соглашения не переживают найма пятого разработчика.
Тест, который ловит то, что не ловит ревью
Инвариант должен проверяться автоматически, иначе он превращается в абзац документации. Два теста стоят своих денег и пишутся за день.
Первый — структурный. Он перебирает каталог базы и падает, если кто-то создал таблицу с 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 % тенантов дают больше половины запросов. Это значит, что средний тенант не существует как объект планирования: считать надо по крупнейшим (ёмкость). И это же означает, что переход крупнейшего тенанта на отдельную ячейку часто улучшает жизнь всех остальных сильнее, чем любая оптимизация.
Ячейки и переезд тенанта
Когда пул перестаёт помещаться в один экземпляр, следующий шаг — не «распилить на микросервисы», а нарезать на ячейки: несколько независимых полных копий системы, каждая обслуживает свой набор тенантов. Ячейка — это единица отказа, единица выкладки и единица ёмкости одновременно.
tenant_id → cell_id, region)] M --> R R -->|cell-eu-1| C1[Ячейка eu-1
приложение + СУБД + кэш] R -->|cell-eu-2| C2[Ячейка eu-2] R -->|cell-ru-1| C3[Ячейка ru-1
региональное требование] R -->|cell-vip-9| C4[Ячейка vip-9
один крупный тенант] C1 --> S1[(Данные ячейки)] C2 --> S2[(Данные ячейки)] C3 --> S3[(Данные ячейки)] C4 --> S4[(Данные ячейки)]
Ячейки решают сразу четыре разные задачи, и в этом их ценность:
- радиус отказа: сломанная ячейка забирает с собой не всех, а 1/N клиентов;
- поэтапный релиз: новая версия выкладывается по ячейкам, и это естественная канарейка на реальном трафике, а не на проценте запросов;
- шум: крупный тенант выносится в свою ячейку без изменения кода;
- регион данных: требование «данные в конкретной стране» превращается в атрибут строки в реестре тенантов, а не в форк системы (ограничения юрисдикций).
Единственный элемент, который остаётся общим, — реестр тенантов и маршрутизация. Он должен быть до неприличия простым, отдельно резервируемым и агрессивно кэшируемым: это тот компонент, отказ которого кладёт всё.
Ключевая операция всей схемы — переезд тенанта между ячейками. Её обычно откладывают, и это ошибка: без неё ячейки не дают почти ничего, потому что нельзя ни разгрузить перегруженную ячейку, ни вынести крупного клиента, ни увести тенанта в нужный регион.
тенант переводится в режим чтения read_only --> verifying: досылка хвоста, сверка контрольных сумм verifying --> switched: реестр переключён на ячейку B switched --> active_target: запись разрешена, тенант работает в B verifying --> read_only: сверка не сошлась, повторяем read_only --> active_source: аварийный откат, реестр не трогали active_target --> [*]: старые данные удаляются
через срок карантина
Три свойства делают этот протокол пригодным для эксплуатации:
- Режим только для чтения вместо простоя. Пользователи тенанта видят баннер «идёт обслуживание, изменения временно недоступны» вместо ошибки. Окно измеряется минутами, а не часами, потому что основная масса данных уже скопирована.
- Откат до переключения бесплатен. Пока запись в реестре не изменена, отмена стоит ровно ничего: удалили копию, сняли режим чтения. Все дорогие решения приняты после того, как сверка сошлась.
- Карантин вместо немедленного удаления. Данные в исходной ячейке живут ещё несколько дней. Это единственная защита от «переехали, а через сутки выяснилось, что часть вложений не скопировалась».
Отдельно: копирование удобнее делать через журнал изменений СУБД, а не через выборки — так вы получаете догоняющую репликацию, а не бесконечную гонку. Механика та же, что в репликации и шардировании и партиционировании.
Миграции по флоту: где заканчивается «одна выкладка»
Как только тенантов больше одной базы, миграция схемы перестаёт быть шагом конвейера и становится распределённой операцией со своими отказами.
Арифметика: 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)
Третье: разброс версий схемы — метрика, а не мелочь. Дашборд «сколько баз на какой версии» должен существовать и быть на виду. Здоровое состояние — один столбик. Два столбика в течение часа — норма. Два столбика в течение недели — вы уже поддерживаете два продукта и просто пока об этом не знаете. Подробнее про то, как жить с несколькими версиями сразу, — обновления и версии; про механику самих конвейеров — непрерывная поставка.
Кастомизация под тенанта: конфиг — да, код — нет
Самый быстрый способ уничтожить экономику многотенантного продукта — согласиться на «маленькую доработку для важного клиента». Через два года «маленьких доработок» будет сорок, и релиз перестанет выкладываться.
Работающая иерархия по возрастанию цены:
- Настройки — значения, которые тенант меняет сам: логотип, поля формы, рабочие часы, шаблоны писем. Цена — ноль. Сюда нужно уметь загонять максимум требований.
- Флаги функций на тенанта — включение готового поведения. Цена — реальная, но управляемая: каждый флаг удваивает число комбинаций для тестирования, поэтому у флага должен быть срок жизни и владелец.
- Точки расширения — вебхуки, правила, скрипты в песочнице, внешние обработчики. Цена высокая (нужна изоляция исполнения, лимиты, версионирование контракта), но зато конечная и одинаковая для всех клиентов.
- Отдельный код для тенанта — конец экономики. Если пришлось, это должно быть оформлено как отдельный платный продукт с отдельной ценой поддержки, а не как «ветка для клиента».
Простое правило для входящих требований: если функциональность просят три и больше клиента — это продуктовая задача, её надо делать в общем коде и продавать всем (приоритизация). Если один — это платная услуга по внедрению, точка расширения или отказ. Ответ «сделаем бесплатно, чтобы не потерять клиента» — это скидка, размер которой вы не посчитали.
Жизненный цикл тенанта — это и есть биллинг
В многотенантном продукте технический жизненный цикл тенанта и коммерческий — один и тот же объект. Все переходы должны быть явными состояниями, а не следствием сочетания четырёх булевых полей.
лимиты пробного периода trial --> active: оплата прошла trial --> expired: пробный период кончился active --> past_due: платёж не прошёл past_due --> active: оплата восстановлена past_due --> suspended: льготный период истёк expired --> active: оплатили позже suspended --> active: оплатили позже suspended --> offboarding: клиент ушёл
или срок хранения истёк active --> offboarding: расторжение offboarding --> purged: данные удалены,
выдан акт purged --> [*] active --> active: смена тарифа, переезд,
смена ступени изоляции
Инженерные обязательства прячутся именно в переходах, и каждое из них стоит денег:
provisioning. Сколько занимает создание тенанта? В пуле — миллисекунды. В силосе — минуты или часы, и это значит, что регистрация не может быть синхронной, нужен экран «готовим ваш стенд» и отдельная обработка отказов.trial. Пробный тенант потребляет ресурсы и почти никогда не платит. Отсюда: отдельные лимиты (меньше фоновых задач, меньше хранилища, меньше вызовов внешних поставщиков), защита от массовой регистрации и — обязательно — понимание, сколько стоит один пробный тенант. Если пробный период стоит вам 4 USD, а конверсия 3 %, то привлечение одного платящего клиента через пробный период стоит 133 USD только в инфраструктуре (юнит-экономика). Механика пробных периодов и оплаты — глава про биллинг.suspended. Данные ещё есть, доступа уже нет. Сколько они хранятся и кто платит за хранение — вопрос, на который должен быть письменный ответ.offboarding→purged. Здесь три технические задачи, которые почти всегда обнаруживаются в последний момент: полная выгрузка данных в машиночитаемом виде, фактическое удаление из всех систем (включая индексы, кэши, витрины, очереди, журналы) и честный ответ про резервные копии. Из резервной копии выборочно удалить одного тенанта нельзя — там нет такой операции. Честная формулировка звучит как «данные исчезнут из резервных копий в течение N дней в силу ротации»; проверять, устраивает ли это ваши обязательства, нужно с юристом, а не с архитектором.
Учёт потребления по тенантам — предпосылка, а не отчётность. Если вы не умеете сказать, сколько запросов, гигабайт и вычислительных секунд израсходовал конкретный тенант, вы не можете ввести потребительское ценообразование, не можете обосновать корпоративный тариф и не можете понять, какой клиент убыточен. Счётчики нужно ставить до того, как понадобится тарификация, а не после (ценообразование).
Что обещает договор и во что это превращается в коде
Корпоративная продажа приносит требования, каждое из которых имеет точную инженерную цену. Полезно держать эту таблицу перед глазами до того, как обещание попало в договор.
| Обещание в договоре | Инженерное следствие | Минимальная ступень | Порядок цены |
|---|---|---|---|
| «Данные хранятся в стране X» | реестр тенантов с регионом, ячейки по регионам, запрет кросс-региональных вызовов, проверка для резервных копий и журналов | 4–5 | новый регион = новый стенд целиком |
| «Отдельная база данных» | маршрутизация, флотовые миграции, отдельные копии | 3 | ощутимо, но управляемо |
| «Шифрование ключами клиента» | внешнее хранилище ключей, ротация, отзыв ключа = потеря доступа к данным как штатный сценарий | 4–5 | высокая, включая поддержку (криптография на практике) |
| «Журнал доступа персонала» | сквозной аудит-лог, неизменяемое хранилище, выдача клиенту | 1 | низкая, если сделано сразу; высокая задним числом |
| «Своё окно обновления» | флот версий, поддержка старых схем, отдельная канарейка | 4 | самая недооценённая строка |
| «SLA 99,9 % именно для нас» | измерение доступности по тенантам, отдельные SLI, отчёт | 1 | средняя (SLI и SLO) |
| «Право на аудит и пентест» | стенд для проверки, регламент, время инженеров | 1 | несколько человеко-недель в год |
| «Удаление данных за N дней» | каскад по всем хранилищам, срок ротации копий, акт | 1 | низкая при проектировании, высокая при переделке |
Про правовую часть здесь важно сказать прямо: эта глава не даёт юридических советов, и никакая инженерная статья их дать не может. Формулировки договоров, применимость требований к вашему случаю, ответственность за трансграничную передачу — предмет работы юриста. Инженер отвечает за другое: за то, чтобы обещание было технически исполнимо и проверяемо.
Полезно приходить к юристу с конкретными вопросами, а не с «посмотрите договор». Пять вопросов, которые обычно решают дело:
- Кто по этому договору является оператором данных, а кто — обработчиком, и какие обязанности отсюда следуют для нас?
- Что именно означает формулировка «хранение на территории» применительно к резервным копиям, журналам и системам наблюдаемости — они тоже под неё попадают?
- Какие сроки на выгрузку и удаление данных мы принимаем и есть ли исключения для резервных копий и требований по хранению отчётности?
- Обязаны ли мы уведомлять клиента о доступе нашего персонала к его данным и в какой форме?
- Что мы обязаны сделать и в какой срок при инциденте, затронувшем данные этого клиента?
Живой пример договорных документов можно посмотреть в разделе /legal этого портала: оферта, политика обработки персональных данных и условия возврата — это ровно тот слой, который в SaaS превращается в требования к коду. Подробный разбор ограничений — в главе про юрисдикции и персональные данные и в треке безопасности (приватность и соответствие требованиям).
Наблюдаемость по тенантам
Многотенантность ломает стандартный подход к мониторингу одним простым фактом: средний показатель по системе не описывает опыт ни одного клиента. Общая доступность 99,95 % может означать «у всех всё хорошо» и «у трёх тенантов из 380 всё лежало сутки» — цифра одна и та же, а разговоры разные.
Практический набор:
tenant_id— обязательный атрибут журналов и трассировок. Стоит почти ничего, окупается в первом же инциденте, когда вопрос звучит как «это у всех или только у них?».tenant_id— не метка метрик. 380 тенантов на 20 метрик на 10 значений других меток — это десятки тысяч временных рядов, и счёт за наблюдаемость обгонит счёт за приложение (мониторинг и кардинальность). Компромисс: метки только для крупных тенантов из явного списка, остальные — в корзинуother, а разбор по конкретному тенанту — через журналы и трассировки (наблюдаемость распределённых систем).- Отдельный SLO для тенантов с индивидуальным договором. Если продали 99,9 % конкретному клиенту, измерять надо конкретного клиента.
- Отчёт «худшие тенанты по задержке» вместо общей персентили. Хвост распределения почти всегда сосредоточен у нескольких клиентов, и это диагноз, а не шум.
- Учёт потребления на тенанта — та же телеметрия, что нужна биллингу. Стройте один поток данных, а не два: расхождение между «биллинговым» и «инженерным» учётом — источник вечных споров.
Как выбрать ступень
к юрисдикции данных?} B -->|да| S5[Ступень 4–5:
ячейка в нужном регионе] B -->|нет| C{Клиент требует
свои ключи шифрования?} C -->|да| S5 C -->|нет| D{Нужны отдельное
восстановление и выгрузка?} D -->|да| S3[Ступень 3:
база на тенанта] D -->|нет| E{Клиент требует
своё окно обновления?} E -->|да| S4[Ступень 4:
свой экземпляр
и оговорка о версии] E -->|нет| F{Тенант создаёт
нагрузку, мешающую другим?} F -->|да| G{Хватит ли квот
и ограничения параллелизма?} G -->|да| S1[Ступень 1 плюс квоты] G -->|нет| S4 F -->|нет| S1 S1 --> H[Проверить: за что именно
клиент платит надбавку?] S3 --> H S4 --> H S5 --> H
Последний блок — не украшение. Каждая ступень выше первой должна быть оформлена как отдельный тариф с отдельной ценой, а не как уступка в переговорах. Иначе получается классическая ловушка: обязательства корпоративного уровня по цене массового продукта, и убыток растёт ровно пропорционально успеху продаж.
Правило по умолчанию для нового продукта: начинайте с пула, оставляя дверь открытой. Шесть решений стоят почти ничего сегодня и экономят квартал завтра:
tenant_idв каждой таблице с первого дня, даже если тенант пока один.- Составные первичные и внешние ключи
(tenant_id, id). - Единая точка доступа к базе, выставляющая контекст тенанта; политики уровня строк включены сразу.
- Никаких запросов, охватывающих несколько тенантов, в прикладном коде — они допустимы только в явно выделенном административном модуле.
- Реестр тенантов с полем «где живёт» — даже если значение пока одно на всех.
- Учёт потребления по тенантам с первого дня.
Обратное направление важно понимать заранее: перейти из пула в силос сложно, но возможно; вернуться из силоса в пул — почти всегда отдельный проект на квартал. Причина в том, что в силосе идентификаторы разъезжаются (в каждой базе свои последовательности), появляются локальные особенности конфигурации, и обратное слияние требует сквозного переназначения ключей. Не отдавайте эту дверь бесплатно.
Типичные ошибки
- Считать, что проверка в контроллере — это изоляция. Инвариант должен жить в хранилище, а тест — перебирать все маршруты автоматически.
- Включить политики уровня строк и ходить под владельцем таблиц. Без
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, и это накладывает вполне конкретные обязанности на того, кто его разместил. Следующая глава — про то, почему лицензия является инженерным ограничением того же класса, что схема базы данных: её нельзя «обойти позже», и она определяет, что вы имеете право поставить клиенту.
Лицензии как инженерное ограничение: копилефт и разрешительные
Источники
- AWS. SaaS Tenant Isolation Strategies — самый подробный публичный разбор ступеней изоляции и способов их проверки; Reducing the Scope of Impact with Cell-Based Architecture — ячейки, маршрутизация и переезд.
- Microsoft. Architecting multitenant solutions on Azure — систематизированный каталог моделей хранения, вычислений и биллинга для многотенантных систем.
- C. D. Weissman, S. Bobrowski. The Design of the Force.com Multitenant Internet Application Development Platform, SIGMOD 2009 — классическая работа о том, как выглядит многотенантность, доведённая до предела.
- PostgreSQL. Row Security Policies — политики уровня строк,
FORCE,USINGиWITH CHECK; pgbouncer: pool modes — почему транзакционный режим меняет правила игры с переменными сессии. - Citus. Multi-tenant applications tutorial — шардирование по тенанту как штатный режим работы, с разбором составных ключей.
- Shopify Engineering. A Pods Architecture to Allow Shopify to Scale и Slack Engineering. Scaling Datastores at Slack with Vitess — два подробных производственных рассказа про нарезку на ячейки и переезд тенантов.