Зачем вообще нужны nginx модули и чем динамический отличается от статического
Архитектурно nginx - это маленькое ядро плюс куча модулей. Даже proxy_pass, gzip, ssl - это модули, просто вкомпилированные в бинарь намертво (статически) на этапе сборки через ./configure --with-... Проблема статической линковки очевидна: чтобы добавить один модуль, надо пересобрать ВЕСЬ nginx и заменить бинарь. На проде это даунтайм и риск.
Динамический модуль - это отдельный .so-файл (shared object), который ядро подгружает на старте директивой load_module. Бинарь nginx остаётся прежним, ты просто кладёшь рядом .so и говоришь его грузить. Это и есть смысл nginx load_module: расширять без пересборки ядра.
Код: Выделить всё
# на самом верху nginx.conf, в main-контексте, ДО блока events
load_module modules/ngx_http_geoip2_module.so;
load_module modules/ngx_http_headers_more_filter_module.so;
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
events {
worker_connections 10240;
}
Где брать .so? Два пути. Первый - готовые пакеты из официального репозитория nginx (пакеты вида nginx-module-geoip2, nginx-module-otel и десятки других) или из репозитория GetPageSpeed, где собрано 100+ модулей под конкретные версии. Это путь по умолчанию: меньше боли, авто-совместимость с пакетным nginx. Второй путь - собрать самому, и вот тут надо понимать механику до винтика.

