В этом уроке разберёмся, что такое openresty, чем он отличается от связки "nginx плюс какой-то скриптинг", почему под капотом лежит особый LuaJIT, как поставить и пощупать resty CLI, и напишем первый content_by_lua. И честно поговорим, когда openresty оправдан, а когда ты просто усложняешь себе жизнь там, где хватило бы ванильного nginx или Angie.
OpenResty что это: не скриптинг, а платформа
Сначала разведём понятия, потому что путаница тут массовая. Есть три разные вещи.
- nginx lua module (ngx_http_lua_module, он же lua-nginx-module) - это C-модуль, который встраивает интерпретатор Lua прямо в рабочие процессы nginx и даёт API ngx.* для работы с запросом. Сам по себе это просто модуль.
- OpenResty - это готовый дистрибутив: ядро nginx плюс набор уже вкомпилированных модулей (lua-nginx-module, stream-lua, ngx_http_headers_more, lua-upstream, set-misc, encrypted-session и другие) плюс свой форк LuaJIT плюс большая коллекция Lua-библиотек lua-resty-*. Это не "nginx с плагином", это самостоятельная серверная платформа.
- lua-resty-* библиотеки - готовые модули на Lua поверх этого API: lua-resty-redis, lua-resty-mysql, lua-resty-http, lua-resty-jwt, lua-resty-lock, lua-resty-mlcache и десятки других. Они и превращают nginx в платформу для бэкенда.
Свежая на 2026 год версия - OpenResty 1.31.1.1 (вышла 5 июня 2026), на ядре nginx 1.31.1. То есть ты получаешь и все современные плюшки nginx (HTTP/2 как отдельная http2 on, HTTP/3, keepalive к бэкенду по умолчанию с proxy_http_version 1.1), и Lua-слой сверху.

Nginx luajit: почему свой форк, а не ванильный
Вот это место, которое многие пропускают, а оно принципиально. OpenResty поставляет не ванильный LuaJIT. Он тащит собственный форк - openresty/luajit2.
Зачем? Ванильный LuaJIT от Mike Pall де-факто заморожен: проект жив как код, но активного развития почти нет, новые фичи и серьёзные фиксы туда не приезжают. А OpenResty гоняет LuaJIT под чудовищной нагрузкой в проде по всему миру и не может ждать. Поэтому luajit2 - это LuaJIT с патчами: исправленные краши, новые байткод-инструкции под нужды ngx_lua, профайлеры (точный memory-профайлер, time-профайлер), расширенные C-API и, главное, по умолчанию включённый GC64-режим.
GC64 - это представление указателей в 64 бита вместо упаковки в NaN-tagged 32-битные значения. Старый LuaJIT исторически держал GC-объекты в нижних 2 ГБ адресного пространства - и на больших дата-плоскостях ты ловил аллокаторные сбои "cannot allocate memory" не из-за нехватки RAM, а из-за исчерпания нижних адресов. GC64 эту мину убирает: heap может расти спокойно. Платишь чуть большим размером объектов и микроскопическим оверхедом, получаешь стабильность под реальной памятью. В openresty/luajit2 GC64 включён по умолчанию - это одна из причин, почему "просто поставить ванильный LuaJIT и lua-nginx-module руками" - плохая идея для прода. Бери дистрибутив целиком, он собран согласованно.
И ещё один кит, без которого ничего не летает - lua-resty-core. Это переписанный на LuaJIT FFI слой API: ngx.* реализован не через медленный Lua C API (который JIT компилировать не умеет, код всегда интерпретируется), а через FFI, который JIT-компилируется. lua-resty-core автозагружается начиная с OpenResty 1.15.8.1 и обязателен для ngx.balancer, ngx.ssl, ngx.semaphore и прочего. Без него ты теряешь главное - скорость.Практическое правило: не собирай ngx_lua вручную против системного LuaJIT, если только ты точно не знаешь зачем. Версии luajit2, lua-nginx-module и lua-resty-core должны быть совместимы между собой - OpenResty гарантирует эту совместимость в одном релизе.
Производительность LuaJIT и ловушка NYI
LuaJIT - это JIT-компилятор: горячие пути твоего Lua-кода транслируются в машинный код и работают почти как C. Именно поэтому openresty не "тормозной скриптовый слой", а пригоден держать десятки тысяч rps на воркер.
Но есть подвох, на котором ломаются новички - NYI, not-yet-implemented. Часть операций LuaJIT компилировать в трассы не умеет. Когда JIT натыкается на такую операцию, он прерывает трассу (trace abort) и сваливается обратно в интерпретатор. Если NYI-операция стоит в горячем цикле, ты теряешь весь профит JIT именно там, где он нужнее всего.
Что в NYI: классические виновники - string.gsub в некоторых режимах, pcall/xpcall кое-где, многое из io.*/os.*, иные конструкции с varargs, разные стандартные функции. Канонический список NYI есть на luajit.org/ext_c_api и в вики проекта. На практике:
- Не вызывай тяжёлые NYI-функции в горячем цикле, который должен быть скомпилирован.
- Профилируй. luajit2 умеет точный time-профайлер; в OpenResty есть resty-cli с флагами под профилирование и SystemTap/bpftrace-тулзы, которые показывают trace aborts.
- Помни: для типичного gateway узкое место чаще сеть и бэкенд, а не Lua. Не оптимизируй вслепую - сначала измерь.
OpenResty установка и resty CLI
Самый честный способ для прода - официальный docker-образ. Эталонный gateway, на который мы опираемся в курсе, собран ровно так:
Код: Выделить всё
FROM openresty/openresty:latest
# конфиги монтируются томами в режиме ro
# healthcheck гоняет валидацию конфига
HEALTHCHECK CMD ["openresty", "-t"]
Для пакетной установки на хост есть официальные репозитории под основные дистрибутивы (openresty.org/en/installation.html) - пакет ставит бинарь в /usr/local/openresty/. Внутри увидишь luajit, nginx, набор Lua-модулей в lualib.
Теперь про resty CLI - это твой быстрый цикл "написал-проверил" без полноценного сервера. resty запускает Lua-скрипт так, будто это командная утилита: под капотом он поднимает headless nginx - без server-блоков, без слушающих сокетов, отключает демонизацию и мастер-процесс, и выполняет твой код в фазе init_worker через ngx.timer. То есть тебе доступен весь ngx.* API (cosocket, таймеры, shared dict), но без необходимости писать конфиг и слушать порт. Идеально для прототипов и тестов библиотек.
Код: Выделить всё
# проверить версию
resty -V
# выполнить строку Lua
resty -e 'print("hello from luajit ", jit.version)'
# выполнить файл
resty test.lua
Первый content_by_lua: разбираем по косточкам
Перейдём от CLI к серверу. Минимальный nginx.conf, который отдаёт ответ из Lua:
Код: Выделить всё
worker_processes auto;
events { worker_connections 1024; }
http {
server {
listen 8080;
location = /hello {
default_type text/plain;
content_by_lua_block {
ngx.say("hello from OpenResty")
ngx.say("uri: ", ngx.var.uri)
ngx.say("remote: ", ngx.var.remote_addr)
}
}
location = /json {
default_type application/json;
content_by_lua_block {
local cjson = require "cjson.safe"
ngx.status = 200
ngx.say(cjson.encode({ ok = true, ts = ngx.now() }))
return ngx.exit(200)
}
}
}
}
- content_by_lua_block - это директива фазы content. Она генерирует тело ответа целиком. В одном location может быть только один content-обработчик - либо content_by_lua, либо proxy_pass, либо root, не вместе. Это терминальная фаза.
- ngx.say / ngx.print - пишут в тело ответа. say добавляет перевод строки, print нет. Они шлют данные клиенту неблокирующе.
- ngx.var.uri, ngx.var.remote_addr - доступ к переменным nginx прямо из Lua. Любая $-переменная конфига видна как ngx.var.имя. Это мост между миром директив и миром кода.
- cjson.safe - сериализация JSON, идёт в комплекте OpenResty, очень быстрая. Вариант .safe не бросает исключение на битых данных, а возвращает nil плюс ошибку - в проде бери именно его.
- ngx.exit(200) - явно завершить обработку с кодом. Хороший тон, чтобы не уехать в следующие фазы случайно.
Ещё нюанс синтаксиса: есть content_by_lua_block { ... } (код прямо в конфиге, удобно для мелочи) и content_by_lua_file /path/app.lua (код в отдельном файле, для нормального проекта - так и надо). Файлы кэшируются и JIT-ятся; для разработки можно lua_code_cache off, чтобы перечитывался без reload, но в проде - строго on, иначе каждый запрос будет перекомпилировать Lua и убьёт перформанс.
Эталонный gateway: OpenResty без единой строчки Lua
Тут будет неожиданный поворот, и он важен для трезвого взгляда. Наш эталонный проект ngx-trace-gateway собран на образе openresty/openresty:latest - но Lua-блоков в нём нет. Вся его логика - сквозная трассировка через цепочки map, структурные JSON-логи на 50+ полей, rate-limit зоны, проксирование, кэш, TLS, security-заголовки - сделана на чистых директивах ванильного nginx. include events.conf, http.conf, четырнадцать globals/* по порядку, sites-enabled/*. healthcheck гоняет openresty -t.
Почему так и почему это правильно? Два повода.
- База на будущее. Проект уже стоит на OpenResty. Это значит, что в любой момент, когда понадобится логика сверх директив - сложная авторизация, динамическая маршрутизация по данным из Redis, агрегация ответов, трансформация тела - её можно добавить через access_by_lua/content_by_lua/balancer_by_lua, ничего не переустанавливая и не меняя образ. Платформа уже под рукой. Цена этого решения близка к нулю: образ openresty по поведению полностью совместим с обычным nginx-конфигом.
- Дисциплина "не тащи Lua без нужды". То, что выражается директивами - делается директивами. map быстрее, проще, безопаснее и понятнее ревьюеру, чем эквивалентный Lua. Трассировку хопов в эталоне делают каскадом map ($http_x_request_id в $trace_request_id, склейка $hop_name:$local_request_id в $current_hop, наращивание цепочки в $request_hops_chain) - и это абсолютно правильно, Lua тут был бы оверинжинирингом.
Типичные грабли
- Блокирующие вызовы в воркере. os.execute, io.popen, синхронный luasql, обычные сетевые библиотеки на LuaSocket - всё это блокирует event-loop и роняет latency всех соседних запросов. Только cosocket и lua-resty-*.
- Глобальные переменные. В Lua переменная без local - глобальная, общая на весь Lua VM воркера. Это утечка состояния между запросами и потенциальная гонка. Всегда local. OpenResty по умолчанию ругается на запись в глобалы в рантайме - не игнорируй это предупреждение.
- Хранение состояния между запросами на уровне модуля. То, что ты положил в переменную модуля (загруженного через require), переживает запрос и шарится. Для разделяемого состояния - lua_shared_dict (общий на воркеры) или резолверы; не складывай туда соединения и запросные данные.
- lua_code_cache off в проде. Удобно в dev, смертельно в prod - каждый запрос перекомпилирует Lua.
- Ванильный LuaJIT руками. Несовместимость версий luajit2/lua-nginx-module/lua-resty-core, отсутствие GC64, "cannot allocate memory" на большом heap. Ставь дистрибутив целиком.
- NYI в горячем цикле. Trace abort убивает JIT там, где он критичен. Профилируй, выноси тяжёлые NYI-функции наружу.
- Lua там, где хватило бы map. Самая частая стратегическая ошибка. Сначала спроси: это не выражается директивами? Часто выражается.
Делается за 10 минут, нужен только Docker.
- Шаг 1. Запусти контейнер и проверь сборку:
В выводе найди версию nginx (ожидаем ядро 1.31.x для openresty 1.31.1.1) и список модулей - убедись, что ngx_http_lua_module присутствует.
Код: Выделить всё
docker run --rm -it openresty/openresty:latest openresty -V - Шаг 2. Проверь, что под капотом именно LuaJIT с GC64, через resty CLI:
Должен увидеть строку LuaJIT 2.1.x, а не "NO JIT".
Код: Выделить всё
docker run --rm -it openresty/openresty:latest \ resty -e 'print(jit and jit.version or "NO JIT"); print(jit.os, jit.arch)' - Шаг 3. Создай файл nginx.conf из урока (location /hello и /json), сохрани в текущую папку.
- Шаг 4. Подними сервер, смонтировав конфиг:
Код: Выделить всё
docker run --rm -p 8080:8080 \ -v "$PWD/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro" \ openresty/openresty:latest - Шаг 5. В другом терминале дёрни оба эндпоинта:
Увидишь свой текст с uri и remote_addr, и валидный JSON с меткой времени из ngx.now().
Код: Выделить всё
curl -s localhost:8080/hello curl -s localhost:8080/json - Шаг 6 (бонус). Добавь в location /hello строку с access_by_lua_block перед content, который пишет в лог через ngx.log(ngx.WARN, "hit hello") - и посмотри, как фазы отрабатывают по очереди в docker-логах.
- Чем openresty отличается от "просто nginx, в который добавили lua-nginx-module"? Назови минимум два компонента, которые даёт дистрибутив сверх голого модуля.
- Почему OpenResty тащит свой форк LuaJIT (luajit2), а не ванильный? Что решает GC64-режим и какую проблему он убирает на больших дата-плоскостях?
- Что такое NYI в LuaJIT и почему NYI-операция в горячем цикле опаснее, чем та же операция в холодном коде, выполняемом раз за запрос?
- Эталонный gateway собран на OpenResty, но без Lua. Объясни, зачем так сделано и в какой ситуации ты добавишь первый *_by_lua-блок.
OpenResty - это не "nginx со скриптами", а полноценная платформа: ядро nginx плюс собственный LuaJIT-форк luajit2 с GC64 и патчами, плюс FFI-слой lua-resty-core, плюс библиотеки lua-resty-*. Она позволяет выполнять настоящую бизнес-логику внутри воркеров nginx на скорости, близкой к C - авторизацию, маршрутизацию, агрегацию, трансформацию, обращения в Redis и БД в момент запроса. resty CLI даёт быстрый цикл прототипирования через headless nginx, а content_by_lua и остальные фазы - точки входа в твой код. Помни два правила: не блокируй воркер и не тащи Lua туда, где справится map. Эталонный gateway держит платформу наготове, но включает Lua точечно - бери этот подход на вооружение. Дальше в курсе мы и начнём писать эти фазы по-настоящему.