Lua-скрипты в Nginx: пять ловушек OpenResty
Lua в OpenResty позволяет вынести проверку доступа, маршрутизацию и работу с кешем прямо в Nginx. Но блокирующий вызов, неверная область данных или тяжёлый обработчик быстро превращают быстрый прокси в источник задержек и 502.

Lua-обработчик в OpenResty может добавить задержку всем запросам не из-за самого языка, а из-за одного блокирующего вызова или неограниченного чтения тела запроса. В этой статье разберём пять ловушек, которые проявляются под нагрузкой: неправильный контекст выполнения, блокирующий ввод-вывод, общий кеш, таймеры и обработка ошибок. Примеры ориентированы на OpenResty 1.25.3.1 и lua-nginx-module 0.10.26.
Сначала уточните, что именно запускает Lua
Обычный Nginx не выполняет Lua из конфигурации. Для этого используют OpenResty либо отдельно собранный динамический модуль ngx_http_lua_module. Эти варианты нельзя считать полностью взаимозаменяемыми: доступные директивы и набор встроенных библиотек зависят от сборки.
Перед расследованием проверьте три вещи:
- версию OpenResty или lua-nginx-module;
- фазу, в которой выполняется обработчик:
rewrite,access,content,header_filterилиbody_filter; - включены ли в сборке нужные возможности, например LuaJIT и поддержка SSL в cosocket API.
Если конфигурация написана для OpenResty, а затем перенесена в обычный Nginx с другим Lua-модулем, одинаковый текст не гарантирует одинаковое поведение.
Lua внутри Nginx — это не отдельный серверный поток. Ошибка в одном обработчике может задержать обработку множества запросов в том же worker-процессе.
Ловушка первая: путаница между фазами
Один и тот же код ведёт себя по-разному в зависимости от точки подключения. В access_by_lua_block удобно принимать решение о доступе, но там не стоит генерировать полноценный ответ, если для этого подходит отдельная content-фаза. В header_filter_by_lua_block можно менять заголовки, но тело ответа уже обрабатывается отдельно.
Частая ошибка — отправить ответ через ngx.say, а затем продолжить выполнение. После завершения ответа нужно явно прекратить текущую обработку или вернуть результат, иначе другой модуль может попытаться записать второй ответ.
access_by_lua_block {
local token = ngx.var.http_authorization
if not token then
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.header.content_type = "text/plain"
ngx.say("authorization required")
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
}
Этот фрагмент показывает принцип, а не полноценную проверку токена: наличие заголовка ещё не доказывает подлинность пользователя. Проверка подписи, сроков действия и отзыва токенов — отдельный слой.
Смежная тема — порядок выполнения rewrite и access-фаз. Если решение зависит от переменной, которую меняет другой модуль, проверьте порядок на минимальном стенде, а не по расположению строк в конфигурации.
Ловушка вторая: блокирующий код внутри worker
В Lua-обработчике нельзя обращаться к обычным файловым операциям, системным командам и синхронным клиентам баз данных так, будто это обычный серверный код. Блокирующий вызов останавливает worker Nginx. Пока он ждёт диск или сеть, этот worker не обслуживает другие запросы.
Для сетевых операций в OpenResty используют cosocket API, например клиентские библиотеки, построенные на ngx.socket.tcp. Но и cosocket не делает запрос быстрым автоматически: таймауты, число соединений и размер ответа нужно ограничивать.
- ставьте короткий connect-, send- и read-timeout, подходящий для конкретной зависимости;
- ограничивайте размер ответа и число повторных попыток;
- не обращайтесь к внешнему сервису на каждый запрос без кеша или локального решения;
- не используйте сетевой вызов в фазах, где cosocket запрещён.
Особенно опасна схема «проверить пользователя в Redis перед каждым запросом». При задержке Redis в 200 мс проблема умножается на число параллельных запросов и соединений, а не остаётся локальной задержкой одного клиента.
Ловушка третья: неправильное хранение состояния
Переменная верхнего уровня в Lua-модуле живёт дольше одного запроса и может быть видна последующим запросам в том же worker. Это удобно для неизменяемой конфигурации, но опасно для данных пользователя или временного результата.
Для состояния конкретного запроса предназначен ngx.ctx. Для небольшого общего кеша между worker-процессами — зона lua_shared_dict. Ни один из этих вариантов не заменяет надёжное внешнее хранилище.
| Хранилище | Где видно | Цена и риск | Подходит для |
|---|---|---|---|
| Локальная переменная модуля | Один worker между запросами | Нет общей согласованности между worker и процессами | Неизменяемых таблиц конфигурации |
ngx.ctx | Текущий запрос | Пропадает после запроса, не подходит для межзапросного кеша | Результата аутентификации и промежуточных данных |
ngx.shared.DICT | Worker-процессы одного Nginx | Ограниченный размер, конкуренция, вытеснение ключей | Небольшого кеша и счётчиков |
| Redis или другая БД | Несколько экземпляров приложения | Сетевая задержка и отдельная точка отказа | Данных, которые должны переживать перезапуск |
http {
lua_shared_dict response_cache 32m;
server {
location / {
content_by_lua_block {
local cache = ngx.shared.response_cache
local cached = cache:get(ngx.var.request_uri)
if cached then
ngx.header.content_type = "text/plain"
ngx.say(cached)
return
end
ngx.say("cache miss")
}
}
}
}
У lua_shared_dict есть конечный размер. При переполнении операции записи могут вытеснять старые ключи, а значение может не поместиться. Обрабатывайте результат set, safe_set и подобных операций, вместо того чтобы считать запись гарантированной.
[1] Worker — отдельный процесс Nginx, который принимает соединения и выполняет обработчики. Shared dict общий для worker-процессов одного экземпляра Nginx, но не для нескольких серверов.
Ловушка четвёртая: чтение тела запроса
Тело запроса не всегда доступно сразу в Lua. Если обработчик проверяет JSON или форму, сначала нужно явно попросить Nginx прочитать тело. После этого данные находятся во временном файле либо в памяти — в зависимости от размера и настроек Nginx.
access_by_lua_block {
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
if #body > 1024 * 1024 then
return ngx.exit(ngx.HTTP_REQUEST_ENTITY_TOO_LARGE)
end
}
Проверка размера после чтения не защищает от уже потраченной памяти или дискового пространства. Лимиты нужно задавать также на уровне Nginx, например через client_max_body_size, а для JSON — проверять тип содержимого и формат разбора.
Не храните тело запроса в ngx.ctx без необходимости. Это увеличивает время жизни большой строки до конца запроса и может поднять потребление памяти при параллельной загрузке.
Ловушка пятая: таймеры и фоновая работа
ngx.timer.at не создаёт отдельный универсальный поток. Таймер выполняется worker-процессом, а значит, тяжёлая функция всё равно конкурирует с обычными запросами. Накопившиеся таймеры могут занять лимиты и начать влиять на основной трафик.
log_by_lua_block {
local ok, err = ngx.timer.at(0, function(premature, request_id)
if premature then
return
end
-- Здесь должна быть короткая неблокирующая операция
ngx.log(ngx.INFO, "request completed: ", request_id)
end, ngx.var.request_id)
if not ok then
ngx.log(ngx.ERR, "timer was not created: ", err)
end
}
В callback проверяйте premature: он означает, что таймер завершили при остановке worker. Для повторяющихся задач учитывайте, что каждый worker может создать свою копию таймера. Планировщик и очередь задач обычно надёжнее, если работа должна выполняться ровно один раз.
Как измерять проблему, а не угадывать
Сравнивайте конфигурацию без Lua и с Lua на одном экземпляре OpenResty, с одинаковым числом worker, размером ответа и профилем запросов. Отдельно измеряйте cache hit и cache miss, быстрый и недоступный внешний сервис, маленькое и большое тело запроса.
- смотрите p50, p95 и p99 времени ответа;
- считайте долю ответов 499, 502 и 504;
- сопоставляйте число активных соединений с временем ожидания внешнего сервиса;
- проверяйте ошибки в error log и рост RSS каждого worker;
- нагружайте стенд тем же количеством worker, что и в проде, иначе результат будет несопоставим.
Если замеров до изменения нет, нельзя честно утверждать, что Lua ускорил систему. Начните с отдельного сценария и зафиксируйте условия теста: версия OpenResty, CPU, память, размер данных и параллельность.
Где подход ломается
Lua в Nginx плохо подходит для длинных бизнес-операций, тяжёлого JSON, массовой агрегации и задач, которым нужна транзакция с несколькими системами. В этих случаях Nginx лучше оставить прокси и точкой простых проверок, а сложную логику передать приложению или очереди.
Отдельно проверяйте перезапуск worker. Локальные таблицы и shared dict не являются долговременным хранилищем. После reload кеш может исчезнуть, а незавершённые таймеры — получить флаг досрочного завершения.
Что делать дальше
Соберите минимальный стенд на той же версии OpenResty, включите отдельные метрики для каждого Lua-обработчика и добавьте таймауты ко всем внешним вызовам. Затем проверьте сценарии cache miss, недоступности зависимости, большого тела запроса и graceful reload. На проде первым делом смотрите p99, 5xx, размер shared dict и задержку внешних cosocket-вызовов.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: lua-nginx-module: официальная документация OpenResty, OpenResty Documentation
Частые вопросы
Можно ли запускать Lua в обычном Nginx без OpenResty?
Почему после добавления Lua выросло время ответа?
Можно ли хранить кеш в таблице Lua?
Почему ngx.req.get_body_data возвращает nil?
Что будет, если таймер не успеет выполниться при reload?
Что делать, если пример не работает в моей версии OpenResty?
Можно ли выполнять тяжёлую обработку в ngx.timer.at?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.