Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Двусторонний websocket-роутинг

Двусторонний websocket-роутинг

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

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

Таким образом, мы разработали свой сервис, который устраняет проблему полнодуплексного общения клиента с сервером через веб-сокет. Полагались на то, что наше решение должно быть горизонтально масштабируемым, cloud-native и написанным на современном асинхронном Python. О сложностях роутинга и о том, как «прицелиться» и «попасть» в нужного пользователя сообщением, и есть наш доклад.

https://conf.python.ru/moscow/2021/abstracts/7746
https://www.youtube.com/watch?v=pVebvoiBIjA

Denis Anikin

May 01, 2023
Tweet

More Decks by Denis Anikin

Other Decks in Programming

Transcript

  1. Двусторонний
    websocket-роутинг
    Владислав Лаухин
    Аникин Денис

    View Slide

  2. Владислав Лаухин
    2
    — разработчик в команде Chat, Raiffeisenbank
    — пилю чат и чатбота
    — развиваю с коллегами коммьюнити питонистов Райфа
    — питон, разработка, девопс

    View Slide

  3. Аникин Денис
    3
    Вдруг кому-то важно кто я такой
    — team lead в команде Chat, Raiffeisenbank
    — community lead в Python Community
    — fullstack: разрабатываю на python и typescript
    — занимаюсь развитием DevOps практик
    — мой сайт: https://xfenix.ru/

    View Slide

  4. В начале опишем
    контекст
    4

    View Slide

  5. Что мы делали?
    5
    — омниканальный чат со своей админкой

    View Slide

  6. Что мы делали?
    6
    — омниканальный чат со своей админкой
    — систему, с которой работают и операторы и клиенты

    View Slide

  7. Что мы делали?
    7
    — омниканальный чат со своей админкой
    — систему, с которой работают и операторы и клиенты
    — систему, в которой много вебсокет соединений, которые эксплуатируются в 2 направлениях

    View Slide

  8. Что мы делали?
    8
    — омниканальный чат со своей админкой
    — систему, с которой работают и операторы и клиенты
    — систему, в которой много вебсокет соединений, которые эксплуатируются в 2 направлениях
    — нам нужно было держать нагрузку и достичь отказоустойчивости

    View Slide

  9. Цели презентации
    9
    — В начале нашей разработки, в интернете не было информации по
    проблеме. Поэтому мы захотели поделиться

    View Slide

  10. Цели презентации
    10
    — В начале нашей разработки, в интернете не было информации по
    проблеме. Поэтому мы захотели поделиться
    — Информации и сейчас немного

    View Slide

  11. Цели презентации
    11
    — В начале нашей разработки, в интернете не было информации по
    проблеме. Поэтому мы захотели поделиться
    — Информации и сейчас немного
    — Хотелось показать, что в среде python разработки существуют не только
    REST’ы и CRUD’ы

    View Slide

  12. Перейдём к делу
    12

    View Slide

  13. Шаг 0: готовые решения
    13
    Пытаемся понять — подходят ли они нам?
    Mercure:
    — нет четкой уверенности, что
    http2 + sse > websocket
    — менее популярное решение
    — меньше на слуху
    — у нас энтерпрайз J (лицензии?)

    View Slide

  14. Шаг 0: готовые решения
    14
    Пытаемся понять — подходят ли они нам?
    Centrifugo:
    — соединение в одном направлении
    — канал «вниз» нужно делать самим
    Mercure:
    — нет четкой уверенности, что
    http2 + sse > websocket
    — менее популярное решение
    — меньше на слуху
    — у нас энтерпрайз J (лицензии?)

    View Slide

  15. Ну раз нет, то…
    15

    View Slide

  16. 16

    View Slide

  17. Наш стек
    17

    View Slide

  18. Больше о проблеме вебсокетов
    18
    Роутинг необходимо
    осуществлять в двух
    направлениях
    2 направления
    применительно к нашим условиям

    View Slide

  19. Больше о проблеме вебсокетов
    19
    Роутинг необходимо
    осуществлять в двух
    направлениях
    2 направления
    Роутинг «вверх» —
    сложная проблема
    (с ней мы и боремся в
    этом докладе)

    применительно к нашим условиям

    View Slide

  20. Больше о проблеме вебсокетов
    20
    Роутинг необходимо
    осуществлять в двух
    направлениях
    2 направления
    Роутинг «вверх» —
    сложная проблема
    (с ней мы и боремся в
    этом докладе)

    Решение необходимо
    горизонтально
    масштабировать

    применительно к нашим условиям

    View Slide

  21. Но для начала поговорим
    как мы уже решали эту
    проблему в другом
    продукте
    21

    View Slide

  22. Подход к
    проблеме 1.0

    View Slide

  23. Что за продукт связан с «проблемой 1.0»?
    23
    — чат-бот. Его задача — помогать клиентам решать типовые проблемы

    View Slide

  24. Что за продукт связан с «проблемой 1.0»?
    24
    — чат-бот. Его задача — помогать клиентам решать типовые проблемы
    — обслуживает весь текстовый канал

    View Slide

  25. Что за продукт связан с «проблемой 1.0»?
    25
    — чат-бот. Его задача — помогать клиентам решать типовые проблемы
    — обслуживает весь текстовый канал
    — частично обслуживает голос

    View Slide

  26. Что за продукт связан с «проблемой 1.0»?
    26
    — чат-бот. Его задача — помогать клиентам решать типовые проблемы
    — обслуживает весь текстовый канал
    — частично обслуживает голос
    — в будущем будет сам звонить клиентам

    View Slide

  27. Что за продукт связан с «проблемой 1.0»?
    27
    — чат-бот. Его задача — помогать клиентам решать типовые проблемы
    — обслуживает весь текстовый канал
    — частично обслуживает голос
    — в будущем будет сам звонить клиентам
    — имеет много интеграций

    View Slide

  28. 28
    Набросок части архитектуры

    View Slide

  29. 29
    Набросок части архитектуры

    View Slide

  30. 30
    Набросок части архитектуры

    View Slide

  31. 31
    Набросок части архитектуры
    ?

    View Slide

  32. 32
    Набросок части архитектуры
    ?
    Вот и сложность канала «вверх»

    View Slide

  33. Как же быть с каналом «вверх»?
    33
    Например, взять RabbitMQ. Но нет уверенности, что:
    — динамические подписки/отписки быстро работают
    — динамическое создание очередей быстро работает
    — что это все держит высокую нагрузку

    View Slide

  34. 34

    View Slide

  35. Как же мы решили
    «проблему 1.0»?
    35

    View Slide

  36. 36

    View Slide

  37. 37
    Приблизительно так

    View Slide

  38. 38
    Приблизительно так

    View Slide

  39. 39

    View Slide

  40. Как устроен «фронт»
    40
    WAIT_TIMEOUT: Final[int] = 300
    async def bot_request_handler():
    #...
    inner_key: str = uuid4.uuid() # <-- вот он наш ключ
    await rabbitmq_connection.publish_message(
    'sometopic', {'return_key': inner_key, 'message': 'Сообщение от пользователя'}
    )
    #...
    while True:
    possible_result: bytes | None = await redis_connection.get(inner_key) # <-- вот он наш ключ
    if possible_result:
    await redis_connection.remove(inner_key)
    return JsonResponse({'answer': possible_result})
    await asyncio.sleep(WAIT_TIMEOUT)
    # ... что-то про таймауты

    View Slide

  41. Как устроен «бэк» (ml ядро)
    41
    MESSAGE_EXPIRATION: Final[int] = 600
    async def ml_core_wannabe_answerer():
    #...
    rabbit_listener: RabbitmqListener = rabbitmq_connection.subscribe('sometopic')
    while True:
    new_message: dict = await rabbit_listener.read_json()
    #...
    result_of_predict: dict = do_some_predict_magic(new_message)
    #...
    redis_connection.set(
    new_message['return_key'], # <-- вот он наш ключ
    json.encode(result_of_predict), MESSAGE_EXPIRATION)

    View Slide

  42. Выводы по решению 1.0
    42
    ✅ ну… оно работает!
    ✅ сделано просто и понятно
    ✅ бизнес очень доволен

    View Slide

  43. Выводы по решению 1.0
    43
    ✅ ну… оно работает!
    ✅ сделано просто и понятно
    ✅ бизнес очень доволен
    ❌ цикл со sleep
    ❌ редис неудобно масштабируется
    ❌ у нас «распределенный монолит»,
    где несколько сервисов пользуются
    «shared DB»

    View Slide

  44. Этот подход в ряде
    случаев имеет право
    на существование
    44

    View Slide

  45. Подход к
    проблеме 2.0

    View Slide

  46. Что за продукт связан с «проблемой 2.0»?
    46
    — чат + чат-«админка», b2c

    View Slide

  47. Что за продукт связан с «проблемой 2.0»?
    47
    — чат + чат-«админка», b2c
    — клиенты с текстовыми обращениями приходят сюда

    View Slide

  48. Что за продукт связан с «проблемой 2.0»?
    48
    — чат + чат-«админка», b2c
    — клиенты с текстовыми обращениями приходят сюда
    — обслуживает мобильное приложение, виджет на сайте, мессенджеры и т.п.

    View Slide

  49. Что за продукт связан с «проблемой 2.0»?
    49
    — чат + чат-«админка», b2c
    — клиенты с текстовыми обращениями приходят сюда
    — обслуживает мобильное приложение, виджет на сайте, мессенджеры и т.п.
    — имеет много интеграций, несколько своих фронтендов

    View Slide

  50. Что за продукт связан с «проблемой 2.0»?
    50
    — чат + чат-«админка», b2c
    — клиенты с текстовыми обращениями приходят сюда
    — обслуживает мобильное приложение, виджет на сайте, мессенджеры и т.п.
    — имеет много интеграций, несколько своих фронтендов
    — активно разрабатывается, бизнес требует большого объема функциональности

    View Slide

  51. А так же
    51
    — MSA архитектура («шареный» redis не пойдет)
    — Кубер

    View Slide

  52. 52
    Общая архитектура
    В общих чертах

    View Slide

  53. 53
    Общая архитектура
    В общих чертах

    View Slide

  54. 54
    Общая архитектура
    В общих чертах

    View Slide

  55. 55
    Общая архитектура
    В общих чертах

    View Slide

  56. 56
    Общая архитектура
    В общих чертах

    View Slide

  57. 57
    Общая архитектура
    В общих чертах

    View Slide

  58. 58
    Общая архитектура
    В общих чертах

    View Slide

  59. 59
    Общая архитектура
    В общих чертах

    View Slide

  60. 60
    Общая архитектура
    В общих чертах

    View Slide

  61. Канал «вниз»
    61
    Нам пришло сообщение от пользователя и мы…
    Валидируем JWT
    Валидируем структуры данных,
    формируем выходную структуру и
    сераилизуем в JSON
    Отправляем в Kafka
    Юзеры пишут JWT

    View Slide

  62. Канал «вниз»
    62
    Нам пришло сообщение от пользователя и мы…
    Валидируем JWT
    Валидируем структуры данных,
    формируем выходную структуру и
    сераилизуем в JSON
    Отправляем в Kafka
    Юзеры пишут JWT
    Pydantic
    валидация

    View Slide

  63. Канал «вниз»
    63
    Нам пришло сообщение от пользователя и мы…
    Валидируем JWT
    Валидируем структуры данных,
    формируем выходную структуру и
    сераилизуем в JSON
    Отправляем в Kafka
    Pydantic
    формируем
    выходную ст.
    Юзеры пишут JWT
    Pydantic
    валидация

    View Slide

  64. Канал «вниз»
    64
    Нам пришло сообщение от пользователя и мы…
    Валидируем JWT
    Валидируем структуры данных,
    формируем выходную структуру и
    сераилизуем в JSON
    Отправляем в Kafka
    Pydantic
    формируем
    выходную ст.
    Юзеры пишут JWT
    Pydantic
    валидация
    Kafka

    View Slide

  65. Вcё довольно просто,
    но дальше
    интереснее…
    65

    View Slide

  66. Основная проблема канала «вверх»
    66
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  67. Основная проблема канала «вверх»
    67
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  68. Основная проблема канала «вверх»
    68
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  69. Основная проблема канала «вверх»
    69
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  70. Основная проблема канала «вверх»
    70
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  71. Основная проблема канала «вверх»
    71
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  72. Основная проблема канала «вверх»
    72
    Сообщение приходит из недр системы (от оператора, например) и идет к пользователю

    View Slide

  73. Написали свой сервис
    Как мы решали вопросы
    73
    1
    Части сервиса общаются через ZeroMQ
    5
    Разделили на две части
    2
    Первая часть держит websocket
    соединение
    3
    Вторая роутит сообщения из кафки,
    «вверх»
    4

    View Slide

  74. Как назвали части:
    1 — websocket сервер
    2 — роутер
    74

    View Slide

  75. Почему ZeroMQ
    75
    Быстро!
    Pub
    фильтрация
    происходит тут
    Sub
    Sub
    Sub
    User ID 1
    User ID 2
    User ID 3

    View Slide

  76. Почему ZeroMQ
    76
    Быстро!
    Нет брокера! (мы ленивые)
    Pub
    фильтрация
    происходит тут
    Sub
    Sub
    Sub
    User ID 1
    User ID 2
    User ID 3

    View Slide

  77. Почему ZeroMQ
    77
    Быстро!
    Нет брокера! (мы ленивые)
    Есть куча механизмов и биндинги
    под python
    Pub
    фильтрация
    происходит тут
    Sub
    Sub
    Sub
    User ID 1
    User ID 2
    User ID 3

    View Slide

  78. Архитектурная схема роутера
    78
    На базе ZeroMQ
    — В качестве ключа мы используем ID
    пользователя
    Coroutine 1
    в любом поде или
    воркере
    Coroutine 2
    в любом поде или
    воркере
    Router
    в любом поде или
    воркере
    Kafka

    View Slide

  79. Архитектурная схема роутера
    79
    На базе ZeroMQ
    — В качестве ключа мы используем ID
    пользователя
    — Каждая корутина со ссылкой на вебсокет
    соединение «сабается» на наш роутер
    Coroutine 1
    в любом поде или
    воркере
    Coroutine 2
    в любом поде или
    воркере
    Router
    в любом поде или
    воркере
    Sub ZeroMQ
    Sub ZeroMQ
    Kafka

    View Slide

  80. Архитектурная схема роутера
    80
    На базе ZeroMQ
    — В качестве ключа мы используем ID
    пользователя
    — Каждая корутина со ссылкой на вебсокет
    соединение «сабается» на наш роутер
    — Роутер паблишит «корутинам» по ключу с
    ID пользователя
    ✨✨✨
    — Корутина получает нужное и отправляет
    пользователю
    Coroutine 1
    в любом поде или
    воркере
    Coroutine 2
    в любом поде или
    воркере
    Router
    в любом поде или
    воркере
    Sub ZeroMQ
    Sub ZeroMQ
    Pub ZeroMQ
    Pub ZeroMQ
    Kafka

    View Slide

  81. Как устроена подписка на роутеры
    81
    Сначала делаем это
    Websocket
    worker 1
    Router 1
    Websocket
    worker 2
    Router 2
    Websocket
    worker 3

    View Slide

  82. Роутер pub’лишит в websocket «сервер» (корутину)
    82
    Потом вот это
    Websocket
    worker 1
    Router 1
    Websocket
    worker 2
    Router 2
    Websocket
    worker 3



    Решение ❌ или ✅
    принимается тут ↓

    View Slide

  83. 83

    View Slide

  84. Как устроен websocket сервер
    84
    async def wannabe_router(message_from_kafka: dict):
    zeromq_context = Context.instance()
    socket = zeromq_context.socket(PUB)
    socket.bind(f'tcp://{ZERO_MQ_HOST}:{ZERO_MQ_PORT}')
    await socket.send_multipart([b'user-id', json.dumps(message_from_kafka)])

    View Slide

  85. Как устроен роутер
    85
    async def wannabe_websocket_server(websocket_client: WebSocket):
    zeromq_context = Context.instance()
    socket = zeromq_context.socket(SUB)
    socket.connect(f'tcp://{ZERO_MQ_HOST}:{ZERO_MQ_PORT}')
    socket.setsockopt(SUBSCRIBE, b'user-id')
    _, message = await socket.recv_multipart()
    await websocket_client.send_json(json.loads(message))

    View Slide

  86. Планы на будущее
    86
    — добавить динамические ключи фильтрации в zeromq связку
    — возможно сделать это с помощью «pub/sub» с фронтенда (иметь возможность
    sub’атся с фронтенда на websocket сервер)
    — написать больше тестов 😏😏😏

    View Slide

  87. Выводы по решению 2.0
    87
    ✅ горизонтально масштабируется
    ✅ больше нет никакой инфры, только код
    ✅ бизнес доволен
    ✅ запах распределенного монолита прошёл

    View Slide

  88. Выводы по решению 2.0
    88
    ✅ горизонтально масштабируется
    ✅ больше нет никакой инфры, только код
    ✅ бизнес доволен
    ✅ запах распределенного монолита прошёл
    ❌ это сложно
    ❌ иногда нужна поясняющая бригада

    View Slide

  89. Финальные выводы презентации
    89
    Возможно, вам подойдут готовые решения!

    View Slide

  90. Финальные выводы презентации
    90
    Возможно, вам подойдут готовые решения!
    Если нет, то:
    — вы узнали как 2 способами решить проблему канала «вверх»

    View Slide

  91. Финальные выводы презентации
    91
    Возможно, вам подойдут готовые решения!
    Если нет, то:
    — вы узнали как 2 способами решить проблему канала «вверх»
    — у нас есть простой способ, который подойдет там, где нас не смущает шареный
    редис

    View Slide

  92. Финальные выводы презентации
    92
    Возможно, вам подойдут готовые решения!
    Если нет, то:
    — вы узнали как 2 способами решить проблему канала «вверх»
    — у нас есть простой способ, который подойдет там, где нас не смущает шареный
    редис
    — а так же есть сложный способ подойдет там, где есть MSA, k8s и настоящее
    горизонтальное масштабирование

    View Slide

  93. https://linktr.ee/laukhin/
    https://xfenix.ru/
    Спасибо.
    С радостью ответим на
    вопросы!

    View Slide