Ruby on Rails
29 April

Построение сервис-ориентированной архитектуры на Rails + Kafka

From Sandbox
Привет, Хабр! Представляю вашему вниманию пост, который является текстовой адаптацией выступления Stella Cotton на RailsConf 2018 и переводом статьи «Building a Service-oriented Architecture with Rails and Kafka» автора Stella Cotton.

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

Что же такое Kafka?


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

Как правило, неотъемлемая часть веб приложений требует так называемый real-time data flow. Kafka предоставляет отказоустойчивую связь между producers, теми, кто генерирует события, и consumers, теми, кто получает эти события. Может быть даже несколько producers и consumers в одном приложении. В Kafka, каждое событие существует в течение заданного времени, поэтому несколько consumers могут читать одно и то же событие снова и снова. Kafka кластер включает в себя несколько brokers, которые являются инстансами Kafka.



Ключевая особенность Kafka — высокая скорость обработки событий. Традиционные системы очередей, например AMQP, имеют инфраструктуру, которая следит за обработанными событиями для каждого consumer-а. Когда количество consumers вырастает до приличного уровня, система с трудом начинает справляться с нагрузкой, потому что ей приходится отслеживать все большее количество состояний. Также, существуют большие проблемы с согласованностью между consumer и системой обработки событий. Например, стоит ли сразу помечать сообщение как отправленное, как только оно обработано системой? А если consumer на другом конце упадет, так и не получив сообщения?

Kafka также имеет отказоустойчивую архитектуру. Система запускается как кластер на одном или нескольких серверах, которые могут горизонтально масштабироваться путем добавления новых машин. Все данные записываются на диск и копируются на несколько брокеров. Для того, чтобы понимать возможности масштабируемости, стоит взглянуть на такие компании как Netflix, LinkedIn, Microsoft. Все они отправляют триллионы сообщений в день через свои Kafka кластеры!

Настройка Kafka в Rails


Heroku предоставляет Kafka cluster add-on, который может быть использован для любого окружения. Для ruby приложений, мы рекомендуем использовать гем ruby-kafka. Минимальная реализация выглядит примерно так:

# config/initializers/kafka_producer.rb
require "kafka"
# Configure the Kafka client with the broker hosts and the Rails
# logger.
$kafka = Kafka.new(["kafka1:9092", "kafka2:9092"], logger: Rails.logger)
# Set up an asynchronous producer that delivers its buffered messages
# every ten seconds:
$kafka_producer = $kafka.async_producer(
  delivery_interval: 10,
)
# Make sure to shut down the producer when exiting.
at_exit { $kafka_producer.shutdown }

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

class OrdersController < ApplicationController
  def create
    @comment = Order.create!(params)
    $kafka_producer.produce(order.to_json, topic: "user_event", partition_key: user.id)
  end
end

Мы поговорим о форматах сериализации ниже, а пока используем старый добрый JSON. Аргумент topic ссылается на лог, в который Kafka запишет это событие. Topics размазаны по разным разделам, которые позволяют разделить данные конкретного topic по разным брокерам для лучшей масштабируемости и надежности. И это действительно хорошая идея — иметь два или более раздела для каждого topic, ведь, если один из разделов падает, ваши события все равно будут записаны и обработаны. Kafka гарантирует, что события доставляются в порядке очереди внутри раздела, но не внутри целого topic. Если порядок событий важен, то отправка partition_key гарантирует, что все события определенного типа будут сохранены на одном разделе.

Kafka для ваших сервисов


Некоторые особенности, которые делают Kafka полезным инструментом, также делают его отказоустойчивой заменой RPC между сервисами. Взглянем на пример e-commerce приложения:

def create_order
  create_order_record
  charge_credit_card # call to Payments Service
  send_confirmation_email # call to Email Service
end

Когда пользователь оформляет заказ, вызывается функция create_order. Это создает заказ в системе, списывает деньги с карты и отправляет email с подтверждением. Как видно, два последних шага вынесены в отдельные сервисы.



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

Например:



В этом событийно-ориентированном подходе, вышестоящий сервис может записать событие в Kafka о том, что заказ был создан. Из-за так называемого at least once подхода, событие будет записано в Kafka как минимум один раз и будет доступно нижестоящим consumer-ам для чтения. Если сервис отправки писем лежит, событие будет ждать на диске, пока consumer не поднимется и не прочтет его.

Еще одна проблема RPC-ориентированной архиектуры — в быстрорастущих системах: добавление нового нижестоящего сервиса влечет за собой изменения в вышестоящем. Например, вы бы хотели добавить еще один шаг после создания заказа. В событийно-ориентированном мире вам нужно будет добавить еще один consumer для обработки нового типа событий.



Включение событий в сервис-ориентированную архитектуру


В посте под названием “What do you mean by “Event-Driven” Мартина Фаулера обсуждается путаница вокруг событийно-ориентированных приложений. Когда разработчики обсуждают подобные системы, они на самом деле говорят об огромном количестве различных приложений. Для того, чтобы дать общее понимание о природе таких систем, Фаулер определил несколько архитектурных шаблонов.

Давайте взглянем, что это за паттерны. Если вы хотите узнать больше, то советую прочитать его доклад на GOTO Chicago 2017.

Event Notification


Первый шаблон Фаулера называется Event Notification. В этом сценарии producer сервис оповещает consumer-ов о произошедшем событии с помощью минимального объема информации:

{
  "event": "order_created",
  "published_at": "2016-03-15T16:35:04Z"
}

Если consumer-ам требуется больше информации о событии, они делают запрос к producer и получают больше данных.

Event-Carried State Transfer


Второй шаблон называется Event-Carried State Transfer. В этом сценарии producer предоставляет дополнительную информацию о событии и consumer может хранить копию эти данных, не делая дополнительных вызовов:

{
  "event": "order_created",
  "order": {
    "order_id": 98765,
    "size": "medium",
    "color": "blue"
  },
  "published_at": "2016-03-15T16:35:04Z"
}

Event-Sourced


Третий шаблон Фаулер назвал Event-Sourced и он является скорее архитектурным. Релизация шаблона предполагает не просто коммуникацию между вашими сервисами, но и сохранение представления события. Это гарантирует, что, даже потеряв базы данных, вы все равно можете восстановить состояние пр��ложения, просто запустив сохраненный поток событий. Другими словами, каждое событие сохраняет определенное состояние приложения в определенный момент.

Большой проблемой такого подхода является то, что код приложения всегда меняется, а с ним может меняться формат или объем данных, которые отдает producer. Это делает проблемным восстановление состояния приложения.

Command Query Responsibility Segregation


И последний шаблон — Command Query Responsibility Segregation, или CQRS. Идея в том, что действия, которые вы применяете к объекту, например: создание, чтение, обновление, должны быть разделены на различные домены. Это значит, что один сервис должен отвечать за создание, другой за обновление и т.д. В объектно-ориентированных системах же все часто хранится в одном сервисе.



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

Проблемы


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

Первая проблема может заключаться в медленных consumer-ах. В событийно-ориентированной системе ваши сервисы должны иметь возможность обрабатывать события моментально при получении их от вышестоящего сервиса. Иначе они будут просто висеть без каких-либо оповещений о проблеме или таймаутах. Единственное место, где можно определить таймауты — сокетное соединение с брокерами Kafka. Если сервис не обрабатывает событие достаточно быстро, то соединение может быть прервано по таймауту, но восстановление сервиса требует дополнительных затрат времени, потому что создание таких сокетов обходится дорого.

Если consumer медленный, как можно увеличить скорость обработки событий? В Kafka вы можете увеличить количество consumer-ов в группе, таким образом, большее количество событий может быть обработано параллельно. Но потребуется как минимум 2 consumer-а на один сервис: в случае, если один упадет, поврежденные разделы можно переназначить.

Также очень важно иметь метрики и оповещения, чтобы следить за скоростью обработки событий. ruby-kafka может работать с ActiveSupport оповещениями, также он имеет StatsD и Datadog модули, которые по-умолчанию включены. Кроме того, гем предоставляет список рекоммендованных метрик для мониторинга.

Еще одним важным аспектом построения систем с Kafka является проектирование consumer-ов с возможностью обработки отказов. Kafka гарантированно отправит событие хотя бы один раз; исключен случай, когда сообщение не отправилось совсем. Но важно, чтобы consumer-ы были готовы обработать повторяющиеся события. Один из способов сделать это — всегда использовать UPSERT для добавления новых записей в базу данных. Если запись уже существует с такими же атрибутами, вызов по сути будет неактивным. Кроме того, вы можете добавить уникальный идентификатор в каждое событие и просто пропускать события, которые уже были обработаны ранее.

Форматы данных


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

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

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

Команда, создавшая Kafka, советует использовать в качестве системы сериализации Avro. Данные отправляются в бинарном виде, и это не самый человекочитаемый формат, зато внутри есть более надежная поддержка схем. Конечный объект в Avro включает в себя одновременно схему и данные. Также Avro поддерживает как простые типы, вроде чисел, так и сложные: даты, массивы и пр. Кроме того, он позволяет включать документацию внутри схемы, что позволяет понимать назначения определенного поля в системе и содержит множество других встроенных инструментов для работы со схемой.

avro-builder — гем, созданный Salsify, который предлагает ruby-подобный DSL для создания схем. Более подробно про Avro можно прочесть в этой статье.

Дополнительная информация


Если вы интересуетесь как хостить Kafka или как он используется в Heroku, есть несколько докладов, которые могут быть вам интересны.

Jeff Chao’s на конфереции DataEngConf SF ’17 “Beyond 50,000 Partitions: How Heroku Operates and Pushes the Limits of Kafka at Scale

Pavel Pravosud на конфереции Dreamforce ’16 “ Dogfooding Kafka: How We Built Heroku’s Real-Time Platform Event Stream

Приятного просмотра!

+7
2.2k 20
Leave a comment
Top of the day