У нас в Mail.ru Group есть Tarantool — это такой сервер приложений на Lua, который по совместительству ещё и база данных (или наоборот?). Он быстрый и классный, но возможности одного сервера всё равно не безграничны. Вертикальное масштабирование тоже не панацея, поэтому в Tarantool есть инструменты для горизонтального масштабирования — модуль vshard [1]. Он позволяет шардировать данные по нескольким серверам, но придётся повозиться, чтобы его настроить и прикрутить бизнес-логику.
Хорошие новости: мы собрали шишек (например [2], [3]) и запилили очередной фреймворк, который заметно упростит решение этой проблемы.
Тarantool Cartridge — это новый фреймворк для разработки сложных распределённых систем. Он позволяет сфокусироваться на написании бизнес-логики вместо решения инфраструктурных проблем. Пот катом я расскажу, как этот фреймворк устроен и как с его помощью писать распределённые сервисы.
А в чём, собственно, проблема?
У нас есть тарантул, есть vshard — чего ещё пожелать?
Во-первых, дело в удобстве. Конфигурация vshard настраивается через Lua-таблицы. Чтобы распределённая система из нескольких процессов Tarantool работала правильно, конфигурация должна везде быть одинаковой. Никто не хочет заниматься этим вручную. Поэтому в ход идут всяческие скрипты, Ansible, системы развёртывания.
Cartridge сам управляет конфигурацией vshard, он делает это на основе своей собственной распределённой конфигурации. По сути, это простой YAML-файл, копия которого хранится в каждом экземпляре Tarantool. Упрощение заключается в том, что фреймворк сам следит за своей конфигурацией и за тем, чтобы она везде была одинаковая.
Во-вторых, дело снова в удобстве. Конфигурация вшард не имеет никакого отношения к разработке бизнес-логики и только отвлекает программиста от работы. Когда мы обсуждаем архитектуру того или иного проекта, то чаще всего речь идёт об отдельных компонентах и их взаимодействии. О выкатке кластера на 3 датацентра думать рано.
Мы решали эти проблемы раз за разом, и в какой-то момент у нас получилось выработать подход, позволяющий упростить работу с приложением на всем его жизненном цикле: создание, разработка, тестирование, CI/CD, сопровождение.
Cartridge вводит понятие роли для каждого процесса Tarantool. Роли — это та концепция, которая позволяет сфокусироваться разработчику на написании кода. Все имеющиеся в проекте роли можно запустить на одном экземпляре Tarantool, и для тестов этого будет достаточно.
Основные возможности Tarantool Cartridge:
- автоматизированное оркестрирование кластера;
- расширение функциональности приложения с помощью новых ролей;
- шаблон приложения для разработки и развертывания;
- встроенное автоматическое шардирование;
- интеграция с тестовым фреймворком Luatest;
- управление кластером с помощью WebUI и API;
- инструменты упаковки и деплоя.
Hello, World!
Мне не терпится показать сам фреймворк, поэтому рассказ про архитектуру оставим на потом, и начнём с простого. Если предположить, что сам Tarantool уже установлен, то остаётся сделать только.
$ tarantoolctl rocks install cartridge-cli
$ export PATH=$PWD/.rocks/bin/:$PATH
Эти две команды установят утилиты командной строки и позволят создать своё первое приложение из шаблона:
$ cartridge create --name myapp
И вот что мы получим:
myapp/
├── .git/
├── .gitignore
├── app/roles/custom.lua
├── deps.sh
├── init.lua
├── myapp-scm-1.rockspec
├── test
│ ├── helper
│ │ ├── integration.lua
│ │ └── unit.lua
│ ├── helper.lua
│ ├── integration/api_test.lua
│ └── unit/sample_test.lua
└── tmp/
Это git-репозиторий с готовым «Hello, World!» приложением. Давайте сразу попробуем его запустить, предварительно установив зависимости (в т.ч. сам фреймворк):
$ tarantoolctl rocks make
$ ./init.lua --http-port 8080
Итак, у нас запущена одна нода будущего шардированного приложения. Любознательный обыватель может сразу открыть веб-интерфейс, мышкой сконфигурировать кластер из одного узла и наслаждаться результатом, но радоваться пока рано. Пока что приложение не умеет делать ничего полезного, поэтому про деплой я расскажу потом, а сейчас наступает время писать код.
Разработка приложений
Вот представьте, мы дизайним проект, который должен принимать данные, сохранять их и раз в сутки строить отчёт.
Мы начинаем рисовать схему, и помещаем на неё три компонента: gateway, storage и scheduler. Прорабатываем архитектуру дальше. Раз мы используем в качестве хранилища vshard, то добавляем в схему vshard-router и vshard-storage. Ни gateway, ни scheduler обращаться в хранилище напрямую не будут, для этого есть роутер, он для того и создан.
Эта схема всё ещё не совсем точно отражает то, что мы будем создавать в проекте, потому что компоненты выглядят абстрактно. Нужно ещё посмотреть, как это спроецируется на реальный Tarantool — сгруппируем наши компоненты по процессам.
Держать vshard-router и gateway на отдельных экземплярах смысла мало. Зачем нам лишний раз ходить по сети, если это и так входит в обязанности роутера? Они должны быть запущены внутри одного процесса. То есть в в одном процессе инициализируюстя и gateway, и vshard.router.cfg, и пусть они взаимодействуют локально.
На этапе проектирования работать с тремя компонентами было удобно, но я, как разработчик, пока пишу код, не хочу задумываться о запуске трёх экземпляров Tarnatool. Мне нужно запустить тесты и проверить, что я правильно написал gateway. Или, может, я хочу продемонстрировать коллегам фичу. Зачем мне мучиться с развёртыванием трёх экземпляров? Именно так родилась концепция ролей. Роль — это обычный луашный модуль, жизненным циклом которого управляет Cartridge. В данном примере их четыре — gateway, router, storage, scheduler. В другом проекте их может быть больше. Все роли можно запустить в одном процессе, и этого будет достаточно.
А когда речь пойдёт о развёртывании в staging или в эксплуатацию, тогда мы назначим каждому процессу Tarantool свой набор ролей в зависимости от аппаратных возможностей:
Управление топологией
Информацию о том, где какие роли запущены, надо где-то хранить. И это «где-то» — распределённая конфигурация, о которой я уже упоминал выше. Самое главное в ней — это топология кластера. Здесь изображено 3 репликационные группы из 5 процессов Tarantool:
Мы не хотим потерять данные, поэтому бережно относимся к информации о запущенных процессах. Cartridge следит за конфигурацией с помощью двухфазного коммита. Как только мы хотим обновить конфигурацию, он сначала проверяет доступность всех экземпляров и их готовность принять новую конфигурацию. После этого второй фазой применяется конфиг. Таким образом, даже если один экземпляр оказался временно недоступен, то ничего страшного не произойдёт. Конфигурация просто не применится и вы заранее увидите ошибку.
Также в секции топологии указан такой важный параметр, как лидер каждой репликационной группы. Обычно это тот экземпляр, на который идёт запись. Остальные чаще всего являются read-only, хотя тут могут быть исключения. Иногда смелые разработчики не боятся конфликтов и могут писать данные на несколько реплик параллельно, но есть некоторые операции, которые несмотря ни на что не должны выполняться дважды. Для этого есть признак лидера.
Жизнь ролей
Чтобы абстрактная роль могла существовать в такой архитектуре, фреймворк должен ими как-то управлять. Естественно, управление происходит без перезапуска процесса Tarantool. Для управления ролями существует 4 колбека. Cartridge сам будет их вызвать в зависимости от того, что у него написано в распределённой конфигурации, тем самым применяя конфигурацию к конкретным ролям.
function init()
function validate_config()
function apply_config()
function stop()
У каждой роли есть функция
init
. Она вызывается один раз либо при включении роли, либо при перезапуске Tarantool’а. Там удобно, например, инициализировать box.space.create, или scheduler может запустить какой-нибудь фоновый fiber, который будет выполнять работу через определённые интервалы времени. Одной функции
init
может быть недостаточно. Cartridge позволяет ролям пользоваться той распределенной конфигурацией, которую он использует для хранения топологии. Мы можем в этой же конфигурации объявить новую секцию и хранить в ней фрагмент бизнес-конфигурации. В моём примере это может быть схема данных, либо настройки расписания для роли scheduler.Кластер вызывает
validate_config
и apply_config
при каждом изменении распределённой конфигурации. Когда конфигурация применяется двухфазным коммитом, кластер проверяет, что каждая роль готова принять эту новую конфигурацию, и при необходимости сообщает пользователю об ошибке. Когда все согласились с тем, что конфигурация нормальная, то выполняется apply_config
. Также у ролей есть метод
stop
, который нужен для очистки результатов жизнедеятельности роли. Если мы говорим, что scheduler на этом сервере больше не нужен, он может остановить те файберы, которые запускал с помощью init
. Роли могут взаимодействовать между собой. Мы привыкли писать вызовы функций на Lua, но может случиться так, что в данном процессе нет нужной нам роли. Чтобы облегчить обращения по сети, мы используем вспомогательный модуль rpc (remote procedure call), который построен на основе стандартного netbox, встроенного в Tarantool. Это может пригодиться, если, например, ваш gateway захочет напрямую попросить scheduler сделать работу прямо сейчас, а не ждать сутки.
Ещё один важный момент — обеспечение отказоустойчивости. Для мониторинга здоровья в Cartridge используется протокол SWIM [4]. Если говорить вкратце, то процессы обмениваются друг с другом «слухами» по UDP — каждый процесс рассказывает своим соседям последние новости, и они отвечают. Если вдруг ответ не пришёл, Tarantool начинает подозревать что-то неладное, а через некоторое время декламирует смерть и начинает рассказывает всем окружающим эту новость.
На основе этого протокола Cartridge организует автоматическую обработку отказов. Каждый процесс следит за своим окружением, и если лидер вдруг перестал отвечать, то реплика может взять его роль на себя, а Cartridge соответствующим образом конфигурирует запущенные роли.
Здесь надо быть аккуратным, потому что частое переключение туда-сюда может привести к конфликтам данных при репликации. Включать автоматический failover наобум, конечно, не стоит. Надо четко понимать, что происходит, и быть уверенными, что репликация не сломается после того, как лидер восстановится и ему вернут корону.
Из всего сказанного может сложиться ощущение, что роли похожи на микросервисы. В каком-то смысле они ими и являются, только как модули внутри процессов Tarantool. Но есть и ряд принципиальных отличий. Во-первых, все роли проекта должны жить в одной кодовой базе. И все процессы Tarantool должны запускаться из одной кодовой базы, чтобы не было сюрпризов вроде тех, когда мы пытаемся инициализировать scheduler, а его попросту нет. Также не стоит допускать различий в версиях кода, потому что поведение системы в такой ситуации очень сложно предсказывать и отлаживать.
В отличие от Docker, мы не можем просто взять «образ» роли, отнести его на другую машину и там запустить. Наши роли не настолько изолированы, как Docker-контейнеры. Также мы не можем запустить на одном экземпляре две одинаковые роли. Роль либо есть, либо её нет, в каком-то смысле это singleton. Ну и в-третьих, внутри всей репликационной группы роли должны быть одинаковыми, потому что иначе было бы нелепо — данные одинаковые, а конфигурация разная.
Инструменты деплоя
Я обещал показать, как Cartridge помогает деплоить приложения. Чтобы облегчить жизнь окружающим, фреймворк упаковывает RPM-пакеты:
$ cartridge pack rpm myapp -- упакует для нас ./myapp-0.1.0-1.rpm
$ sudo yum install ./myapp-0.1.0-1.rpm
Установленный пакет несёт в себе почти всё необходимое: и приложение, и установленные луашные зависимости. Tarantool на сервер тоже приедет как зависимость RPM-пакета, и наш сервис готов к запуску. Делается это через systemd, но прежде необходимо написать немного конфигурации. Как минимум, указать URI каждого процесса. Трёх для примера хватит.
$ sudo tee /etc/tarantool/conf.d/demo.yml <<CONFIG
myapp.router: {"advertise_uri": "localhost:3301", "http_port": 8080}
myapp.storage_A: {"advertise_uri": "localhost:3302", "http_enabled": False}
myapp.storage_B: {"advertise_uri": "localhost:3303", "http_enabled": False}
CONFIG
Здесь есть интересный нюанс. Вместо того, чтобы указать лишь порт бинарного протокола, мы указываем публичный адрес процесса целиком включая hostname. Это нужно для того, чтобы узлы кластера знали, как друг с другом соединиться. Плохая идея использовать в качестве advertise_uri адрес 0.0.0.0, это должен быть внешний IP-адрес, а не bind сокета. Без него ничего работать не будет, поэтому Cartridge попросту не даст запустить узел с неправильным advertise_uri.
Теперь, когда конфигурация готова, можно запустить процессы. Так как обычный systemd-юнит не позволяет стартовать больше одного процесса, приложения на Cartridge устанавливает т.н. instantiated-юниты, которые работают так:
$ sudo systemctl start myapp@router
$ sudo systemctl start myapp@storage_A
$ sudo systemctl start myapp@storage_B
В конфигурации мы указали HTTP-порт, на котором Cartridge обслуживает веб-интерфейс — 8080. Зайдём на него и посмотрим:
Мы видим, что процессы хоть и запущены, но пока не сконфигурированы. Картридж пока не знает, кто с кем должен реплицироваться и не может принять решение самостоятельно, поэтому ждёт наших действий. А у нас выбор не большой: жизнь нового кластера начинается с конфигурации первого узла. Потом добавим к кластеру остальные, назначим им роли, и на этом деплой можно считать успешно завершённым.
Нальём кружечку любимого напитка и расслабимся после долгой рабочей недели. Приложение можно эксплуатировать.
Итоги
А что итоги? Пробуйте, пользуйтесь, оставляйте обратную связь, заводите тикеты на гитхабе.
Ссылки
[1] Tarantool » 2.2 » Reference » Rocks reference » Module vshard
[2] Как мы внедряли ядро инвестиционного бизнеса Альфа-Банка на базе Tarantool
[3] Архитектура биллинга нового поколения: трансформация с переходом на Tarantool
[4] SWIM — протокол построения кластера
[5] GitHub — tarantool/cartridge-cli
[6] GitHub — tarantool/cartridge