понедельник, 28 мая 2018 г.

[prog.thoughts] На тему добавления поддержки распределенности в SO-5

В этом посте попробую зафиксировать текущие мысли и планы на тему поддержки распределенности в SO-5.

Краткий взгляд в историю вопроса

Прежде чем перейти к текущим соображениям о том, как можно добавить распределенность в SO-5, пройдемся по истории для того, чтобы было лучше понятно, почему в SO-5 "из коробки" распределенности нет.

Изначально в SO-4 поддержка распределенности была. Причем была в двух вариантах.

Глобальные агенты

Первоначально распределенность в SO-4 появилась в виде идеи "глобальных" агентов. Суть в том, что в SO-4 все сообщения должны были принадлежать конкретным агентам. И для отсылки сообщения нужно было знать, во-первых, имя агента, и, во-вторых, имя сообщения. Например, "лампочка_1.включить" -- это сообщение "включить" агента "лампочка_1".

Понятное дело, отсылать можно было только сообщения существующих агентов. Т.е. если в программе есть агент "лампочка_1", то можно отсылать его сообщения. Если такого агента нет, то отослать сообщение "лампочка_1.включить" не получится. Соответственно, если у нас приложение из нескольких частей, то когда одна часть хочет отослать сообщение другой части, то должно быть известно, сообщение какого агента отсылается. Но как узнать, какие агенты находятся в другой части приложения?

Так и появилось понятие "глобального" агента. Это агент со своим именем и со своим множеством сообщений, но не имеющий реализации. В каждой части приложения регистрируется глобальный агент с одним и тем же именем и одним и тем же типом. Получается, что такой агент как бы присутствует во всем приложении, в каждой его части. Т.е. является глобальным.

SO-4 специальным образом обрабатывал операцию отсылки сообщений глобальный агентов. Когда такое сообщение появлялось, то SO-4 смотрел на существующие в данный момент каналы связи, сериализовал сообщение в двоичное представление и отсылал сообщение в каждый доступный канал. Т.е. по умолчанию использовалась broadcast-схема доставки сообщений глобальных агентов. Хотя была еще и point-to-point схема, когда при отсылке можно было явно указать, в какой канал сообщение должно уйти.

Каналы связи между частями приложения обслуживались специальными транспортными агентами. Программист вручную создавал транспортных агентов. При этом сам программист должен был следить за топологией распределенного приложения. Например, в этой части приложения мы открываем серверный канал (ichannel), к которому будут подключаться клиенты. А вот в этих частях создаем клиентские каналы (ochannel), которые будут подключаться к указанному серверу.

Фильтры доставки

Поскольку по умолчанию использовалась broadcast-схема, то быстро всплыла проблема "засирания" каналов сообщениями, в которых удаленная сторона не нуждается. Например, части A и B хотят обмениваться сообщениями глобального агента gA1. Но если в A отсылается сообщение агента gA1, то оно уйдет не только в B, но и в связанные с A части C, D, E и т.д. Даже если в этих частях про gA1 ничего знать не хотят.

Для решения этой проблемы были сделаны "фильтры доставки". При создании транспортного агента программист делал перечень имен глобальных агентов, в сообщениях которых программист заинтересован. Когда транспортный агент устанавливал подключение, он отсылал свой фильтр на другую сторону. И другая сторона понимала, какие сообщения стоит отсылать в канал, а какие -- нет.

Сериализация/десериализация сообщений

Ценность распределенности в SO-4 была именно в том, чтобы распределенность была именно прозрачной. Чтобы программист делал минимум телодвижений для того, чтобы экземпляры его сообщений могли ходить через сеть. Но т.к. в C++ не было (да и нет сейчас) рефлексии и интроспекции, то приходилось заставлять пользователя описывать сообщения и их содержимое явным образом. Что-то вроде:

SOL4_MSG_START( msg_submit_response,
      aag_3::blind_smsc::child::a_child_iface_t::msg_submit_response )

   SOL4_MSG_FIELD( m_trx_id )
   SOL4_MSG_FIELD( m_error_code )
   SOL4_MSG_FIELD( m_error_description )
   SOL4_MSG_FIELD( m_message_id )
   SOL4_MSG_FIELD( m_source_addr )
   SOL4_MSG_FIELD( m_registered_delivery )

   SOL4_MSG_CHECKER(
      aag_3::blind_smsc::child::a_child_iface_t::msg_submit_response::check )
SOL4_MSG_FINISH()

За макросами SOL4_MSG_* скрывалась некая магия по генерации кода сериализации/десериализации сообщений в свой собственный формат. Этот формат, с одной стороны, был более-менее гибким. Но, с другой стороны, был довольно "тяжеловесным". Сами сообщения и значения полей сообщений предварялись метаинформацией. В частности, в этой метаинформации содержались текстовые названия сообщения и его полей. Что приводило к довольно большим накладным расходам. В принципе, в те времена народ сходил с ума от XML и использовал XML для представления данных повсеместно. В сравнении с XML-ем накладные расходы SO-протокола были поменьше, но все равно довольно значительными.

Сложность поддержки взаимодействия point-to-point

Сообщение глобального агента можно было отослать прямо в конкретный канал, тем самым организовав взаимодействие point-to-point. Но в этом случае нужно было иметь актуальный идентификатор канала. А этот идентификатор менялся при разрывах и восстановлении связи. Поэтому для того, чтобы работать в режиме point-to-point обе стороны должны были следить за состоянием канала и сохранять последний валидный идентификатор канала. Что требовало дополнительной работы от программиста.

MBAPI -- Message Box API

Для того, чтобы сделать разработку распределенных приложений на SO-4 проще, над уже имевшимся механизмом глобальных агентов и транспортных агентов был построен дополнительный слой под названием MBAPI (Message Box API). MBAPI отличался от использования глобальных агентов тремя принципиальными вещами.

Во-первых, все сообщения должны были наследоваться от специального базового типа и для их сериализации/десериализации использовался инструмент под названием ObjESSty (созданный специально для того, чтобы поддерживать сложные C++ные структуры данных). Двоичное представление данных в ObjESSty занимало гораздо меньше места, чем в случае SO-протокола.

Во-вторых, сообщения отсылались в "почтовые ящики". Пользователь просто говорил -- отсылаю сообщение в ящик M1. А подсистема MBAPI сама разбиралась, в какой канал это сообщение должно уйти. Для этого MBAPI сам контролировал состояние каналов и при появлении новых каналов обменивался с удаленной стороной информацией о том, какие почтовые ящики известны обеим сторонам.

В-третьих, использовался другой механизм подписки на MBAPI сообщения. Нужно было определять "почтальонов", которые анализировали сообщение и, если сообщения по нужным критериям удовлетворяло получателя, то сообщение доставлялось подписчику. Можно было, например, подписаться на сообщения типа T, которые идут в почтовый ящик M1 от почтового ящика M2, и у которых такое-то поле имеет такое-то значение.

Вот, скажем, пример создания не самого тривиального почтальона:

so_add_destroyable_traits(
      new so_msg_templ_postman_t< msg_delivery_receipt >(
            so_query_name(), "msg_delivery_receipt",
            and_analyzer(
                  and_analyzer(
                        dest_equality_analyzer(
                              mbox_dest_t(cfg.send_history_mbox())),
                        reply_to_equality_analyzer( 
                              mbox_dest_t(cfg.delivery_receipt_history_mbox()))
                  ),
                  not_rerouted_by_analyzer(so_query_name())),
            cfg.interception_priority(),
            intercept_msg));

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

Использование MBAPI сделало разработку распределенных приложений на SO-4 гораздо удобнее и гибче. Хотя из-за своей гибкости и навороченности MBAPI требовал от разработчика написания довольно громоздких конструкций, да и работало это не слишком производительно, т.к. почтальоны должны были анализировать практически каждое сообщение (хотя там и применялись специальные оптимизации, но все равно пропускная способность MBAPI была заметно ниже, чем у обычного механизма доставки сообщений в SO-4).

В итоге мы наелись

Встроенная распределенность в SO-4, как на базе только глобальных агентов, так и на базе MBAPI хорошо себя показывала пока нагрузка на приложения была небольшой и обмен данными между частями приложения измерялся парой тысяч небольших сообщений в секунду. Но как только нагрузки возрастали, как-только в какой-то части возникали "затыки", так сразу же начинались проблемы. Чему способствовали, во-первых, асинхронная природа взаимодействия в SObjectizer-е и сложность построения механизмов обратной связи для регулирования нагрузки, и, во-вторых, отсутствие в SO-протоколе деления пакетов на сегменты и невозможность доставки сегментов в зависимости от приоритета отосланных сообщений.

Еще одна серьезная проблема -- это заточенность SO-протокола и MBAPI (который базировался на ObjESSty) на C++. Где-то после 2006-го года стало понятно, что большие распределенные приложения могут строится на базе компонентов, написанных на разных языках. И интероперабильность -- это не пустой звук.

Желающие более подробно почитать о проблемах с SO-протоколом и MBAPI могут заглянуть сюда.

В общем, когда стало понятно, что развивать дальше SO-4 нет смысла и нужно думать над SO-5, то было очевидно, что встроенная распределенность в духе SO-4 не нужна.

Начало истории SO-5

В разработке SO-5 мы собирались исходить из принципа минималистичного ядра, на которое могут наслаиваться дополнительные слои. Отсюда, кстати говоря, и такое понятие, как so_layer_t. Планировалось, что будут создаваться различные слои для решения тех или иных задач, и это слои будут навешиваться разработчиком на SO-5 по мере необходимости. В том числе предполагалось, что могут быть созданы различные транспортные слои, каждый для своих специфических задач.

Так же в SO-5 мы ушли от использования имен агентов и сообщений. Ключевым элементом стал mbox. Что произошло не случайно, а благодаря опыту работы с MBAPI.

Причем изначально мы думали, что у нас будет несколько типов mbox-ов: local_mbox для организации взаимодействия внутри одного процесса и remote_mbox для того, чтобы можно было отсылать данные в другие процессы. Правда, развить всю эту историю с remote_mbox мы не смогли (сейчас уже даже не вспомню почему), выжили только local_mbox-ы, которые стали называться просто mbox-ы (хотя следы local_mbox-ов в SO-5 еще местами видны).

Примечательно, что когда мы в 2013-2014-ом начали выводить SO-5 на публику, мы еще пытались "тянуть" вместе с ядром SO-5 ряд дополнительных проектов. В частности, so_sysconf_4 для конфигурирования приложений из DLL-ек и mbapi_4 (версия MBAPI для SO-5). Но интереса это не вызывало, а для нас их развитие оказалось слишком тяжелым занятием с очень туманными перспективами. Подобного масштаба разработки мы могли вести в Интервэйле, где перед нами стояли большие и серьезные задачи. Где подобная разработка была оправдана и, главное, обеспечена ресурсами. Ну а вне Интервэйла тянуть такой же объем работ по развитию дополнительных библиотек было просто некому.

Сложилась следующая ситуация: понятно, что какой-то простой, претендующий на универсальность, но не масштабируемый и не интероперабильный транспорт, похожий на то, что было в SO-4, не нужен. Нужна поддержка различных протоколов и средств представления данных, которые бы подходили под разные типы задач. Но ресурсов на развитие таких средств в наличии не было. Тем более, что в самом ядре SObjectizer-а было еще много работы. И, можно сказать, что только где-то к середине 2017-го года мы достигли такой точки в развитии SObjectizer-а, когда стало понятно, что все свои основные хотелки в SO-5 мы воплотили и можем заняться чем-то еще.

Текущий взгляд на нужность поддержки распределенности в SObjectizer

Откуда возник всплеск внимания к распределенности в SO-5?

Я лично до сих пор придерживаюсь мнения о том, что в свое время мы приняли правильные решения. Сперва когда не стали поддерживать распределенность в ядре SO-5. Затем когда перестали тратить ресурсы на развитие MBAPI-4. Все-таки в современном мире гораздо лучше иметь возможность встроить в свое SObjectizer-приложение какой-то готовый транспорт (вроде HTTP средствами RESTinio или MQTT средствами mosquitto_transport), нежели развивать и поддерживать какое-то свое решение, которое кроме SObjectizer-а никто больше не понимает.

Но, с другой стороны, потенциальные пользователи SObjectizer, как мне кажется, делятся на две категории.

Первая категория -- это люди, которые хорошо представляю себе сложность решаемой ими задачи, которые знают какой тип транспорта и представления данных им нужен, и которые рассматривают SObjectizer как один из подходящих инструментов. Мы сами, когда становимся пользователями, а не разработчиками SObjectizer-а, попадаем именно в такую категорию. Соответственно, эти пользователи будут очень придирчиво изучать любую реализацию любого транспорта, которую мы сделаем для SO-5. И, если по каким-то причинам реализация их не устроит, то они просто пошлют наш транспорт лесом и будут использовать то, что им больше подходит.

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

Очевидно, во вторую категорию попадают разработчики-новички, коих сейчас в C++, наверное, даже побольше, чем лет 7-8 назад. Но не только. Тут могут быть и опытные разработчики, которые занимаются созданием proof-of-concept или quick-and-dirty прототипа.

Боюсь, мы до сих пор недооценивали значимость второй категории пользователей, концентрируясь на том, чтобы сделать инструмент, удовлетворяющий первую категорию. Думаю, что с точки зрения маркетинга это был серьезный просчет. И если мы хотим сделать из SObjectizer-а широко востребованный, хотя бы в узких кругах, инструмент, то нам нужно предложить какое-то решение и для второй категории пользователей. Отсюда и очередной раунд размышлений над поддержкой распределенности в SO-5.

Распределенности в ядре SObjectizer-5 не будет, но...

Средства для построения распределенных приложений на базе SObjectizer, ежели таковые появятся, будут реализованы в виде дополнительных библиотек. Вроде so_5_extra. Т.е. не будет такого, что человек взял только ядро SO-5 и построил на его основе тяжелое распределенное приложение.

По крайне мере не будет пока мы развиваем SObjectizer за свой счет. В конце-концов, мы не Facebook, который может наговнякать написать proxygen, выложить его на github и это все будет составлять какие-то доли процентов от общих затрат на разработку в FB. У нас условия другие и мы развиваем только небольшие библиотеки, поскольку затраты на большие комбайны просто не вытянем.

Соответственно, пока мысль крутится вокруг того, чтобы создавать небольшие инструменты, вроде mosquitto_transport и смотреть, что из этого получается. Два-три подобных инструмента и можно будет посмотреть, как заходит данная тема в массы вообще. Чем народ пользуется, чем не пользуется. Что нравится, что не нравится. Чего не хватает, что можно убрать. Можно ли свести разные подходы к одному знаменателю и т.д.

Если при этом потребуется еще что-то от самого ядра SObjectizer-а, то ядро мы под эти нужды доработаем.

Зачем кому-то может потребоваться распределенность в SO-5?

Попробую перечислить несколько сценариев, которые сейчас рассматриваются для того, чтобы сделать библиотечное решение для удовлетворения конкретного сценария. Хотя деление, конечно, несколько условное и какой-то подход может использоваться для решения разных задач. Но, тем не менее, надеюсь, представленное деление удобно с точки зрения рассмотрения некоторых специфических технических моментов.

Тот самый fail-fast (на одной ноде)

Если мы хотим, чтобы наше C++ приложение, в котором высока вероятность сбоев (например, используется глючный код, который часто делит на ноль или обращается по нулевым указателям) или каких-то других проблем (скажем, зависаний при работе с 3rd party библиотеками или внешним оборудованием), то выбор у нас совсем небольшой: разделить наше приложение на отдельные процессы. Если какой-то процесс падает, то мы его перезапускаем. Если какой-то процесс зависает, то мы его сперва горохаем, а потом перезапускаем.

