Зачем nginx вообще делит обработку на фазы
Когда запрос приходит в nginx, он не обрабатывается одним куском кода. Внутри есть конвейер - последовательность строго упорядоченных фаз, через которые проходит каждый HTTP-запрос. Это не выдумка OpenResty, это архитектура самого nginx: модули регистрируют свои обработчики в нужной фазе, и ядро прогоняет запрос по ним по очереди. Сначала разбор и переписывание URI, потом контроль доступа, потом генерация контента, потом фильтрация ответа, в самом конце - логирование. Каждая фаза решает свою задачу и не лезет в чужую.
Почему это важно для тебя. Директивы вроде limit_req, auth_request, try_files, proxy_pass - все они живут в определённых фазах. Когда ты пишешь access_by_lua, ты буквально подключаешься в ту же access-фазу, где работает штатный модуль авторизации. Поэтому твой Lua-код выполнится в правильный момент: после того как nginx уже определил location и переписал URI, но до того как начал генерировать ответ. Если бы Lua-хук не привязывался к фазе, ты бы не знал, что уже посчитано, а что ещё нет. Фаза - это контракт о том, какие данные доступны и что можно делать.
Полный порядок Lua-директив OpenResty по жизненному циклу:
- init_by_lua - один раз при старте/reload мастер-процесса, ДО форка воркеров. Прогрев: загрузить модули, прочитать конфиг-файлы, заполнить разделяемые словари константами. Cosocket НЕЛЬЗЯ (сети ещё нет).
- init_worker_by_lua - один раз на КАЖДЫЙ воркер после форка. Тут стартуют фоновые таймеры (ngx.timer.at / ngx.timer.every) - опрос health, обновление кэша конфигурации.
- set_by_lua - вычислить значение переменной nginx. Синхронно и быстро, БЛОКИРУЕТ воркер. Cosocket и ngx.sleep НЕЛЬЗЯ.
- ssl_client_hello_by_lua / ssl_certificate_by_lua - на TLS-хендшейке, до выбора сертификата. Динамические сертификаты по SNI.
- rewrite_by_lua - раннее вмешательство: переписать URI, аргументы, заголовки, сделать редирект.
- access_by_lua - контроль доступа: пустить или нет. Здесь авторизация, проверка токенов, поход в Redis/БД.
- balancer_by_lua - выбор конкретного бэкенда в момент проксирования. Сердце Lua-балансировки.
- content_by_lua - сгенерировать тело ответа целиком средствами Lua (вместо proxy_pass).
- header_filter_by_lua - модификация заголовков ответа перед отправкой.
- body_filter_by_lua - модификация тела ответа кусками по мере прохождения.
- log_by_lua - после того как ответ ушёл клиенту: метрики, аналитика, отправка в очередь. Клиента НЕ задерживает.

Что можно и нельзя в каждой фазе - openresty фазы без иллюзий
Главная грабля новичка - думать, что Lua везде одинаковый. Нет. Возможности зависят от фазы, потому что в разный момент жизни запроса доступны разные ресурсы ядра nginx.
Самое жёсткое ограничение - cosocket (неблокирующие сокеты ngx.socket.tcp, основа всех lua-resty-* клиентов к Redis, MySQL, HTTP). Cosocket требует, чтобы у текущей фазы был "yield-механизм" - возможность приостановить корутину и вернуть управление в event loop. Не во всех фазах он есть.
Разбор по фазам, где чаще всего ломаются:Cosocket и ngx.sleep доступны в: rewrite, access, content, ssl_certificate, ssl_client_hello, init_worker (внутри таймеров), а также в фоновых таймерах. НЕДОСТУПНЫ в: set_by_lua, header_filter_by_lua, body_filter_by_lua, balancer_by_lua, log_by_lua, init_by_lua.
- set_by_lua - синхронный, без сети и без sleep. Если положишь сюда запрос в Redis, получишь ошибку API disabled. Используй только для быстрых чистых вычислений.
- header_filter / body_filter - cosocket нет: ответ уже формируется, момент для походов по сети упущен. Тут только трансформация того что есть. В body_filter помни про ngx.arg[1] (кусок тела) и ngx.arg[2] (флаг eof) - тело приходит чанками, а не целиком.
- balancer_by_lua - cosocket НЕТ напрямую. Это критично. Нельзя в момент балансировки сходить в БД за адресом бэкенда. Решение каноническое: данные о пирах достаёшь раньше - в access_by_lua через cosocket - и передаёшь в balancer через таблицу ngx.ctx.
- log_by_lua - cosocket НЕТ. Отправить метрику по сети прямо тут нельзя без блокировки. Правильно: складываешь в shared dict или ставишь ngx.timer.at(0, ...), который улетит уже после фазы и спокойно сходит по сети из таймера.
Практика 1: авторизация в access_by_lua и метрика в log_by_lua
Классический сценарий gateway: проверить ключ доступа до того как запрос уйдёт на бэкенд, а после ответа - записать факт в метрику. Это ровно то место, где паттерн nginx lua access_by_lua раскрывается полностью.
Код: Выделить всё
http {
lua_shared_dict req_counters 10m;
server {
listen 80;
location /api/ {
access_by_lua_block {
local key = ngx.var.http_x_api_key
if not key or key == "" then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- неблокирующий поход в Redis: тут cosocket доступен
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(200) -- мс
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "redis connect: ", err)
return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
local consumer = red:get("apikey:" .. key)
if consumer == ngx.null then
red:set_keepalive(10000, 100) -- вернуть в пул, НЕ close
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
red:set_keepalive(10000, 100)
-- кладём consumer в ctx, чтобы log-фаза его увидела
ngx.ctx.consumer = consumer
}
log_by_lua_block {
-- cosocket тут НЕЛЬЗЯ, пишем в shared dict
local who = ngx.ctx.consumer or "anon"
local dict = ngx.shared.req_counters
dict:incr(who .. ":" .. ngx.var.status, 1, 0)
}
proxy_pass http://backend;
}
}
}
Практика 2: balancer_by_lua - механизм Lua-балансировки
Вот фаза, ради которой многие и приходят в OpenResty. Штатный upstream в nginx статичен: список серверов задан в конфиге, чтобы поменять - нужен reload. balancer_by_lua даёт выбирать бэкенд кодом в момент запроса: по содержимому заголовка, по результату consistent hashing, по данным из shared dict, который обновляет фоновый таймер. Это и есть основа динамических апстримов (так устроены крупные gateway, включая то, как раньше работал ingress).
Ключевая функция - ngx.balancer.set_current_peer(host, port) из lua-resty-core. Она говорит nginx: "проксируй вот на этот адрес". Важно: домены НЕ поддерживаются, только IP - резолвить надо заранее (через lua-resty-dns в access-фазе).
Код: Выделить всё
http {
upstream backend {
server 0.0.0.1; # плейсхолдер, реальный адрес задаст Lua
balancer_by_lua_block {
local balancer = require "ngx.balancer"
-- адрес мы посчитали раньше, в access-фазе, и положили в ctx
local peer = ngx.ctx.peer or { host = "10.0.0.10", port = 9000 }
local ok, err = balancer.set_current_peer(peer.host, peer.port)
if not ok then
ngx.log(ngx.ERR, "set_current_peer: ", err)
return ngx.exit(500)
end
-- ретраи: до 2 попыток, таймауты на этот пир
balancer.set_more_tries(1)
balancer.set_timeouts(1, 2, 2) -- connect, send, read (сек)
}
keepalive 32;
}
server {
listen 80;
location / {
access_by_lua_block {
-- ТУТ можно cosocket: выбрать пир, например через shared dict
local pool = { {host="10.0.0.10", port=9000},
{host="10.0.0.11", port=9000} }
local idx = (tonumber(ngx.var.connection) or 0) % #pool + 1
ngx.ctx.peer = pool[idx]
}
proxy_pass http://backend;
}
}
}
Практика 3: динамические сертификаты в ssl_certificate_by_lua
Мульти-тенант: тысячи доменов, на каждый - свой TLS-сертификат, и они появляются динамически (привязка домена клиентом, выпуск через ACME). Прописывать ssl_certificate статикой на каждый - безумие и reload на каждый чих. Решение - ssl_certificate_by_lua: код вызывается на хендшейке, видит SNI, грузит нужный сертификат на лету (из файла, из БД, из кэша). Так работают платформы вроде Cloudflare/хостингов под капотом.
Код: Выделить всё
server {
listen 443 ssl;
server_name _;
# заглушки - реальный серт подставит Lua
ssl_certificate /etc/nginx/fallback.crt;
ssl_certificate_key /etc/nginx/fallback.key;
ssl_certificate_by_lua_block {
local ssl = require "ngx.ssl"
local name = ssl.server_name() -- SNI клиента
if not name then return end
-- тут cosocket доступен: можно сходить за сертом в кэш/БД
local cert_pem, key_pem = load_cert_for(name) -- твоя функция
if not cert_pem then
ngx.log(ngx.ERR, "no cert for ", name)
return
end
ssl.clear_certs() -- убрать заглушку
ssl.set_der_cert(ssl.cert_pem_to_der(cert_pem))
ssl.set_der_priv_key(ssl.priv_key_pem_to_der(key_pem))
}
}
Типичные грабли
- ngx.exit ведёт себя по-разному. В rewrite/access ngx.exit(ngx.HTTP_FORBIDDEN) прерывает запрос и отдаёт статус. В content фазе ngx.exit(ngx.OK) завершает отдачу. Не путай ngx.HTTP_OK (200, реально завершить с кодом) и ngx.OK (0, "продолжить как обычно"). В access вернуть ngx.exit(ngx.OK) - значит "пропустить дальше", а ngx.exit(ngx.HTTP_OK) - "ответить 200 прямо сейчас".
- Порядок постпонится. rewrite_by_lua по умолчанию выполняется в КОНЦЕ rewrite-фазы, после штатных rewrite-директив. Если нужно строго до них - есть rewrite_by_lua_no_postpone, но почти всегда он не нужен и только путает.
- Cosocket не в той фазе. Самая частая ошибка - запрос по сети в set/header/body/log/balancer. Получишь "API disabled in the context of ...". Лечится переносом сети в access и передачей результата через ngx.ctx.
- ngx.ctx не переживает internal redirect. При rewrite/try_files в новый location создаётся новый ngx.ctx. Если данные нужны после редиректа - не полагайся слепо на ctx.
- Тяжёлая логика в set_by_lua. Он блокирующий и зовётся ровно когда переменная нужна (лениво). Цикл на миллион итераций тут заморозит воркер. Для тяжёлого - rewrite/access.
- content_by_lua и proxy_pass в одном location несовместимы - оба претендуют на content-фазу. Выбирай одно.
Соберём минимальный стенд и увидим порядок фаз своими глазами.
- 1. Запусти OpenResty в Docker: docker run -d -p 8080:80 -v $PWD/conf:/usr/local/openresty/nginx/conf/conf.d openresty/openresty:latest (актуальный образ 2026 - OpenResty 1.31.1.1).
- 2. В conf.d создай server c location /trace и навешай по одному lua-блоку на каждую фазу, в каждом - ngx.log(ngx.ERR, "PHASE: rewrite") и так далее для rewrite/access/content/header_filter/body_filter/log.
- 3. В content_by_lua сделай ngx.say("hello from content").
- 4. Дёрни curl -s localhost:8080/trace и смотри error.log. Убедись, что строки идут строго в порядке: rewrite -> access -> content -> header_filter -> body_filter -> log.
- 5. Теперь добавь в set_by_lua_block попытку ngx.sleep(0.1). Перезапусти, проверь nginx -t и логи - поймай ошибку "API disabled" и осознай, почему set синхронный.
- 6. Перенеси любую "сетевую" задачу (хоть resolver через socket) в access_by_lua - ошибка уйдёт.
- 7. Бонус: добавь log_by_lua с ngx.timer.at(0, function() ... end) и убедись, что код таймера выполняется уже ПОСЛЕ того как клиент получил ответ.
- 1. В каких фазах cosocket НЕДОСТУПЕН и куда переносить сетевые вызовы из balancer_by_lua и log_by_lua?
- 2. Чем отличается ngx.exit(ngx.OK) от ngx.exit(ngx.HTTP_OK) внутри access_by_lua?
- 3. Почему выбор бэкенда обычно делают в access_by_lua, а в balancer_by_lua остаётся только set_current_peer?
- 4. Зачем после работы с resty.redis звать set_keepalive вместо close, и где ngx.ctx теряет свои данные?
Фазы - это не теория, а карта, по которой ты размещаешь Lua. Сеть и решения - в rewrite/access; динамический бэкенд - в balancer (а данные для него готовь раньше); генерация - в content; правка ответа - в фильтрах; учёт - в log без блокировки. Помни про cosocket-ограничения и про разницу ngx.OK/HTTP_OK - и openresty content_by_lua, nginx lua rewrite_by_lua, balancer_by_lua перестанут быть магией и станут инструментом. Дальше мы соберём из этих кубиков настоящий API-gateway.