lua-resty-core: FFI-фундамент, на котором стоит весь nginx lua api
Начнём с того, что почти никто не замечает, пока оно просто работает. Когда ты пишешь ngx.var.uri или ngx.shared.cache:get(), ты вызываешь не магию, а конкретную реализацию. Исторически эти функции были написаны на C и торчали в Lua через классический Lua C API (lua_pushcfunction и подобное). Это работает, но каждый вызов через C API - это дорогой переход с пересборкой стека, который LuaJIT не может заинлайнить в свой JIT-компилятор. На горячем пути, где ngx.var.* дёргается миллионы раз в секунду, это заметно.
Библиотека lua-resty-core переписала весь этот API через FFI (Foreign Function Interface) LuaJIT. FFI позволяет звать сишные функции напрямую, и - что важнее - JIT-компилятор LuaJIT умеет такие вызовы инлайнить и оптимизировать как нативный код. Грубо говоря, lua-resty-core - это быстрая FFI-версия всего ngx.*, которая заменяет старую медленную C-API-версию.
Ключевой факт для 2026: начиная с OpenResty 1.15.8.1 библиотека resty.core загружается автоматически. Тебе не нужно ничего делать - ngx.* уже работает через FFI. В актуальной сборке (OpenResty 1.31.1.1 на базе nginx 1.31.1, июнь 2026) это давно дефолт. Но есть важный нюанс: ряд продвинутых API физически живёт ТОЛЬКО в lua-resty-core и без неё просто не существует. Это ngx.balancer (Lua-балансировка апстрима), ngx.ssl (динамические сертификаты по SNI), ngx.semaphore (синхронизация лёгких потоков). Если ты соберёшь голый lua-nginx-module без resty.core или отключишь её, эти модули выдадут ошибку загрузки.
Код: Выделить всё
-- В современном OpenResty это уже сделано за тебя.
-- Но если видишь старый код или работаешь с ручной сборкой -
-- именно так включали FFI-ядро явно:
require "resty.core"
-- Проверить, что ядро на месте и FFI-путь активен:
local ok, core = pcall(require, "resty.core")
if not ok then
ngx.log(ngx.ERR, "resty.core not loaded: ", core)
end

