пятница, 15 июня 2018 г.

[prog.thoughts] Несколько слов о попытке спроектировать транспорт для SObjectizer-а на базе ZeroMQ

Сегодня попытаемся поговорить о том, зачем же я засел за изучение ZeroMQ. И, надеюсь, подведем некоторый итог небольшой серии постов про ZeroMQ (раз, два). Речь пойдет о попытках разработать транспорт для SObjectizer-а, чтобы дать пользователям возможность с минимальными усилиями строить распределенные SObjectizer-приложения. Поэтому, если кому-то не интересно читать про SObjectizer, то данный пост можно проигнорировать.

Ну а если тема SObjectizer-а вам интересна или вы вообще интересуетесь тем, как появляются те или иные проектные решения, тогда, надеюсь, данный пост будет вам интересен.

Транспорт для SObjectizer-а: зачем это вообще?

Почему мы вообще вернулись к серьезной работе над этим вопросом?

От поддержки "родного" транспорта в SO-5 мы отказались в пользу подхода, в котором для каждой конкретной задачи выбирается наиболее подходящий для этой задачи транспорт. Где-то это будет MQTT, где-то REST API, где-то взаимодействие через shared memory и т.д.

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

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

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

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

Сейчас ситуация поменялась. SO-5 вобрал в себя то, что мы хотели в нем воплотить. И сейчас SO-5 развивается под влиянием пожеланий пользователей. Есть пожелания -- в SO-5 добавляется что-то новое. Нет пожеланий -- в SO-5 исправляются какие-то мелкие недочеты.

Такая ситуация освобождает часть наших ресурсов на то, чтобы вернуться к проблеме распределенности и постараться удовлетворить и вторую группу [потенциальных] пользователей. Отсюда и очередной, на этот раз более серьезный подход к снаряду к теме транспорта для SObjectizer-а.

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

Что у нас в бэкграунде?

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

Что зарекомендовало себя хорошо в прошлом?

Абстрагирование логических связей от физических каналов

Думаю, что одна из самых главных вещей, которая самым серьезным образом упростила разработку распределенных приложений на SObjectizer (особенно с появлением MBAPI), -- это разделение между логическими и физическими связями между компонентами. Так, компонент с именем M1 просто отсылал сообщение компоненту с именем M2 не задумываясь о том, где M2 находится и каким каналом связи эти два компонента сейчас связаны.

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

Автоматическая проверка работоспособности каналов и переподключение

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

Так что на уровне транспорта нужно иметь автоматические ping-и (или heartbeat-сообщения), а так же функциональность по автоматическому разрыву связи и переподключению при возникновению каких-либо проблем (в том числе и при отсутствии ответов на ping-и). Причем хорошо бы иметь возможность настройки параметров переподключений (сколько раз, с каким темпом, на какие IP-адреса и порты и пр.).

Логическая адресация компонентов по именам

Наш опыт показывает, что если дать возможность пользователю просто называть свои компоненты с помощью строковых имен, то этого уже хватает для построения довольно сложных взаимосвязей между компонентами. Именами легко оперировать (особенно если не ограничивать их длину 10 символами), их легко логировать, сохранять в БД, восстанавливать из БД, включать внутрь сообщений и т.д., и т.п.

Транзитные узлы

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

Представьте себе такую цепочку (здесь Pi -- это отдельный процесс, а Mj -- это логический компонент внутри процесса):

P1(M1) <-> P2(M2)  <-> P3(M3, M4)

Т.е. P1 связан с P2, а P2 связан с P3, но P1 с P3 не связан напрямую.

В ранних версиях MBAPI в этом случае M1 не мог отсылать сообщения M3 и M4. Требовалось установить прямое подключение от P1 к P3, что не всегда было удобно. Скажем, когда сервером является P2, а P1 и P3 подключаются к нему как клиенты.

В более поздних версиях MBAPI мы добавили более хитрую систему маршрутизации сообщений и M1 получил возможность общаться с M3/M4 через транзитный узел P2. Что очень сильно повысило удобство работы с MBAPI.

Уведомления о доступности компонентов

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

И в обратную сторону -- если MBAPI обнаруживал разрыв канала связи, то из списка доступных компонентов удалялись те, которые были видны только через разорванный канал. Соответственно, M1 мог обнаружить, что M3 больше недоступен и мог перестать отсылать сообщения в сторону M3.

Что было плохо или совсем плохо?

Понятное дело, что не все было гладко и мы кое-где накосячили. Что со временем вылезло боком. Причем сильно вылезло.

Буферизация и управление нагрузкой на каналы

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

Мы пошли по пути буфферизации данных. Т.е. отосланное сообщение сериализовалось в нужное двоичное представление и это представление добавлялось во внутренний буфер. Из которого данные уходили в сеть, когда канал связи оказывался готовым к передаче очередной порции данных.

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

Все это вело себя достаточно плохо как только где-то начинались серьезные проблемы с каналом и/или производительностью. Разрывались каналы, удалялось содержимое связанных с каналами буферов. Это вело к потере данных и прикладным агентам приходилось перепосылать данные заново при восстановлении каналов.

Отсутствие приоритизации трафика

Представим себе, что в P1 есть компоненты M1 и M2, а в процессе P2 есть компонент M3. Компонент M1 отсылает M3 частые мелкие сообщения, а компонент M2 -- редкие большие. Когда большое сообщение начинает отсылаться в канал, то оно блокирует передачу всех остальных данных в этом канале. Тем самым мелкие сообщения от M1 начинают скапливаться в буфере ожидая, когда до них дойдет очередь.

Более правильно было бы дробить сообщения на небольшие порции и перемежать порции, относящиеся к разным сообщениям в соответствии с приоритетами сообщений. Но у нас в SOP/MBAPI такой функциональности не было.

Заточенность под C++ и невозможность интероперабильности с другими ЯП

Тут все просто. Во-первых, самодельный транспортный протокол. Во-вторых, самодельная система сериализации, заточенная под особенности C++. Как результат -- невозможность соединить по SOP/MBAPI компоненты, написанные на разных языках программирования. Что не было такой уж серьезной проблемой в начале 2000-х, но которая является более чем серьезной в наше время.

Что еще важно отметить про специфику SObjectizer-а?

Есть несколько факторов, которые так же стоит упомянуть, поскольку они могут иметь важное значение в тех или иных ситуациях. И решения, которые учитывают данные факторы, многократно лучше, чем когда эти факторы игнорируются.

Асинхронность и движение сообщений в любых направлениях

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

Что еще важно, так это то, чтобы смена направления движения сообщения не требовала перекоммутации процессов. Так, если M1 отсылал в сторону M2 сообщения A, B и C, а в ответ ждал сообщение D, то когда в один прекрасный момент от M2 в M1 должно начать приходить сообщение Е, то для этого не нужно устанавливать новые каналы связи между M1 и M2 или переконфигурировать существующие каналы.

Возможность мониторинга происходящего в транспортном слое

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

Три концептуальных уровня транспорта для SObjectizer

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

Ввод-вывод

Самый объемный и муторный уровень -- это работа с вводом-выводом. Т.е., создание каналов связи, контроль их готовности к операциям ввода-вывода, сами операции ввода-вывода, обработка их результатов.

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

Как раз это тот уровень, с которым совершенно не хочется иметь дело даже если ограничится всего одним типом транспорта, классическими TCP-шными сокетами. Даже если использовать какую-то готовую обертку, в виде ACE, Asio, libev/libevent/libuv. И уж совсем все становится грустно, когда мы пытаемся замахнуться на дополнительные виды транспорта: UDP-шные сокеты, IPC (вроде пайпов), shared memory.

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

Управление потоком данных и буферизация

На этом уровне нужно заниматься вопросами сериализации/десериализации сообщений, а так же скорости и порядка их доставки/обработки. Скажем, приоритизация трафика, скорее всего должна выполняться на этом уровне, особенно если уровень ввода-вывода никакой приоритизации не поддерживает. Балансировка нагрузки между несколькими подключениями так же, вероятно, должна осуществляться здесь (опять же, если нижележащий уровень об этом ничего не знает).

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

Высокоуровневый API для пользователя

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

Здесь нужно заниматься такими вещами, как схемы адресации компонентов (используются ли простые строковые имена или что-то другое), выбор форматов представления данных (например, в каких-то направлениях данные могут представляться в JSON, в каких-то ProtoBuf), выбор способов отсылки сообщений, подписки и обработки сообщений, выбор механизмов оповещения о [не]доступности компонентов и т.д.

Попытка натянуть сову на глоб транспорт на ZeroMQ

Итак, после столь долгого вступления можно перейти к тому, что же вышло из попытки представить себе использование ZeroMQ в качестве подложки для транспортного слоя SObjectizer-а. А сказать тут, к сожалению, особенно-то и нечего.

Почему выбор пал на ZeroMQ?

С ответом на вопрос "Почему ZeroMQ" все достаточно-просто.

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

Во-вторых, ZeroMQ имеет не только биндинги к большому количеству языков, но и даже реализации на других языках (вроде Java). Так что если встанет вопрос об интероперабильности SObjectizer-овского транспорта с другими ЯП, то этот вопрос станет менее острым, т.к. на уровне самого транспорта особых проблем можно не ждать. Тут уж вопрос упрется в то, как сообщения оформляются, а не в то, как они передаются по сети.

Где планировалось задействовать ZeroMQ?

Первоначально я сунулся в ZeroMQ с мыслью о том, что на ZeroMQ можно будет полностью переложить задачи уровня ввода-вывода и, частично, задачи уровня потока данных и буфферизации. Идея была в том, чтобы сделать какой-то фасад, за которым будет спрятана вся работа с ZeroMQ. Чтобы пользователю вообще не нужно погружаться в детали, связанные с ZeroMQ -- только определить тип транспорта (tcp, ipc, inproc) и задать самые базовые настройки для этого транспорта. А дальше фасад сам работает с ZeroMQ, не погружая в это пользователя.

Что получается на данный момент?

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

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

Для меня плохо то, что ZeroMQ -- это не чистой воды "надстройка над TCP". Это какой-то набор захардкоженных паттернов взаимодействия, которые авторы ZeroMQ захотели зашить в ДНК своего инструмента. В итоге, ни один из базовых паттернов (т.е. REQ/REP, PUB/SUB, PUSH/PULL, exclusive pairs) в чистом виде не подходит.

А это значит, что на базе ZeroMQ таки нужно будет строить что-то свое. И это свое:

  • во-первых, будет иметь какую-то хитрую комбинацию из ZeroMQ-шных паттернов. Какую именно сейчас не понятно. Нужно опять штудировать "0MQ - The Guide", чтобы разобраться с тем, где будут создаваться какие-нибудь DEALER-ы, а где какие-нибудь ROUTER-ы, как между ними сообщения ходят, что и где к этим сообщениям приписывается и пр.
  • во-вторых, при этом всем над ZeroMQ нужно будет строить собственную систему проверки жизнеспособности каналов, а так же систему адресации и доставки сообщений нужному компоненту, в том числе и через промежуточные узлы.

Перспективка так себе. Особенно с учетом того, что ZeroMQ не самая легковесная зависимость и большую часть вышеперечисленного хотелось бы иметь "из коробки".

Смущает еще и то, что фасад над ZeroMQ будет скрывать от пользователя часть важных деталей. Например, если потребуется открыть не один серверный сокет, а сразу несколько, для того, чтобы с каждым из них использовался свой ZeroMQ-шный паттерн. Для кого-то из пользователей SObjectizer-а может быть и пофиг, что задавая конфигурацию "tcp://*:5566" он получает не только "tcp://*:5566", но и "tcp://*:5567" и "tcp://*:5568". А для кого-то это будет проблема.

Итого

В сухом остатке. Желания задействовать ZeroMQ в качестве базы для транспорта в SObjectizer-е у меня лично сильно поубавилось. Но и вариант писать транспорт самостоятельно так же не сильно радует. Если найдется какая-то альтернатива ZeroMQ, которая действительно будет удобной "надстройкой над TCP", то имело бы смысл посмотреть на что-то другое. Если не найдется, то придется сильно думать.

К сожалению, пока не знаю, что можно считать альтернативой ZeroMQ, кроме nanomsg-nng. А nanomsg-nng альтернатива так себе, поскольку, насколько я помню, захардкоженные в ДНК библиотеки паттерны, вроде REQ/REP, есть и там.

Вопросы к заинтересованным читателям

Есть несколько вопросов к тем, кто хотел бы видеть "родной" транспорт в SObjectizer-е для построения на его основе распределенных SObjectizer-приложений.

Поддержку каких видов транспорта вы хотели бы видеть? TCP, UDP, Pipes, shared-memory? Если сразу несколько вариантов, то что для вас более приоритетно?

Зависимость от ZeroMQ вы восприняли бы нормально или категорически против?

Насколько актуальна для вас интероперабильность с другими языками программирования?

Буду признателен тем, кто найдет время и возможность ответить на эти вопросы (или хотя бы на один из них).

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