В данной статье хочу поделится опытом работы с NJS, интерпретатора JavaScript для Nginx разрабатываемого в компании Nginx Inc, описав на реальном примере его основные возможности. NJS это подмножество ЯП JavaScript, которое позволяет расширить функциональность Nginx. На вопрос зачем свой интерпретатор??? подробно ответил Дмитрий Волынцев. Если вкратце: NJS это nginx-way, а JavaScript более прогрессивный, «родной» и без GC в отличии от Lua.

A long time ago...

На прошлой работе в наследство мне достался gitlab с некоторым количеством разношерстых CI/CD-пайплайнов с docker-compose, dind и прочими прелестями, которые были переведены на рельсы kaniko. Образы же, которые использовались ранее в CI, переехали в первозданном виде. Работали они исправно до того дня, пока у нашего gitlab не сменился IP и CI превратился в тыкву. Проблема была в том, что в одном из docker-образов, участвовавшего в CI, был git, который по ssh тянул Python-модули. Для ssh нужен приватный ключ и ... он был в образе вместе с known_hosts. И любой CI завершался ошибкой проверки ключа из-за несовпадения реального IP и указанного в known_hosts. Из имеющихся Dockfile-ов был быстро собран новый образ и добавлена опция StrictHostKeyChecking no. Но неприятный привкус остался и появилось желание перенести либы в приватный PyPI-репозиторий. Дополнительным бонусом, после перехода на приватный PyPI, становился более простой пайплайн и нормальное описание requirements.txt

Выбор сделан, Господа!

Мы всё крутим в облаках и Kubernetes и в итоге хотелось получить небольшой сервис который представлял из себя stateless-контейнер с внешнем хранилищем. Ну а так как мы используем S3, то и приоритет был за ним. И по возможности с аутентификацией в gitlab (можно и самому дописать по необходимости).

Беглый поиск дал несколько результатов s3pypi, pypicloud и вариант с «ручным» созданием html-файлов для репы. Последний вариант отпал сам-собой.

s3pypi: Это cli для использования хостинга на S3. Выкладываем файлы, генерим html и заливаем в тот же бакет. Для домашнего использования подойдет.

pypicloud: Казался интересным проектом, но после прочтения доки пришло разочарование. Несмотря на хорошую документацию и возможности расширения под свои задачи, на деле оказался избыточен и сложный в настройке. Поправить код под свои задачи, по тогдашним прикидкам, заняло бы 3-5 дней. Так же сервису необходима БД. Оставили его на случай, если более ничего не найдем.

Более углубленный поиск дал модуль для Nginx, ngx_aws_auth. Результатом его тестирования стал XML отображаемый в браузере, по которому было видно содержимое бакета S3. Последний коммит, на момент поиска, был год назад. Репозиторий выглядел заброшенным.

Обратившись к первоисточнику и прочитав PEP-503 понял, что XML можно конвертировать в HTML налету и отдавать его pip. Ещё немного погуглив по словам Nginx и S3 наткнулся на пример аутентификации в S3 написанный на JS для Nginx. Так я познакомился с NJS.

Взяв за основу этот пример, через час наблюдал в своем браузере тот же XML, что и при использовании модуля ngx_aws_auth, но написано уже все было на JS.

Решение на nginx мне очень нравилось. Во-первых хорошая документация и множество примеров, во-вторых мы получаем все плюшки Nginx по работе с файлами (из коробки), в-третьих любой человек умеющий писать конфиги для Nginx, сможет разобраться что к чему. Так же плюсом для меня является минимализм, по сравнению с Python или Go (если писать с нуля), не говоря уж об nexus.

TL;DR Через 2 дня тестовая версия PyPi уже была использована в CI.

Как это работает?

В Nginx подгружается модуль ngx_http_js_module, включен в официальный docker-образ. Импортируем наш скрипт c помощью директивы js_importв конфигурацию Nginx. Вызов функции осуществляется директивой js_content. Для установки переменных используется директива js_set, которая аргументом принимает только функцию описанную в скрипте. А вот выполнять подзапросы в NJS мы можем только с помощью Nginx, ни каких Вам там XMLHttpRequest. Для этого в конфигурации Nginx должен быть добавлен соответствующий локейшн. А в скрипте должен быть описан подзапрос (subrequest) к этому локейшену. Чтобы иметь возможность обратиться к функции из конфига Nginx, в самом скрипте имя функции необходимо экспортировать export default.

nginx.conf

load_module modules/ngx_http_js_module.so;
http {
  js_import   imported_name  from script.js;

server {
  listen 8080;
  ...
  location = /sub-query {
    internal;

    proxy_pass http://upstream;
  }

  location / {
    js_content imported_name.request;
  }
}

script.js

function request(r) {
  function call_back(resp) {
    // handler's code
    r.return(resp.status, resp.responseBody);
  }

  r.subrequest('/sub-query', { method: r.method }, call_back);
}

export default {request}

При запросе в браузере http://localhost:8080/ мы попадаем в location /в котором директива js_content вызывает функцию request описанную в нашем скрипте script.js. В свою очередь в функции request осуществляется подзапрос к location = /sub-query, с методом (в текущем примере GET) полученным из аргумента (r), неявно передаваемым при вызове этой функции. Обработка ответа подзапроса будет осуществлена в функции call_back.

Пробуем  S3

Чтобы сделать запрос к приватному S3-хранилищу, нам необходимы:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Из используемого http-метода, текущая дата/время, S3_NAME и URI генерируется определенного вида строка, которая подписывается (HMAC_SHA1) с помощью SECRET_KEY. Далее строку, вида AWS $ACCESS_KEY:$HASH, можно использовать в заголовке авторизации. Та же дата/время, что была использована для генерации строки на предыдущем шаге, должна быть добавлена в заголовок X-amz-date. В коде это выглядит так:

nginx.conf

load_module modules/ngx_http_js_module.so;
http {
  js_import   s3      from     s3.js;

  js_set      $s3_datetime     s3.date_now;
  js_set      $s3_auth         s3.s3_sign;

server {
  listen 8080;
  ...
  location ~* /s3-query/(?<s3_path>.*) {
    internal;

    proxy_set_header    X-amz-date     $s3_datetime;
    proxy_set_header    Authorization  $s3_auth;

    proxy_pass          $s3_endpoint/$s3_path;
  }

  location ~ "^/(?<prefix>[\w-]*)[/]?(?<postfix>[\w-\.]*)$" {
    js_content s3.request;
  }
}

s3.js(пример авторизации AWS Sign v2, переведена в статус deprecated)

var crypt = require('crypto');

var s3_bucket = process.env.S3_BUCKET;
var s3_access_key = process.env.S3_ACCESS_KEY;
var s3_secret_key = process.env.S3_SECRET_KEY;
var _datetime = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');

function date_now() {
  return _datetime
}

function s3_sign(r) {
  var s2s = r.method + '\n\n\n\n';

  s2s += `x-amz-date:${date_now()}\n`;
  s2s += '/' + s3_bucket;
  s2s += r.uri.endsWith('/') ? '/' : r.variables.s3_path;

  return `AWS ${s3_access_key}:${crypt.createHmac('sha1', s3_secret_key).update(s2s).digest('base64')}`;
}

function request(r) {
  var v = r.variables;

  function call_back(resp) {
    r.return(resp.status, resp.responseBody);
  }

  var _subrequest_uri = r.uri;
  if (r.uri === '/') {
    // root
    _subrequest_uri = '/?delimiter=/';

  } else if (v.prefix !== '' && v.postfix === '') {
    // directory
    var slash = v.prefix.endsWith('/') ? '' : '/';
    _subrequest_uri = '/?prefix=' + v.prefix + slash;
  }

  r.subrequest(`/s3-query${_subrequest_uri}`, { method: r.method }, call_back);
}

export default {request, s3_sign, date_now}

Немного пояснения про _subrequest_uri: это переменная которая в зависимости от изначального uri формирует запрос к S3. Если нужно получить содержимое «корня», в таком случае необходимо сформировать uri-запрос с указанием разделителя delimiter, который вернет список всех xml-элементов CommonPrefixes, что соответствует директориям (в случае с PyPI, список всех пакетов). Если нужно получить список содержимого в определенной директории (список всех версий пакетов), тогда uri-запрос должен содержать поле prefix с именем директории (пакета) обязательно заканчивающийся на слэш /. В противном случае возможны коллизии при запросе содержимого директории, например. Есть директории aiohttp-request и aiohttp-requests и если в запросе будет указано /?prefix=aiohttp-request, тогда в ответе будет содержимое обеих директорий. Если же на конце будет слэш, /?prefix=aiohttp-request/, то в ответе будет только нужная директория. И если мы запрашиваем файл, то результирующий uri не должен отличать от изначального.

Сохраняем, перезапускаем Nginx. В браузере вводим адрес нашего Nginx, результатом работы запроса будет XML, например:

Список директорий
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>myback-space</Name>
  <Prefix></Prefix>
  <Marker></Marker>
  <MaxKeys>10000</MaxKeys>
  <Delimiter>/</Delimiter>
  <IsTruncated>false</IsTruncated>
  <CommonPrefixes>
    <Prefix>new/</Prefix>
  </CommonPrefixes>
  <CommonPrefixes>
    <Prefix>old/</Prefix>
  </CommonPrefixes>
</ListBucketResult>

Из списка директорий понадобятся только элементы CommonPrefixes.

Добавив, в браузере, к нашему адресу нужную нам директорию, получим ее содержимое так же в виде XML:

Список файлов в директории
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name> myback-space</Name>
  <Prefix>old/</Prefix>
  <Marker></Marker>
  <MaxKeys>10000</MaxKeys>
  <Delimiter></Delimiter>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>old/giphy.mp4</Key>
    <LastModified>2020-08-21T20:27:46.000Z</LastModified>
    <ETag>&#34;00000000000000000000000000000000-1&#34;</ETag>
    <Size>1350084</Size>
    <Owner>
      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>
      <DisplayName></DisplayName>
    </Owner>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>old/hsd-k8s.jpg</Key>
    <LastModified>2020-08-31T16:40:01.000Z</LastModified>
    <ETag>&#34;b2d76df4aeb4493c5456366748218093&#34;</ETag>
    <Size>93183</Size>
    <Owner>
      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>
      <DisplayName></DisplayName>
    </Owner>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
</ListBucketResult>

Из списка файлов возьмем только элементы Key.

Остается полученный XML распарсить и отдать в виде HTML, предварительно заменив заголовок Content-Type на text/html.

function request(r) {
  var v = r.variables;

  function call_back(resp) {
    var body = resp.responseBody;

    if (r.method !== 'PUT' && resp.status < 400 && v.postfix === '') {
      r.headersOut['Content-Type'] = "text/html; charset=utf-8";
      body = toHTML(body);
    }

    r.return(resp.status, body);
  }
  
  var _subrequest_uri = r.uri;
  ...
}

function toHTML(xml_str) {
  var keysMap = {
    'CommonPrefixes': 'Prefix',
    'Contents': 'Key',
  };

  var pattern = `<k>(?<v>.*?)<\/k>`;
  var out = [];

  for(var group_key in keysMap) {
    var reS;
    var reGroup = new RegExp(pattern.replace(/k/g, group_key), 'g');

    while(reS = reGroup.exec(xml_str)) {
      var data = new RegExp(pattern.replace(/k/g, keysMap[group_key]), 'g');
      var reValue = data.exec(reS);
      var a_text = '';

      if (group_key === 'CommonPrefixes') {
        a_text = reValue.groups.v.replace(/\//g, '');
      } else {
        a_text = reValue.groups.v.split('/').slice(-1);
      }

      out.push(`<a href="/${reValue.groups.v}">${a_text}</a>`);
    }
  }

  return '<html><body>\n' + out.join('</br>\n') + '\n</html></body>'
}

Пробуем PyPI

Проверяем, что нигде и ничего не ломается на заведомо рабочих пакетах.

# Создаем для тестов новое окружение
python3 -m venv venv
. ./venv/bin/activate

# Скачиваем рабочие пакеты.
pip download aiohttp

# Загружаем в приватную репу
for wheel in *.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; done

rm -f *.whl

# Устанавливаем из приватной репы
pip install aiohttp -i http://localhost:8080

Повторяем с нашими либами.

# Создаем для тестов новое окружение
python3 -m venv venv
. ./venv/bin/activate

pip install setuptools wheel
python setup.py bdist_wheel
for wheel in dist/*.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; done

pip install our_pkg --extra-index-url http://localhost:8080

В CI, создание и загрузка пакета выглядит так:

pip install setuptools wheel
python setup.py bdist_wheel

curl -sSfT dist/*.whl -u "gitlab-ci-token:${CI_JOB_TOKEN}" "https://pypi.our-domain.com/${CI_PROJECT_NAME}"

Аутентификация

В Gitlab возможно использовать JWT для аутентификации/авторизации внешних сервисов. Воспользовавшись директивой auth_request в Nginx, перенаправим аутентификационные данные в подзапрос содержащий вызов функции в скрипте. В скрипте будет сделан ещё один подзапрос на url Gitlab-а и если аутентификационные данные указаны были верно, то Gitlab вернет код 200 и будет разрешена загрузка/скачивание пакета. Почему не воспользоваться одним подзапросом и сразу не отправить данные в Gitlab? Потому, что придется тогда править файл конфигурации Nginx каждый раз, как у нас будут какие-то изменения в авторизации, а это достаточно муторное занятие. Так же, если в Kubernetes используется политика read-only root filesystem, то это ещё больше добавляет сложностей при подмене nginx.conf через configmap. И становится абсолютно невозможна конфигурация Nginx через configmap при одновременном использовании политик запрещающих подключение томов (pvc) и read-only root filesystem (такое тоже бывает).

Используя промежуточным звеном NJS, мы получаем возможность менять указанные параметры в конфиге nginx с помощью переменных окружения и делать какие-нибудь проверки в скрипте (например, неверно указанного URL).

nginx.conf

location = /auth-provider {
  internal;

  proxy_pass $auth_url;
}

location = /auth {
  internal;

  proxy_set_header Content-Length "";
  proxy_pass_request_body off;
  js_content auth.auth;
}

location ~ "^/(?<prefix>[\w-]*)[/]?(?<postfix>[\w-\.]*)$" {
  auth_request /auth;

  js_content s3.request;
}

s3.js

var env = process.env;
var env_bool = new RegExp(/[Tt]rue|[Yy]es|[Oo]n|[TtYy]|1/);
var auth_disabled  = env_bool.test(env.DISABLE_AUTH);
var gitlab_url = env.AUTH_URL;

function url() {
  return `${gitlab_url}/jwt/auth?service=container_registry`
}

function auth(r) {
  if (auth_disabled) {
    r.return(202, '{"auth": "disabled"}');
    return null
  }

  r.subrequest('/auth-provider',
                {method: 'GET', body: ''},
                function(res) {
                  r.return(res.status, "");
                });
}

export default {auth, url}

Скорее всего назревает вопрос: -А почему бы не использовать готовые модули? Там ведь всё уже сделано! Например,  var AWS = require('aws-sdk') и не надо писать "велосипед" с S3-аутентификацией!

Перейдем к минусам

Для меня, невозможность импортирование внешние JS-модулей, стало неприятной, но ожидаемой особенностью. Описанный в примере выше require('crypto'), это build-in-модули и require работает только для них. Так же нет возможности переиспользовать код из скриптов и приходится копи-пастить его по разным файлам. Надеюсь, что когда-нибудь этот функционал будет реализован.

Так же для текущего проекта в Nginx должно быть отключено сжатие gzip off;

Потому, что нет gzip-модуля в NJS и подключить его невозможно, соотвественно нет возможности работать с сжатыми данными. Правда, не особо это и минус для данного кейса. Текста не много, а передаваемые файлы уже сжатые и дополнительное сжатие им не особо поможет. Так же это не на столько нагруженный или критичный сервис, чтобы заморачиваться с отдачей контента на несколько миллисекунд быстрее.

Отладка скрипта долгая и возможна только через «принты» в error.log. В зависимости от выставленного уровня логирования info, warn или error возможно использовать 3 метода r.log, r.warn, r.error соответственно. Некоторые скрипты пытаюсь отлаживать в Chrome (v8) или консольной тулзе njs, но не все возможно там проверить. При отладке кода, ака функциональное тестирование, history выглядит примерно так:

docker-compose restart nginx
curl localhost:8080/
docker-compose logs --tail 10 nginx

и таких последовательностей может быть сотни.

Написание кода с использование подзапросов и переменных для них, превращается в запутанный клубок. Иногда начинаешь метаться по разным окнам IDE пытаясь разобраться в последовательности действий твоего кода. Это не сложно, но иногда сильно напрягает.

Нет полноценной поддержки ES6.

Может есть и ещё какие-то недостатки, но ни с чем я более не сталкивался. Поделитесь инфой если у Вас есть отрицательный опыт эксплуатации NJS.

Заключение

NJS - легковесный open-source интерпретатор, позволяющий реализовать в Nginx разные сценарии на ЯП JavaScript. При его разработке было уделено большое внимание производительности. Конечно много чего в нём ещё не хватает, но проект развивается силами небольшой команды и они активно добавляют новые фичи и фиксят баги. Я же надеюсь, что когда-нибудь NJS позволит подключать внешние модули, что сделает функционал Nginx практически неограниченным. Но есть NGINX Plus и каких-то фич скорее всего не будет!

Репозиторий с полным кодом к статье

njs-pypi с поддержкой AWS Sign v4

Описание директив модуля ngx_http_js_module

Официальный репозиторий NJS и документация

Примеры использования NJS от Дмитрия Волынцева

njs - родной JavaScript-скриптинг в nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

NJS в production / Выступление Василия Сошникова на HighLoad++ 2019

Подпись и аутентификация REST-запросов в AWS