От Action к Any

AlekseyL
AlekseyL
Dec 20, 2017 · 8 min read

English version: From Action To Any

Подкапотные нюансы AnyCable для мигрирующих с ActionCable.

Что будет интересного:

  • Как можно посчитать количество пользователей онлайн в ActionCable и почему это сломается в AnyCable
  • Чем отличается current_user в базовом классе Connection при использовании AnyCable от ActionCable.
  • Почему AnyCable гарантировано ломает devise при использовании Rails сессии и как с этим быть.
  • Чем отличается stop_all_streams в AnyCable от своего аналога в ActionCable
  • Как разорвать соединение извне в ActionCable и как это можно сделать AnyCable
  • Где была и куда делась проверка заголовка Origin в AnyCable.

Это небольшое мемо будет интересно для тех кто хочет копнуть на полштыка глубже в теме ActionCable в контексте его альтернативы AnyCable.

Обращаю внимание читателя на то, что все описанные отличия AnyCable от ActionCable актуальны для версии anycable-rails 0.5.2, anycable 0.5.0, и actioncable 5.1.4! Многое из указанного уже вынесено в issue или будет вынесено в ближайшее время и в скором времени будет исправлено или изменено.

Введение

Для тех кто не в курсе: ActionCable это подсистема фреймворка Ruby on Rails для реализации realtime web приложений. AnyCable представляет собой высокопроизводительную альтернативу ActionCable с максимально возможным сохранением экосистемы рельс и ActionCable. Для тех у кого есть вопросы зачем менять родное шило на чужое мыло отвечают три следующих сравнительных картинки .

Используемой памяти:

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions). Источник: AnyCable — ActionCable на стероидах

Загрузки ядер:

ActionCable VS AnyCable WebSocket Shootout benchmark, источник: AnyCable — ActionCable на стероидах

Задержки сообщений при росте числа подключений:

Задержка броадкастинга в секундах от количества подключений, источник: AnyCable — ActionCable на стероидах

Так или иначе, вот три причины, каждая из которых уже сама по себе достаточна для того, чтобы задуматься о внедрении AnyCable на место ActionCable.

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

Вот хорошая табличка текущей совместимости обощенная Владимиром Дементьевым, автором AnyCable:

Но помимо этой таблички есть еще кое-какие нюансы, которые стоит понимать, при замене ActionCable на AnyCable.

Начнем со стороны того, что никак не цепляет такая перемена: фронтэнда.

Front-end

Со стороны фронта изменения в общем-то отсутствуют, ws-сервера AnyCable полностью совеместимы сейчас с кодом ActionCable на фронте.

Back-end

Вот тут все немножко сложнее. Для начала можем посмотреть на архитектуру, так сказать, с высоты:

Сравнение архитектур AnyCable VS ActionCable. Источник: anycable-rails repo
Более подробно архитектура AnyCable. Источник: ActionCable on steroids

Что мы видим: вебсокет сервер вынесен отдельно, ActionCable заменен на Anycable RPC с вызовом по gRPC.

Minus Rack middleware

Если проявить внимательность, уже на этом этапе можно предугадать первую проблему, с которой придется столкнуться при переводе приложения с ActionCable на AnyCable: несмотря на загрузку Rails приложения, AnyCable это RPC сервис, а ActionCable это Rack based сервер. А это значит, что все Rack-middleware недоступно в AnyCable из коробки! Если вы хотите, что-то использовать вам придется реализовывать это через дополнительные обертки/костыли.

Самый простой и яркий пример, который коснется практически всех, кто будет переводить свое приложение с ActionCable: недоступно Warden middleware, это значит, что если у вас была аутентификация с использованием devise в ActionCable, она сломается. Stackoverflow приводит, например, вот такой способ использования devise совместно с ActionCable:

verified_user = env['warden'].user

В AnyCable это бессмысленно, потому что middle нет и env['warden'] == nil.

Как это можно подлечить? Например так:

gRPC и некоторые некомсомольские методы

Еще одним следствием вынесения поддержания сокетов в отдельный сервер и упрощение Rack приложения до gRPC стало то, что многие функции стали работать в пассивном режиме — всего лишь выставляя значения, которые будут возвращены в ws-server, тогда как в исходном ActionCable они непосредственно осуществляли соответствующие действия.

Например, метод stop_all_streams в исходном варианте непосредственно отписывал стримы от подписок, и после вызова можно считать, что команда отписаться уже ушла на редис, в случае с AnyCable до тех пор пока не произойдет возврата из вызова gRPC в ws-сервер процедура отписки стримов не запустится.

В целом знание этой особенности реализации может быть актуально в двух случаях: если после вызова stop_all_streams далее по коду выкинет эксепшен, то поведение ActionCable будет отличным от AnyCable, и это может помочь быстрее найти проблему на стыке AnyCable и ws-сервера, если таковая возникнет.

Long live Connection!

Продолжаем копаться в классе ActionCable::Connection. В классическом исполнении ActionCable инстансы класса ActionCable::Connection живут все время соединения! Как ни странно, но в документации акцент этот пропущен, основной акцент в документации делается на то что объекты каналов живут долго, но собственно они живут внутри как раз объекта Connection, в коде ActionCable::Channel есть на этот счет отсылка:

Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then lives until the consumer disconnects.

Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it.

Вот тут мы пришли к следующему отличию AnyCable: в AnyCable нет долгоживущих объектов! Все объекты создаются только на время вызова по gRPC, и сериализуются/десериализуются посредством globalid gem’а.

Таким образом, все ActiveRecord объекты, которые были привязаны в ActionCable::Connection через вызов identified_by, теперь релоадятся перед использованием, т.е. то что раньше было просто current_user, на самом деле теперь всегда current_user.reload.

С одной стороны это удобно: у объекта всегда актуальное на момент вызова состояние. С другой, теперь каждый вызов методов Connection/Channel из JS может приводить к дополнительным обращениям к БД, по числу таких idetified_by объектов, и в ряде случаев, имеет смысл отказаться от использования ActiveRecord объектов заменив их на простые структуры.

Броадкастну, а в ответ тишина…

Продолжаем по прежнему копаться в классе Connection и его отличиях от базового варианта. Теперь нас интересуют InternalChannel и RemoteConnections ( оба находятся в namespace ActionCable::Connection). Каждый создаваемый на бекенде объект коннекшен не только контролирует подписку пользователя на каналы, но и сам дополнительно создает и подписывается на внутренний канал сообщений, который в частности используется для принудительного разрыва соединения извне, например, из основного Rails приложения.

Если мы запустим обычный демо пример action cable, который приводится в описании ActionCable, присоединимся из двух браузеров к паре каналов и после этого посмотрим как дела в Redis.pubsub, мы увидим вот такой вывод:

2.4.1 :020 > redis.pubsub('channels') => ["action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8x", 
"messages:1:comments",
"messages:2:comments",
"_action_cable_internal", "action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8z"]

Где каналы с префиксом messages — это каналы чат комнат, которые и приводятся в коде демо-приложения, а каналы с префиксом action_cable/ — это внутренние каналы соединений, о которых было сказано выше. Если мы запустим AnyCable приложение и попробуем заглянуть в pubsub redis’а то мы увидим там только одну очередь сообщений, по умолчанию она называется __anycable__!

На что влияет такая реализация? Ну во-первых пропускная способность всех ваших кабелей теперь зависит от пропускной способности одной очереди pubsub в редисе. Во-вторых полезные советы как определить кто присоединен к вашим сокетам и сколько их, стали теперь вредными, в смысле, нерабочими.

Ну и возвращаясь к теме заголовка, если мы заглянем в код класса RemoteConnections, чтобы подсмотреть как отключаются связи извне мы увидим:

ActionCable.server
.remote_connections
.where(current_user: User.find(1))
.disconnect

и в методе дисконнект:

server.broadcast internal_channel, type: "disconnect"

Таким образом в ActionCable пройдет сообщение disconnect и соединение закроется, но в AnyCable каналов таких нет, напомню в anycable у нас только одна очередь __anycable__, и этот броадкаст молча уйдет в молоко.

В общем как и говорила нам табличка совместимости:

Disconnect remote clients : —

Как в итоге разорвать соединение извне в AnyCable? Без внесения изменений в ws-сервер, в общем-то, нет гарантированного способа выборочно разорвать соединение из основного rails-приложения, есть только ненадежный: допилить на front-end’е класс канала и отправить на такой доработанный класс специальное сообщение, которое заставит сокет пересоединиться, в чем мы ему можем с удовольствием отказать. Если по каким-то причинам JS проигнорирует наше сообщение, придется ждать или обновления вашего любимого варианта AnyCable ws-сервера, или на худой конец его перезапуска.

Пара слов про конфиг

Про конфиг ActionCable можно почитать в официальном гайде, в двух словах в config/cable.yml лежат настройки pubsub адаптера, остальные можно вписать через config.action_cable.

Про конфигурацию Anycable есть кратко в официальном репозитории Основные отличия:

  • Не используется cable.yml для своих настроек, во-первых потому что pubsub адаптера как такового на уровне gRPC-сервиса нет и в нем нет потребности, потому что на сообщения подписывается непосредственно ws-сервер, а во-вторых потому что для целей броадкастинга используется только redis его можно только настроить, но выбрать вместо него другой движок для pubsub, например postgreSQL, нет возможности*.
  • Для заполнения настроек используется гем anyway_config, так что настройки можно выставить и через окружение, и через config/anycable.yml, и вообще через вызов Anycable.configure.

Также подробнее про связь настроек в config/anycable.yml с параметрами запуска ws-сервера можно прочитать в комментариях к дефолтным настройкам config/anycable.yml добавленным в генератор с версии anycable-rails 0.5.2.

*Речь про текущую версию AnyCable и anycable-rails ( 0.5.2 ), добавление других pubsub адаптеров указано в планах и обсуждается

Cross Site WebSocket Hijacking (CSWSH).

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

В контексте AnyCable это проявляется на старте ws-server’а, ему указываются заголовки, которые он будет передавать на gRPC сервис:

-headers=cookie,x-api-token,origin

Как видно можно разрешить передавать куки для определения сессии и origin для его проверки. Но в отличе от ActionCable, в Anycable gRPC отсутствует встроенная проверка (allow_request_origin?):

# ActionCable::Connection::Base
# Called by the server
when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
# This method should not be called directly -- instead rely upon on the #
connect (and #disconnect) callbacks.
def process #:nodoc:
logger.info started_request_message
if websocket.possible? && allow_request_origin?
respond_to_successful_request
else
respond_to_invalid_request
end
end

Проверку origin легко можно вернуть в приложение самостоятельно, используя вызов allow_request_origin? и настройки ActionCable:

config.action_cable.allowed_request_origins = ['http://rubyonrails.com', %r{http://ruby.*}]

Или же переложить проверку на плечи nginx.

Заключение

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


Мои благодарности Владимиру Дементьеву автору AnyCable за сам гем и замечания к данной статье.


Ссылки

ActionCable:

  1. ActionCable guide
  2. Ответы на вопросы как определить кто присоединен к вашим сокетам и сколько их
  3. Chat app rails 5 + ActionCable + devise
  4. ActionCable examples

Anycable:

  1. AnyCable — ActionCable на стероидах
  2. Anycable.io
  3. Anycable на гитхабе

Безопасность сокетов:

  1. OWASP проверка сокетов на предмет разных угроз в том числе кроссайтового подключения
  2. Большое и подробное мемо на тестирование Вебсокетов на безопасность от горячих финских парней, в том числе по вопросу Crossite ws Hijacking
  3. Еще про кроссайтовые сокеты и как уберечься от Кристиана Шнайдера.
  4. Ответы на security.stackexchange.com: Preventing CSRF against Websockets и Is Origin header useful for websocket protection.
  5. Как подделать ориджин в JS ( ну в общем практически никак, либо куки не прилипнут, либо ориджин будет исходный)
  6. Еще статья про безопасность WS с обсуждениями

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store