--- 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.* ```