OpenResty и Lua под нагрузкой: что ломается первым
Lua внутри OpenResty выполняется в рабочих процессах Nginx, поэтому один медленный вызов способен занять worker и поднять задержку для множества запросов. Разбираем узкие места, измерения и границы подхода.

Почему OpenResty начинает тормозить не там, где вы ждёте
В OpenResty один неудачный Lua-вызов может занять рабочий процесс Nginx, а вместе с ним — обработку множества клиентских запросов. Чаще всего первыми ломаются не сами Lua-вычисления, а блокирующий ввод-вывод, общий словарь, таймеры и внешние сервисы.
Ниже — разбор для OpenResty 1.25.3.x и модуля lua-nginx-module. Цифры производительности намеренно не приведены: они зависят от числа worker-процессов, CPU, размера ответов, характера Lua-кода и сетевой топологии. Вместо универсальных «тысяч запросов в секунду» важнее понять, какой показатель снимать именно в вашей системе.
Lua в OpenResty не создаёт отдельный поток для каждого запроса. Если код перестал уступать управление, worker перестал обслуживать остальные запросы.
Как устроено выполнение Lua в OpenResty
OpenResty запускает Lua-код внутри событийной модели Nginx. Запрос проходит фазы Nginx, а директивы вроде access_by_lua_block, content_by_lua_block или log_by_lua_block подключают Lua к конкретной фазе.
Внутри одного worker-процесса запросы обслуживаются кооперативно. Операции OpenResty, которые умеют уступать управление, позволяют worker переключиться на другой запрос. К ним относятся многие операции cosocket и ngx.sleep. Обычный тяжёлый цикл на Lua, синхронный системный вызов или библиотека с блокирующим вводом-выводом такой возможности не дают.
Кооперативная многозадачность* — модель, в которой задача сама должна передать управление другим задачам. Если она этого не делает, планировщик не может принудительно прервать её в безопасной точке.
Что ломается первым
1. CPU уходит в Lua-цикл
Первый опасный сценарий — логика, которая выглядит небольшой на одном запросе, но выполняется для каждого запроса. Разбор большого JSON, сортировка массива, регулярные выражения или обход длинного списка занимают CPU worker-процесса. Пока Lua выполняет обычный код, соседние запросы в этом worker ждут.
Симптомы: растёт p95 и p99, CPU одного или нескольких worker-процессов приближается к пределу, а upstream при этом отвечает быстро. Средняя задержка может оставаться приемлемой и скрывать проблему.
Особенно опасны неограниченные входные данные. Проверка заголовка обычно дёшево обходится, а разбор тела запроса размером в несколько мегабайт уже превращает каждый worker в обработчик тяжёлых задач.
2. Блокирующий ввод-вывод останавливает worker
В Lua-коде нельзя бездумно использовать файловые операции, системные команды или сторонние библиотеки, которые ждут сеть синхронно. В OpenResty это не становится неблокирующим только потому, что вызов написан на Lua.
Запрос к базе, Redis или HTTP-сервису стоит выполнять через поддерживаемые OpenResty cosocket-интерфейсы и с таймаутами. Но и cosocket не отменяет цену ожидания: worker может обслуживать другие запросы, однако соединения, память, очередь и лимиты внешнего сервиса всё равно расходуются.
Главная ошибка — добавить один внешний вызов в фазу авторизации и считать задачу решённой. Если сервис авторизации отвечает за 300 миллисекунд, такой путь становится частью задержки каждого запроса. При отказе сервиса нужно заранее определить: отдавать ошибку, использовать короткий кэш или пропускать запрос по безопасному правилу.
3. Общий словарь превращается в точку конкуренции
lua_shared_dict удобен для небольших кэшей, счётчиков, маркеров блокировки и простых лимитов. Память словаря общая для worker-процессов, поэтому данные не нужно синхронизировать через сеть. Цена удобства — конкуренция за общий ресурс.
Проблемы начинаются, когда каждый запрос часто записывает в один и тот же словарь, хранит там крупные значения или пытается реализовать сложную очередь. Узким местом становится не поиск значения, а частые операции записи и вытеснение элементов из ограниченной зоны памяти.
Не используйте shared dict как полноценную базу. Для него нужны короткоживущие значения, понятный лимит памяти и политика поведения при переполнении. Счётчик запросов и кэш ответа — разные задачи: первый требует аккуратной атомарной операции, второй — контроля размера и срока жизни.
4. Пулы соединений упираются во внешний сервис
Неблокирующий cosocket не создаёт бесконечный запас соединений. При росте параллельности OpenResty может упереться в число файловых дескрипторов, лимиты соединений upstream, размер очереди или rate limit внешнего API.
Если код открывает новое соединение на каждый запрос и не переиспользует его там, где это поддерживает клиентская библиотека, растут TCP-задержки и число активных дескрипторов. Если пул слишком большой, он начинает давить уже не OpenResty, а базу данных или Redis.
Смотрите на три времени отдельно: ожидание свободного соединения, время установления соединения и время ответа upstream. Одно суммарное значение latency не показывает, где именно образовалась очередь.
5. Таймеры незаметно накапливают работу
Таймеры удобны для отложенной задачи, очистки или фоновой синхронизации. Но таймер на каждый входящий запрос — почти всегда сигнал опасной архитектуры. При всплеске трафика количество отложенных задач растёт вместе с количеством запросов.
Если таймеры создают повторные таймеры, забывают проверять состояние после завершения или запускают сетевой вызов без ограничения параллельности, нагрузка продолжает жить после того, как клиент отключился. В итоге основная очередь уже исчезла, а worker всё ещё занят фоновыми задачами.
Как отличить узкое место от случайного всплеска
Проверяйте систему на одном сценарии за раз. Нужны одинаковые версия OpenResty, конфигурация worker-процессов, размер запросов, число соединений и длительность теста. Сравнивайте как минимум p50, p95 и p99, а не только среднее время ответа.
- Снимите базовую линию. Измерьте маршрут без Lua, затем тот же маршрут с Lua-логикой. Зафиксируйте CPU worker-процессов, RSS, число активных соединений, ошибки 499 и 5xx, время ответа upstream. Результат — таблица с одинаковыми условиями теста.
- Добавьте CPU-профилирование. Проверьте, не занимает ли Lua большую часть времени сериализацией, регулярными выражениями, разбором JSON или обходом коллекций. Признак проблемы — рост времени CPU при неизменном времени ответа внешнего сервиса.
- Разделите ожидание сети и вычисления. Отдельно записывайте момент перед вызовом upstream и после него. Так вы увидите, тормозит ли OpenResty или зависимая система. В рабочие логи не добавляйте тела запросов и секреты.
- Проверьте shared dict. Следите за свободной памятью, частотой промахов и вытеснений, количеством записей и распределением ключей. Если все запросы обновляют один ключ, проблема может быть в конкуренции, а не в размере словаря.
- Повторите тест с ограничением внешнего сервиса. Искусственно увеличьте задержку upstream и проверьте таймауты, очередь и поведение при ошибке. Система должна деградировать предсказуемо, а не занимать все worker-процессы.
Для наблюдаемости полезно разделять метрики по фазам и маршрутам. Один общий показатель для всего Nginx скроет, что только endpoint авторизации делает медленнее весь трафик.
Сравнение подходов к тяжёлой логике
| Подход | Что выигрываете | Чем платите | Когда выбирать |
|---|---|---|---|
| Lua внутри OpenResty | Мало сетевых переходов, быстрые проверки рядом с прокси | CPU и ошибки влияют прямо на worker Nginx | Маршрутизация, короткие проверки, небольшие преобразования |
| Внешний сервис | Изоляция ресурсов, отдельное масштабирование | Сетевая задержка, отказ upstream, сложнее трассировка | Тяжёлая бизнес-логика, модели данных, длительные операции |
| Очередь и фоновые worker-ы | Запрос не ждёт завершения долгой задачи | Необходимость повторов, дедупликации и контроля очереди | Уведомления, импорт, отчёты и другие асинхронные операции |
Критерий выбора простой: если действие должно завершиться до ответа клиенту и укладывается в короткий ограниченный бюджет CPU и сети, его можно оставить в OpenResty. Если оно может ждать, повторяться, обрабатывать большие объёмы или обращаться к нескольким системам, выносите его из request path.
Граничные случаи и отказоустойчивость
Нельзя считать Lua-код безопасным только потому, что на обычном трафике он быстрый. Проверьте ситуации, которые меняют профиль нагрузки.
- Большое тело запроса. Ограничьте размер до значения, которое реально может обработать worker, и не разбирайте тело, если решение можно принять по заголовкам.
- Медленный клиент. Долгая отправка запроса удерживает соединение и может задержать фазы, зависящие от тела. Лимиты чтения и таймауты должны соответствовать назначению endpoint.
- Отказ кэша или Redis. Определите, является ли кэш обязательным. Если без него нельзя безопасно продолжать, возвращайте контролируемую ошибку, а не запускайте бесконечные повторы.
- Перезапуск worker. Память Lua и локальное состояние worker исчезают. Всё, что должно переживать перезапуск, храните во внешнем хранилище.
- Деплой новой схемы ключей. Изменение формата значения в shared dict может столкнуть старый и новый код. Используйте версию ключа или заранее согласованный формат.
Инвалидация кэша — отдельная тема; здесь важен только один предел: shared dict не заменяет согласованное хранилище данных и не должен быть единственным источником истины.
Что делать дальше
Начните с одного маршрута, где Lua участвует в критическом пути. Зафиксируйте p95 и p99, CPU worker-процессов, время upstream, число активных соединений и состояние shared dict. Затем вынесите из request path самый тяжёлый или самый ненадёжный вызов и повторите тест на тех же условиях.
После этого проверьте продакшен по трём сигналам: рост хвоста задержки, очередь к внешнему сервису и дисбаланс CPU между worker-процессами. Если меняете OpenResty или lua-nginx-module, сверяйте поведение с документацией именно вашей версии: названия фаз, ограничения API и доступные директивы нельзя переносить между сборками автоматически.
Источники: официальная документация модуля описывает фазы, cosocket, shared dict и таймеры. Перед внедрением сверяйте также конкретную версию OpenResty и параметры сборки.
Сноска. Worker-процесс — отдельный процесс Nginx, который принимает и обрабатывает соединения. В OpenResty Lua-код выполняется внутри такого процесса, а не в отдельном универсальном пуле потоков.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: lua-nginx-module: официальная документация OpenResty, OpenResty documentation
Частые вопросы
Можно ли выполнять HTTP-запросы из Lua в OpenResty?
Почему один тяжёлый Lua-цикл влияет на другие запросы?
Можно ли хранить сессии и данные приложения в lua_shared_dict?
Что будет, если внешний Redis или HTTP-сервис не отвечает?
Как понять, что проблема именно в shared dict?
Что делать, если у меня другая версия OpenResty?
Есть ли смысл выносить всю Lua-логику в отдельный сервис?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.