# теория · шаг 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');
}
Не 500 и не таймаут. Клиент не получает ничего: соединение обрывается без
кода ответа, а в логе nginx появляется строка worker process 29 exited on
signal 9. Воркера убила система за память, и вместе с ним оборвались все
соседние соединения этого процесса.
На что это похоже
Модуль легко принять за «встроенный Node.js», и отсюда все неприятности. Node - отдельный процесс: он падает сам по себе, его перезапускают, соседи не замечают.
njs - код, вставленный в конвейер между двумя фазами обработки. Ближе аналогия
с общей кухней: повар готовит своё блюдо на той же плите, где стоят чужие
кастрюли. Плохо приготовленное блюдо - это 500 одному клиенту, а вот
опрокинутая плита - это остывший обед у всех.
Механизм: куда подключается скрипт
Правило
Скрипт лежит файлом, подключается один раз в 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
То есть установленный пакет всё ещё несёт старый движок, и обещания про ловимую ошибку вместо съеденной памяти к нему не относятся - что замер выше и показал. Прежде чем полагаться на поведение из статьи, спроси у своей сборки, на чём она работает.
Модуля может не быть вовсе. В образах nginx:* с Docker Hub njs нет; в
официальных пакетах это отдельный пакет; в дистрибутивах бывает по-разному.
Проверяется одной командой, и делать это надо до того, как написан скрипт:
nginx -V 2>&1 | tr ' ' '\n' | grep njs
Зачем это в работе
Честный список короткий. Подписать запрос к чужому 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 - JavaScript в рабочем процессе nginx, а не Node.js: свои API
(ngx.fetch(), WebCrypto, разделяемые словари) и точки подключения js_set,
js_access, js_content, фильтры, в stream - js_preread. В js_access
отказ пишется как r.return(код), а разрешение - как пустой return;
r.done() там даёт TypeError и 500. Исключение стоит один запрос, память -
всех соединений воркера: замер показал signal 9 и оборванное соседнее
соединение. return в том же location отменяет js_access - это фазы из
тринадцатого раздела. Пакет nginx-module-njs под 1.31.5 несёт njs 1.0.1 на
старом движке, хотя устаревшим объявлен именно он; проверяй свою сборку
прежде, чем верить статье.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий