...

суббота, 30 мая 2020 г.

Программируем прямо в 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 <b>world</b>!")
    ';
  }
}


Несколько обработчиков
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 {
    -- подключение основного модуля
    -- в принципе, этот блок можно опустить
    require "resty.core"
    collectgarbage("collect")  -- just to collect any garbage
}

в блоках *_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", {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 {}

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 вообще.


Только конфиги, только хардкор
force no-www
server {
    listen 80;
    server_name example.org;
}

server {
    listen 80;
    server_name www.example.org;
    return 301 $scheme://example.org$request_uri;
}

force https
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;

    # let the browsers know that we only accept HTTPS
    add_header Strict-Transport-Security max-age=2592000;

    ...
}

Редирект на определенный путь в URI
location /old-site {
    rewrite ^/old-site/(.*) http://example.org/new-site/$1 permanent;
}

Кэш файлов
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

Кэширование с Upstream
upstream backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

server {
    ...
    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Напоследок, большой список конфигурационных шаблонов с Lua и без, с разной степенью сложности

Заключение


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

На правах рекламы


Необходим сервер для размещения сайта? Наша компания предлагает надёжные серверы с посуточной или единоразовой оплатой, каждый сервер подключён к интернет-каналу в 500 Мегабит и бесплатно защищён от DDoS-атак!

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий