1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 2 из 5

njs: JavaScript внутри nginx

Коротко

njs - интерпретатор JavaScript внутри рабочего процесса nginx, а не Node.js рядом с ним. Точки подключения: js_set (вычислить переменную), js_access (пускать ли), js_content (собрать ответ), фильтры тела и заголовков, в stream - js_preread. Исключение в скрипте превращается в 500 и воркер переживает. Бесконтрольный рост памяти - нет: воркера убивает система, и вместе с ним умирают все соединения, которые он обслуживал.

Решаешь, брать ли njs вообще - тебе хватит «Коротко», «Зачем это в работе» и «Что ломается без этого».

Скрипт собирает строку в цикле - сто тысяч раз по десять символов, мегабайт на выходе. Что получит клиент?

/etc/nginx/njs/app.js
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 одному клиенту, а вот опрокинутая плита - это остывший обед у всех.

Механизм: куда подключается скрипт

фаза rewrite фаза доступа содержимое фильтры ответа js_set при чтении js_access js_content js_body_filter всё это - тот же процесс, что держит соседние соединения
Скрипт не сбоку от конвейера, а внутри него

Правило

Скрипт лежит файлом, подключается один раз в http, дальше вызывается там, где нужен. Внутри - язык почти целиком современный, объект запроса r и небольшой свой набор API: ngx.fetch() для исходящих запросов, WebCrypto, разделяемые словари между воркерами. Ни npm, ни файловой системы «как в ноде» нет.

/etc/nginx/nginx.confhttp
js_path   /etc/nginx/njs/;
js_import main from app.js;
js_set $signature main.sign;
/etc/nginx/conf.d/shop.local.confhttp server location /api/
proxy_set_header X-Signature $signature;
proxy_pass http://app:8000;

Функция для js_set просто возвращает строку, и дальше переменная работает как любая другая - в заголовке, в логе, в карте.

Разбор: как разрешить запрос в js_access

Здесь легко потерять вечер, потому что «отказать» и «пропустить» пишутся несимметрично. Отказ - это r.return(код). А разрешение - это ничего: функция просто заканчивается.

/etc/nginx/njs/app.js
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, в лог уезжает строка с именем функции и номером строки, а следующий запрос обслуживается как ни в чём не бывало:

↳  вывод/var/log/nginx/error.log
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, до фазы доступа.

/etc/nginx/conf.d/shop.local.confhttp server
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 - на вопрос о себе отвечает так:

↳  выводjs_content, печатающий njs.version и njs.engine
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.

Теперь сам

Скрипт считает подпись и складывает её в переменную. На стенде всё работает, а под нагрузкой сайт начинает отдавать пустые ответы пачками, причём и на тех адресах, где скрипта нет. Что случилось и куда смотреть?

/etc/nginx/njs/app.js
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 на старом движке, хотя устаревшим объявлен именно он; проверяй свою сборку прежде, чем верить статье.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.