Сборка стороннего модуля под СВОЮ версию: совместимость как закон
Допустим, готового пакета нет (свежий форк модуля, экзотическая платформа, патч). Тогда модуль собирается так:
Код: Выделить всё
# 1. узнаём ТОЧНУЮ версию и флаги сборки своего nginx
nginx -V
# nginx version: nginx/1.30.0
# built by gcc ...
# configure arguments: --prefix=/etc/nginx --with-http_ssl_module ...
# 2. качаем ИСХОДНИКИ РОВНО ТОЙ ЖЕ версии
wget https://nginx.org/download/nginx-1.30.0.tar.gz
tar xzf nginx-1.30.0.tar.gz
cd nginx-1.30.0
# 3. configure с ТЕМИ ЖЕ аргументами + наш модуль динамически
./configure --with-compat --add-dynamic-module=../ngx_http_geoip2_module
make modules
# 4. готовый .so лежит в objs/
cp objs/ngx_http_geoip2_module.so /etc/nginx/modules/
Грабля версии. Модуль собирается под конкретную версию nginx. Не похожую, а конкретную. Между минорными релизами меняются внутренние структуры (ngx_http_request_t и десятки других), смещения полей, ABI. Если собрать модуль под 1.29.x и подсунуть в 1.30.0, в лучшем случае nginx откажется грузить и упадёт на старте с ошибкой, в худшем - подгрузит и будет рандомно сегфолтиться под нагрузкой, потому что читает структуру по неправильным смещениям. Поэтому шаг nginx -V не опционален, а обязателен.
Грабля флагов. Если ты собирал модуль без --with-compat, а ядро nginx собрано с другим набором define-ов (например, с debug или без него), nginx при загрузке выдаст знаменитое "module is not binary compatible". Флаг --with-compat включает режим бинарной совместимости и сглаживает часть различий в флагах - его ставят почти всегда. Но он не спасает от разницы в версиях, только от разницы в некоторых опциях конфигурации.
Модульный арсенал 2026: что ставить и зачемПрактическое правило: если можешь поставить модуль пакетом под свою версию - ставь пакетом. Самосбор оправдан, только когда пакета нет. И после КАЖДОГО апгрейда nginx все самосборные .so надо пересобирать заново.
Краткий разбор боевых nginx модулей, которые реально используют на шлюзах.
headers-more (ngx_http_headers_more) - кумулятивная правка заголовков. Штатный add_header умеет только добавлять и, что коварно, перестаёт работать в location, если там появился свой add_header (наследование заголовков в nginx неинтуитивное). headers-more даёт more_set_headers (перезаписать), more_clear_headers (удалить), причём работает и на заголовках ответа от апстрима. Классика - убрать палевный Server от бэкенда:
Код: Выделить всё
more_clear_headers 'Server';
more_set_headers 'X-Frame-Options: SAMEORIGIN';
Код: Выделить всё
brotli on;
brotli_comp_level 5;
brotli_types text/css application/javascript application/json image/svg+xml;
brotli_static on; # отдавать предсжатые .br с диска
Код: Выделить всё
geoip2 /etc/nginx/geoip/GeoLite2-Country.mmdb {
$geoip2_country_code source=$remote_addr country iso_code;
}
map $geoip2_country_code $blocked_country {
default 0;
RU 0;
CN 1; # пример: режем по стране
}
server {
if ($blocked_country) { return 403; }
}
ngx_http_substitutions (substitutions_filter) - правка ТЕЛА ответа на лету по регэкспам, замена строк. Спасательный круг, когда бэкенд возвращает захардкоженный http:// вместо https://, или старый домен, а исходники трогать нельзя. Дорого по CPU (буферизует и парсит тело), на горячих путях аккуратно.
ngx_otel_module - официальная OpenTelemetry-трассировка. Важный факт 2026: это OPEN-SOURCE модуль (не только для Plus), доступен с nginx 1.25.3, в -otel docker-образах включён по умолчанию с 1.27.5. Шлёт спаны по OTLP/gRPC, пробрасывает W3C trace context. Накладные ~10-15%, против ~50% у старых сторонних трейсеров. Подробно его разбираем в уроке про наблюдаемость, тут отмечаем как штатный путь.
nginx njs: официальный JavaScript внутри nginx и революция QuickJS
Теперь к njs. Это официальный модуль от команды nginx, дающий писать логику на JavaScript прямо в конфиге. Не Lua, не отдельный язык - подмножество JS. Ставится тоже динамическим модулем (nginx-module-njs), грузится load_module и даёт директивы js_import, js_set, js_content, и аналоги в stream-контексте.
И вот тут - главная новость 2026, которую надо усвоить чётко, иначе будешь писать на мёртвом движке. У njs ДВА движка:
- Классический njs-движок - родной, написан под nginx. Поддерживает ES5 плюс отдельные кусочки ES6. С выходом njs 1.0.0 он официально DEPRECATED. Не выпилен пока, но новые конфиги на нём писать не надо.
- QuickJS - встраиваемый движок Фабриса Беллара, подключён к njs (доступен с njs 0.9.x). Даёт полноценный ES2023: модули, async/await, Proxy, BigInt, асинхронные генераторы. Это рекомендованный путь.
Код: Выделить всё
load_module modules/ngx_http_js_module.so;
js_engine qjs; # выбираем QuickJS вместо классического движка
js_import main from /etc/nginx/njs/main.js;
njs vs Lua/OpenResty. Честное сравнение без фанатизма:
- njs - официальный, поддерживается командой nginx, ставится одним пакетом, никаких форков LuaJIT. Язык знаком всем (JS). Есть Fetch API и WebCrypto прямо из коробки - можно делать HTTP-запросы и криптографию без сторонних библиотек.
- njs - легче по экосистеме, но и менее мощный, чем OpenResty. Нет богатого набора lua-resty-* библиотек, нет таких зрелых паттернов кэша (mlcache) и балансировки (balancer_by_lua).
- Критичное ограничение модели: асинхронность (await, Fetch) работает только в js_content и js_access. В js_set функция обязана быть СИНХРОННОЙ и быстрой - она вызывается при вычислении переменной, заблокировать там воркер нельзя. Это частая ошибка новичков: пытаются сделать async-fetch в js_set и ловят непонятное поведение.
nginx waf: ModSecurity, Coraza и стратегия защиты
Модули - это удобство. WAF (Web Application Firewall) - это безопасность, и тут расстановка сил в 2026 поменялась. WAF на шлюзе ловит инъекции, XSS, path traversal, сканеры - по набору правил, до того как мусор дойдёт до приложения.
nginx modsecurity (ModSecurity-nginx) - исторически главный коннектор. Это динамический модуль (ngx_http_modsecurity_module.so), который подключает движок ModSecurity v3 (libmodsecurity) и грузит правила OWASP Core Rule Set (CRS) - индустриальный набор, закрывающий весь топ OWASP.
Код: Выделить всё
load_module modules/ngx_http_modsecurity_module.so;
server {
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;
# main.conf подключает modsecurity.conf + crs-setup.conf + rules/*.conf
}
Coraza - современная альтернатива, тот самый вектор. WAF на Go, бинарно-совместимый с языком правил SecLang и на 100% совместимый с OWASP CRS v4 - то есть твои правила переезжают почти без изменений. Coraza уже встраивают в шлюзы (ngrok, APISIX, Caddy), а коннектор под nginx активно допиливают силами сообщества. Если строишь WAF с нуля в 2026 - закладывайся на CRS v4 и держи Coraza как стратегический выбор.
Третий путь - внешний WAF: вынести фильтрацию в отдельный слой (Coraza/CRS контейнером перед шлюзом, либо облачный WAF/CDN с защитой). Развязывает жизненный цикл WAF и nginx, проще обновлять правила, но добавляет сетевой хоп.
Что бы ты ни выбрал - три железных правила эксплуатации WAF: (1) сначала режим только-логирование (DetectionOnly), собери ложные срабатывания на реальном трафике, и лишь потом блокировку; (2) настрой paranoia level CRS под себя - максимальная паранойя ловит больше, но и ложит легитимные запросы; (3) WAF не отменяет патчей. Это эшелон, а не замена обновлению nginx и приложения.
Практика: динамический модуль geoip2 плюс njs на QuickJS
Соберём боевой фрагмент: грузим динамический geoip2, восстанавливаем реальный IP, и на njs/QuickJS делаем простой контроллер - отдаёт JSON со страной клиента и режет запрос, если страна в чёрном списке. Это маленький, но настоящий API-эндпоинт без бэкенда.
Код: Выделить всё
# /etc/nginx/njs/geo.js
function info(r) {
var country = r.variables.geoip2_country_code || 'XX';
var blocked = ['CN', 'KP'];
if (blocked.indexOf(country) >= 0) {
r.return(403, JSON.stringify({error: 'blocked', country: country}));
return;
}
r.headersOut['Content-Type'] = 'application/json';
r.return(200, JSON.stringify({ip: r.remoteAddress, country: country}));
}
export default { info };
Код: Выделить всё
# nginx.conf (main)
load_module modules/ngx_http_geoip2_module.so;
load_module modules/ngx_http_js_module.so;
http {
js_engine qjs; # современный движок ES2023
js_import geo from /etc/nginx/njs/geo.js;
geoip2 /etc/nginx/geoip/GeoLite2-Country.mmdb {
$geoip2_country_code source=$remote_addr country iso_code;
}
# доверяем реальному IP только от своего CDN (пример сети)
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 80;
location = /whoami {
js_content geo.info; # async тут разрешён, это js_content
}
}
}
Код: Выделить всё
nginx -t && nginx -s reload
curl -s http://127.0.0.1/whoami
# {"ip":"203.0.113.7","country":"DE"}
Типичные грабли
- "module is not binary compatible" - .so собран под другую версию nginx или без --with-compat. Лечение: nginx -V, скачать исходники РОВНО той версии, пересобрать. После апгрейда nginx - пересобрать все самосборные модули.
- load_module не в том месте - директива работает только в main-контексте (самый верх, до events), не внутри http или server. Иначе "load_module directive is not allowed here".
- Порядок load_module для конфликтующих фильтров (brotli и substitutions над одним телом) - помни про обратный порядок цепочки фильтров.
- njs на мёртвом движке - забыл js_engine qjs, пишешь async/Proxy и получаешь синтаксические ошибки, потому что работает классический ES5-движок. И не забывай обновлять njs - была CVE-2026-8711 в js_fetch.
- async в js_set - не работает по дизайну. Fetch/await только в js_content и js_access. В js_set - синхронно и быстро.
- geoip2 без realip за прокси - геолоцируешь адрес балансировщика, а не клиента. Сначала восстанови IP, потом геолоцируй.
- WAF сразу в блокировку - словишь шквал ложных срабатываний на проде. Сначала DetectionOnly, потом блок.
- Шаг 1. Узнай свою версию: nginx -V. Запиши версию и флаги.
- Шаг 2. Поставь njs пакетом (nginx-module-njs) или собери под свою версию через --add-dynamic-module + make modules. Добавь load_module ngx_http_js_module.so в main.
- Шаг 3. Включи js_engine qjs и проверь, что движок современный: напиши в JS const x = [1,2].at(-1); (ES2022) - на классическом движке упадёт, на QuickJS отработает.
- Шаг 4. Скачай GeoLite2-Country.mmdb (нужен бесплатный аккаунт MaxMind), поставь geoip2-модуль, пропиши блок geoip2 и переменную страны.
- Шаг 5. Собери эндпоинт /whoami из практики. Проверь curl - сначала с локалхоста (страна XX или твоя), потом подставь X-Forwarded-For с публичным IP и убедись, что country меняется.
- Шаг 6. Добавь страну своего тестового IP в blocked внутри geo.js, перезагрузи, убедись что отдаётся 403.
- Шаг 7 (со звёздочкой). Подними рядом контейнер с Coraza+CRS v4 в режиме DetectionOnly, прогони через него /whoami?id=1' OR '1'='1 и посмотри в логе сработавшее правило SQLi.
- Почему .so-модуль, собранный под nginx 1.29.x, нельзя грузить в 1.30.0, и что для этого надо сделать?
- Какой движок njs в 2026 рекомендован, какой deprecated, и какой директивой переключаются?
- Почему async/Fetch нельзя использовать в js_set, и где его использовать можно?
- Чем geoip2 отличается от старого geoip и почему перед ним обязателен модуль realip за CDN?
Расширение nginx - это три инструмента под три задачи. Динамические модули (load_module) добавляют функциональность без пересборки ядра, но требуют железной совместимости версий - собирай под свою или ставь пакетом, пересобирай после апгрейда. njs даёт официальный JavaScript внутри nginx; в 2026 пиши на QuickJS (js_engine qjs, ES2023), классический движок deprecated, и держи модуль свежим из-за CVE в js_fetch. WAF (ModSecurity или стратегически Coraza, оба с OWASP CRS v4) - это эшелон защиты на шлюзе, который запускают сначала в режиме логирования. Дальше - OpenResty и Lua, где те же идеи раскрываются на полную мощность.