Слой запроса и ответа: openresty ngx.var, ngx.req и вывод
Теперь сам API. Первое, что нужно - доступ к переменным nginx. Через openresty ngx.var ты читаешь и пишешь любые переменные текущего запроса: ngx.var.uri, ngx.var.remote_addr, ngx.var.http_user_agent, ngx.var.arg_id. Чтение - дёшево, но не бесплатно: каждое обращение лезет в механизм переменных nginx, поэтому в цикле кэшируй в локальную переменную. Запись доступна только для тех переменных, что объявлены через set в конфиге или встроены как изменяемые (например ngx.var.args).
Код: Выделить всё
-- читаем переменные nginx
local uri = ngx.var.uri
local ip = ngx.var.remote_addr
local ua = ngx.var.http_user_agent -- заголовок User-Agent
local id = ngx.var.arg_id -- параметр ?id=...
-- запись: переменная должна быть объявлена в конфиге через set $my_flag "";
ngx.var.my_flag = "blocked"
Код: Выделить всё
local method = ngx.req.get_method() -- "GET", "POST", ...
local headers = ngx.req.get_headers() -- таблица заголовков
local args = ngx.req.get_uri_args() -- ?a=1&b=2 -> { a="1", b="2" }
-- тело надо сначала прочитать, иначе get_body_data вернёт nil
ngx.req.read_body()
local body = ngx.req.get_body_data() -- сырое тело
local post = ngx.req.get_post_args() -- разбор form-urlencoded
Вывод ответа: ngx.say печатает с переводом строки, ngx.print без него, ngx.status задаёт код, ngx.header.NAME пишет заголовок ответа, ngx.exit завершает обработку с указанным кодом. ngx.log пишет в error_log с уровнем (ngx.ERR, ngx.WARN, ngx.INFO).
Код: Выделить всё
ngx.status = 403
ngx.header["Content-Type"] = "application/json"
ngx.header["X-Gate"] = "lua"
ngx.say('{"error":"forbidden"}')
ngx.log(ngx.WARN, "blocked ip=", ngx.var.remote_addr)
return ngx.exit(403)
nginx lua_shared_dict: общая память на всех воркеров
Главная боль новичка в OpenResty: переменные в Lua живут в пределах одного запроса (или одного воркера для модульных upvalue), а воркеров у тебя несколько. Глобальная переменная в одном воркере не видна другому. Счётчик запросов, кэш, флаги rate-limit - всё это нужно разделять между всеми процессами. Для этого есть nginx lua_shared_dict - кусок разделяемой памяти, доступный всем воркерам одновременно, с блокировками под капотом.
Объявляется он в http-блоке, размер задаётся сразу и фиксированно:
Код: Выделить всё
http {
lua_shared_dict counters 10m;
lua_shared_dict mycache 50m;
}
- get(key) - значение или nil. Возвращает ещё и flags.
- set(key, val, exp?, flags?) - записать, опционально с TTL в секундах. Возвращает success, err, forcible.
- safe_set(key, val, ...) - как set, но НЕ вытесняет чужие данные при нехватке памяти, а честно вернёт "no memory".
- add / safe_add - записать, только если ключа ещё нет (атомарно).
- replace - записать, только если ключ уже есть.
- incr(key, n, init?, init_ttl?) - атомарный инкремент. Параметр init - вот что критично: без него incr по несуществующему ключу вернёт ошибку, а с init он создаст ключ со значением init и прибавит n. Это идиома для счётчиков.
- get_stale(key) - вернёт значение, ДАЖЕ если у него истёк TTL (и третьим значением stale=true). Незаменимо для паттерна "отдать протухшее, пока обновляем в фоне".
- delete, expire, ttl, flush_all, flush_expired - управление.
- get_keys(max?) - список ключей (осторожно, дорого, не для горячего пути).
Код: Выделить всё
local counters = ngx.shared.counters
-- атомарный счётчик запросов на IP с авто-инициализацией
local key = "req:" .. ngx.var.remote_addr
local newval, err = counters:incr(key, 1, 0) -- init=0, если ключа нет
if not newval then
ngx.log(ngx.ERR, "incr failed: ", err)
end
-- запись с TTL и контролем вытеснения
local ok, err, forcible = counters:safe_set("flag:" .. key, "1", 60)
if forcible then
ngx.log(ngx.WARN, "shared dict full, evicting; bump zone size")
end
-- паттерн "stale-while-revalidate" на shared dict
local val, flags, stale = ngx.shared.mycache:get_stale("k1")
if stale then
-- отдаём протухшее немедленно, а обновление запускаем в фоне (см. ngx.timer)
end
Когда из Lua нужно сходить наружу - в Redis, в HTTP-сервис, в MySQL - ты НЕ берёшь синхронную библиотеку. Любой блокирующий сетевой вызов заморозит весь воркер nginx целиком, а с ним сотни-тысячи параллельных запросов. Правильный путь - openresty cosocket: ngx.socket.tcp(). Это неблокирующий сокет, привязанный к событийному циклу nginx. Пока сокет ждёт ответа, воркер обслуживает другие запросы. На cosocket построены lua-resty-redis, lua-resty-http, lua-resty-mysql - все официальные клиенты.
Код: Выделить всё
local sock = ngx.socket.tcp()
sock:settimeouts(1000, 1000, 1000) -- connect, send, read - в мс, всегда ставь!
local ok, err = sock:connect("10.0.0.5", 6379)
if not ok then
ngx.log(ngx.ERR, "connect failed: ", err)
return ngx.exit(502)
end
sock:send("PING\r\n")
local line, err = sock:receive("*l")
Код: Выделить всё
-- max_idle_timeout=10с, pool_size=100 соединений НА КАЖДЫЙ воркер
local ok, err = sock:setkeepalive(10000, 100)
if not ok then
ngx.log(ngx.ERR, "setkeepalive failed: ", err)
end
-- ВАЖНО: после setkeepalive сокет нельзя использовать - он отдан в пул!
- set_keepalive ОБЯЗАТЕЛЕН. Забыл - и каждый запрос открывает новый коннект, база захлёбывается в TIME_WAIT, latency растёт. Это самая частая причина "почему мой gateway медленный".
- Пул - per-worker, не глобальный. Размер pool_size применяется к КАЖДОМУ воркеру отдельно. Если у Redis потолок 1000 соединений, а воркеров 10 - ставь pool_size около 100 (1000/10), иначе под нагрузкой суммарно откроешь 10000 и положишь базу. Это арифметика, которую все забывают.
- Коннект НЕЛЬЗЯ хранить на уровне модуля. Соблазн велик: завести local red в верхней части модуля и переиспользовать. Нельзя. Lua-объект сокета привязан к конкретному запросу/потоку; шарить его между запросами - это гонка (race), один поток получит ответ, предназначенный другому. Создавай сокет ВНУТРИ обработчика запроса, отдавай в пул через set_keepalive, на следующем запросе connect сам достанет готовое соединение из пула. Переиспользуется именно TCP-соединение в пуле, а не Lua-объект.
Фоновые задачи и смертельный грех блокирующего вызова
ngx.timer.at(delay, callback, ...) запускает функцию в фоне, вне контекста запроса. Это способ делать то, что нельзя делать в горячем пути ответа: дописать лог в БД, обновить протухший кэш (тот самый stale-while-revalidate), отправить метрику. Колбэк таймера получает первым аргументом premature (true, если воркер завершается - тогда сворачивайся быстро). В таймере НЕТ ngx.var и ngx.req запроса, но cosocket и shared dict доступны.
Код: Выделить всё
local function refresh(premature, key)
if premature then return end
local sock = ngx.socket.tcp()
-- ... сходить в источник, обновить ngx.shared.mycache ...
end
local ok, err = ngx.timer.at(0, refresh, "k1") -- 0 = запустить почти сразу
if not ok then ngx.log(ngx.ERR, "timer failed: ", err) end
Мини-лаба: счётчик в shared dict и запрос к Redis с пулом
Соберём руками маленький, но боевой кусок: считаем хиты в разделяемой памяти и подтягиваем значение из Redis через пул.
- Шаг 1. В http-блок добавь зону: lua_shared_dict hits 5m;
- Шаг 2. Подними локальный Redis (docker run -p 6379:6379 redis) - он будет апстримом для cosocket.
- Шаг 3. Создай location /stat с content_by_lua_block (см. код ниже).
- Шаг 4. Проверь: nginx -t, затем перезапусти и сделай несколько curl на /stat. Счётчик в ответе должен расти, а соединения к Redis - переиспользоваться (следи через redis-cli INFO clients: число connected_clients не должно расти линейно с запросами).
- Шаг 5. Эксперимент: закомментируй setkeepalive и под нагрузкой (ab или wrk) посмотри, как растёт connected_clients и latency. Верни обратно - и сравни. Это нагляднее любой теории.
Код: Выделить всё
location /stat {
content_by_lua_block {
-- 1) счётчик в разделяемой памяти
local hits = ngx.shared.hits
local n = hits:incr("total", 1, 0) -- init=0 - ключ создастся сам
-- 2) поход в Redis через cosocket с пулом
local sock = ngx.socket.tcp()
sock:settimeouts(500, 500, 500)
local ok, err = sock:connect("127.0.0.1", 6379)
if not ok then
ngx.status = 502
ngx.say("redis connect error: ", err)
return
end
sock:send("INCR page:views\r\n")
local reply, rerr = sock:receive("*l") -- ответ вида ":42"
-- 3) ОБЯЗАТЕЛЬНО вернуть соединение в пул, не close!
local kok, kerr = sock:setkeepalive(10000, 100)
if not kok then
ngx.log(ngx.WARN, "keepalive: ", kerr)
end
ngx.header["Content-Type"] = "text/plain"
ngx.say("worker hits=", n, " redis=", reply or rerr)
}
}
- Зачем нужна lua-resty-core и какие три модуля без неё вообще не заработают? Что значит "FFI-фундамент"?
- Что вернёт ngx.req.get_body_data() без вызова ngx.req.read_body() и что произойдёт, если тело ушло во временный файл?
- Чем incr(key, 1, 0) отличается от incr(key, 1), и зачем нужен safe_set вместо обычного set? Что означает третий возвращаемый флаг forcible?
- Почему нельзя сохранить объект Redis-соединения в upvalue модуля, и что именно переиспользуется при set_keepalive? Как посчитать pool_size, если у Redis лимит соединений и N воркеров?
ngx.* - это четыре слоя инструментов. lua-resty-core даёт FFI-скорость и продвинутые модули (balancer/ssl/semaphore). ngx.var/ngx.req/ngx.header/ngx.say дают полный доступ к запросу и ответу. lua_shared_dict хранит примитивы в общей памяти всех воркеров - с богатым API incr+init, get_stale, safe_set и вытеснением по LRU. cosocket даёт неблокирующий выход в сеть, но требует трёх дисциплин: set_keepalive обязателен, пул per-worker, коннект не хранить на модуле. И сквозное правило: ни одного блокирующего вызова на пути воркера. Освоишь этот арсенал - и Lua в nginx из игрушки превращается в инструмент построения настоящего API-gateway.