Net GRPC, или «не осилили»
Продолжая цикл статей про разработку сложных высоконагруженных систем, хочу поделится опытом про разработку второго голосового робота, который стал крупным работающим проектом в масштабах страны.
Начинали мы с нуля, с эскизов. Задача была реализовать систему, обладающую следующими функциональными и техническими требованиями:
поддержка разговора с абонентом в режиме реального времени
адаптация на высокую нагрузку + масштабируемость (тысячи одновременных звонков)
доп. фичи, в виде отправки расшифровки разговора абоненту, текстовые нотификации в телеграм и прочее.
В качестве основного технологического стека (именно под голосовую часть) была выбрана связка Asterisk +.Net (главным образом потому что у самого был такой опыт, те Asterisk не оспаривался а вот под стек для программного решения были споры), а для масштабируемости — микросервисная архитектура, в том числе со сложными сервисами‑оркестраторами.
Если кратко, описание решения выглядело примерно таким:
сервис управления состоянием звонка (Asterisk REST API + web‑сокеты). Управление звонком и получение его стейта в режиме реального времени;
ряд сервисов по управлению непосредственно голосом: вывод TCP трафика, преобразование в GRPC, распознавание голоса, получение текстового ответа, синтезация ответа, отправка голоса обратно в канал (TCP). Итого, цепочка из ~ 7 сервисов с дуплексной передачей трафика;
ряд сервисов по сохранению результатов — параметров звонка, расшифровки, самой голосовой записи.
Нас сильно подвела реализация.Net (6.0-7.0) GRPC, которая, как показала практика, не выдерживала нужных нам значений RPC. Если взять 1 звонок:
пакеты по 320 байт
каждые 20 мили секунд, или 50 раз в секунду (архитектура Asterisk)
дуплексная передача, итого Х2
работа сервиса в режиме прокси, еще Х2
500 одновременных звонков на 1 под в цепочке
Итого RPC = 5022* 500 = 10 тыс / сек, те кол‑во сетевых операций чтения/записи GRPC пакетов.
.Net же показывал более‑менее стабильные результаты до 50 звонков (те в 10 раз ниже ожидаемого). Далее начинались деградации (задержка времени между пакетами), которая накапливалась и доходила до неприличных 70 мсек (вместо 20).
Итого, после двух недель исследования, было принято волевое решение вылезти за рамки компетенций и переписать голосовую часть на GO.
Прототип показал успех, а значит — можно развивать.
И почему нет?
Cервис (каждый) простой, минимум функционала
микросервисная архитектура адаптирована под «зоопарк», главное поддержать контракт «вход‑выход» и сборку в Docker
GO, несмотря на отсутствие объектно‑ориентированной парадигмы, хорошо рулит потоками (байты летают в обе стороны со свистом ;-)
и, кстати, в GO больше Linux‑адаптированных пакетов под работу с голосом (у.Net в этом плане все совсем скудно, даже Core в этой части подвязан на Windows).
И да, GO показывал полное отсутствие деградаций до 1000 звонков. 20мсек, иногда до 22 мсек, но больше мы никак не фиксировали.
Итого, некритичная к RPC часть системы так и оставалась жить на.Net, а критичные сервисы (7 штук) стали постепенно переезжать на GO.
Кстати, решение получилось годным, задержка в разговоре не превышала 1.5 сек в штатном варианте (если не рассматривать аномальные всплески нагрузки).
Здесь я бы хотел сделать выжимку, чтобы подсветить иные нюансы разработки данного решения.
Во‑первых, у нас был грамотный архитектор) сам, когда увидел концепт такой системы, оценил его как «ненадежный и рискованный», те важна была первоначальна экспертиза и уверенность в возможности реализации такого решения.
Отладка. Никакой возможности подебажить даже теоретически. Ну а толку? Подключите вы этот свой под к сети, влезете дебагом — трафик будет тормозить, картина испорчена. Здесь как раз возникает необходимость писать понятные логи, еще и в режиме «агрессивной многопоточки».
Тесты. Тоже не имеют ценности. Ну те разве что писать внешний «эмулятор» в количестве двух штук, один подает трафик в сервис другой получает, и также обратно.
Про юниты/интеграционные можно вообще забыть, так как основной механизм — это чтение/запись пактов из/в сети/сеть и их перекидывание.
Логики почти нет.
Деплой и оркестрация
А вот тут уже начинаются сложности. Возможностей k8s из коробки не хватает для управления TCP трафиком (нет штатных механизмов балансирования нагрузки). В систему добавляются умные «поды‑оркестраторы», которые следят за нагрузкой подов голосовых, не отправляют обслуживание новых звонков на уже загруженные поды;
Стейты. Не осилили.
Во всяком случае, не в первой версии (дальше не знаю). Наработки — чтобы в случае «смерти» одного голосового пода весь его трафик мог направиться на иной под, были, но такие улучшения слишком сложны и нестабильны для первых версий.
Те опять же, к системе работающей «целевым образом» добавляется система для «разруливания» «нештатных ситуаций», а это уже по сути «второй контур».
Риски. Да, корневую часть пришлось переписать на GO что значительно увеличило бюджет.
Но кто ж знал? Это область неизведанного — платформа не вытянула/архитектурно ошиблись/и так далее. Те пока не попробуешь — не узнаешь, а «явных противопоказаний» на теоретическом уровне к такому решению (.net) не было.
Kerman
То есть вы взяли gRPC/protobuf, только чтобы кидаться бинарными пакетами по 320 байт? Странное решение.
Тут бы подошёл обычный советский TCP. Отключаем Nagle (NoDelay = true) и вперёд кидаться друг друга пакетами практически безлимитно.
Протобуф - он какбы не для этого. Это суровый сериализатор сложных структур. И он отлично и быстро работает. Но вам он зачем? Если нет структуры - не нужен и сериализатор.
Mihail37 Автор
Не совсем так. Голый TCP - это стандарт Asterisk а, и мы должны были его придерживаться только в первой точке в цепочке по работе с голосом, которая взаимодействует именно с Астером. Далее - по GRPC цепочке - пакеты можно было укрупнять и обогащать, увеличивая задержки + появляются сквозные идентификаторы, признаки попутного анализа пакетов голоса (например, есть ли в них речь или пакет пустой), те в структуре GRPC пакетов уже не только голые аудио-байты. К тому же же yandex-speech-recognition или text-to-speech работают по протоколу GRPC.
Kerman
Вам бы неплохо было бы определиться с требованиями к системе. gRPC работает через протобуф, протобуф - через https. Последний работает через TCP. Угадайте, включен ли у него алгоритм Нагла? Спойлер - он включен у всех по умолчанию. Соответственно, у вас будут задержки, лишние буфферы, лишний расход цпу. Если вас задержки не устраивают, то у вас путь один - к TCP. Это не значит, что нельзя "обогащать пакеты". Можно и ещё как. Простую информацию можно встраивать прямо в пакет (ну кто запретит пару байт с флагами вписать?), сложную и несинхронную оставить на gRPC.
Mihail37 Автор
Все же хочется снизить риск человеческих ошибок при разборе голых байт, и в принципе использовать более удобные инструменты если нет противопоказаний к обратному.
Тут скорее вопрос к реализации сетевого стека в части GRPC на .net под Linux, если при прочих равных такое же решение на GO прекрасно едет.
Так как в реальном проекте главное - это именно решить проблему, с гарантией, что лучше чем пытаться тюнить текущий стек «на костылях». Хотя именно в .Net такую критику - что "вы не умеете его готовить" - по данной проблеме, встречаю постоянно)
Kerman
Я не знаю, почему у вас не едет дотнет под линукс. Я давно и плотно работаю с gRPC и у меня всё прекрасно едет. Что дотнет, что линукс, что gRPC - технологии, отлаженные годами. И это мейнстрим. И я сильно сомневаюсь, что в их связке есть какие-то проблемы. И я сильно сомневаюсь, что Го намного быстрее. Скорее всего вы умалчиваете какой-то интересный факт, а может и не заметили что-то.
Я вам то же самое скажу. Вы не умеете его готовить. Прекрасно работает под большими нагрузками. В том числе и под линуксом.
Mihail37 Автор
Могу добавить что информация актуальна на конец 2022, может быть в .Net 8 - более стабильной мажорной версии - это поведение поменялось в лучшую сторону.
PS помню еще .net 5, где попытки обратиться к GRPC-стриму, который был переведен в Dispose состояние (программистские ошибки, да), но все же такой баг в коде приводил к дедлокам на уровне платформы и stopper-у всего приложения.
Так что в те времена складывалось впечатление о "сырости" .net-ного GRPC.
Kerman
В конце 22го был уже .NET 6. Который LTS. И я на нём строил сервер для приложения на основе gRPC/protobuf. Под линуксом, да. И работал он прекрасно, полностью выжимая канал. Без перерасходов памяти, без сильной нагрузки на cpu. Без дедлоков. Хотя я не пробовал никогда лезть в задиспоженый стрим. Это как-то странно.
По мотивам той системы я уже писал статью на хабре.
Так что не знаю. Похоже, у нас разные дотнеты, разные линуксы и разные gRPC.