Геораспределённая архитектура: регионы, ячейки, failover и аварийное восстановление
Разговор про второй регион почти всегда начинается одинаково: кто-то на ретроспективе после инцидента говорит «нам нужен мультирегион», все кивают, в бэклог падает эпик, а через полгода в проде появляется вторая копия сервисов, которая никогда не принимала боевой трафик, база в ней отстаёт на неизвестное число минут, и при следующей аварии никто не решается переключиться, потому что непонятно, что там с данными. Деньги потрачены, доступность не выросла, зато выросла стоимость инфраструктуры и когнитивная нагрузка на дежурных.
Причина этого сценария не в лени и не в некомпетентности. Она в том, что «мультирегион» — это не архитектурное решение, а название для трёх разных решений, у которых разные цели, разные топологии и разная цена. Пока эти три задачи не разделены, обсуждение идёт про технологии («возьмём Aurora Global Database»), а не про требования, и заканчивается закономерно.
Эта статья — про то, как перевести «хотим переживать падение региона» в конкретные числа, из чисел получить топологию, а из топологии — процедуру переключения, которую действительно можно выполнить в три часа ночи. Предполагается, что вы уже знакомы с материалом про устойчивость (таймауты, размыкатели, деградация), масштабирование (репликация, шардирование) и system design (оценки на салфетке) — эти понятия дальше используются без пояснений.
Механику репликации и теорию согласованности статья не пересказывает: они разобраны в треке «Распределённые системы» — репликация, CAP и PACELC, консенсус. Здесь — уровень выше: какую топологию из этих кирпичей собрать и что она стоит.
1. Три разные задачи под одним словом
Прежде чем рисовать схемы, ответьте на вопрос: зачем вам второй регион. Ответов ровно три, и они почти не пересекаются.
| Задача | Что улучшаем | Что достаточно сделать | Чего делать НЕ надо |
|---|---|---|---|
| Латентность | время ответа пользователям на другом континенте | CDN и edge-кэш, реплики на чтение рядом с пользователем | переносить запись; она может остаться в одном регионе |
| Доступность / DR | переживание отказа целого региона | вторая площадка с данными и ёмкостью + процедура переключения | делать её active-active «за компанию» |
| Резидентность | соблюдение требований к месту хранения данных | физическое разделение данных по юрисдикциям, маршрутизация по «домашнему» региону | реплицировать эти данные куда попало «для надёжности» |
Три задачи ортогональны. Можно иметь глобальную низкую задержку без всякого DR (CDN перед одним регионом), можно иметь строгий DR без единой миллисекунды выигрыша по латентности (тёплый резерв, который никогда не принимает трафик), можно иметь резидентность и при этом нулевую отказоустойчивость (по одному региону на юрисдикцию, и падение любого — полный отказ для его пользователей).
Самая частая и самая дорогая ошибка — решать задачу латентности средствами active-active записи. Симптом: команда, у которой 95 % пользователей в одной стране, строит двустороннюю репликацию с разрешением конфликтов, потому что «так делают в больших компаниях». Стоимость такой системы — не деньги на инфраструктуру, а навсегда изменившаяся модель данных: с момента, когда запись возможна в двух местах, любой инвариант вида «уникальный email» или «остаток не может стать отрицательным» перестаёт быть гарантией и превращается в вероятностное утверждение.
континенте"| C{"Медленно на чтении
или на записи?"} C -->|чтение| C1["CDN + edge-кэш
+ реплики на чтение
Запись остаётся дома"] C -->|запись| C2["Партиционирование
по домашнему региону
(не active-active!)"] B -->|"Не переживём
отказ региона"| D{"Какой RTO
требует бизнес?"} D -->|"сутки"| D1["Бэкапы + план
развёртывания
самый дешёвый вариант"] D -->|"часы"| D2["Pilot light:
данные едут,
вычисления выключены"] D -->|"минуты"| D3["Warm standby:
уменьшенная копия
под трафиком"] D -->|"секунды"| D4["Active-active
самый дорогой вариант,
требует изменения модели данных"] B -->|"Регулятор требует
хранить в стране"| E["Разделение по юрисдикциям
+ маршрутизация по home region
DR решается ВНУТРИ юрисдикции"] C1 --> F["Записать ADR:
какую из трёх задач решаем
и какую НЕ решаем"] C2 --> F D1 --> F D2 --> F D3 --> F D4 --> F E --> F
Последний блок — не украшение. Половина неудачных мультирегиональных проектов начиналась с того, что цель не была зафиксирована письменно, и через три месяца команда обнаруживала, что решает все три задачи сразу, ни одну до конца. Формат решения — обычный ADR из статьи «Архитектурные решения», и в разделе «Последствия» обязана быть строчка «эта архитектура НЕ защищает от X».
2. Словарь: зона, регион, ячейка, точка присутствия
Термины у облачных провайдеров называются по-разному, но иерархия везде одна, и различает уровни ровно одно свойство: что именно ломается вместе.
- Зона доступности (availability zone, AZ) — один или несколько дата-центров с независимым питанием, охлаждением и сетевым вводом. Зоны одного региона соединены выделенными линиями с задержкой порядка миллисекунды. Отдельные зоны падают регулярно: пожар, отказ ввода, ошибка в сетевой конфигурации.
- Регион — группа зон в одной географии, обычно 3+. Регион — это граница управляющей плоскости: API создания ресурсов, балансировщики, сервисы имён чаще всего региональны. Регион падает целиком редко, но падает: авария управляющей плоскости, ошибка в общем для региона сервисе, потеря сетевой связности.
- Ячейка (cell) — полная, самодостаточная копия стека, обслуживающая подмножество пользователей. Ячейка живёт внутри региона (или растянута на его зоны) и не имеет общих компонентов с другими ячейками, кроме тонкого маршрутизатора.
- Точка присутствия (PoP, edge) — узел на краю сети, где терминируется TLS и отдаётся кэш. Подробно про этот уровень — в статьях «Serverless и edge» и «CDN и edge».
Из картинки следует главный тезис главы. Зоны и регионы защищают от инфраструктурных отказов: сгорел ввод, оборвался кабель, отказала стойка. От логических отказов они не защищают вообще: плохая миграция схемы, отравленный запрос, кривой конфиг, распространённый управляющей плоскостью, приезжают во все зоны и, если у вас настроена автоматическая репликация конфигурации, — во все регионы. Ячейки — единственный из уровней, который ограничивает радиус поражения логической ошибки, потому что деплой и конфигурация раскатываются по ячейкам последовательно.
Формально: радиус поражения (blast radius) — это доля пользователей, которую задевает один отказ. Проектировать надо не «доступность», а именно эту долю: разница между «упало для всех на 20 минут» и «упало для 6 % на 20 минут» — это разница между инцидентом первой и третьей категории, между обзвоном ключевых клиентов и строчкой в еженедельном отчёте.
3. RTO и RPO: два числа, из которых растёт вся архитектура
Два определения, которые надо знать наизусть, потому что из них выводится всё остальное.
- RTO (recovery time objective) — максимально допустимое время недоступности сервиса. Измеряется в минутах простоя.
- RPO (recovery point objective) — максимально допустимый объём потерянных данных, выраженный во времени: «мы согласны потерять последние 5 минут транзакций».
Их постоянно путают и часто произносят как одно требование («нам нужен RTO 5 минут»), хотя они улучшаются разными деньгами и разными механизмами. RTO — про скорость переключения вычислений и трафика. RPO — целиком про механику репликации данных, и никакие оркестраторы его не улучшают.
Ключевое следствие, которое стоит повесить на стену: при асинхронной репликации RPO равен лагу репликации в момент отказа, а не нулю. Не «мы настроили реплику, значит данные в безопасности», а «мы потеряем ровно то, что не успело доехать». Лаг при этом не константа: в спокойное время он 200 мс, а в момент пиковой записи или при сетевой деградации — минуты. И измерять его надо не «на глаз по графику», а честно, через heartbeat.
-- В основном регионе: раз в секунду пишем метку в таблицу-биение.
-- Эта запись идёт по тому же пути, что и боевые данные, поэтому её задержка
-- отражает реальный лаг, а не состояние отдельного канала мониторинга.
CREATE TABLE replication_heartbeat (
id smallint PRIMARY KEY DEFAULT 1,
written_at timestamptz NOT NULL,
CONSTRAINT single_row CHECK (id = 1)
);
INSERT INTO replication_heartbeat (id, written_at)
VALUES (1, clock_timestamp())
ON CONFLICT (id) DO UPDATE SET written_at = EXCLUDED.written_at;
-- В резервном регионе: измеряем лаг ПО ДАННЫМ, а не по позиции журнала.
-- now() - written_at показывает, на сколько секунд назад мы откатимся,
-- если прямо сейчас произойдёт эвакуация. Это и есть текущий RPO.
SELECT
now() - written_at AS current_rpo,
pg_last_wal_replay_lsn() AS replayed_lsn,
pg_is_in_recovery() AS is_standby
FROM replication_heartbeat;
Почему именно так, а не через pg_stat_replication: позиция журнала показывает, сколько байт не доехало, а бизнесу нужно время и число операций. Метрика current_rpo в секундах кладётся на дашборд рядом с алертом «RPO превысил обещанный SLA» — и это единственный способ узнать о деградации репликации до аварии, а не во время неё.
Четыре уровня готовности
Классификация, к которой независимо пришли все крупные провайдеры (см. документ AWS «Disaster Recovery of Workloads on AWS» и «Disaster recovery planning guide» от Google Cloud):
| Уровень | RPO | RTO | Что работает во втором регионе постоянно | Относительная цена |
|---|---|---|---|---|
| Backup & restore | часы | часы–сутки | ничего, только хранилище бэкапов | 1x + копейки |
| Pilot light | секунды–минуты | десятки минут–часы | реплика данных, выключенные вычисления | ~1,15x |
| Warm standby | секунды | минуты | уменьшенная копия стека под небольшим трафиком | ~1,4x |
| Active-active | ~0 (при синхронной записи) | секунды | полная копия под боевым трафиком | 2x и выше |
Три практических замечания, которые не пишут в документации.
Первое. RTO уровня «backup & restore» почти всегда недооценивают в разы. Восстановление терабайтной базы из объектного хранилища — это не «скачать файл», это распаковка, накат журнала, прогрев кэшей и повторное построение индексов; на реальных объёмах легко получаются часы. Единственный способ узнать своё настоящее RTO — восстановиться из бэкапа с секундомером. Компания GitLab в постмортеме потери данных 31 января 2017 описала пять механизмов резервного копирования, из которых в нужный момент не сработал ни один — не потому, что их не было, а потому, что их не проверяли восстановлением.
Второе. Разрыв между «pilot light» и «warm standby» — это не деньги на серверы, это уверенность. Резерв, который никогда не обслуживал боевой трафик, с высокой вероятностью не обслужит его и в аварию: протухшие сертификаты, не накатанные миграции, не тот размер пула соединений, забытая переменная окружения. Резерв, через который постоянно идёт хотя бы 5 % трафика, проверяет сам себя каждую минуту.
Третье. Active-active даёт RPO≈0 только при синхронной репликации кворумом через регионы, а за это платится задержкой на каждой записи (раздел 4). Асинхронный active-active — это не RPO 0, а «RPO 0 обычно и потеря нескольких секунд в момент аварии, плюс конфликты записи, которые кто-то должен разрешать».
4. Физика: почему запись через океан стоит дорого
Скорость света в оптоволокне — примерно 200 000 км/с, то есть 5 мкс на километр в одну сторону. Реальный маршрут длиннее прямой линии в 1,3–1,6 раза, плюс задержки на оборудовании. Практическое правило: RTT ≈ (расстояние в км / 100) миллисекунд, и уменьшить это число нельзя ни деньгами, ни оптимизацией кода.
| Пара | Расстояние | Реальный RTT | Что это значит для синхронной записи |
|---|---|---|---|
| Внутри зоны | — | 0,2–0,5 мс | незаметно |
| Между зонами одного региона | 10–100 км | 0,5–2 мс | кворум из 3 зон — стандартная практика |
| Франкфурт ↔ Париж | 480 км | ~10 мс | заметно на транзакциях с несколькими раундами |
| Франкфурт ↔ Вирджиния | 6 200 км | 85–95 мс | коммит становится дороже всей остальной обработки |
| Вирджиния ↔ Сингапур | 15 500 км | 220–250 мс | синхронная запись невозможна как продуктовое решение |
Теперь арифметика. Пусть транзакция требует одного кворумного раунда (протокол типа Raft с лидером и репликами в других регионах — механика в статье «Консенсус»). Кворум из трёх регионов достигается, когда ответила ближайшая из двух удалённых реплик. Если у лидера во Франкфурте реплики в Париже (10 мс) и Вирджинии (90 мс), кворум придёт через ~10 мс — приемлемо. Если реплики в Вирджинии и Сингапуре, кворум придёт через ~90 мс, и это на каждый коммит.
Дальше считаем сквозной эффект. Оформление заказа — 4 последовательные записи. Одна зона: 4 × 2 мс = 8 мс на коммиты. Кворум через океан: 4 × 90 мс = 360 мс, к которым добавится время самой обработки. Пользователь получает ответ за полсекунды вместо восьмидесяти миллисекунд — при том, что 95 % ваших пользователей живут в одной стране и не получают взамен ничего.
Отсюда — правило, которое почти всегда верно: географию выбирают под задержку кворума, а не под красивую карту. Три зоны одного региона плюс один удалённый регион для DR почти всегда лучше, чем три далеко разнесённых региона в кворуме. Google Spanner (статья OSDI 2012) решает эту задачу иначе — через TrueTime и явное «время ожидания коммита», но и там географию конфигурации подбирают под допустимую задержку записи; физику обойти не удалось никому.
Есть и второй счёт, менее очевидный: число раундов. Если ваш протокол делает два обращения к БД на запрос, а не одно, то через океан вы платите двойную цену. Именно поэтому в геораспределённых системах особенно жёстко борются за «одна операция — один раунд»: batch API, хранимая логика на стороне БД, идемпотентные апсерты вместо «прочитать, проверить, записать».
5. Четыре топологии данных
Вычисления реплицировать легко: они без состояния, их можно развернуть где угодно. Вся сложность геораспределения — в данных. Топологий, по большому счёту, четыре.
1. Одна запись, реплики на чтение (single-writer). Записи идут в один регион, асинхронные реплики в других регионах обслуживают чтения. Просто, дёшево, сохраняет все инварианты. Цена: пользователи вне основного региона пишут через океан (медленно) и читают устаревшие данные (лаг). Ловушка read-your-writes: пользователь создал комментарий и не увидел его, потому что чтение ушло на локальную реплику. Лечится закреплением сессии на основной регион на N секунд после записи — тот же приём, что и с репликами в статье «Кэширование и масштабирование».
2. Партиционирование по домашнему региону (geo-partitioning). Данные пользователя целиком живут в его регионе; каждый регион — мастер для своего подмножества. Конфликтов нет по построению, потому что один и тот же объект пишется только в одном месте. Это правильный ответ на задачу «латентность записи для глобальной аудитории» и одновременно — на задачу резидентности. Цена: межрегиональные операции (перевод денег между пользователями разных регионов) становятся распределённой транзакцией и требуют саги — см. «Saga и распределённые транзакции».
3. Active-active с разрешением конфликтов. Пишут все регионы, конфликты разрешаются автоматически (last-write-wins, векторные часы, CRDT) или вручную. Даёт минимальный RTO и локальную задержку записи, но меняет модель данных: инварианты, требующие глобального взгляда, становятся недостижимыми. LWW при этом просто теряет данные молча — это не «стратегия разрешения», а «стратегия потери», и применять её можно только там, где потеря допустима (профиль, настройки, счётчик просмотров). Там, где нужна сходимость без потерь, применяют CRDT — см. работу Shapiro et al..
4. Синхронный кворум через регионы. RPO 0 ценой задержки из раздела 4. Оправдано для небольшого объёма критичных данных (метаданные биллинга, реестр аренд, конфигурация) — и почти никогда для всего массива.
данные с лагом"| R1 end subgraph T2["2 · Партиционирование по home region"] P1["EU: мастер
для EU-юзеров"] P2["US: мастер
для US-юзеров"] U2["Пользователь US"] -->|"запись 5 мс"| P2 P1 <-->|"только справочники
и агрегаты"| P2 end subgraph T3["3 · Active-active"] A1["EU: мастер"] <-->|"двусторонняя
репликация"| A2["US: мастер"] A1 -.->|конфликты| CR["Разрешение:
LWW / CRDT / вручную"] A2 -.->|конфликты| CR end subgraph T4["4 · Синхронный кворум"] Q1["EU"] --- Q2["US"] Q2 --- Q3["AP"] Q3 --- Q1 QN["Коммит ждёт
большинство:
+90 мс"] end
Выбор почти всегда такой: топология 1 по умолчанию, топология 2 — когда появилась настоящая глобальная аудитория или требование резидентности, топология 4 — точечно для метаданных, топология 3 — только когда доказано, что 1 и 2 не годятся. Обратный порядок («начнём с active-active, потом упростим») не работает: упростить модель данных, потерявшую инварианты, стоит дороже, чем построить её заново.
6. Ячеистая архитектура: радиус поражения как проектный параметр
Регионы защищают от отказов инфраструктуры. От собственных ошибок защищают ячейки.
Ячейка — полный вертикальный срез системы: свои вычисления, своя база, своя очередь, свой кэш. Пользователь (или арендатор) привязан к одной ячейке и обслуживается только ей. Над ячейками стоит тонкий маршрутизатор, единственная задача которого — по идентификатору узнать ячейку. Подробный разбор подхода — в документе AWS «Reducing the Scope of Impact with Cell-Based Architecture».
Что даёт разрез на ячейки:
- Ограниченный радиус поражения. 8 ячеек — максимум 12,5 % пользователей за один отказ.
- Деплой как эксперимент. Раскатка по одной ячейке — это канареечный релиз с настоящей изоляцией данных, а не только трафика (сравните с приёмами из «CD и стратегии релиза»).
- Известный потолок. Ячейку тестируют на максимальную ёмкость один раз, дальше система растёт добавлением ячеек, а не увеличением одной большой. Это избавляет от целого класса аварий «выросли и упёрлись в неизвестный предел».
- Естественная единица эвакуации. Ячейку можно перенести в другой регион целиком.
Цена: маршрутизатор становится критической зависимостью (его надо делать предельно простым и статически стабильным), кросс-ячеечные операции усложняются, а утилизация падает — как у переборок в статье «Устойчивость», только уровнем выше.
Shuffle sharding: почему случайные пары лучше блоков
Если клиенту выделять не одну ячейку, а случайное подмножество из k узлов, изоляция получается почти бесплатно. Приём описан в AWS Builders’ Library, «Workload isolation using shuffle-sharding». Арифметика простая и очень убедительная.
from math import comb
def blast_radius(n_nodes: int, shard_size: int) -> dict:
"""Радиус поражения при shuffle sharding.
Один «отравленный» клиент выводит из строя все k своих узлов.
Другой клиент пострадает ПОЛНОСТЬЮ, только если все его k узлов
попали в ту же самую комбинацию, то есть в один вариант из C(n, k).
Сложность: O(1) по времени (comb считается за константу для малых n),
O(1) по памяти. Никакой симуляции не требуется.
"""
total = comb(n_nodes, shard_size) # число различных шардов
full_overlap = 1 / total # доля клиентов, потерявших всё
# Доля клиентов, задетых хотя бы одним общим узлом:
# 1 - (доля шардов, не пересекающихся с поражённым)
disjoint = comb(n_nodes - shard_size, shard_size) / total
return {
"комбинаций": total,
"полный отказ, %": round(full_overlap * 100, 4),
"частичное влияние, %": round((1 - disjoint) * 100, 1),
}
for n, k in [(8, 1), (8, 2), (16, 2), (100, 5)]:
print(n, k, blast_radius(n, k))
# 8 1 {'комбинаций': 8, 'полный отказ, %': 12.5, 'частичное влияние, %': 12.5}
# 8 2 {'комбинаций': 28, 'полный отказ, %': 3.5714, 'частичное влияние, %': 46.4}
# 16 2 {'комбинаций': 120, 'полный отказ, %': 0.8333, 'частичное влияние, %': 24.2}
# 100 5 {'комбинаций': 75287520, 'полный отказ, %': 0.0, 'частичное влияние, %': 23.0}
Читать таблицу надо так: при 100 узлах и шарде из 5 вероятность, что два клиента делят все пять узлов, — одна на 75 миллионов. Полный отказ из-за чужой ошибки практически исключён. Взамен 23 % клиентов почувствуют частичную деградацию — и вот тут работают паттерны из главы про устойчивость: клиент с 5 узлами, из которых сломан один, переживает это ретраем на здоровый, если ретрай настроен правильно.
Важное условие, о котором забывают: shuffle sharding работает только если клиент умеет пережить отказ части своего шарда. Если библиотека клиента при первой же ошибке считает весь сервис недоступным, вся математика выше бесполезна.
Маршрутизатор ячеек: привязка должна быть фактом, а не функцией
Соблазн — вычислять ячейку как hash(user_id) % n_cells. Так делать нельзя: при изменении числа ячеек переедут все, а мигрировать одного проблемного арендатора будет невозможно. Привязка к ячейке — это хранимый факт с историей, а не чистая функция.
from dataclasses import dataclass
from enum import Enum
class Placement(Enum):
ACTIVE = "active" # ячейка обслуживает арендатора
MIGRATING = "migrating" # идёт перенос: пишем в старую, копируем в новую
DRAINING = "draining" # новые сессии не принимаем, старые дорабатывают
@dataclass(frozen=True)
class CellBinding:
tenant_id: str
cell_id: str
region: str
state: Placement
target_cell_id: str | None = None # заполнено во время миграции
class CellRouter:
"""Маршрутизатор ячеек: три обязательных свойства.
1. Статическая стабильность: решение принимается по ЛОКАЛЬНОЙ копии
таблицы привязок. Недоступность её хранилища не должна ронять
маршрутизацию — иначе радиус поражения снова 100 %.
2. Никакой бизнес-логики: чем он проще, тем меньше причин ему падать.
3. Идемпотентность: один tenant_id всегда попадает в одну ячейку,
пока привязка не изменена явно.
"""
def __init__(self, bindings: dict[str, CellBinding], fallback_cell: str):
self._bindings = bindings # локальный снимок, обновляется фоном
self._fallback = fallback_cell # для новых арендаторов
def resolve(self, tenant_id: str) -> str:
binding = self._bindings.get(tenant_id)
if binding is None:
# Новый арендатор: выбрать наименее загруженную ячейку
# и СОХРАНИТЬ решение, а не пересчитывать его каждый раз.
return self._fallback
# Даже во время миграции истина остаётся в исходной ячейке:
# переключение — отдельный явный шаг, а не побочный эффект.
return binding.cell_id
def evacuate(self, cell_id: str) -> list[str]:
"""Арендаторы, подлежащие переносу. Сам перенос асинхронный,
с проверкой целостности и явным переключением привязки."""
return [b.tenant_id for b in self._bindings.values()
if b.cell_id == cell_id and b.state is Placement.ACTIVE]
Такая модель — прямое продолжение разговора про арендаторов в статье «Мультитенантность»: ячейка и есть физическая единица изоляции арендаторов, а не просто tenant_id в условии WHERE.
7. Маршрутизация трафика и статическая стабильность
Данные подготовлены, ячейки нарезаны. Осталось решить, как трафик узнаёт, что регион сменился. Вариантов три, и у каждого своя цена переключения.
| Механизм | Время реального переключения | Ограничения |
|---|---|---|
| DNS с малым TTL | 5–30 минут | TTL соблюдают не все: провайдерские резолверы округляют вверх, клиенты и JVM кэшируют, браузеры удерживают соединения |
| Anycast (BGP) | десятки секунд | требует своей автономной системы и адресного пространства; сходимость BGP не мгновенна |
| Глобальный балансировщик провайдера | секунды | вы зависите от его управляющей плоскости — той самой, которая может отказать вместе с регионом |
Самое частое разочарование — DNS. План «поменяем запись, TTL 60 секунд, переключимся за минуту» на практике даёт долгий хвост: часть трафика приходит в мёртвый регион ещё десятки минут. Планировать RTO надо по этому хвосту, а не по TTL; детали работы резолверов и кэширования — в статье «DNS».
Общее правило для этого слоя: глобальный маршрутизатор обязан быть проще всего, что за ним стоит. Он единственный компонент архитектуры, у которого нет второго экземпляра в другом регионе, — значит, в нём не должно быть ни бизнес-логики, ни обращений к базам, ни зависимостей, способных деградировать вместе с регионом, который он должен обойти.
Статическая стабильность
Главный принцип геораспределённых архитектур звучит контринтуитивно: во время аварии система не должна ничего создавать. Он называется статической стабильностью и разобран в AWS Builders’ Library, «Static stability using Availability Zones».
Смысл: если план переключения включает «запустим инстансы в резервном регионе», «создадим правило балансировщика», «выпустим сертификат» — вы поставили своё восстановление в зависимость от управляющей плоскости, которая в момент региональной аварии перегружена запросами всех остальных клиентов провайдера, делающих ровно то же самое. Авария AWS 7 декабря 2021 года (официальный разбор) — учебный пример: сервисы данных работали, а управляющая плоскость не отвечала, и все планы вида «в аварии мы создадим ресурсы» не сработали.
Практический вывод: ёмкость резервного региона должна быть создана и оплачена заранее, а переключение обязано быть изменением одного значения (флаг, вес маршрутизации, DNS-запись), а не последовательностью создающих операций.
def sizing_for_static_stability(peak_rps: int, regions: int,
per_node_rps: int, headroom: float = 0.30) -> dict:
"""Сколько ёмкости надо держать, чтобы пережить потерю одного региона
БЕЗ создания новых ресурсов.
Принцип N+1: оставшиеся регионы обязаны принять весь трафик,
не выходя за целевую утилизацию (1 - headroom).
"""
surviving = regions - 1
rps_per_surviving = peak_rps / surviving # весь пик на оставшихся
nodes_needed = rps_per_surviving / (per_node_rps * (1 - headroom))
total_nodes = nodes_needed * regions # столько держим всегда
single_region_nodes = peak_rps / (per_node_rps * (1 - headroom))
return {
"узлов в каждом регионе": round(nodes_needed + 0.5),
"узлов всего": round(total_nodes + 0.5),
"переплата к одному региону": round(total_nodes / single_region_nodes, 2),
}
print(sizing_for_static_stability(peak_rps=20_000, regions=2, per_node_rps=500))
# {'узлов в каждом регионе': 58, 'узлов всего': 115, 'переплата к одному региону': 2.0}
print(sizing_for_static_stability(peak_rps=20_000, regions=3, per_node_rps=500))
# {'узлов в каждом регионе': 29, 'узлов всего': 86, 'переплата к одному региону': 1.5}
print(sizing_for_static_stability(peak_rps=20_000, regions=4, per_node_rps=500))
# {'узлов в каждом регионе': 20, 'узлов всего': 77, 'переплата к одному региону': 1.33}
Последнее число — самый весомый аргумент в разговоре с финансами: два региона стоят двойной ёмкости, три — полуторной, четыре — трети сверху. Чем больше активных регионов, тем дешевле обходится каждая следующая девятка доступности. Резерв, который простаивает, стоит столько же, сколько активный, но при этом не проверяется трафиком — отсюда популярность конфигураций «три активных региона по 33 % трафика» вместо «один активный плюс один холодный».
Про целевую утилизацию и запас 30 % — та же логика, что в статье «Планирование ёмкости»: за 70 % утилизации очереди начинают расти нелинейно, и «свободные 10 %» на деле означают отказ.
8. Переключение: кто принимает решение и что такое split brain
Самая недооценённая часть — не техника переключения, а решение о нём.
на автоматике по флапу сети D->>DB: Какой сейчас лаг репликации? DB-->>D: RPO = 4 с (≈ 90 незавершённых операций) Note over D: Решение принимается ПО ЧИСЛУ,
а не по ощущению D->>DB: Отгородить регион A (fencing): отозвать токен записи DB-->>D: Токен эпохи 42 отозван, регион A не сможет писать D->>DB: Промоутировать реплику в мастер (эпоха 43) DB-->>D: Готово, принимаю запись D->>R: Перевести 100 % трафика на регион B R->>B: Трафик пошёл B->>DB: Записи идут в новый мастер Note over B: Прогрев кэшей: первые минуты
латентность выше обычной Note over D,DB: Регион A вернулся — НЕ включать автоматически.
Сначала сверка расхождений, потом обратное переключение
Три ошибки, которые видно на этой диаграмме.
Автоматическое переключение по недоступности. Сеть флапнула на 40 секунд — автоматика переключила регион — сеть вернулась — два мастера принимают запись. Это split brain, и он гораздо дороже лишних десяти минут простоя: расхождение данных придётся разбирать вручную, а часть операций — компенсировать. Автоматический failover уместен для реплик внутри региона, где есть кворум и низкая задержка; для межрегионального переключения почти всегда правильнее «автоматика готовит, человек подтверждает».
Отсутствие отгораживания (fencing). Прежде чем повысить реплику, нужно гарантировать, что старый мастер больше не примет запись, даже если он на самом деле жив и просто не виден мониторингу. Механизм — токен с номером эпохи: хранилище отвергает запись со старым номером. Без fencing вы не переключаете мастер, а создаёте второй.
Автоматический возврат. Вернувшийся регион содержит данные, которых нет в новом мастере (те самые «жёлтые точки» с картинки RPO). Автоматическое обратное переключение затрёт часть работы. Возврат — отдельная запланированная операция в спокойное время, с предварительной сверкой расхождений.
данные продолжают ехать Standby --> Warming: решение вернуть трафик Warming --> Active: кэши прогреты,
латентность в норме Active --> Suspect: health-check провалился Suspect --> Active: восстановился < 60 с
(не переключаемся) Suspect --> Fenced: подтверждён отказ,
токен записи отозван Fenced --> Recovering: регион вернулся,
трафика не даём Recovering --> Reconciling: сверка расхождений
с новым мастером Reconciling --> Standby: расхождения разобраны Reconciling --> Rebuilding: расхождения велики —
перелив данных заново Rebuilding --> Standby note right of Suspect Задержка перед решением — главный предохранитель от split brain end note
Полезная деталь: состояние Draining существует не только для аварий. Регулярный плановый вывод региона (Netflix называет это эвакуацией) — единственный способ убедиться, что переключение работает; об этом раздел 11.
9. Данные, которые нельзя увезти: резидентность и домашний регион
Третья задача из раздела 1 — регуляторная. Требования вида «персональные данные граждан хранятся на территории страны» превращают географию из вопроса инженерии в вопрос соответствия, и архитектурно это выглядит так: у каждого арендатора есть домашний регион, и данные с высоким уровнем чувствительности не покидают его никогда, включая бэкапы, журналы, кэши и очереди.
Отсюда конкретное следствие для модели данных: её надо резать на три класса.
Три класса на схеме:
- Резидентные данные — не покидают домашний регион. DR для них решается внутри юрисдикции: вторая зона, второй регион той же страны, физически отдельная площадка.
- Метаданные маршрутизации — минимальный набор, нужный маршрутизатору везде (кто есть, где живёт, активен ли). Их приходится реплицировать глобально, поэтому в них не должно быть ничего чувствительного: идентификатор, код региона, статус.
- Глобальные справочники — только для чтения, реплицируются свободно, конфликтов не порождают.
Два подводных камня. Первый: ключи шифрования тоже имеют географию. Данные лежат в стране, а ключ управляется сервисом в другом регионе — с точки зрения ряда регуляторов данные считаются доступными вовне. Второй: журналы и трассировки — это данные. Централизованный сбор логов «во все регионы разом» — самый частый способ незаметно нарушить резидентность; фильтровать и обезличивать надо на источнике, до отправки.
10. Сколько это стоит на самом деле
Три статьи расходов, из которых обычно считают только первую.
Дублирование ёмкости. Из расчёта в разделе 7: 2 региона — переплата 100 %, 3 — 50 %, 4 — 34 %.
Межрегиональный трафик. Тарифицируется отдельно и обычно на порядок дороже внутризонального. Прикидка: сервис, реплицирующий 50 Мбит/с изменений между регионами, прокачивает примерно 16 ТБ в месяц; при типовой цене порядка 0,02 USD за гигабайт это около 320 USD в месяц — терпимо. Но если между регионами ходит не поток изменений, а сами запросы (сервис A в EU дёргает сервис B в US на каждый пользовательский запрос), объёмы и задержки растут вместе, и счёт становится ощутимым. Правило: между регионами ходит репликация, а не вызовы. Регион обязан обслуживать запрос целиком локально — иначе вы получили не отказоустойчивость, а систему, которая падает, если упал любой из двух регионов.
Сложность как расход. Удвоение числа сред, ветвление процедур деплоя, удвоенное пространство состояний при отладке, обучение дежурных, регулярные учения. Это не строчка в счёте провайдера, но по опыту именно она съедает больше всего.
Обратите внимание на две точки. Мультизонность внутри одного региона — самая дешёвая доступность из существующих: три зоны дают защиту от подавляющего большинства реальных инфраструктурных отказов почти без переплаты и без изменения модели данных. Начинать надо с неё, и очень многим системам этого достаточно. И холодный резерв без учений — дорогая иллюзия: деньги платятся, а RTO неизвестен, потому что процедуру никто не выполнял.
Полезная проверка перед стартом проекта: посчитайте стоимость часа простоя (потерянная выручка + компенсации по SLA + репутация) и умножьте на разницу RTO между вариантами. Если переход от warm standby к active-active сокращает ожидаемый годовой простой на 20 минут, а стоит 300 000 USD в год, вы платите 15 000 USD за минуту — решение может быть верным для платёжной системы и заведомо неверным для внутреннего портала. Механика такого счёта — в разделе про CBAM статьи «Архитектурные решения».
11. Непроверенный failover не существует
Тезис жёсткий, но подтверждается каждым крупным постмортемом: процедура переключения, которую не выполняли на боевой системе, с вероятностью, близкой к единице, не сработает в аварию. Причины всегда бытовые: истёкший сертификат в резерве, не накатанная миграция, забытый параметр, недостающая квота, runbook, ссылающийся на сотрудника, который уволился.
Лестница зрелости проверок:
- Восстановление из бэкапа с секундомером — ежемесячно, на отдельный стенд. Даёт настоящее RTO уровня «backup & restore» и подтверждает, что бэкапы читаются.
- Проверка RPO по данным — постоянно, метрикой
current_rpoиз раздела 3, с алертом на превышение обещанного значения. - Плановая эвакуация региона — раз в квартал, в рабочее время, с дежурной командой на связи. Трафик переводится на другие регионы, система работает без эвакуированного региона несколько часов.
- Внезапное учение — раз в полгода, без предупреждения команды (но с предупреждением руководства), с настоящим отключением.
Netflix публично описал эту практику в заметке «Active-Active for Multi-Regional Resiliency»: регулярная эвакуация целого региона — не героизм, а рутина, потому что только рутина даёт уверенность. Общая методология учений и хаос-экспериментов разобрана в статье «Хаос-инженерия и учения».
Отдельно стоит завести автоматическую предполётную проверку резервного региона — она гоняется по расписанию, а не «накануне учения», иначе проверяет подготовку, а не готовность. Минимальный набор: current_rpo ниже обещанного порога; число готовых реплик достаточно, чтобы принять весь трафик без создания ресурсов; сертификаты живут дольше горизонта учения; контрольная сумма схемы БД в резерве совпадает с основным регионом; фича-флаги и конфигурация не разъехались. Каждая из этих четырёх-пяти проверок ловит реальный класс отказов, которые иначе всплывают ровно в момент эвакуации.
Отдельно про runbook. Он должен быть исполнимым в три часа ночи человеком, который не проектировал систему: конкретные команды, конкретные критерии («переключаемся, если недоступность держится более 10 минут И current_rpo меньше 60 секунд»), явный список того, чего делать нельзя. Runbook в формате «поднять реплику» — это не runbook, а напоминание.
12. Типичные ошибки
- Второй регион как символ зрелости. Строится потому, что «так у взрослых», без сформулированной задачи из раздела 1. Итог — двойной счёт и неизменившаяся доступность.
- Резерв, который никогда не видел трафика. В аварию выясняется, что он не работает. Лечится либо постоянной долей трафика, либо регулярной эвакуацией.
- RPO считается нулевым при асинхронной репликации. Реальный RPO равен лагу; лаг надо измерять по данным и алертить на него.
- Переключение зависит от управляющей плоскости. План «в аварию создадим ресурсы» ломается ровно тогда, когда нужен. Нарушение статической стабильности.
- Автоматический failover без fencing. Прямая дорога к split brain и ручному разбору расхождений.
- Кросс-региональные вызовы вместо кросс-региональной репликации. Система с двумя регионами, которая падает при отказе любого, — это система с доступностью хуже, чем у одного региона.
- Общий компонент, о котором забыли. Один сервис аутентификации, одна очередь, один шедулер «в основном регионе». Радиус поражения снова 100 %, вся остальная работа обесценена. Ищется вопросом «что у нас существует в единственном экземпляре?».
- Логи, метрики и ключи шифрования вне юрисдикции. Резидентность нарушают не основные таблицы, а телеметрия.
- Ячейки без изоляции данных. Разрезали вычисления, оставили общую базу. Это не ячейки, а группы подов: логическая ошибка по-прежнему кладёт всех.
- RTO обещан бизнесу, но не измерен. Число в договоре, которое никто не проверял восстановлением, — это не обязательство, а лотерея.
13. Как это выглядит в проде
AWS. Регион делится на зоны, крупные сервисы внутри региона разрезаны на ячейки; управляющая плоскость намеренно отделена от плоскости данных, чтобы отказ первой не мешал второй работать. Разборы аварий публикуются: ноябрь 2020, Kinesis в us-east-1 и декабрь 2021, деградация управляющей плоскости — оба стоит прочитать целиком, это лучший учебный материал по теме, чем любая книга.
Netflix. Три активных региона, регулярная эвакуация одного из них, ёмкость рассчитана так, чтобы двое оставшихся приняли весь трафик. Данные — асинхронная репликация с явным принятием eventual consistency: профиль и позиция просмотра могут разъехаться на секунды, и продукт спроектирован так, что это не критично.
Google Spanner. Синхронная репликация кворумом с TrueTime; конфигурация географии подбирается под допустимую задержку записи. Цена честности — задержка коммита, и её платят осознанно, для данных, где она оправдана.
Cloudflare. Anycast поверх сотен точек присутствия: переключение при отказе площадки — вопрос сходимости BGP, а не DNS. Обратная сторона — глобальная плоскость конфигурации, ошибка в которой распространяется всюду сразу; их разбор аварии 21 июня 2022 показывает, как это выглядит.
Типичная зрелая продуктовая компания без глобальной аудитории. Один регион, три зоны, синхронный кворум внутри региона, асинхронная реплика в соседнем регионе с измеряемым RPO, ручное переключение по runbook, ежеквартальные учения. Это скучно, дёшево и покрывает подавляющее большинство реальных сценариев. Если вы не уверены, что вам нужно больше, — вам не нужно больше.
Мини-итог
- «Мультирегион» — это три разные задачи: латентность, отказоустойчивость, резидентность. Их решают разными топологиями; смешивание — источник дорогих ошибок.
- RTO и RPO — не одно требование, а два независимых. RPO целиком определяется механикой репликации и при асинхронной репликации равен её лагу, который надо измерять по данным.
- Физику не обойти: RTT ≈ расстояние/100 мс. Синхронная запись через океан — продуктовое решение, а не техническое.
- Топология по умолчанию — одна запись плюс реплики чтения; партиционирование по домашнему региону — правильный ответ на глобальную аудиторию; active-active меняет модель данных навсегда и берётся последним.
- Регионы защищают от инфраструктурных отказов, ячейки — от собственных ошибок. Радиус поражения — проектируемая величина, и shuffle sharding делает её пренебрежимо малой почти бесплатно.
- Статическая стабильность: во время аварии система ничего не создаёт. Ёмкость оплачена заранее, переключение — изменение одного значения.
- Межрегиональный failover — решение человека по числам, с отгораживанием старого мастера и без автоматического возврата.
- Три активных региона дешевле двух в пересчёте на девятку доступности и проверяют себя трафиком, а холодный резерв без учений — самая дорогая форма самообмана.
- Непроверенный failover не существует. Регулярная эвакуация региона — рутина, а не подвиг.
Источники
- AWS. Disaster Recovery of Workloads on AWS: Recovery in the Cloud — каноническая классификация четырёх стратегий с RTO/RPO.
- AWS Builders’ Library. Static stability using Availability Zones — почему план восстановления не должен создавать ресурсы.
- AWS Builders’ Library. Workload isolation using shuffle-sharding — арифметика радиуса поражения.
- AWS Well-Architected. Reducing the Scope of Impact with Cell-Based Architecture — ячейки, маршрутизаторы, эксплуатация.
- Google Cloud. Disaster recovery planning guide — та же классификация с другой стороны и подробностями про данные.
- Corbett et al. Spanner: Google’s Globally-Distributed Database, OSDI 2012 — как выглядит честная синхронная геораспределённая запись и чего она стоит.
- Shapiro et al. A comprehensive study of Convergent and Commutative Replicated Data Types — формальная основа CRDT для active-active.
- Netflix Tech Blog. Active-Active for Multi-Regional Resiliency — практика эвакуации региона.
- AWS. Summary of the Amazon Kinesis Event in Northern Virginia и Summary of the AWS Service Event in the Northern Virginia Region — два подробных постмортема региональных аварий.
- GitLab. Postmortem of database outage of January 31 — что бывает, когда бэкапы есть, а восстановление не проверялось.
- Cloudflare. Cloudflare outage on June 21, 2022 — цена глобальной плоскости конфигурации.
- Google SRE. Managing Critical State: Distributed Consensus for Reliability — кворумы, эпохи, отгораживание.
Что дальше
Мы разнесли систему по географии и научились переживать потерю целого региона. Остался слой, через который весь этот трафик физически проходит и в котором принимается половина решений о его судьбе, — кромка: маршрутизация и переключение, аутентификация, лимиты, ретраи, трассировка, единый формат ошибок. Где именно всё это исполняется — в библиотеке, в API-шлюзе, в BFF или в service mesh — обычно решается молча, а потом оборачивается тройными ретраями и рассогласованными таймаутами. Об этом — завершающая статья трека.
Читайте дальше: «Кромка системы: API-шлюз, BFF, sidecar и service mesh».