
Привет, меня зовут Игорь и я работаю в команде Tarantool. При разработке мне часто требуется быстрое прототипирование приложений с базой данных, например, для тестирования кода или для создания MVP. Конечно же хочется, чтобы такой прототип требовал минимальных усилий по доработке, если вдруг будет решено пустить его в работу.
Мне не нравится тратить время на настройку SQL базы данных, думать, как управлять шардированием данных, тратить много времени на изучение интерфейсов коннекторов. Хочется просто написать несколько строчек кода и запустить его, чтобы все работало из коробки. В быстрой разработке распределенных приложений мне помогает Cartridge — фреймворк для управления кластерными приложениями на основе NoSQL базы данных Tarantool.
Сегодня я хочу рассказать о том, как можно быстро написать приложение на Cartridge, покрыть его тестами и запустить. Статья будет интересна всем, кто устал тратить много времени на прототипирование приложений, а также людям, которые хотят попробовать новую NoSQL технологию.
Содержание
В статье вы узнаете о том, что такое Cartridge и познакомитесь с принципами написания кластерной бизнес-логики в нем.
Мы напишем кластерное приложение для хранения данных о сотрудниках некоторой компании, нас ждет:
- Создание приложения из шаблона с cartridge-cli
- Описание своей бизнес-логики на Lua в кластерных ролях Cartridge
- Хранилище данных
- Пользовательское HTTP API
- Написание тестов
- Локальный запуск и настройка небольшого кластера
- Загрузка конфигурации
- Настройка фейловера
Cartridge
Cartridge — это фреймворк для разработки кластерных приложений, он управляет несколькими инстансами NoSQL БД Tarantool и шардирует данные с помощью модуля vshard. Tarantool — это персистентая in-memory база данных, он очень быстрый за счет хранения данных в оперативной памяти, но при этом надежный — Tarantool сбрасывает все данные на жесткий диск и позволяет настроить репликацию, а Cartridge сам заботится о настройке узлов Tarantool и шардировании узлов кластера, так что все, что нужно разработчику — написать бизнес-логику приложений и произвести настройку фейловера.
Преимущества Сartridge
- Шардирование и репликация из коробки
- Встроенный failover
- NoSQL язык кластерных запросов — CRUD
- Интеграционное тестирование всего кластера
- Управление кластером с помощью ansible
- Утилита для администрирования кластера
- Инструменты для мониторинга
Создаем первое приложение
Для этого нам понадобится cartridge-cli — это утилита для работы с картриджными приложениями. Она позволяет создавать приложение из шаблона, управлять локально запущенным кластером и подключаться к инстансам тарантула.
Установим Tarantool и cartridge-cli
Установка на Debian или Ubuntu:
curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash sudo apt install cartridge-cli
Установка на CentOS, Fedora или ALT Linux:
curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash sudo yum install cartridge-cli
Установка на MacOS:
brew install tarantool brew install cartridge-cli
Создадим шаблонное приложение с именем myapp:
cartridge create --name myapp cd myapp tree .
Получим структуру проекта примерно такого содержания:
myapp ├── app │ └── roles │ └── custom.lua ├── test ├── init.lua ├── myapp-scm-1.rockspec
init.lua— входная точка приложение на картридже, здесь прописывается конфигурация кластера и вызываются функции, которые должны будут выполнены на старте каждого узла приложения.- в директории
app/roles/хранятся "роли", в которых описывается бизнес-логика приложения. myapp-scm-1.rockspec— файл для указания зависимостей
На этом шаге вы уже получите готовое к работе hello world-приложение. Его можно запустить с помощью команд
cartridge build cartridge start -d cartridge replicasets setup --bootstrap-vshard
После этого по адресу localhost:8081/hello мы увидим hello-world.
Давайте теперь на основе шаблона напишем свое небольшое приложение — шардированное хранилище с HTTP API для наполнения и получения данных. Для этого нам потребуется понимание того, как пишется кластерная бизнес-логика в Cartridge.
Пишем бизнес-логику в Cartridge
В основе каждого кластерного приложения лежат роли — Lua-модули, в которых описывается бизнес-логика приложения. Например, это могут быть модули, которые занимаются хранением данных, предоставляют HTTP API или кэширует данные из Oracle. Роль назначается на набор инстансов, объединенных репликацией (репликасет) и включается на каждом из них. У разных репликасетов может быть разный набор ролей.
В cartridge есть кластерная конфигурация, которая хранится на каждом из узлов кластера. Там описывается топология, а также туда можно добавить конфигурацию, которой будет пользоваться ваша роль. Такую конфигурацию можно изменять в рантайме и влиять на поведение роли.
Каждая роль имеет структуру следующего вида:
return { role_name = 'your_role_name', init = init, validate_config = validate_config, apply_config = apply_config, stop = stop, rpc_function = rpc_function, dependencies = { 'another_role_name', }, }
Жизненный цикл роли
- Инстанс запускается.
- Роль с именем
role_nameждет запуска всех зависимых ролей, указанных вdependencies. - Вызывается функция
validate_config, которая проверяет валидность конфига роли. - Вызывается функция инициализации роли
init, в которой производятся действия, которые должны запускаться один раз на старте роли. - Вызывается
apply_config, которая применяет конфиг (если таковой имеется).validate_configиapply_configвызываются также при каждом изменении конфигурации роли. - Роль попадает в registry, откуда будет доступна для других ролей на этом же узле с помощью
cartridge.service_get('your_role_name'). - Объявленные в роли функции будут доступны для вызова с других узлов с помощью
cartridge.rpc_call('your_role_name', 'rpc_function'). - Перед выключением или перезапуском роли запускется функция
stop, которая завершает работу роли, например, удаляют созданные ролью файберы.
Кластерные NoSQL-запросы
В Cartridge есть несколько вариантов написания кластерных запросов:
Вызовы функций через API vshard (это сложный способ, но зато очень гибкий):
vshard.router.callrw(bucket_id, 'app.roles.myrole.my_rpc_func', {...})
- Простые вызовы функций
crud.insert/get/replace/ ... - Ограниченная поддержка вычисления
bucket_id - Роли должны иметь зависимость от
crud-router/crud-storage
- Простые вызовы функций
Схема приложения
Представим, что мы хотим кластер с одним роутером и с двумя группами стораджей по два инстанса. Такая топология характерна и для Redis Cluster, и для Mongodb кластера. Также в нашем кластере будет один инстанс — stateboard (в котором stateful-failover будет сохранять состояние текущих мастеров). Когда требуется повышенная надёжность, вместо stateboard лучше использовать кластер etcd.
Роутер будет распределять запросы по кластеру, а также управлять фейловером.

Пишем свои роли
Нам потребуется написать две своих роли, одну для хранения данных, вторую для HTTP API.
В директории app/roles создаем два новых файла: app/roles/storage.lua и app/roles/api.lua
Хранилище данных
Опишем роль для хранения данных. В функции init мы создадим таблицу и индексы для нее, а в зависимости добавим crud-storage.
Если вы привыкли к SQL, то Lua-код в init-функции будет эквивалентен следующему псевдо-SQL коду:
CREATE TABLE employee( bucket_id unsigned, employee_id string, name string, department string, position string, salary unsigned ); CREATE UNIQUE INDEX primary ON employee(employee_id); CREATE INDEX bucket_id ON employee(bucket_id);
Добавим следующий код в файл app/roles/storage.lua :
local function init(opts) -- в opts хранится признак, вызывается функция на мастере или на реплике -- мы создаем таблицы только на мастере, на реплике они появятся автоматически if opts.is_master then -- Создаем таблицу с сотрудниками local employee = box.schema.space.create('employee', {if_not_exists = true}) -- задаем формат employee:format({ {name = 'bucket_id', type = 'unsigned'}, {name = 'employee_id', type = 'string', comment = 'ID сотрудника'}, {name = 'name', type = 'string', comment = 'ФИО сотрудника'}, {name = 'department', type = 'string', comment = 'Отдел'}, {name = 'position', type = 'string', comment = 'Должность'}, {name = 'salary', type = 'unsigned', comment = 'Зарплата'} }) -- Создаем первичный индекс employee:create_index('primary', {parts = {{field = 'employee_id'}}, if_not_exists = true }) -- Индекс по bucket_id, он необходим для шардирования employee:create_index('bucket_id', {parts = {{field = 'bucket_id'}}, unique = false, if_not_exists = true }) end return true end return { init = init, -- <<< не забываем про зависимость от crud-storage dependencies = {'cartridge.roles.crud-storage'}, }
Остальные функции из API роли нам не понадобятся — у нашей роли нет конфигурации и она не выделяет ресурсы, которые нужно очищать после завершения работы.
HTTP API
Нам понадобится вторая роль для наполнения таблиц данными и получения этих данных по запросу. Она будет обращаться к встроенному в Cartridge HTTP-серверу и иметь зависимость от crud-router.
Определим функцию для POST-запроса. В теле запроса будет приходить объект, который мы хотим записать в базу.
local function post_employee(request) -- достаем объект из тела запроса local employee = request:json() -- записываем в БД local _, err = crud.insert_object('employee', employee) -- В случае ошибки пишем ее в лог и возвращаем 500 if err ~= nil then log.error(err) return {status = 500} end return {status = 200} end
В GET-метод будет передаваться уровень зарплаты сотрудников и в качестве ответа мы будем возвращать JSON со списком сотрудников, которые имеют зарплату выше заданной.
SELECT employee_id, name, department, position, salary FROM employee WHERE salary >= @salary
local function get_employees_by_salary(request) -- достаем query-параметр salary local salary = tonumber(request:query_param('salary') or 0) -- отбираем данные о сотрудниках local employees, err = crud.select('employee', {{'>=', 'salary', salary}}) -- В случае ошибки пишем ее в лог и возвращаем 500 if err ~= nil then log.error(err) return { status = 500 } end -- в employees хранится список строк, удовлетворяющих условию и формат спейса -- unflatten_rows нужна, чтобы преобразовать строку таблицы в таблицу вида ключ-значение employees = crud.unflatten_rows(employees.rows, employees.metadata) employees = fun.iter(employees):map(function(x) return { employee_id = x.employee_id, name = x.name, department = x.department, position = x.position, salary = x.salary, } end):totable() return request:render({json = employees}) end
Теперь напишем init-функцию роли. Здесь мы обратимся к registry Cartridge для получения HTTP-сервера и используем его для назначения HTTP-эндпоинтов нашего приложения.
local function init() -- получаем HTTP-сервер из registry Cartridge local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd serivce") -- прописываем роуты httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary) httpd:route({method = 'POST', path = '/employee'}, post_employee) return true end
Соберем все это вместе:
local cartridge = require('cartridge') local crud = require('crud') local log = require('log') local fun = require('fun') -- метод GET /employees будет возвращать список сотрудников с зарплатой больше заданной local function get_employees_by_salary(request) -- достаем query-параметр salary local salary = tonumber(request:query_param('salary') or 0) -- отбираем данные о сотрудниках local employees, err = crud.select('employee', {{'>=', 'salary', salary}}) -- В случае ошибки пишем ее в лог и возвращаем 500 if err ~= nil then log.error(err) return { status = 500 } end -- в employees хранится список строк, удовлетворяющих условию и формат спейса -- unflatten_rows нужна, чтобы преобразовать строку таблицы в таблицу вида ключ-значение employees = crud.unflatten_rows(employees.rows, employees.metadata) employees = fun.iter(employees):map(function(x) return { employee_id = x.employee_id, name = x.name, department = x.department, position = x.position, salary = x.salary, } end):totable() return request:render({json = employees}) end local function post_employee(request) -- достаем объект из тела запроса local employee = request:json() -- записываем в БД local _, err = crud.insert_object('employee', employee) -- В случае ошибки пишем ее в лог и возвращаем 500 if err ~= nil then log.error(err) return {status = 500} end return {status = 200} end local function init() -- получаем HTTP-сервер из registry Cartridge local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd service") -- прописываем роуты httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary) httpd:route({method = 'POST', path = '/employee'}, post_employee) return true end return { init = init, -- добавляем зависимость от crud-router dependencies = {'cartridge.roles.crud-router'}, }
init.lua
Опишем файл init.lua, который будет являться входной точкой приложения на Cartridge. В init-файле картриджа необходимо вызвать функцию cartridge.cfg() для настройки инстанса кластера.
cartridge.cfg(<opts>, <box_opts>)
<opts>— параметры кластера по умолчанию
- список доступных ролей (нужно указать все роли, даже перманентные, иначе они не появятся в кластере)
- параметры шардирования
- конфигурация WebUI
- и другое
<box_opts>— параметры Tarantool по умолчанию (которые передаются в box.cfg{} инстанса)
#!/usr/bin/env tarantool require('strict').on() -- указываем путь для поиска модулей if package.setsearchroot ~= nil then package.setsearchroot() end -- конфигурируем Cartridge local cartridge = require('cartridge') local ok, err = cartridge.cfg({ roles = { 'cartridge.roles.vshard-storage', 'cartridge.roles.vshard-router', 'cartridge.roles.metrics', -- <<< Добавляем crud-роли 'cartridge.roles.crud-storage', 'cartridge.roles.crud-router', -- <<< Добавляем кастомные роли 'app.roles.storage', 'app.roles.api', }, cluster_cookie = 'myapp-cluster-cookie', }) assert(ok, tostring(err))
Последним шагом будет описание зависимостей нашего приложения в файле myapp-scm-1.rockspec.
package = 'myapp' version = 'scm-1' source = { url = '/dev/null', } -- Добавляем зависимости dependencies = { 'tarantool', 'lua >= 5.1', 'checks == 3.1.0-1', 'cartridge == 2.7.3-1', 'metrics == 0.11.0-1', 'crud == 0.8.0-1', } build = { type = 'none'; }
Код приложения уже готов к запуску, но сначала мы напишем тесты и удостоверимся в его работоспособности.
Пишем тесты
Любое приложение нуждается в тестировании. Для unit-тестов хватит обычного luatest, но если вы хотите написать хороший интеграционный тест, вам поможет модуль cartridge.test-helpers. Он поставляется вместе с Cartridge и позволяет поднять в тестах кластер любого состава, который вам нужен.
local cartridge_helpers = require('cartridge.test-helpers') -- создаем тестовый кластер local cluster = cartridge_helpers.Cluster:new({ server_command = './init.lua', -- entrypoint тестового приложения datadir = './tmp', -- директория для xlog, snap и других файлов use_vshard = true, -- включение шардирования кластера -- список репликасетов: replicasets = { { alias = 'api', uuid = cartridge_helpers.uuid('a'), roles = {'app.roles.custom'}, -- список ролей, назначенных на репликасет -- список инстансов в репликасете: servers = { { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' }, ... }, }, ... } })
Напишем вспомогательный модуль, который будем использовать в интеграционных тестах. В нем создается тестовый кластер из двух репликасетов, в каждом из которых будет по одному инстансу:
Код вспомогательного модуля:
local fio = require('fio') local t = require('luatest') local cartridge_helpers = require('cartridge.test-helpers') local helper = {} helper.root = fio.dirname(fio.abspath(package.search('init'))) helper.datadir = fio.pathjoin(helper.root, 'tmp', 'db_test') helper.server_command = fio.pathjoin(helper.root, 'init.lua') helper.cluster = cartridge_helpers.Cluster:new({ server_command = helper.server_command, datadir = helper.datadir, use_vshard = true, replicasets = { { alias = 'api', uuid = cartridge_helpers.uuid('a'), roles = {'app.roles.api'}, servers = { { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' }, }, }, { alias = 'storage', uuid = cartridge_helpers.uuid('b'), roles = {'app.roles.storage'}, servers = { { instance_uuid = cartridge_helpers.uuid('b', 1), alias = 'storage' }, }, }, } }) function helper.truncate_space_on_cluster(cluster, space_name) assert(cluster ~= nil) for _, server in ipairs(cluster.servers) do server.net_box:eval([[ local space_name = ... local space = box.space[space_name] if space ~= nil and not box.cfg.read_only then space:truncate() end ]], {space_name}) end end function helper.stop_cluster(cluster) assert(cluster ~= nil) cluster:stop() fio.rmtree(cluster.datadir) end t.before_suite(function() fio.rmtree(helper.datadir) fio.mktree(helper.datadir) box.cfg({work_dir = helper.datadir}) end) return helper
Код интеграционного теста:
local t = require('luatest') local g = t.group('integration_api') local helper = require('test.helper') local cluster = helper.cluster g.before_all = function() g.cluster = helper.cluster g.cluster:start() end g.after_all = function() helper.stop_cluster(g.cluster) end g.before_each = function() helper.truncate_space_on_cluster(g.cluster, 'employee') end g.test_get_employee = function() local server = cluster.main_server -- наполним хранилище данными через HTTP API: local response = server:http_request('post', '/employee', {json = {name = 'John Doe', department = 'Delivery', position = 'Developer', salary = 10000, employee_id = 'john_doe'}}) t.assert_equals(response.status, 200) response = server:http_request('post', '/employee', {json = {name = 'Jane Doe', department = 'Delivery', position = 'Developer', salary = 20000, employee_id = 'jane_doe'}}) t.assert_equals(response.status, 200) -- Делаем GET запрос и проверяем правильность выдаваемых данных response = server:http_request('get', '/employees?salary=15000.0') t.assert_equals(response.status, 200) t.assert_equals(response.json[1], {name = 'Jane Doe', department = 'Delivery', employee_id = 'jane_doe', position = 'Developer', salary = 20000 }) end
Запускаем тесты
Если вы уже запускали приложение
Остановите его:
cartridge stop
Удалите папку с данными:
rm -rf tmp/
Соберем приложение и установим зависимости:
cartridge build ./deps.sh
Запустим линтер:
.rocks/bin/luacheck .

Запустим тесты с записью покрытия:
.rocks/bin/luatest --coverage

Сгенерируем отчеты по покрытию тестов и посмотрим на результат:
.rocks/bin/luacov . grep -A999 '^Summary' tmp/luacov.report.out

Локальный запуск
Для локального запуска приложений можно воспользоваться cartridge-cli, но сначала нужно добавить написанные нами роли в replicasets.yml:
router: instances: - router roles: - failover-coordinator - app.roles.api all_rw: false s-1: instances: - s1-master - s1-replica roles: - app.roles.storage weight: 1 all_rw: false vshard_group: default s-2: instances: - s2-master - s2-replica roles: - app.roles.storage weight: 1 all_rw: false vshard_group: default
С параметрами запускаемых инстансов можно ознакомиться в instances.yml.
Запускаем кластер локально:
cartridge build cartridge start -d cartridge replicasets setup --bootstrap-vshard

Теперь мы можем зайти в webui и загрузить конфигурацию ролей, а также настроить фейловер. Чтобы настроить stateful failover, необходимо:
- нажать на кнопку Failover
- выбрать stateful
- прописать адрес и пароль:
- localhost:4401
- passwd

Давайте посмотрим на его работу. Сейчас в репликасете s-1 лидером является s1-master.

Остановим его:
cartridge stop s1-master
Лидер переключится на s1-replica:

Восстановим s1-master:
cartridge start -d s1-master
s1-master поднялся, но благодаря stateful-фейловеру лидером остается s1-replica:

Загрузим конфигурацию для роли cartridge.roles.metrics, для этого необходимо перейти на вкладку Code и создать файл metrics.yml следующего содержания:
export: - path: '/metrics' format: prometheus - path: '/health' format: health

После того, как мы нажмем на кнопку Apply, метрики будут доступны на каждом узле приложения по эндпоинту localhost:8081/metrics и появится health-check по адресу localhost:8081/health.
На этом базовая настройка небольшого приложения завершена: кластер готов к работе и теперь мы можем написать приложение, которое будет общаться с кластером с помощью HTTP API или через коннектор, а также можем расширять функциональность кластера.
Заключение
Многим р��зработчикам не нравится тратить время на настройку базы данных. Нам хочется, чтобы все обязанности по управлению кластером взял на себя какой-нибудь фреймворк, а нам приходилось только писать код. Для решения этой проблемы я использую Cartridge — фреймворк, который управляет кластером из нескольких инстансов Tarantool.
В статье я рассказал:
- как построить надежное кластерное приложение с помощью Cartridge и Tarantool,
- как написать код небольшого приложения для хранения информации о сотрудниках,
- как добавить тесты,
- как настроить кластер.
Hадеюсь, мой рассказ был полезен, и вы будете использовать Cartridge для создания приложений. Буду рад услышать обратную связь о том, получилось ли у вас легко и быстро написать приложение на Cartridge, а также вопросы по его использованию.
Что дальше?
- Познакомьтесь с документацией на официальном сайте
- Попробуйте Cartridge в песочнице
- Задайте вопросы сообществу в Telegram-чате
- Узнайте больше про Cartridge
- Посмотрите на возможности cartridge-cli
- Приобщитесь к нашему опыту деплоя и эксплуатации приложений
- Погрузитесь в мониторинг Tarantool
- Напишите нагрузочный тест на Пандоре или k6
