Для обеспечения работы всех наших внешних продуктов мы используем популярный nginx. Это быстро и это надежно. Проблем с ним почти нет. Наши продукты также постоянно развиваются, появляются новые сервисы, добавляется новый функционал, расширяется старый. Аудитория и нагрузка только растет. Сейчас мы хотим рассказать о том, как мы ускорили разработку, неплохо увеличили производительность и упростили добавление в наши сервисы этого нового функционала, при этом сохранив доступность и отказоустойчивость затронутых приложений. Речь пойдет о концепции “nginx as web application”.
А именно, о сторонних модулях (в основном LUA), позволяющих делать совершенно магические вещи быстро и надежно.
Основная задумка довольно простая. Возьмем следующие факторы:
— сложность логики приложения,
— количество компонентов приложения,
— размер аудитории.
С определенного момента становится довольно сложно поддерживать приложение отзывчивым и быстрым, иногда даже и работоспособным. Продукт становится многокомпонентным, географически распределенным. И им пользуются все больше людей. При этом существуют требования бизнеса по отзывчивости и отказоустойчивости, которые надо в первую очередь соблюдать.
Путей решения этой проблемы несколько. Можно все вообще сломать и переделать на другие технологии. Безусловно, этот вариант работает, но нам он не очень понравился и мы решили переделывать постепенно. За основу была взята сборка openresty (nginx+LUA). Почему LUA. Без помощи cgi, fastcgi и других cgi прямо в конфигурационном файле nginx можно заскриптовать мощный, красивый и быстрый функционал. Все работает асинхронно. Причем не только с клиентами, но и с бекендами. При этом не вмешиваясь в event loop вебсервера, без callbacks, полностью используя имеющийся функционал nginx.
На данный момент доступны следующие бекенды:
— Redis
— Memcache
— MySQL
— PostgreSQL
В дополнении можно подключить еще модули для использования, например RabbitMQ и ZeroMQ.
Это работает довольно быстро. Во всяком случае быстрее, чем php-fpm ))
Логичный вопрос, а почему бы все вообще не переписать на C? Писать на LUA сильно проще и быстрее. И мы сразу избавлены от проблем, связанных с асинхронностью и nginx event loop.
Мы, как обычно, не будем приводить полный код, только основные части. Эти все штуки раньше были сделаны на php.
1. Эту часть придумал и сделал наш коллега AotD. Есть хранилище картинок. Их надо показывать пользователям, причем желательно производить при этом некоторые операции, например, resize. Картинки мы храним в ceph, это аналог Amazon S3. Для обработки картинок используется ImageMagick. На ресайзере есть каталог с кэшем, туда складываются обработанные картинки.
Парсим запрос пользователя, определяем картинку, нужное ему разрешение и идем в ceph, затем на лету обрабатываем и показываем.
serve_image.lua
Подключаем биндинг imagic.lua. Должен быть доступен LuaJIT.
nginx_partial_resizer.conf.template
2. Firewall для API. Валидация запроса, идентификация клиента, контроль rps и шлагбаум для тех, кто нам не нужен.
Firewall.lua
Validator.lua
Apikey.lua
Storages.lua
3. Дополнительные сервисы, например межкомпонентное взаимодействие по протоколу AMQP. Пример здесь.
4. Как я уже писал. Модуль самодиагностики приложения с возможностью “умного” управления маршрутами прохождения запроса через бекенды. Еще в разработке.
5. Адаптеры для интерфейсов API. В некоторых случаях необходимо подправить, дополнить или расширить имеющиеся методы. Чтобы все не переписывать, LUA поможет. Например, json<->xml conversion на лету.
6…. идей еще много.
Бенчмарков как таковых не будет. Продукты слишком сложны и рпс после бенча сильно зависит от многих факторов. Однако, для наших продуктов, мы добились 20-кратного увеличения производительности для затронутого функционала, а в некоторых случаях все стало быстрее до ~200 раз.
Ощутимые плюсы. Все, что раньше было 5 мегабайтами кода на php, превращается в 100кб файл на lua.
— скорость разработки,
— скорость работы приложения,
— надежность,
— асинхронная работа с клиентами и бекендами, не ломающая при этом event loop nginx,
— LUA sugar feel good! Корутины, shared dictionary для всех форков nginx, сабреквесты, куча биндингов.
Неощутимые минусы.
— делать надо все аккуратно и помнить про асинхронность и event loop nginx.
— фронтенд работает настолько быстро, что это может не понравиться бекенду. Между ними прямая связь, без прослоек. Я, например, уверен, что 10000 запросов в секунду LUA на фронтенде прожует легко. Но, если при этом оно захочет пойти в базу, тут могут возникнуть проблемы.
— довольно непросто отладить, если что-то пойдет не так.
Кстати, пока пишется эта статья, прямо в этот момент наш программист рассказывает про все это в подробностях на highload.
С удовольствием ответим на вопросы в комментариях.
Напоследок, здесь можно найти небольшую подборку информации по теме.
А именно, о сторонних модулях (в основном LUA), позволяющих делать совершенно магические вещи быстро и надежно.
Проблемы и решение
Основная задумка довольно простая. Возьмем следующие факторы:
— сложность логики приложения,
— количество компонентов приложения,
— размер аудитории.
С определенного момента становится довольно сложно поддерживать приложение отзывчивым и быстрым, иногда даже и работоспособным. Продукт становится многокомпонентным, географически распределенным. И им пользуются все больше людей. При этом существуют требования бизнеса по отзывчивости и отказоустойчивости, которые надо в первую очередь соблюдать.
Путей решения этой проблемы несколько. Можно все вообще сломать и переделать на другие технологии. Безусловно, этот вариант работает, но нам он не очень понравился и мы решили переделывать постепенно. За основу была взята сборка openresty (nginx+LUA). Почему LUA. Без помощи cgi, fastcgi и других cgi прямо в конфигурационном файле nginx можно заскриптовать мощный, красивый и быстрый функционал. Все работает асинхронно. Причем не только с клиентами, но и с бекендами. При этом не вмешиваясь в event loop вебсервера, без callbacks, полностью используя имеющийся функционал nginx.
На данный момент доступны следующие бекенды:
— Redis
— Memcache
— MySQL
— PostgreSQL
В дополнении можно подключить еще модули для использования, например RabbitMQ и ZeroMQ.
Это работает довольно быстро. Во всяком случае быстрее, чем php-fpm ))
Логичный вопрос, а почему бы все вообще не переписать на C? Писать на LUA сильно проще и быстрее. И мы сразу избавлены от проблем, связанных с асинхронностью и nginx event loop.
Примеры. Идеи
Мы, как обычно, не будем приводить полный код, только основные части. Эти все штуки раньше были сделаны на php.
1. Эту часть придумал и сделал наш коллега 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", {vars = { name = name .. ext }})
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 ~ ^/(?<name>(?<first>.)(?<second>..)[^_]+)((?<size>_[^.]+)|)(?<ext>\.[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 {}
2. 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;
3. Дополнительные сервисы, например межкомпонентное взаимодействие по протоколу AMQP. Пример здесь.
4. Как я уже писал. Модуль самодиагностики приложения с возможностью “умного” управления маршрутами прохождения запроса через бекенды. Еще в разработке.
5. Адаптеры для интерфейсов API. В некоторых случаях необходимо подправить, дополнить или расширить имеющиеся методы. Чтобы все не переписывать, LUA поможет. Например, json<->xml conversion на лету.
6…. идей еще много.
Бенчмарков как таковых не будет. Продукты слишком сложны и рпс после бенча сильно зависит от многих факторов. Однако, для наших продуктов, мы добились 20-кратного увеличения производительности для затронутого функционала, а в некоторых случаях все стало быстрее до ~200 раз.
Плюсы и минусы
Ощутимые плюсы. Все, что раньше было 5 мегабайтами кода на php, превращается в 100кб файл на lua.
— скорость разработки,
— скорость работы приложения,
— надежность,
— асинхронная работа с клиентами и бекендами, не ломающая при этом event loop nginx,
— LUA sugar feel good! Корутины, shared dictionary для всех форков nginx, сабреквесты, куча биндингов.
Неощутимые минусы.
— делать надо все аккуратно и помнить про асинхронность и event loop nginx.
— фронтенд работает настолько быстро, что это может не понравиться бекенду. Между ними прямая связь, без прослоек. Я, например, уверен, что 10000 запросов в секунду LUA на фронтенде прожует легко. Но, если при этом оно захочет пойти в базу, тут могут возникнуть проблемы.
— довольно непросто отладить, если что-то пойдет не так.
Кстати, пока пишется эта статья, прямо в этот момент наш программист рассказывает про все это в подробностях на highload.
С удовольствием ответим на вопросы в комментариях.
Напоследок, здесь можно найти небольшую подборку информации по теме.