---
title: OTUS Cartridge Practice (5/5)
tags: Tarantool, trainings, otus, cartridge
description:
---
# Практика: Сервис сокращения ссылок
## ТЗ
- Пользователь хотел бы сократить ссылку:
- Чтобы вместить её в смс или в твиттер
- Сохранить в QR код
- Или даже расположить в печатной продукции
- Необходимо хранение соответствий
## Общий план
- Настроить хранилище Tarantool (понедельник)
- Настроить репликацию (вторник)
- Настроить масштабирование (среда)
- Запустить http-сервер (вчера)
- *Собрать проект вместе и все готово :)* (сегодня)
## День 5 — Cartridge
- Сегодня мы соберем приложение в пакет `Cartridge`
- Поуправляем кластером через веб интерфейс
- Создадим кластерный тест к своему приложению
## Intro
- `Cartidge` — фреймворк, который предлагает простой способ разработки и управления кластерным приложением
- Приложение разделяется на модули специальным образом — такие модули называются «роли»
- После чего вы запускаете одно или несколько экземпляров приложения и конфигурируете из них кластер с помощью `web ui`
- `Cartridge` содержит модуль для тестирования приложений в кластерном виде
- Для деплоя `Cartridge` предоставляет интерфейс для упаковки приложения в `rpm`/`deb`/`targz` пакет
## Тология
## Подготовка
- Удаление устаревших рабочих директорий
```bash
rm -rf data
```
- Удаление файла топологии
```bash
rm -rf topology.lua
```
- Теперь топология будет настраиваться в рантайме
- Установим утилиту `Cartridge CLI` (https://github.com/tarantool/cartridge-cli#installation)
```bash
sudo yum install cartridge-cli
```
- Установим фреймворк `Cartridge`
```bash
tarantoolctl rocks install cartridge 2.3.0
```
- Установим библиотеку для экспорта метрик приложения
```bash
tarantoolctl rocks install metrics
```
## Код
- У всех узлов теперь входная точка с инициализацией `cartridge`
- `init.lua` — входная точка
```lua=
#!/usr/bin/env tarantool
--[[
Устанавливаем текущую директорию,
как начальный путь загрузки всех модулей
]]
package.setsearchroot()
local cartridge = require('cartridge')
local log = require('log')
--[[
Конфигурируем и запускаем cartridge на узле
Задаем небольшое количество корзин шардирования
для удобства разработки и отладки
Указываем какие роли мы будем использовать в кластере
- vshard storage для хранения шардированных данных
- vshard router для доступа к ним
- metrics для метрик
Указываем рабочую директорию для хранения снапов, икслогов
и конфигурации приложения `one`
]]
local _, err = cartridge.cfg({
workdir = 'data',
bucket_count = 32,
roles = {
'app',
'httpapp',
'schema',
'cartridge.roles.vshard-storage',
'cartridge.roles.vshard-router',
'cartridge.roles.metrics',
},
})
if err ~= nil then
log.info(err)
os.exit(1)
end
```
- `schema.lua` — роль
```lua=
local schema = {}
function schema.init(opts)
if not opts.is_master then
return
end
--[[
Создание схемы данных, индексов
]]
--[[
Таблица `redirector` — хранилище коротких ссылок
if_not_exists — флаг, о том чтобы не генерировать исключение, если спейс уже существует
]]
box.schema.space.create(
'redirector',
{ if_not_exists=true }
)
box.space.redirector:format({
{ name='source', type='string' },
{ name='short', type='string' },
{ name='bucket_id', type='unsigned'},
})
--[[
Первичный ключ, первый создаваемый индекс
]]
box.space.redirector:create_index(
'primary',
{
parts = { 'source' },
if_not_exists = true,
}
)
--[[
Вторичный индекс, для быстрого поиска строки по короткой ссылке
]]
box.space.redirector:create_index(
'short',
{
parts = { 'short' },
if_not_exists = true,
}
)
--[[
Индекс для шардирования данных
]]
box.space.redirector:create_index(
'bucket_id',
{
parts = { 'bucket_id' },
unique = false,
if_not_exists = true,
}
)
end
schema.dependencies = {
'cartridge.roles.vshard-storage'
}
return schema
```
- `app.lua` — роль
```lua=
local app = {}
local digest = require 'digest'
local vshard = require 'vshard'
function app.url(short)
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local url, err = vshard.router.callro(
bucket_id,
'box.space.redirector.index.short:get',
{short}
)
if url ~= nil then
return url[1]
elseif err ~= nil then
error(err)
else
return
end
end
function app.shorten(url)
-- while true do
--[[ map/reduce для поиска существующего url ]]
local shards, err = vshard.router.routeall()
if err ~= nil then error(err) end
for uid, replica in pairs(shards) do
local rec = replica:callro('box.space.redirector:get',{ url })
if rec ~= nil then
return rec[2]
end
end
--[[ пробуем создать ]]
local short = digest.base64_encode(digest.urandom(6),
{nopad=true, nowrap=true, urlsafe=true})
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local success, err = vshard.router.callrw(
bucket_id,
'box.space.redirector:insert',
{ {url, short, bucket_id} },
{ timeout = 1 }
)
if success then
return short
elseif err ~= nil then
error(err)
end
-- end
end
function app.init(opts)
end
function app.stop()
end
app.dependencies = {
'cartridge.roles.vshard-router'
}
return app
```
- `httpapp.lua` — роль
```lua=
local log = require('log')
local socket = require('socket')
local cartridge = require('cartridge')
--[[
HTTP сервер обработки запросов
]]
local serveraddr = '0.0.0.0'
local port = 8080
local hostname = 'localhost' ---<<<!!!!! Вставить dns имя или ip своей виртуалки
--[[
Утилиты для декодирования значений GET параметров url строки
]]
local hex_to_char = function(x)
return string.char(tonumber(x, 16))
end
local unescape = function(url)
return url:gsub("%%(%x%x)", hex_to_char)
end
--[[
HTML форма для создания короткой ссылки
]]
local form = [[
<html>
<head></head>
<body>
<form>
<input name=url type=text />
<input type=submit />
</form>
</body>
</html>
]]
--[[
Функция возвращающая успешный (200) http пакет
с данными `data`
]]
local function response200(data)
return ("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Length: %u\r\nConnection: close\r\n\r\n%s")
:format(#data, data)
end
--[[
Возврат ошибки 404
]]
local function response404()
return "HTTP/1.1 404 Not Found\r\nContent-Type: text; charset=UTF-8\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found"
end
--[[
Редирект на страницу target
]]
local function redirect(target)
return ("HTTP/1.1 301 Moved Permanently\r\nLocation: %s\r\nContent-Type: text/html\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
:format(target)
end
--[[
TCP/IP сервер с оооочень простой (прямо таки простейшей)
логикой http протокола
Для каждого соединения в отдельном файбере запускается
функция `function(client)`, которая обрабатывает http запросы
и отвечает http ответы
]]
--[[
(ну почти REST) API
GET / — html форма для создания короткой cсылки
GET /?url=* — создание короткой ссылки по исходной в url параметре
GET /* - переход по короткой ссылке
]]
local function handler(client)
local request_line = client:read('\r\n', 10)
if request_line == nil then
log.info('Timeout')
return
end
--[[
Если запрос корня — предлагаем форму ввода
]]
if request_line:startswith('GET / HTTP') then
client:write(response200(form))
--[[
Запрос на создание короткой ссылки
]]
elseif request_line:startswith('GET /?url=') then
local url = request_line:sub(#'GET /?url=')
url = url:sub(2, url:find(' ') - 1)
url = unescape(url)
local short = cartridge.service_get('app').shorten(url)
if short == nil then
client:write(response404())
return
end
local pasteable_short = 'http://' .. hostname .. ':' .. tostring(port) .. '/' .. short
client:write(response200(pasteable_short))
--[[
Запрос на переход по короткой ссылке
Редиректим на исходную ссылку, если она была в базе
]]
elseif request_line:startswith('GET /') then
local short = request_line:sub(#'GET /')
short = short:sub(2, short:find(' ') - 1)
local source = cartridge.service_get('app').url(short)
if source == nil then
client:write(response404())
end
client:write(redirect(source))
end
end
local server
local function init()
server = socket.tcp_server(
serveraddr, port,
handler)
if server ~= nil then
server:listen()
end
end
local function stop()
if server ~= nil then
server:close()
end
server = nil
end
return {
init=init,
stop=stop,
dependencies={'app'},
}
```
## Запуск
- Какие узлы с какими параметрами запускать
- `instances.yml`
```yaml=
makeshort:
cluster_cookie: ""
replication_connect_quorum: 1
makeshort.router:
workdir: dev/3301
advertise_uri: 127.0.0.1:3301
http_port: 8081
makeshort.storage-01:
workdir: dev/3302
advertise_uri: 127.0.0.1:3302
http_port: 8082
makeshort.storage-02:
workdir: dev/3303
advertise_uri: 127.0.0.1:3303
http_port: 8083
makeshort.storage-11:
workdir: dev/3304
advertise_uri: 127.0.0.1:3304
http_port: 8084
makeshort.storage-12:
workdir: dev/3305
advertise_uri: 127.0.0.1:3305
http_port: 8085
```
- Запуск
```bash
cartridge start --name makeshort
```
- *Останов* по `Ctrl-C`
## Настройка топологии в рантайме
- Зайдем в админку `http://127.0.0.1:8081`
- Сконфигурируем `router` с ролью `httpapp`
- Кнопка `Configure` напротив сервера
- Выбрать роль `httpapp`
- Автоматически выберуться связанные роли
- `Create replica set`
- Сконфигурируем `storage-01`, `storage-02` в один репликасет с ролью `schema`
- Кнопка `Configure` напротив сервера `storage-01`
- Выбрать роль `schema`
- Автоматически выберуться связанные роли
- `Create replica set`
- Кнопка `Configure` напротив сервера `storage-02`
- Переключим табу в Join Replica Set
- Выберем необходимы реплика сет с ролью `schema`
- `Join replica set`
- Сконфигурируем `storage-11`, `storage-12` в другой репликасет с ролью `schema`
- Аналогично
- Таким образом у нас получится топология
- Один апп сервер
- С двумя шардами
- В каждом шарде по два узла
```graphviz
digraph onnode {
router[label="Роутер (httpapp)"]
subgraph cluster_1 {
label="Московский шард (schema)"
storage01[label="Хранилище" shape=cylinder]
storage02[label="Хранилище" shape=cylinder]
style=dashed
}
subgraph cluster_3 {
label="Питерский шард (schema)"
storage11[label="Хранилище" shape=cylinder]
storage12[label="Хранилище" shape=cylinder]
style=dashed
}
router -> storage01, storage02, storage11, storage12
storage01 -> storage02
storage01 -> storage02[dir=back]
storage11 -> storage12
storage11 -> storage12[dir=back]
}
```
- Зависимые модули ролей включатся автоматически
- Теперь запустим первоначальную настройку `Bootstrap vshard`
## Метрики
- Откроем админку `http://127.0.0.1:8081`
- Вкладка `Code` — это кластерный конфиг
- Создать файл в админке `metrics.yml`
```yaml
export:
- path: '/metrics'
format: 'prometheus'
```
- Теперь роль `metrics` (на каждом узле) получит обновленный конфиг и создаст страницу с метриками узла
- Проверим, например, аптайм
```
curl 127.0.0.1:8081/metrics | grep -ns1 uptime
```
## Тесты
- Установим библиотеку для тестирования
```bash
tarantoolctl rocks install luatest
```
- Создадим тест, в котором будет запускаться кластер
- `test/basic_test.lua`
```lua=
local fio = require('fio')
local log = require('log')
local client = require('http.client')
local uri = require('uri')
local t = require('luatest')
local g = t.group('starwars')
local helpers = require('cartridge.test-helpers')
g.before_all(function()
local tempdir = fio.tempdir()
--[[
Перед каждым тестом поднимаем кластер
с указанной топологией
]]
local cluster = helpers.Cluster:new({
datadir = tempdir,
server_command = 'init.lua',
use_vshard = true,
replicasets = {{
roles = {'httpapp'},
servers = {{
alias = 'httpapp',
}}},
{
roles = {'schema'},
servers = {{
alias = 'storage',
}}},
{
roles = {'schema'},
servers = {{
alias = 'storage2',
}}},
},
})
cluster:start()
g.cluster = cluster
end)
g.after_all(function()
g.cluster:stop()
end)
function g.test_starwars_storage()
local cluster = g.cluster
local server = cluster:server('httpapp')
--[[
Простой тест для проверки укорачивания ссылок
]]
local url = uri.parse(server.advertise_uri)
local host = url.host
local port = 8080
--[[
Укорачиваем урл
]]
local source = 'https://google.com/?q=' .. tostring(os.time())
local makeshort = 'http://' .. host .. ':' .. port .. '/?url='
local response = client.get(makeshort .. source)
local short = response.body
log.info(short, '->', source)
--[[
Проверяем редирект
]]
local redirect = client.get(short, {follow_location=true})
t.assert_equals(redirect.headers['location'], source,
"Source url and redirect not equal")
--[[
Проверяем укорачивание той же ссылки ещё раз
]]
local response = client.get(makeshort .. source)
local short2 = response.body
t.assert_equals(short, short2, "Short twice does not work")
end
```
- Запуск
```
./rocks/bin/luatest
```
- Запуск с логами
```
./rocks/bin/luatest -с
```
## Фейловер (восстановление лидера при отказе)
- Чтобы консистентно переключать лидера репликасета в распределённой среде необходим внешний журнал событий, который бы позволял нам четко понимать кто в кластере отвественный за переключения и какие переключения делалил в истории
- Запустим журнал кластерных событий
- Для разработки
```bash
.rocks/bin/stateboard --workdir ./tmp/stateboard --listen 4401 --password qwerty
```
- В проде поднимать `etcd` с междатацентровой кворумной записью
- В админке кластера `http://127.0.0.1:8081` добавим на все репликасеты `failover-coordinator`
- Настроим кластер так, чтобы события о переключениях записывались в журнал
- Кнопка `Failover:...`
- Перключим в `stateful`
- Оставим `tarantool`
- Укажем `127.0.0.1:4401`
- Пароль `qwerty`
- Переключим `storage-01` в позицию лидера
- Клавиша напротив сервера — `Promote ...`
- Найдем process id для какого-нибудь стораджа
```bash
cat tmp/run/makeshort.storage-01.pid
```
- Завершим процесс
```
kill -9 `cat tmp/run/makeshort.storage-01.pid`
```
- Проверим состояние кластера и переключение лидера
- Перезапустим
```bash
cartridge start --name makeshort storage-01
```
- Проверим состояние кластера вновь — в этот раз изменения лидера быть не должно
## Упаковка
- Удалим рантайм директории
```
rm -rf tmp/ data/
```
- Файл проекта
- `makeshort-scm-1.rockspec`
```lua=
package = "makeshort"
version = "scm-1"
source = {
url = "*** please add URL for source tarball, zip or repository here ***"
}
description = {
homepage = "*** please enter a project homepage ***",
license = "*** please specify a license ***"
}
dependencies = {
'cartridge == 2.3.0',
'metrics',
}
build = {
type = "builtin",
modules = {
app = "app.lua",
httpapp = "httpapp.lua",
init = "init.lua",
schema = "schema.lua",
}
}
```
- Соберем rpm пакет
```bash
cartridge pack rpm --version 1
```
- Установим (centos 8)
```bash
sudo dnf install cartridge
```
- Шаблон установленных сервисов
```
/etc/systemd/system/makeshort@.service
```
- Конкретный сервис
```
/etc/systemd/system/makeshort.service
```
- Директория с приложением
```
/usr/share/tarantool/makeshort/
```
- Директории с рантайм файлами
```
/var/lib/tarantool/makeshort.*
```