Соответственно, встает вопрос о том, как просто и эффективно организовать передачу информации между этими отдельными процессами, с учетом двух ключевых моментов:

  • используется не просто один язык программирования, но даже один и тот же компилятор для сборки всех частей нашего приложения. Это может быть архиважно, например, для такого варианта IPC, как разделяемая память. Ведь в этом случае мы можем вообще не парится на счет сериализации/десериализации данных -- представление объекта (если это POD-объект) будет точно таким же в разных частях приложения;
  • использование локальных механизмов IPC, не требующих работы с сетевым стеком. Как то: разделяемая память или каналы (unix pipes или windows pipes). При этом, даже если мы используем IPC, для которого требуется сериализация/десериализация объектов, вроде unix/windows pipes, то мы можем не заморачиваться по ряду вопросов. Например, о порядке следования байт. Можно даже не сильно париться и по поводу формата представления данных: если у нас POD-объекты, которые не хранят внутри себя указатели, то мы их можем просто писать/читать в "сыром" виде.

Горизонтальное масштабирование и/или отказоустойчивость "по взрослому"

В данном случае мы либо точно знаем, что производительности одной ноды не достаточно и нужно разнести решение нашей задачи на несколько нод, либо мы хотим, чтобы наше приложение/сервис переживало выход из строя одной или нескольких нод.

В таких сценариях нам нужно будет обеспечить обмен информацией между частями приложения, которые работают на разных нодах. Для чего нам потребуется использовать сетевой стек и какой-то протокол. Например, это может быть работа на базе UDP и собственного протокола. Или же использование TCP+HTTP+JSON или же применение какого-то MQ-шного брокера.

Соответственно, первый ключевой момент в том, что нам придется озадачится форматом представления данных. В принципе, в каких-то случаях мы сможем гонять по сети и "сырые" представления POD-объектов. Но это будет возможно далеко не всегда. И вопрос представления данных нужно будет рассматривать довольно серьезно.

Второй ключевой момент в том, что все-таки в этом случае у нас будут части приложения, написанные на одном и том же языке программирования. Поэтому мы можем задействовать заточенные под этот язык системы сериализации данных. Вот как мы в свое время использовали библиотеку ObjESSty, которая понимала не только основные типы из STL, но и такие вещи, как наследование, включая множественное наследование. Этот момент позволяет нам организовать обмен довольно сложными структурами данных не думая о том, насколько эти структуры вообще представимы в каком-нибудь Python-е, Java или C#.

И, наконец, самый главный ключевой момент. Если мы решаем именно проблему горизонтального масштабирования и нам при этом нужен активный обмен данными между нодами, то, с высокой вероятностью, мы решаем вычислительную задачу. А для этой ниши уже есть специализированные готовые инструменты, проверенные годами. Например, различные реализации MPI (скажем, Open MPI и MPICH). Или интересный, заточенный под современный C++ фреймворк HPX.

Функциональная декомпозиция на отдельные самостоятельные компоненты

Это тот случай, когда мы разделяем свое приложение на части в зависимости от того, что каждая из частей делает. Например, какая-то часть взаимодействует со специализированными устройствами (вроде Hardware Security Module). Какая-то часть занимается аутентификацией пользователей. Какая-то часть маршрутизирует запросы. Какая-то часть обрабатывает запросы типа X, а какая-то часть -- запросы типа Y. Какая-то часть интегрируется с древней системой A, а какая-то часть собирает данные от новой системы B.

В принципе, это то, что сейчас называют модным словосочетанием "микросервисная архитектура". Но не только. Декомпозиция и распределенность может быть продиктована самой задачей. Например, в современном сложном устройстве вроде автомобиля, корабля или самолета может быть несколько десятков компьютеров, каждый из которых занимается своей специфической задачей, но при этом входить в общую локальную сеть и взаимодействовать друг с другом.

Первый ключевой момент в том, что здесь у нас для взаимодействия компонентов, скорее всего, будет использоваться сетевой стек. И, возможно, даже та или иная вариация на тему message bus. Например, может быть работа через общего брокера сообщений или даже через какие-то БД. Хотя могут быть и случаи, когда два компонента, специально размещенные на одной ноде, будут работать через локальный IPC. Например, компонент для аутентификации клиентов может обращаться к компоненту для работы с HSM, оба эти компонента будут жить на той машине, к которой подключен HSM, общаться они могут через unix pipes.

Второй ключевой момент в том, что компоненты могут быть написаны на разных языках программирования. Посему вопрос представления данных, которыми обмениваются компоненты, становится чрезвычайно важным. Свои велосипеды для сериализации/десериализации здесь могут использоваться разве что в очень уж специфических случаях. Скорее всего иметь дело придется с какими-то индустриальными стандартами, вроде XML, JSON, ProtoBuf, Thrief и т.д. А то и с несколькими сразу. Например, один компонент может использовать XML, а ряд других, написанных спустя несколько лет, -- ProtoBuf.

Что значит наличие всех этих сценариев для SObjectizer?

Очевидно, что своей небольшой командой мы не сможем сделать хороший инструментарий, который бы закрыл все описанные выше сценарии. Хотя, если бы кто-то занес нам 100500 денег... ;)

Про 100500 -- это шутка, но с учетом текущих зарплат программистов в РБ, речь в любом случае будет идти не об одном десятке тысяч USD.

Понятно, что никаких внешних вливаний не будет, нам придется все делать самим. Соответственно, мощный и универсальный комбайн "на все случаи жизнь" мы за свой счет не сделаем. А делать какую-то непонятную универсальную хрень нет смысла. За непонятной хренью уже сейчас желающие могут сходить в CAF, там любят говорить (подчеркну, говорить) про высокую производительность.

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

  • сценарий с разбиением C++ приложения на части для работы на одной ноде и общением посредством локальных IPC;
  • а так же сценарий с функциональной декомпозицией на отдельные компоненты и интероперабильностью с другими языками программирования.

Cценарий с распараллеливанием вычислений сейчас представляется наименее перспективным в связи с тем, что для этого сценария уже есть инструментарий вроде Open MPI, MPICH, HPX и др.

Возможная последовательность действий

На данный момент план работ по добавлению поддержки распределенности в SObjectizer-приложения выглядит как-то так:

  1. Разработка инструмента для организации взаимодействия между C++ процессами через какой-то локальный IPC. Возможно, через shared memory. Возможно, через unix/windows pipes. Вероятно, такой инструмент будет частью so_5_extra, т.е. разрабатываться будет под двойной лицензией. Но тут еще нужно провести предварительное исследование на предмет наличия готовых сторонних кросс-платформенных оберток вокруг этих IPC.
  2. Разработка Kafka-клиента для C++ и SObjectizer. Чтобы из SObjectizer-приложения можно было взаимодействовать с другими компонентами через Apache Kafka. Этот инструмент предполагается разрабатывать как отдельный проект под двойной лицензией (т.е. GNU Affero GPL v3 + коммерческая лицензия).
  3. Дальнейшее развитие mosquitto_transport. В частности, поддержка QoS отличных от 0, поддержка retained-сообщений, кросс-платформенность (для чего, вероятно, придется отказаться от libmosquitto в зависимостях). Но это произойдет если кому-то MQTT-ный транспорт для SObjectizer потребуется.

Вопросы к тем, кто заинтересован в поддержке распределенности в SObjectizer

Как вообще вам наш взгляд на проблему поддержки распределенности в SObjectizer? С чем не согласны?

Для каких сценариев вам хотелось бы иметь поддержку распределенности в SObjectizer?

Устраивает ли вас наш план? Может вы хотели бы видеть поддержку какого-то другого типа транспорта вместо Kafka и MQTT?

Наличие в зависимостях такой штуки, как Boost, станет ли для вас стоп-фактором? Вообще как вы относитесь к тяжелым зависимостям (масштаба Boost-а, ACE, Xerces)?

Может быть вы знаете какие-то готовые инструменты, которые могли бы быть использованы при реализации поддержки распределенности в SObjectizer? Вроде таких: Aeron, GameNetworkingSockets.

Комментариев нет: