Openresty что это
Перейти к содержимому

Openresty что это

  • автор:

О чудесном сервере OpenResty

Для тех, кто не в курсе, кратко расскажу про OpenResty. Кому-то в Taobao стукнуло в голову, что будет очень хорошим решением взять nginx и засунуть в него LuaJIT. Именно «в него», а не «к нему». Так родился модуль Lua для nginx, на основе которого был сделан OpenResty и еще пару подобных проектов. Nginx однопоточен, LuaJIT однопоточен, вот они как бы в event-driven модели должны хорошо согласовываться. Питон/php/node.js тоже однопоточны, но пожирнее и помедленнее будут. С тех пор OpenResty распространился в основном по китаю, хоть и в остальной мир потихоньку проникает.
Мотивация создателей мне ясна: берем самый быстрый веб-сервер (пусть и неполноценный), берем самый быстрый скриптовый язык, которым на 2011 года действительно был именно Lua c его LuaJIT, скрещиваем их вместе, говорим волшебные слова — получаем замечательный полноценный веб-сервер. Правда, уже на момент 2017 года дистанция между V8 и LuaJIT неумолимо сокращалась, и сейчас они имеют похожую производительность выполнения кода.

Возникает еще один вопрос: а при чем тут nginx вообще? Смысл создания nginx заключался в том, чтобы сделать невидимую тонкую прослойку между системными вызовами ядра ОС, а посему функций у nginx может быть немного: статический контент, кэш, трансляция между двумя протоколами. На загрузках меньше 1000 запросов в секунду разница между nginx и каким-нибудь apache уже становится ничтожная. О чем думали создатели, засовывая в поток nginx скриптовый язык со сборкой мусора?

Если у вас, допустим, кодовая база на Perl, и вы не Booking, вы не найдёте перловых программистов. Потому что их нет, их всех забрали, а учить их долго и сложно

Палю годноту: Booking набирает кодеров без знания перла для работы с перлом. Так что если кто мечтает писать на перле — имейте в виду.

Программируем прямо в Nginx

Nginx — великолепный веб-сервер. Все мы привыкли использовать его в связке с бекендомами на разных языках программирования. Но оказывается можно писать простые программы прямо внутри конфигурационного файла Nginx. Это можно использовать для балансировки, написания простых API и даже отдавать динамические страницы прямо из конфига.

В статье мы разберем примеры написания простых программ в конфиге nginx.

Выглядит это как написание кода в конфиге, что выглядит диковато, но удобно. Код выполняется асинхронно, не вмешиваясь в основной цикл событий Nginx, без коллбэков. Работает быстро и, что немаловажно, в совместимости с другими модулями и всем базовым функционалом.
Основным решением для Lua + Nginx считается OpenResty. Там много готовых модулей, как собственных на Lua, так и интегрированных из Nginx. Он отлично масштабируется и при этом сохраняет высокую производительность и пропускную способность Nginx.

Установка

Сам Nginx устанавливать не нужно, OpenResty включает его в свою сборку.

wget https://openresty.org/download/openresty-1.15.8.3.tar.gz tar -xvf openresty-1.15.8.3.tar.gz cd openresty-1.15.8.3/ sudo apt-get install build-essential 

Также доустановим ещё несколько пакетов для поддержки CLI, регулярных выражений и SSL:

sudo apt-get install libreadline-dev libncurses5-dev libpcre3-dev libssl-dev perl

И начнём сборку

./configure -j2 --with-pcre-jit --with-ipv6 make -j2 sudo make install 

Наконец, запускаем OpenResty

sudo /usr/local/openresty/bin/openresty

Вывода не последует, сервер просто запустится и будет доступен:

sudo /usr/local/openresty/bin/openresty -s quit

Hello world

Сначала создадим директорию и конфиг для нашего сайта

sudo mkdir /usr/local/openresty/nginx/sites sudo nano /usr/local/openresty/nginx/sites/default.conf
server < listen 80 default_server; listen [::]:80 default_server; root /usr/local/openresty/nginx/html/default; index index.html index.htm; location / < default_type 'text/plain'; content_by_lua_file /usr/local/openresty/nginx/html/default/index.lua; >>

Исполнять скрипты можно прямо в конфиге, но удобнее сразу подключить внешний файл

sudo nano /usr/local/openresty/nginx/html/default/index.lua
local name = ngx.var.arg_name or "Anonymous" ngx.say("Hello, ", name, "!")
sudo mkdir /usr/local/openresty/nginx/html/default sudo mv /usr/local/openresty/nginx/html/index.html /usr/local/openresty/nginx/html/default

Примеры

Ниже собраны более практичные примеры из разных источников:

ruhighload.com

Вывод HTML

server < location /hello < default_type 'text/html'; content_by_lua ' ngx.say("Hello world!") '; > >

Несколько обработчиков

server < location / < default_type 'text/plain'; content_by_lua_file /var/www/lua/index.lua; >location /admin < default_type 'text/plain'; content_by_lua_file /var/www/lua/admin.lua; >>

Глобальные переменные

http < # объявляем глобальный контейнер lua_shared_dict stats 1m; server < location / < content_by_lua ' # увеличим переменную hits на 1 при каждом запросе ngx.shared.stats:incr("hits", 1) # выведем текущее значение ngx.say(ngx.shared.stats:get("hits")) '; >> >

Скрипт для подсчета количества запросов в Redis

apt-get install lua-nginx-redis
server < location / < content_by_lua ' local redis = require "nginx.redis" local red = redis:new() local ok, err = red:connect("127.0.0.1", 6379) ok, err = red:incr("test") local res, err = red:get("test") ngx.say("hits: ", res) '; >>
openresty.org

Routing MySQL Queries Based On URI Args
Dynamic Request Routing Based on Redis
Web App for OpenResty User Survey
Code and data for the openresty.org site — любой сайт, посвящённый определенной веб-технологии, использует её, и openresty.org не исключение

habr.com/ru/post/270463

Поиск с кэшированием запросов

-- search.lua local string = ngx.var.arg_string -- получим параметр из GET запроса if string == nil then ngx.exec("/") -- если параметра нет, то сделаем редирект end local path = "/?string=" .. string local redis = require "resty.redis" -- подключим библиотеку по работе с redis local red = redis:new() red:set_timeout(1000) -- 1 sec local ok, err = red:connect("127.0.0.1", 6379) if not ok then ngx.exec(path) -- если нельзя подключиться к redis, то сделаем редирект end res, err = red:get("search:" .. string); -- получим данные из redis if res == ngx.null then ngx.exec(path) -- если данных нет, то сделаем редирект else ngx.header.content_type = 'application/json' ngx.say(res) -- если данные есть, то отдадим их end
# nginx.conf location /search-by-string < content_by_lua_file lua/search.lua; >
habr.com/ru/post/326486

Load balancer

В блоке http <> инициализируем lua.

Код с комментариями:

# путь до локально установленных *.lua библиотек с добавлением системных путей lua_package_path "/usr/local/lib/lua/?.lua;;"; init_by_lua_block

в блоках *_lua_block уже идёт lua-код со своим синтаксисом и функциями.

Основной сервер, который принимает на себя внешние запросы.

Код с комментариями:

server < listen 80; server_name test.domain.local; location / < # проверяем наличие cookie "upid" и если нет — выставляем по желаемому алгоритму if ($cookie_upid = "") < # инициализируем пустую переменную nginx-а, в которую запишем выбранный ID бэкенда set $upstream_id ''; rewrite_by_lua_block < -- инициализируем математический генератор для более рандомного рандома используя время nginx-а math.randomseed(ngx.time()) -- также пропускаем первое значение, которое совсем не рандомное (см документацию) math.random(100) local num = math.random(100) -- получив число, бесхитростно и в лоб реализуем веса 20% / 80% if num >20 then ngx.var.upstream_id = 1 ngx.ctx.upid = ngx.var.upstream_id else ngx.var.upstream_id = 2 ngx.ctx.upid = ngx.var.upstream_id end -- ID запоминаем в переменной nginx-а "upstream_id" и в "upid" таблицы ngx.ctx модуля lua, которая используется для хранения значений в рамках одного запроса > # отдаём клиенту куку "upid" со значением выбранного ID # время жизни явно не задаём, потому она будет действительна только на одну сессию (до закрытия браузера), что нас устраивает add_header Set-Cookie "upid=$upstream_id; Domain=$host; Path=/"; > # если же кука у клиента уже есть, то запоминаем ID в ngx.ctx.upid текущего запроса if ($cookie_upid != "") < rewrite_by_lua_block < ngx.ctx.upid = ngx.var.cookie_upid >> # передаём обработку запроса на блок upstream-ов proxy_pass http://ab_test; > >

Блок upstream, который используя lua заменяет встроенную логику nginx.

Код с комментариями:

upstream ab_test < # заглушка, чтобы nginx не ругался. В алгоритме не участвует server 127.0.0.1:8001; balancer_by_lua_block < local balancer = require "ngx.balancer" -- инициализируем локальные переменные -- port выбираем динамически, в зависимости от запомненного ID бэкенда local host = "127.0.0.1" local port = 8000 + ngx.ctx.upid -- задаём выбранный upstream и обрабатываем код возврата local ok, err = balancer.set_current_peer(host, port) if not ok then ngx.log(ngx.ERR, "failed to set the current peer: ", err) return ngx.exit(500) end -- в общем случае надо, конечно же, искать доступный бэкенд, но нам не к чему >>

Ну и простой демонстрационный бэкенд, на который в итоге придут клиенты.

код без комментариев:

server < listen 127.0.0.1:8001; server_name test.domain.local; location / < root /var/www/html; index index.html; >> server < listen 127.0.0.1:8002; server_name test.domain.local; location / < root /var/www/html; index index2.html; >>

При запуске nginx-a с этой конфигурацией в логи свалится предупреждение:

use of lua-resty-core with LuaJIT 2.0 is not recommended; use LuaJIT 2.1+ instead while connecting to upstream
2Гис (пост)

Эту часть придумал и сделал наш коллега AotD. Есть хранилище картинок. Их надо показывать пользователям, причем желательно производить при этом некоторые операции, например, resize. Картинки мы храним в ceph, это аналог Amazon S3. Для обработки картинок используется ImageMagick. На ресайзере есть каталог с кэшем, туда складываются обработанные картинки.
Парсим запрос пользователя, определяем картинку, нужное ему разрешение и идем в ceph, затем на лету обрабатываем и показываем.

serve_image.lua

require "config" local function return_not_found(msg) ngx.status = ngx.HTTP_NOT_FOUND if msg then ngx.header["X-Message"] = msg end ngx.exit(0) end local name, size, ext = ngx.var.name, ngx.var.size, ngx.var.ext if not size or size == '' then return_not_found() end if not image_scales[size] then return_not_found('Unexpected image scale') end local cache_dir = static_storage_path .. '/' .. ngx.var.first .. '/' .. ngx.var.second .. '/' local original_fname = cache_dir .. name .. ext local dest_fname = cache_dir .. name .. size .. ext -- make sure the file exists local file = io.open(original_fname) if not file then -- download file contents from ceph ngx.req.read_body() local data = ngx.location.capture("/ceph_loader", >) if data.status == ngx.HTTP_OK and data.body:len()>0 then os.execute( "mkdir -p " .. cache_dir ) local original = io.open(original_fname, "w") original:write(data.body) original:close() else return_not_found('Original returned ' .. data.status) end end local magick = require("imagick") magick.thumb(original_fname, image_scales[size], dest_fname) ngx.exec("@after_resize")

Подключаем биндинг imagic.lua. Должен быть доступен LuaJIT.
nginx_partial_resizer.conf.template

# Old images location ~ ^/(small|small_wide|medium|big|mobile|scaled|original|iphone_(preview|retina_preview|big|retina_big|small|retina_small))_ < rewrite /([^/]+)$ /__CEPH_BUCKET__/$1 break; proxy_pass __UPSTREAM__; ># Try get image from ceph, then from local cache, then from scaled by lua original # If image test.png is original, when user wants test_30x30.png: # 1) Try get it from ceph, if not exists # 2) Try get it from /cache/t/es/test_30x30.ong, if not exists # 3) Resize original test.png and put it in /cache/t/es/test_30x30.ong location ~ ^/(?(?.)(?..)[^_]+)((?_[^.]+)|)(?\.[a-zA-Z]*)$ < proxy_intercept_errors on; rewrite /([^/]+)$ /__CEPH_BUCKET__/$1 break; proxy_pass __UPSTREAM__; error_page 404 403 = @local; ># Helper failover location for upper command cause you can't write # try_files __UPSTREAM__ /cache/$uri @resizer =404; location @local < try_files /cache/$first/$second/$name$size$ext @resize; ># If scaled file not found in local cache resize it with lua magic! location @resize < # lua_code_cache off; content_by_lua_file "__APP_DIR__/lua/serve_image.lua"; ># serve scaled file, invoked in @resizer serve_image.lua location @after_resize < try_files /cache/$first/$second/$name$size$ext =404; ># used in @resizer serve_image.lua to download original image # $name contains original image file name location =/ceph_loader < internal; rewrite ^(.+)$ /__CEPH_BUCKET__/$name break; proxy_set_header Cache-Control no-cache; proxy_set_header If-Modified-Since ""; proxy_set_header If-None-Match ""; proxy_pass __UPSTREAM__; >location =/favicon.ico < return 404; >location =/robots.txt <>

Firewall для API. Валидация запроса, идентификация клиента, контроль rps и шлагбаум для тех, кто нам не нужен.

firewall.lua

module(. package.seeall); local function ban(type, element) CStorage.banPermanent:set(type .. '__' .. element, 1); ngx.location.capture('/postgres_ban', < ['vars'] = < ['type'] = type, ['value'] = element>>); end local function checkBanned(apiKey) -- init search criteria local searchCriteria = <>; searchCriteria['key'] = apiKey; if ngx.var.remote_addr then searchCriteria['ip'] = ngx.var.remote_addr; end; -- search in ban lists for type, item in pairs(searchCriteria) do local storageKey = type .. '__' .. item; if CStorage.banPermanent:get(storageKey) then ngx.exit(444); elseif CStorage.banTmp:get(storageKey) then -- calculate rps and check is our client still bad boy 8-) local rps = CStorage.RPS:incr(storageKey, 1); if not(rps) then CStorage.RPS:set(storageKey, 1, 1); rps=1; end; if rps then if rps > config.app_params['ban_params']['rps_for_ip_to_permanent_ban'] then CStorage.RPS:delete(storageKey); ban(type, item); ngx.exit(444); elseif config.app_params['ban_params']['rps_for_ip_to_tmp_ban'] > 0 and rps == config.app_params['ban_params']['rps_for_ip_to_tmp_ban'] then local attemptsCount = CStorage.banTmp:incr(storageKey, 1) - 1; if attemptsCount > config.app_params['ban_params']['tmp_ban']['max_attempt_to_exceed_rps'] then -- permanent ban CStorage.banTmp:delete(storageKey); ban(type, item); end; end; end; ngx.exit(444); end; end; end; local function checkTemporaryBlocked(apiKey) local blockedData = CStorage.tmpBlockedDemoKeys:get(apiKey); if blockedData then --storage.tmpBlockedDemoKeys:incr(apiKey, 1); -- think about it. return CApiException.throw('tmpDemoBlocked'); end; end; local function checkRPS(apiKey) local rps = nil; -- check rps for IP and ban it if it's needed if ngx.var.remote_addr then local ip = 'ip__' .. tostring(ngx.var.remote_addr); rps = CStorage.RPS:incr(ip, 1); if not(rps) then CStorage.RPS:set(ip, 1, 1); rps = 1; end; if rps > config.app_params['ban_params']['rps_for_ip_to_permanent_ban'] then ban('ip', tostring(ngx.var.remote_addr)); ngx.exit(444); elseif config.app_params['ban_params']['rps_for_ip_to_tmp_ban'] > 0 and rps > config.app_params['ban_params']['rps_for_ip_to_tmp_ban'] then CStorage.banTmp:set(ip, 1, config.app_params['ban_params']['tmp_ban']['time']); ngx.exit(444); end; end; local apiKey_key_storage = 'key_' .. apiKey['key']; -- check rps for key rps = CStorage.RPS:incr(apiKey_key_storage, 1); if not(rps) then CStorage.RPS:set(apiKey_key_storage, 1, 1); rps = 1; end; if apiKey['max_rps'] and rps > tonumber(apiKey['max_rps']) then if apiKey['mode'] == 'demo' then CApiKey.blockTemporary(apiKey['key']); return CApiException.throw('tmpDemoBlocked'); else CApiKey.block(apiKey['key']); return CApiException.throw('blocked'); end; end; -- similar check requests per period (RPP) for key if apiKey['max_request_count_per_period'] and apiKey['period_length'] then local rpp = CStorage.RPP:incr(apiKey_key_storage, 1); if not(rpp) then CStorage.RPP:set(apiKey_key_storage, 1, tonumber(apiKey['period_length'])); rpp = 1; end; if rpp > tonumber(apiKey['max_request_count_per_period']) then if apiKey['mode'] == 'demo' then CApiKey.blockTemporary(apiKey['key']); return CApiException.throw('tmpDemoBlocked'); else CApiKey.block(apiKey['key']); return CApiException.throw('blocked'); end; end; end; end; function run() local apiKey = ngx.ctx.REQUEST['key']; if not(apiKey) then return CApiException.throw('unauthorized'); end; apiKey = tostring(apiKey) -- check permanent and temporary banned checkBanned(apiKey); -- check api key apiKey = CApiKey.getData(apiKey); if not(apiKey) then return CApiException.throw('forbidden'); end; apiKey = JSON:decode(apiKey); if not(apiKey['is_active']) then return CApiException.throw('blocked'); end; apiKey['key'] = tostring(apiKey['key']); -- check is key in tmp blocked list if apiKey['mode'] == 'demo' then checkTemporaryBlocked(apiKey['key']); end; -- check requests count per second and per period checkRPS(apiKey); -- set apiKey's json to global parameter; in index.lua we send it through nginx to php application ngx.ctx.GLOBAL['api_key'] = JSON:encode(apiKey); end;

validator.lua

module(. package.seeall); local function checkApiVersion() local apiVersion = ''; if not (ngx.ctx.REQUEST['version']) then local nginx_request = tostring(ngx.var.uri); local version = nginx_request:sub(2,4); if tonumber(version:sub(1,1)) and tonumber(version:sub(3,3)) then apiVersion = version; else return CApiException.throw('versionIsRequired'); end; else apiVersion = ngx.ctx.REQUEST['version']; end; local isSupported = false; for i, version in pairs(config.app_params['supported_api_version']) do if apiVersion == version then isSupported = true; end; end; if not (isSupported) then CApiException.throw('unsupportedVersion'); end; ngx.ctx.GLOBAL['api_version'] = apiVersion; end; local function checkKey() if not (ngx.ctx.REQUEST['key']) then CApiException.throw('unauthorized'); end; end; function run() checkApiVersion(); checkKey(); end;

apikey.lua

module ( . package.seeall ) function init() if not(ngx.ctx.GLOBAL['CApiKey']) then ngx.ctx.GLOBAL['CApiKey'] = <>; end end; function flush() CStorage.apiKey:flush_all(); CStorage.apiKey:flush_expired(); end; function load() local dbError = nil; local dbData = ngx.location.capture('/postgres_get_keys'); dbData = dbData.body; dbData, dbError = rdsParser.parse(dbData); if dbData ~= nil then local rows = dbData.resultset if rows then for i, row in ipairs(rows) do local cacheKeyData = <>; for col, val in pairs(row) do if val ~= rdsParser.null then cacheKeyData[col] = val; else cacheKeyData[col] = nil; end end CStorage.apiKey:set(tostring(cacheKeyData['key']),JSON:encode(cacheKeyData)); end; end; end; end; function checkNotEmpty() if not(ngx.ctx.GLOBAL['CApiKey']['loaded']) then local cnt = CHelper.tablelength(CStorage.apiKey:get_keys(1)); if cnt == 0 then load(); end; ngx.ctx.GLOBAL['CApiKey']['loaded'] = 1; end; end; function getData(key) checkNotEmpty(); return CStorage.apiKey:get(key); end; function getStatus(key) key = getData(key); local result = ''; if key ~= nil then key = JSON:decode(key); if key['is_active'] ~= nil and key['is_active'] == true then result = 'allowed'; else result = 'blocked'; end; else result = 'forbidden'; end; return result; end; function blockTemporary(apiKey) apiKey = tostring(apiKey); local isset = getData(apiKey); if isset then CStorage.tmpBlockedDemoKeys:set(apiKey, 1, config.app_params['ban_params']['time_demo_apikey_block_tmp']); end; end; function block(apiKey) apiKey = tostring(apiKey); local keyData = getData(apiKey); if keyData then ngx.location.capture('/redis_get', < ['vars'] = < ['key'] = apiKey >>); keyData['is_active'] = false; CStorage.apiKey:set(apiKey,JSON:encode(cacheKeyData)); end; end;

storages.lua

module ( . package.seeall ) apiKey = ngx.shared.apiKey; RPS = ngx.shared.RPS; RPP = ngx.shared.RPP; banPermanent = ngx.shared.banPermanent; banTmp = ngx.shared.banTmp; tmpBlockedDemoKeys = ngx.shared.tmpBlockedDemoKeys;
Бонус! Примеры без использования Lua вообще.

Только конфиги, только хардкор

OpenResty: превращаем NGINX в полноценный сервер приложений

Мы вновь публикуем расшифровку доклада с конференции HighLoad++ 2016, которая проходила в подмосковном Сколково 7—8 ноября прошлого года. Владимир Протасов рассказывает, как расширить функциональность NGINX с помощью OpenResty и Lua.

Всем привет, меня зовут Владимир Протасов, я работаю в Parallels. Расскажу чуть-чуть о себе. Три четверти своей жизни я занимаюсь тем, что пишу код. Стал программистом до мозга костей в прямом смысле: я иногда во сне вижу код. Четверть жизни — промышленная разработка, написание кода, который идёт прямо в продакшн. Код, которым некоторые из вас пользуются, но не догадываются об этом.

Чтобы вы понимали насколько всё было плохо. Когда я был маленьким джуниором, я пришёл, и мне выдали такие двухтерабайтные базы. Это сейчас тут у всех highload. Я ходил на конференции, спрашивал: «Ребят, расскажите, у вас big data, всё круто? Сколько у вас там базы?» Мне отвечали: «У нас 100 гигабайт!» Я говорил: «Круто, 100 гигабайт!» А про себя думал, как бы аккуратненько сохранить покерфейс. Думаешь, да, ребята крутые, а потом возвращаешься и ковыряешься с этими многотерабайтными базами. И это — будучи джуниором. Представляете себе, какой это удар?

Я знаю больше 20 языков программирования. Это то, в чём мне пришлось разобраться в процессе работы. Тебе выдают код на Erlang, на C, на С++, на Lua, на Python, на Ruby, на чем-то еще, и тебе надо это всё пилить. В общем пришлось. Точное количество посчитать так и не удалось, но где-то на 20 число потерялось.

Поскольку все присутствующие знают, что такое Parallels, и чем мы занимаемся, говорить о том, какие мы крутые и что делаем, не буду. Расскажу только, что у нас 13 офисов по миру, больше 300 сотрудников, разработка в Москве, Таллине и на Мальте. При желании можно взять и переехать на Мальту, если зимой холодно и надо погреть спинку.

Конкретно наш отдел пишет на Python 2. Мы занимаемся бизнесом и нам некогда внедрять модные технологии, поэтому мы страдаем. У нас Django, потому что в ней всё есть, а лишнее мы взяли и выкинули. Также MySQL, Redis и NGINX. Ещё у нас — много других крутых штук. У нас есть MongoDB, у нас кролики бегают, у нас чего только нет — но это не моё, и я этим не занимаюсь.

OpenResty

О себе я рассказал. Давайте разберёмся, о чем я буду сегодня говорить:

  • Что такое OpenResty и с чем его едят?
  • Зачем изобретать ещё один велосипед, когда у нас есть Python, NodeJS, PHP, Go и прочие крутые штуки, которыми все довольны?
  • И немножечко примеров из жизни. Мне пришлось сильно урезать доклад, потому что он у меня получался на 3,5 часа, поэтому примеров будет мало.

В NGINX уже сделаны кеширование и статический контент. Вам не нужно париться, как это сделать по-человечески, чтобы у вас где-нибудь не затормозило, чтобы вы где-то дескрипторы не потеряли. Nginx очень удобно деплоить, вам не нужно задумываться, что взять — WSGI, PHP-FPM, Gunicorn, Unicorn. Nginx поставили, админам отдали, они знают, как с этим работать. Nginx структурированно обрабатывает запросы. Я об этом немножко позже расскажу. Вкратце у него есть фаза, когда он только принял запрос, когда он обработал и когда отдал контент пользователю.

Nginx крут, но есть одна проблема: он недостаточно гибок даже при всех тех крутых фишках, что ребята впихнули в конфиг, при том, что можно настроить. Этой мощи не хватает. Поэтому ребята из Taobao когда-то давно, кажется, лет восемь назад, встроили туда Lua. Что он даёт?

  • Размер. Он маленький. LuaJIT дает где-то 100-200 килобайт оверхеда по памяти и минимальный оверхед по производительности.
  • Скорость. Интерпретатор LuaJIT во многих ситуациях близок к C, в некоторых ситуациях он проигрывает Java, в некоторых — обгоняет её. Какое-то время он считался state of art, крутейшим JIT-компилятором. Сейчас есть более крутые, но они очень тяжелые, к примеру, тот же V8. Некоторые JS-ные интерпретаторы и джавовский HotSpot в каких-то точках быстрее, но в каких-то местах всё ещё проигрывают.
  • Простота в освоении. Если у вас, допустим, кодовая база на Perl, и вы не Booking, вы не найдёте перловых программистов. Потому что их нет, их всех забрали, а учить их долго и сложно. Если вы хотите программистов на чем-то другом, возможно, их тоже их придётся переучивать, либо находить. В случае Lua всё просто. Lua учится любым джуниором за три дня. Мне потребовалось где-то часа два, чтобы разобраться. Через два часа я уже писал код в продакшн. Где-то через неделю он прямо в продакшн и уехал.

Тут много всего. В OpenResty собрали кучу модулей, как луашных, так и энджинсовских. И у вас все готовое — задеплоил и работает.

Примеры

Хватит лирики, переходим к коду. Вот маленький Hello World:

Что здесь есть? это энджинсовский location. Мы не паримся, не пишем свой роутинг, не берём какой-то готовый — у нас уже есть в NGINX, мы живем хорошо и лениво.

content_by_lua_block – это блок, который говорит, что мы отдаем контент при помощи Lua-скрипта. Берем энджинсовскую переменную remote_addr и подсовываем её в string.format . Это то же самое, что и sprintf , только на Lua, только правильный. И отдаём клиенту.

В результате это будет выглядеть вот так:

Но вернёмся в реальный мир. В продакшн никто не деплоит Hello World. У нас приложение обычно ходит в базу или ещё куда-то и большую часть времени ждёт ответа.

Просто сидит и ждёт. Это не очень хорошо. Когда приходят 100.000 пользователей, нам очень тяжко. Поэтому давайте в качестве примера накидаем простенькое приложение. Будем искать картинки, например, котиков. Только мы не будем просто так искать, мы будем расширять ключевые слова и, если пользователь поискал «котята», мы ему найдём котиков, пушистиков и прочее. Для начала нам нужно получить данные запроса на бэкенде. Выглядит это так:

Две строчки позволяют вам забрать GET-параметры, никаких сложностей. Дальше мы, допустим, из базы данных с табличкой по ключевому слову и расширению получаем обычным SQL-запросом эту информацию. Всё просто. Выглядит это так:

Подключаем библиотечку resty.mysql , которая у нас уже есть в комплекте. Нам ничего не нужно ставить, всё готовое. Указываем, как подключиться, и делаем SQL-запрос:

Тут немножечко страшно, но всё работает. Здесь 10 — это лимит. Мы вытаскиваем 10 записей, мы ленивые, не хотим больше показывать. В SQL про лимит я забыл.

Дальше мы находим картинки по всем запросам. Мы собираем пачку запросов и заполняем Lua-табличку, которая называется reqs , и делаем ngx.location.capture_multi .

Все эти запросы уходят в параллель, и нам возвращаются ответы. Время работы равно времени ответа самого медленного. Если у нас все отстреливаются за 50 миллисекунд, и мы отправили сотню запросов, то ответ у нас придёт за 50 миллисекунд.

Поскольку мы ленивые и не хотим писать обработку HTTP и кэширования, мы заставим NGINX делать всё за нас. Как вы видели, там был запрос на url/fetch , вот он:

Мы делаем простой proxy_pass , указываем, куда закэшировать, как это сделать, и у нас всё работает.

Но этого недостаточно, нам ещё нужно отдать данные пользователю. Самая простая идея — это всё серилизовать в JSON, легко, в две строчки. Отдаём Content-Type, отдаём JSON.

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

Что с этим делать? Само собой, мы будем отдавать пользователю HTML. Генерировать ручками — не комильфо, поэтому мы хотим использовать шаблоны. Для этого есть библиотека lua-resty-template .

Вы, наверное, увидели три страшные буквы OPM. OpenResty идет со своим пакетным менеджером, через который можно поставить ещё кучу разных модулей, в частности, lua-resty-template . Это простой движок шаблонов, близкий к Django templates. Там можно написать код и сделать подстановку переменных.

В результате всё будет выглядеть примерно вот так:

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

Всё круто, но мы же в девелопменте, и не хотим пока пользователям показывать. Давайте сделаем авторизацию. Чтобы это сделать, давайте посмотрим, как NGINX обрабатывает запрос в терминах OpenResty:

  • Первая фаза — access, когда пользователь только пришел, и мы на него посмотрели по заголовкам, по IP-адресу, по прочим данным. Можно сразу отрубить его, если он нам не понравился. Это можно использовать для авторизации, либо, если нам приходит очень много запросов, мы можем их легко рубить на этой фазе.
  • rewrite. Переписываем какие-то данные запроса.
  • content. Отдаём контент пользователю.
  • headers filter. Подменяем заголовки ответа. Если мы использовали proxy_pass , мы можем переписать какие-то заголовки, прежде чем отдать пользователю.
  • body filter. Можем подменить тело.
  • log — логирование. Можно писать логи в elasticsearch без дополнительного слоя.

Мы добавим это в тот location , который мы описали до этого, и засунем туда такой код:

Мы смотрим, есть ли у нас cookie token. Если нет, то кидаем на авторизацию. Пользователи хитрые и могут догадаться, что нужно поставить cookie token. Поэтому мы ещё положим её в Redis:

Код работы с Redis очень простой и ничем не отличается от других языков. При этом весь ввод/вывод что там, что здесь, он не блокирующий. Если пишете синхронный код, то работает асинхронно. Примерно как с gevent, только сделано хорошо.

Давайте сделаем саму авторизацию:

Говорим, что нам нужно читать тело запроса. Получаем POST-аргументы, проверяем, что логин и пароль правильные. Если неправильные, то кидаем на авторизацию. А если правильные, то записываем token в Redis:

Не забываем поставить cookie, это тоже делается в две строчки:

Пример простой, умозрительный. Мы конечно же не будем делать сервис, который показывает людям котиков. Хотя кто нас знает. Поэтому давайте пройдёмся по тому что можно сделать в продакшне.

    Минималистичный бэкенд. Иногда нам требуется в бэкенд выдать совсем чуть-чуть данных: где-то нужно дату подставить, где-то какой-то список вывести, сказать, сколько сейчас пользователей на сайте, прикрутить счётчик или статистику. Что-то такое небольшое. Минимальные какие-то кусочки можно очень легко сделать. На этом получится быстро, легко и здорово.

Итоги

  • Надеюсь смог донести, что OpenResty — это очень удобный фреймворк, заточенный под веб.
  • У него низкий порог вхождения, поскольку код похож на то, на чём мы пишем, язык довольно прост и минималистичен.
  • Он предоставляет асинхронный I/O без коллбеков, у нас не будет лапши, как мы можем иногда написать в NodeJS.
  • У него легкий деплой, поскольку нам нужен только NGINX c нужным модулем и наш код, и всё сразу работает.
  • Большое и отзывчивое сообщество.

Как мы построили real-time аналитическую платформу, используя Kafka, OpenResty и ClickHouse

Привет, меня зовут Василий Макогонский. Больше половины своей жизни я занимаюсь программированием, и уже около 5 лет я работаю CTO в компании Futurra Group.

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

«Чтобы что-то улучшить, нужно сначала это измерить»

Эта цитата как нельзя лучше отражает наш итеративный подход к изменениям в продуктах. Перед началом всех изменений и тестов, мы фиксируем показатели, на которые будем ориентироваться для принятия дальнейших решений. Очень простые изменения могут повлечь за собой глобальные изменения в поведении пользователей, а это в свою очередь может привести к замедлению роста проекта. По этой причине мы собираем все события, которые происходят в приложении, чтобы понимать глубину влияния изменений. Миллионы пользователей каждый день используют наши продукты, и наша задача — сохранить каждое событие, которое произошло во время сессии.

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

Для этого формализуем и выделим основные наши цели:

  1. Идемпотентность данных.
  2. Отказоустойчивость системы.
  3. Масштабируемость всех частей системы.
  4. Failover.
  5. Возможность останавливать части системы для их модернизации или профилактики.
  6. Сбор данных должен быть универсальным для всех платформ, проектов, задач.
  7. Иметь возможность отправлять зашифрованные данные.
  8. Удобная кастомизация метрик, удобство в добавлении разных разрезов к данным.
  9. Возможность принимать данные максимально эффективно из разных частей мира.
  10. Отображение данных в режиме Realtime ( <2s).

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

В итоге мы собрали все необходимые инструменты:

  1. GEO-dns (Route53).
  2. OpenResty (Nginx+Lua).
  3. Kafka+Zookeeper.
  4. Consumer tasks (PHP).
  5. ClickHouse.
  6. Supervisor.
  7. Mysql для словарей, определения проектов и метрик.

Для начала нам нужна точка входа в систему — место, куда будем отправлять данные. Также необходимо понимать, что скорость подключения и отправки данных к одному серверу из разных стран будет заметно отличаться, учитывая расстояния и особенности построения мировой сети.

Выбор пал на сервис от Amazon Route53, он же GEO-dns в нашем случае. Есть много других подобных провайдеров. Но у нас уже был успешный опыт работы именно с Route53, который также имеет возможность сделать Failover на случай падения сервера. Это необходимый функционал в построении высоконагруженных систем.

Направляем наш домен на Route53. Для начала необходимо зарегистрироваться на AWS, если еще нет аккаунта. Далее добавляем наш домен в «Hosted zones», а на стороне регистратора меняем NS сервера, которые выдаст AWS в консоли при добавлении нового домена. Обычно это два NS сервера, но, бывает, для большей надежности выдают более четырех.

Следующим этапом будет настройка записей в DNS, чтобы при ответе мы получали наши адреса серверов. Используем записи «A». Если серверов несколько, как в нашем случае, делаем несколько записей. Если необходимо использовать под регион отдельный сервер/серверы, выбираем записи с использованием геолокации (Routing policy). Можно выставить дефолтный IP сервера для всех регионов и, например, несколько — для наиболее клиенто вместительных стран.

В данном случае это будут те серверы, которые у нас принимают HTTP запросы для сбора статистики, то есть первое звено.

Для настройки фейловера нам необходимо указать в Route53 проверку конкретных серверов, которые мы использовали в «A» записях. Для этого создаем в Health checks протокол проверки (в нашем случае HTTP, но если используем сертификаты, то выбираем HTTPS), периодичность и собственно IP наших серверов.

На данном этапе у нас уже есть готовый балансировщик, сервер под регион, фейловер (если в наличии не один, а несколько серверов).

Первым звеном в системе использовали OpenResty. Для чего? По сути это тот же любимый многими веб-сервер Nginx, но со встроенным модулем Lua, который нужен нам для буферизации данных и для оптимизации скорости. Если кратко, то данные приняли в систему максимально быстро и отправили на клиент код 200.

Алгоритм будет работать в следующем порядке. Принимаем кучу запросов по HTTP, собираем эти данные в пачки. К каждому запросу при необходимости (в нашем случае это — базовая информация о запросе, например IP клиента, время запроса в UTC, GEO идентификация по IP). Для ГЕО идентификации используем сервис Maxmind, у которого есть готовые библиотеки для NGINX.

Далее собранные нами данные архивируем посредством библиотек LUA, и отправляем данные на серверы с Kafka.

Все это в памяти, без использования медленных дисков.

Можно использовать не только OpenResty для этого, но и софт от FluentD и т.д. Кому как удобней. В нашем случае входящие данные принимаются по обычному HTTP.

Следующий этап — это очередь. В Kafka можно делить на партиции. Например, 10 партиций и тройной репликацией. Это позволит сохранить данные даже при отказе не одного, а многих серверов в кластере. Но минусом будет конечно же избыточность, так как данные будут сохранены в трех копиях.

Первый и самый главный этап закончен. Данные успешно были приняты в систему для последующей обработки.

Связка Kafka+Zookeeper получилась очень удачной, так как стабильность их работы в кластере показала себя более чем удовлетворительно.

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

Почему выбор пал на ClickHouse как хранилище?

  • Это колоночная БД, а это значит, что каждая колонка в таблице представлена в файловой системе как отдельный файл. Это удобно, когда у нас нет определенный структуры данных, и в некотором будущем структура будет меняться. Например, когда нужно добавить новый разрез к уже существующей метрике.
  • Данные соединяются (merge) к старым на фоне, как следствие имеем возможность быстро данные отправлять и не ждать реальной вставки на диски.
  • Есть возможность сохранять свежие данные (последние) на SSD или NVM дисках, а уже более старые (например, недельной давности) отправлять на большие HDD диски. Это в свою очередь удешевляет цену сохранности данных. Этот процесс также отрабатывает на фоне.
  • Opensource проект, его постоянное развитие.
  • Куча аналитических встроенных функций.
  • Родной SQL с минимальными отличиями.
  • Возможность делать materialized views, когда нужно делать представление данных как результат работы запроса (select).
  • Масштабируемость. Distributed таблицы, те, которые по сути не имеют в себе данных. А при выборке из них делают распределение на всех шардах.

Данные для простоты организации архитектуры нужно представить в удобном виде. Вот, как это сделали мы. Наш виртуальный проект — это база данных, в которой каждая метрика — это таблица. В свою очередь таблицы имеют колонки, а это у нас разрезы метрик, таким образом мы можем трекать данные и иметь возможность эти данные дополнять различными параметрами или характеристиками.

Еще один важный момент. Все данные мы не будет нормализовать, все сохраняем так, как получили. Потому что скорость выборки для нас более важна на сегодня, чем сам размер баз данных. Стоит и учитывать, что ClickHouse сам сжимает данные после объединения со старыми записями.

Да, за основу обработки данных мы взяли конечно же PHP, так как опыт работы с ним у нас достаточный для решения почти всех задач.

  • Стягиваем данные из Kafka пачками.
  • Достаем оригинал из архива.
  • Если данные были изначально зашифрованы, расшифровываем.
  • Определяем проект, принадлежность к конкретной метрике.
  • Группируем данные по проекту, метрике.
  • Создаем БД в Clickhouse, если такой еще нет.
  • Если были добавлены новые поля к метрикам, добавляем к таблице колонку (делаем alter table).
  • И отправляем уже обработанные, сгруппированные данные в Clickhouse хранилище.

Таких обработчиков необходимо запустить столько, сколько партиций в очереди Kafka. Так как каждая партиция обрабатывается своей таской.

На сегодня мы собираем и анализируем более 800 миллионов запросов в сутки со всех континентов на достаточно небольших серверных мощностях. Наши аналитики, продуктовые менеджеры, а также коллеги, ответственные за направления, имеют возможность качественно собирать, агрегировать и анализировать данные. При необходимости всегда добавляем новые разрезы по данным, проводим тесты и двигаем продукты вперед.

Собирайте данные, и чем больше — тем лучше! Надеюсь, было полезно. Всем успехов!

�� Подобається Сподобалось 9

До обраного В обраному 11

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *