Создание устойчивого серверного ПО
Добрый день, ЛОР! Давно не постил, потому что нечего было сказать. А сейчас, кажется, есть.
Имеем проблему создания типичного транзакционного сервиса - банковское приложение, интернет-магазин, аренда чего-либо, т.д. Как сделать его устойчивым как с точки зрения корректности - запросы не теряются, дважды не повторяются, если не могут примениться - откатываются с отображением ошибки пользователю - так и с точки зрения UX - сервис сохраняет доступность?
Я ознакомился с книгами, блогами и выступлениями с конференций на эту тему, и хочу поделиться своими идеями, как это можно реализовать. Мне интересны ваши мысли, и то, как бы вы к данной задаче подошли - или вам уже приходилось это делать?
Итак, начнём с идемпотентности - свойства запроса обладать возможностью быть исполненным несколько раз, и иметь эффект как от одного исполнения. Ценность в следующем - допустим, клиент производил запрос, и соединение оборвалось. Был запрос принят или нет - не понятно. Если клиент переводил на другой счёт деньги - может ли он обновить страницу и не бояться, что деньги спишутся дважды? Проще всего достижимо добавлением id запроса в его тело - если запрос уже был исполнен, операция не производится, и возвращается сохранённый результат.
Далее распределённые транзакции, самый оптимальный вариант - сага. Сценарий - при заказе еды с доставкой, необходимо списать деньги со счёта, подать заявку на кухню, вызвать курьера. Все три действия должны произойти атомарно - если одно из них исполнить невозможно, два других нужно отменить, и передать пользователю ошибку. Откатываемая цепочка транзакций называется сагой.
Как достигнуть устойчивости? Имеем парк серверов, каждый из них выполняет свою операцию, с возможностью её отменить - их необходимо координировать в условиях риска выхода из строя серверов, ПО и сети. При выходе из строя сервера необходимо, чтобы он возобновил исполнение присвоенных ему операций - нужно сохранять их в реплицированную БД. Кажется, любая простая БД типа Ключ-Значение подойдёт. Когда исполнение возобновится - оно должно продолжить с момента последней транзакции в цепочке - то есть после каждой транзакции в цепочке, промежуточное состояние должно сохраняться в БД - с возможностью возобновления операции другим исполнителем.
Теперь координация задач - её можно производить либо централизованно, с механизмами RPC, либо распределённо, через брокер сообщений, с реакцией на их получение. В первом случае получаем лучшую отслеживаемость, во втором - большую устойчивость к отказу исполнителей - сообщения могут хранится в брокере.
Уточним, какой конкретно мерой будет достигаться устойчивость. Наш сервис будет устойчивым, поскольку в момент принятия запроса, достоверно известно, сколько на его исполнение необходимо памяти и процессорного времени - и мы можем надёжно их зарезервировать. Если их нет - мы сразу отвечаем ошибкой. Сюда можно отнести время исполнения - но это можно контролировать на стороне клиента. Верхнеуровнево - сервер разделён по памяти и процессору на обработчики, и мы можем достоверно сказать, сколько обработчиков могут исполняться одновременно. Предсказуемость - ключевая составляющая устойчивости.
Но какой должен быть лимит по ресурсам для абстрактного запроса? Поскольку если запрос - «Исполнить SQL выражение» (в случае, если наш сервис - база данных) - у него может быть самое разное потребление памяти и процессорного времени. Решением, на мой взгляд, будет разделение непредсказуемого запроса на предсказуемые - и заранее зная их количество и стоимость, можно оценить затраты на изначальный запрос. Для SQL запроса это будет сначала запрос типа PLAN - сам по себе достаточно предсказуемый - а затем проверка наличия ресурсов и исполнение для каждого шага плана. Но если верхнеуровневый запрос, не имеющий достаточно ресурсов, имеет смысл отбросить сразу - то порождённые запросы стоит выстраивать в очередь.
Зная ограничения по ресурсам, необходимо реализовать их соблюдение. В этом нам поможет ядро ОС - оно отслеживает объём памяти, закреплённый за процессом, и процессорное время, им затраченное. В Linux порог можно выставить командой ulimit и системным вызовом setrlimit. Вводить ограничения необходимо именно на уровне процесса, поскольку память по факту принадлежит не потоку и не корутине, а именно процессу (стек у каждого свой, но куча - общая для процесса), и если память кончается - OOM Killer убивает процесс. Ситуация сложнее для cgroup, в которых выставлено убийство всей cgroup сразу (как для подов в k8s), но это отдельная история, и ulimit можно ставить чуть ниже суммарного ограничения для cgroup. Теперь касательно процессорного времени - оно также действует на процесс целиком (может есть лимит на конкретный поток?). Конечно, запуск процесса под каждый запрос - схема плохо масштабируемая (хотя с идеологической точки зрения, кажется, наиболее правильная), поэтому можно использовать пул процессов, поднимающих свои лимиты для каждого запроса. Также можно запускать N корутин в каждом процессе - суммируя их лимиты. Если одна корутина перейдёт порог, убиты будут все N - но это лучше, чем если бы все запросы жили корутинами в одном процессе, и умерли бы все.
Я вполне представляю, как это можно реализовать для Reverse-proxy (nginx, apache), Python и gRPC. Для разных методов gRPC, каждый из которых предсказуем, указываем в Reverse Proxy свой обработчик - пул процессов. Python достаточно низкоуровневый для общения с ядром, чтобы каждый процесс мог менять свои лимиты. И реализация gRPC имеет параметр max_concurrent_rpcs - чтобы предотвратить DDOS (только на L7, или ещё на L3+4 - не знаю. Но кажется в любом случае, AntiDDOS на L3+4 умеет Reverse Proxy). Если вам подход показался разумным - как бы вы реализовывали на своём ЯП?