# теория · шаг 2 из 5
njs: JavaScript внутри nginx
Коротко
njs - интерпретатор JavaScript внутри рабочего процесса nginx, а не Node.js
рядом с ним. Точки подключения: js_set (вычислить переменную), js_access
(пускать ли), js_content (собрать ответ), фильтры тела и заголовков, в
stream - js_preread. Последствие исключения зависит от обработчика: возможны
500 или пустая переменная с записью в журнале. Если рост памяти приведёт
к убийству воркера системой, оборвутся все его соединения.
Решаешь, брать ли njs вообще - тебе хватит «Коротко», «Зачем это в работе» и «Что ломается без этого».
Скрипт собирает строку в цикле - сто тысяч раз по десять символов, мегабайт на выходе. Что получит клиент?
function loop(r) {
var s = '';
for (var i = 0; i < 100000; i++) { s += '0123456789'; }
r.return(200, 'склейка дала ' + s.length + '\n');
}
В прогоне стенда с ограниченной памятью клиент не получил HTTP-ответа: соединение обрывается без
кода ответа, а в логе nginx появляется строка worker process 29 exited on
signal 9. Воркера убила система за память, и вместе с ним оборвались все
соседние соединения этого процесса.
На что это похоже
Скрипт njs выполняется внутри воркера. Пока он занят синхронными вычислениями, этот процесс не обслуживает другие готовые события. Если процесс завершится, пострадают все его соединения. Отдельный сервис изолировал бы свой процесс, но его отказ тоже затронул бы зависимые запросы.
Механизм: куда подключается скрипт
Правило
Скрипт лежит файлом, подключается один раз в http, дальше вызывается там, где
нужен. Внутри - язык почти целиком современный, объект запроса r и небольшой
свой набор API: ngx.fetch() для исходящих запросов, WebCrypto, разделяемые
словари между воркерами. Ни npm, ни файловой системы «как в ноде» нет.
js_path /etc/nginx/njs/;
js_import main from app.js;
js_set $signature main.sign;
proxy_set_header X-Signature $signature;
proxy_pass http://app:8000;
Функция для js_set просто возвращает строку, и дальше переменная работает как
любая другая - в заголовке, в логе, в карте.
Разбор: как разрешить запрос в js_access
Здесь легко потерять вечер, потому что «отказать» и «пропустить» пишутся
несимметрично. Отказ - это r.return(код). А разрешение - это ничего:
функция просто заканчивается.
function checkAccess(r) {
if (r.headersIn['X-Token'] === 'good') {
return; // пропустить: никаких вызовов
}
r.return(403); // отказать
}
Что происходит при попытке написать это «правильнее» - прогон на стенде:
| в разрешающей ветке | ответ | что в error_log |
|---|---|---|
return; |
200 |
- |
r.done(); |
500 |
TypeError: cannot set done while not filtering |
r.allow(); |
500 |
нет такого метода |
r.return(0); |
обрыв | - |
r.done() относится к фильтрам, и подсказка в логе говорит об этом прямо -
если лог смотреть. Иначе получается ровно то, за что njs ругают: «сделал по
образцу, работает через раз».
Разбор: цена ошибки и цена памяти
Две разные цены, и путать их дорого.
Ошибка обработчика ответа. В стенде обращение к полю у null до отправки
заголовков дало 500,
в лог уезжает строка с именем функции и номером строки, а следующий запрос
обслуживается как ни в чём не бывало:
js exception: TypeError: cannot get property "nothing" of null
at boom (/etc/nginx/njs/app.js:9)
Память стоит всех соединений воркера. Замер: открыто соединение A, по нему успешно прошёл запрос; затем по соединению B запускается тот самый цикл со склейкой.
| шаг | что получилось |
|---|---|
| запрос по A | HTTP/1.1 200 OK |
| запрос по B (тяжёлый скрипт) | пустой ответ, соединение закрыто |
| повторный запрос по A | пустой ответ, соединение закрыто |
| лог nginx | worker process 30 exited on signal 9 |
Соединение A ни в чём не виновато и умирает вместе с процессом. Попытка
поймать это через try/catch не помогает: 'x'.repeat(1000000000) в блоке
try дал тот же результат - воркер убит, до catch дело не дошло.
Что ломается без этого
return в том же location отменяет js_access. Тот же порядок фаз, что в
тринадцатом разделе: return срабатывает на фазе rewrite, до фазы доступа.
location /guard {
js_access main.checkAccess;
return 200 "прошёл\n";
}
На стенде этот блок отдаёт 200 и с верным токеном, и без него - проверка не
запускается вовсе. Стоит поменять return на отдачу файла или proxy_pass -
и без токена сразу 403. Такую заглушку пишут как раз при отладке скрипта, и
вывод из неё делают неверный.
Движок не тот, который ожидаешь. В 1.0.0 старый собственный движок объявлен
устаревшим в пользу QuickJS, и советы «ориентируйся на QuickJS» верны по
намерению. Но пакет из официального репозитория под 1.31.5 -
nginx-module-njs версии 1.31.5+1.0.1 - на вопрос о себе отвечает так:
njs.version=1.0.1 engine=njs worker=0
То есть установленный пакет всё ещё несёт старый движок, и обещания про ловимую ошибку вместо съеденной памяти к нему не относятся - что замер выше и показал. Прежде чем полагаться на поведение из статьи, спроси у своей сборки, на чём она работает.
Модуля может не быть. Проверь состав своей сборки и установленные пакеты.
Если njs поставляется динамическим модулем, его нужно подключить через
load_module. Действующие строки подключения в файлах на диске можно найти так:
nginx -T 2>&1 | grep '^[[:space:]]*load_module.*js_module'
Отсутствие строки не доказывает отсутствие пакета: установленный модуль может
быть не подключён. nginx -V показывает параметры сборки, но не перечисляет
все установленные динамические модули. nginx -T читает файлы с диска; работающий
процесс может использовать прежний конфиг. На учебном стенде проверь конфиг с
нужной js-директивой через nginx -t, выполни reload и проверь ответ обработчика.
Зачем это в работе
Честный список короткий. Подписать запрос к чужому API, у которого своя схема
подписи, а приложение трогать нельзя. Разобрать токен и достать из него роль
для маршрутизации. Переписать тело ответа легаси-сервиса, который никто уже не
собирает. Проверить условие доступа, для которого не хватает map и
auth_request.
Главный вопрос перед первой строкой - точно ли это работа nginx. Подпись,
проверка токена и сборка ответа обычно работа приложения; njs оправдан там,
где приложение изменить нельзя или где логике место именно на границе. И
проверять скрипт удобнее не на живом сервере: в дистрибутивах есть отдельная
утилита njs с интерпретатором, в которой файл гоняется без nginx.
Теперь сам
Скрипт считает подпись и складывает её в переменную. На стенде всё работает, а под нагрузкой сайт начинает отдавать пустые ответы пачками, причём и на тех адресах, где скрипта нет. Что случилось и куда смотреть?
function sign(r) {
var s = '';
for (var i = 0; i < r.headersIn['X-Items'].length; i++) {
s += r.headersIn['X-Items'][i] + ':' + i;
}
return s;
}
Ответ: длина строки растёт вместе с длиной чужого заголовка, а склейка в цикле
на старом движке освобождает память только в конце запроса. Достаточно одного
клиента с длинным X-Items, чтобы воркер вырос и был убит по памяти - вместе
со всеми соединениями, которые он держал, отсюда и пустые ответы на посторонних
адресах. Смотреть в error_log на worker process ... exited on signal 9;
лечится ограничением длины входа до цикла и переносом такой работы в
приложение.
Главное
Код njs работает внутри воркера nginx, поэтому проверь его API, точку подключения и потребление ресурсов. Ошибка обработчика может испортить ответ, а исчерпание памяти процесса затронет соседние соединения. Проверяй также порядок фаз: ранний return способен обойти js_access.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий