TL;DR
Fly.io отказались от централизованного «источника истины» и строят оркестрацию вокруг отдельных рабочих узлов, а не единого планировщика. Попытка использовать Consul как глобальную систему маршрутизации и дополнять его кэшем на SQLite показала ограничения классического распределённого консенсуса на глобальных расстояниях.
В ответ родился Corrosion — сервис-дискавери на Rust, который реплицирует SQLite через gossip-протокол (на базе идей SWIM) и CRDT-расширение cr-sqlite, без Raft и других формального консенсуса. Реальные инциденты — от дедлока в коде прокси до «безобидных» DDL-миграций и истёкших сертификатов — подсветили слабые места, привели к добавлению watchdog-ов, чекпойнтов в объектном хранилище и пересмотру модели обновлений.
В итоге архитектура эволюционировала к регионализированному состоянию: локальные кластеры Corrosion по регионам плюс глобальный слой привязки приложений к регионам, что уменьшает радиус поражения при ошибках и лучше соответствует реальному масштабу платформы.
Fly.io превращает контейнеры Docker в Fly Machines — микро-ВМ (micro-VM), работающие на нашем собственном оборудовании по всему миру. Самое сложное в управлении этой платформой — не серверы и не сеть, а связывание этих двух компонентов воедино.
Несколько раз в секунду, когда CI/CD-конвейеры клиентов создают или останавливают Fly Machines, наша система синхронизации состояния рассылает обновления по внутренней меш-сети, чтобы пограничные прокси — от Токио до Амстердама — могли поддерживать актуальную таблицу маршрутов и направлять запросы пользователей к ближайшим экземплярам приложений.
1 сентября 2024 года в 15:30 по восточному времени (EST) появилась новая Fly Machine с параметром конфигурации “virtual service”, только что добавленным разработчиком. Через несколько секунд каждый прокси-сервер в нашем парке машин намертво завис. Это был самый серьёзный сбой за всё время: в течение этого периода ни один пользовательский запрос не мог дойти до приложений клиентов.
Распределённые системы усиливают масштаб аварий. Передавая данные по сети, они распространяют и ошибки в системах, которые от этих данных зависят. В случае Corrosion — нашей системы распределения состояния — такие баги разносятся особенно быстро. Код прокси, обрабатывавший то самое обновление Corrosion, оказался жертвой печально известной ловушки конкурентности в Rust: выражение if let, работающее с RwLock, ошибочно (хотя и логично) предположило в ветке else, что блокировка уже снята. Результат — мгновенная и крайне заразная взаимная блокировка (deadlock).
Главный урок, который мы усвоили дорогой ценой: никогда не доверяйте распределённой системе, у которой нет своей занимательной истории провала. Если распределённая система ни разу не испортила вам выходные или не заставила всю ночь сидеть перед монитором, вы её ещё не поняли. Именно поэтому мы представляем Corrosion — нестандартную систему обнаружения сервисов, созданную для нашей платформы и выложенную в открытый доступ.
Наши грабли, целящиеся прямо в лицо
Синхронизация состояния — самая трудная задача при эксплуатации такой платформы, как наша. Зачем же для неё строить ещё одну рискованную распределённую систему? Потому что, что бы мы ни пробовали, те самые грабли всё равно поджидают, чтобы заехать по лбу. Причина — в нашей модели оркестрации.
Почти любой популярный оркестратор (включая Kubernetes) полагается на централизованную базу данных, на основе которой принимаются решения о размещении новых рабочих нагрузок. Отдельные серверы знают, что они у себя запускают, но центральная база остаётся «источником истины». В Fly.io, чтобы масштабироваться на десятки регионов по всему миру, мы переворачиваем эту идею: «источником истины» по своим рабочим нагрузкам становятся сами отдельные серверы.
В нашей платформе центральный API «выставляет задачи на торги» фактически на глобальном рынке конкурирующих исполнительных серверов (physical workers). Перенеся «источник истины» с центрального планировщика на отдельные серверы, мы масштабируемся горизонтально, не упираясь в базу данных, которой одновременно требуются высокая отзывчивость и согласованность между Сан-Паулу, Вирджинией и Сиднеем.
Модель торгов изящна, но её недостаточно для маршрутизации сетевых запросов. Чтобы HTTP-запрос в Токио нашёл ближайший экземпляр нужного приложения в Сиднее, нам действительно нужна некая глобальная карта всех размещаемых у нас приложений.
Дольше, чем следовало бы, мы опирались на HashiCorp Consul для маршрутизации трафика. Consul — замечательное ПО. Не стройте на нём глобальную систему маршрутизации. Потом мы поверх Consul сделали кэши в SQLite. SQLite тоже отличная штука. Но и так делать не стоит.
Как курица, оставленная жариться на гриле без присмотра на заднем дворе, «по-настоящему глобальный распределённый консенсус» обещает вкуснятину, а приносит одно лишь возгорание. Протоколы консенсуса вроде Raft «сыпятся» на больших расстояниях. И они противоречат архитектуре нашей платформы: наш кластер Consul, работавший на самом мощном «железе», какое мы могли купить, попусту тратил время, добиваясь консенсуса по обновлениям, которые изначально не могли конфликтовать.
Corrosion
Чтобы построить глобальную базу маршрутизации, мы отказались от распределённого консенсуса и взяли за основу реальные протоколы маршрутизации.
Такой протокол, как OSPF, работает в той же модели и с теми же ограничениями, что и мы. OSPF — это протокол маршрутизации на основе состояния связей (link-state), что удобно для нас: маршрутизаторы сами являются источником истины по своим связям и обязаны быстро сообщать об изменениях всем остальным маршрутизаторам, чтобы сеть могла принимать решения о маршрутизации трафика.
Нам в чём-то проще, чем OSPF. Его алгоритм «затопления» (flooding) не может предполагать связность между произвольными маршрутизаторами (собственно, ради решения этой проблемы OSPF и существует). У нас же между серверами работает глобальная, полносвязная mesh-сеть WireGuard. Нам остаётся лишь эффективно распространять «слухи» (gossip).
Corrosion — это программа на Rust, которая реплицирует базу данных SQLite с помощью gossip-протокола.
Как и в Consul, наш gossip-протокол построен на SWIM. Начните с самого простого и «тупого» протокола членства в группе, какой только можно представить: каждый узел шлёт heartbeat-сигналы всем известным ему узлам. Теперь всего две доработки: во-первых, на каждом шаге рассылайте сигналы случайному подмножеству узлов, а не всем подряд. Во-вторых, вместо того чтобы паниковать при сбое heartbeat-сигнала, помечайте узел как «подозрительный» и просите другое случайное подмножество соседей проверить его доступность (ping). SWIM очень быстро сходится к согласованному представлению о членстве.
Когда с членством разобрались, мы запускаем QUIC между узлами кластера, чтобы рассылать изменения и синхронизировать состояние для новых узлов.
Corrosion выглядит как глобально синхронизированная база данных. Её можно открыть через SQLite и просто читать таблицы. Интересным её делает как раз то, чего там нет: никаких блокировок, никаких центральных серверов и никакого распределённого консенсуса. Вместо этого мы используем нашу модель оркестрации: узлы владеют своим состоянием, поэтому обновления от разных узлов почти никогда не конфликтуют.
Порядок мы всё же задаём. Каждый узел в кластере Corrosion в итоге получит один и тот же набор обновлений — пусть и в разном порядке. Чтобы каждый экземпляр пришёл к одному и тому же рабочему представлению состояния, мы используем cr-sqlite — расширение SQLite с поддержкой CRDT-репликации.
cr-sqlite работает так: указанные таблицы SQLite помечаются как управляемые CRDT. Для этих таблиц изменения любых столбцов любых строк протоколируются в специальной таблице crsql_changes. Обновления применяются по правилу «побеждает последняя запись» (last-write-wins) с использованием логических меток времени, то есть с каузальным упорядочиванием, а не по реальному (wall-clock) времени. Подробности устройства можно почитать на GitHub.
По мере обновления строк в обычных SQL-таблицах Corrosion соответствующие изменения собираются из crsql_changes. Их упаковывают в пакеты (batches) и распространяют по кластеру через gossip-протокол.
Когда всё идёт штатно, с Corrosion легко иметь дело. Многим потребителям данных не обязательно знать о её существовании — важно лишь, где лежит база. Мы не переживаем о «выборах лидера» и не грызем ногти, глядя на метрики очередей обновлений. И работает это всё очень быстро.
Бывает
Это история о том, как мы однажды приняли ряд безупречных инженерных решений и больше никогда не сталкивались с проблемами.
Мы уже рассказывали о худшем инциденте с участием Corrosion: она предельно эффективно распространила через gossip-протокол баг с взаимной блокировкой на все прокси-сервера в нашем парке и обрушила всю сеть. По правде говоря, в том сбое Corrosion была скорее свидетелем. Но в других эпизодах она отличилась сама.
Классическая эксплуатационная проблема: неожиданно «дорогая» DDL-миграция. Вы написали простую миграцию, протестировали, влили в ветку main и отправились спать, ошибочно полагая, что в проде она не устроит простоя. Такое случается даже с лучшими из нас.
А теперь добавим перца. Вы внесли пустяковое на вид изменение схемы в таблицу CRDT, подключённую к глобальной системе gossip. И вот при деплое тысячи мощных серверов по всему миру начинают в унисон слать сообщения о согласовании (reconciliation) состояния базы — и этим кладут весь кластер.
Ровно так и было у нас в прошлом году, когда один из коллег добавил nullable-столбец в таблицу Corrosion. Новые nullable-столбцы — криптонит для больших таблиц Corrosion: в cr-sqlite нужно дозаполнить значения во всех строках. Вышло так, будто каждая Fly Machine на нашей платформе одновременно сменила состояние — словно назло нам.
Ещё более жёсткая байка: долгое время мы держали и Corrosion, и Consul — ведь две распределённые системы это как бы вдвое надёжнее. Как-то утром у Consul истёк mTLS-сертификат. Каждый узел в нашем парке разорвал соединение с Consul.
Мы должны были пережить это без последствий. Corrosion работала. Но под капотом каждый узел в парке крутил цикл с экспоненциальным backoff (паузы с увеличением интервала), пытаясь восстановить соединение с Consul. Каждая такая попытка снова вызывала код обновления состояния Fly Machine. Этот код вёл к записи в Corrosion.
Пока мы разобрались, что вообще происходит, мы буквально забили аплинки почти по всему парку. Просим прощения у наших аплинк-провайдеров.
Подобного на Fly.io не случалось уже давно, но теперь всё наше внимание — на том, как не допустить следующий инцидент.
Итерации
Оглядываясь назад, при развертывании Corrosion мы повторили ошибку, которую допустили с Consul: построили один глобальный домен состояния. В дизайне Corrosion ничего не требовало этого, и сейчас мы отказываемся от этого решения. Держите эту мысль. Уже от небольших доработок мы получили большой эффект.
Во-первых и главное — мы поставили сторожевые таймеры (watchdog-и) повсюду. Мы показывали вам заразный баг с дедлоком — он оказался смертельным, потому что в нашей модели риска не было пункта «эти программы на Tokio могут зависнуть». Теперь это не так. Во всех наших программах на Tokio есть встроенные watchdog-и: при залипании цикла событий сервис перезапускается и срабатывает громкое оповещение (алерты). Watchdog-и уже отменили несколько потенциальных аварий. Минимум кода, лёгкая победа. Сделайте так и у себя.
Затем мы как следует протестировали саму Corrosion. Мы уже писали о баге, который нашли в библиотеке Rust parking_lot. Несколько месяцев искали похожие ошибки с помощью Antithesis. И да, рекомендуем. Antithesis без труда воспроизвёл наши шаги по багу в parking_lot; будь он у нас тогда, тема для блога едва ли бы появилась. «Multiverse-отладка» — убийственно эффективна для распределённых систем.
Сколько ни тестируй, доверия к распределённой системе это полностью не добавит. Поэтому мы упростили восстановление базы данных Corrosion с узлов. Мы храним чекпойнты (снимки) базы Corrosion в объектном хранилище. Это было мудро. Когда в прошлом году всё окончательно пошло вразнос, у нас была опция перезапустить кластер — что в итоге мы и сделали. Это занимает время (база большая, а распространение изменений дорого), но диагностика и устранение проблем в распределённых системах отнимают ещё больше.
Мы также улучшили то, как узлы передают данные в Corrosion. До недавнего времени при каждом обновлении локальной базы узла мы публиковали инкрементальное обновление в Corrosion. Теперь же мы отказались от частичных обновлений. Когда меняется какая-то Fly Machine, мы заново публикуем весь набор данных для этой машины. Благодаря тому, как Corrosion разрешает изменения собственных записей, узел, получающий повторно опубликованные данные о Fly Machine, автоматически отфильтровывает no-op-изменения, прежде чем рассылать их через gossip-протокол. Устранение частичных обновлений перекрывает целый пласт багов (и, как нам кажется, добивает пару особо хитрых, за которыми мы давно гонялись). Так и надо было делать с самого начала.
И наконец вернёмся к проблеме глобального состояния. После «заразного» дедлока мы пришли к выводу, что нужно уйти от единственного кластера. Мы взялись за проект, который называем «регионализацией»: он создаёт двухуровневую схему баз данных. В каждом регионе, где мы работаем, запускается кластер Corrosion с детальными данными о каждой Fly Machine в этом регионе. Глобальный кластер затем привязывает приложения к регионам — этого достаточно, чтобы пограничные прокси-серверы принимали решения о маршрутизации трафика.
Регионализация уменьшает масштаб аварий, связанных с состоянием. Большинство объектов, которые мы отслеживаем, не обязаны иметь значение за пределами своего региона (что важно: большая часть изменений кода, касающихся этих объектов, тоже локальна для региона). Мы можем развертывать изменения такого рода так, чтобы в худшем случае они представляли риск лишь для одного региона.
Новая система работает
У большинства распределённых систем есть проблемы с синхронизацией состояния. У Corrosion иной «облик» по сравнению с большинством из них:
Она не опирается на распределённый консенсус — как Consul, Zookeeper, Etcd, Raft или rqlite (который мы едва не выбрали).
Она не зависит от крупномасштабного централизованного хранилища данных — вроде FoundationDB или баз, работающих поверх S3-совместимого объектного хранилища.
При этом система по-настоящему распределённая (узлы запускаются на тысячах серверов), сходится быстро (за секунды) и выглядит как простая база SQLite. Красота!
Достичь такого было непросто. Corrosion — значимая часть работы каждого инженера Fly.io, пишущего на Rust.
Часть успеха Corrosion в том, что мы внимательно относимся к тому, что в неё помещаем. Не каждому виду состояния, которым мы управляем, требуется распространение через gossip-протокол. Например, tkdb — бэкенд для наших токенов Macaroon — это гораздо более простой сервис на SQLite с репликацией через Litestream. То же самое и с Pet Sematary, хранилищем секретов, которое мы построили взамен HashiCorp Vault.
Тем не менее существует немало задач распределённого состояния, которым скорее подходит что-то, напоминающее протокол маршрутизации на основе состояния связей (link-state), а не распределённую базу данных. Если ваша задача из этого класса — смело пробуйте Corrosion.
Если в этой истории вы узнали свою инфраструктуру — дедлоки, падения, странное поведение сети и боль от гонки за доступностью — логично не останавливаться на теории. Ниже несколько демо-занятий, которые преподаватели курсов OTUS проведут бесплатно. Приходите пообщаться с экспертами и вместе разобрать проблемы уже на живых системах:
17 ноября, 20:00 — Инцидент-менеджмент в SRE. Как быстро находить, устранять и предотвращать сбои в системе. Записаться
19 ноября, 20:00 — Времена жизни и управление памятью в Rust. Записаться
26 ноября, 20:00 — Протоколы маршрутизации и резервирование или как быстро перестроить таблицу маршрутизации, не привлекая внимание. Записаться
Немного практики в тему — пройдите вступительный тест по Highload Architect и узнаете, есть ли пробелы в знаниях.