В данной статье хочу поделится опытом работы с NJS, интерпретатора JavaScript для Nginx разрабатываемого в компании Nginx Inc, описав на реальном примере его основные возможности. NJS это подмножество ЯП JavaScript, которое позволяет расширить функциональность Nginx. На вопрос
O aso ua leva…
На прошлой работе в наследство мне достался 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. Последний коммит, на момент поиска, был год назад. Репозиторий выглядел заброшенным.
Обратившись к первоисточнику и прочитав
Взяв за основу этот пример, через час наблюдал в своем браузере тот же XML, что и при использовании модуля ngx_aws_auth, но написано уже все было на JS.
Решение на nginx мне очень нравилось. Во-первых хорошая документация и множество примеров, во-вторых мы получаем все плюшки Nginx по работе с файлами (из коробки), в-третьих любой человек умеющий писать конфиги для Nginx, сможет разобраться что к чему. Так же плюсом для меня является минимализм, по сравнению с Python или Go (если писать с нуля), не говоря уж об nexus.
TL;DR Через 2 дня тестовая версия PyPi уже была использована в CI.
E faapefea ona galulue?
В 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
ta'ua se galuega 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 + 'nnnn';
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>"00000000000000000000000000000000-1"</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>"b2d76df4aeb4493c5456366748218093"</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="/sm/${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}"
Fa'amaoni
В 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’), это
Так же для текущего проекта в 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.
iʻuga
NJS — легковесный open-source интерпретатор, позволяющий реализовать в Nginx разные сценарии на ЯП JavaScript. При его разработке было уделено большое внимание производительности. Конечно много чего в нём ещё не хватает, но проект развивается силами небольшой команды и они активно добавляют новые фичи и фиксят баги. Я же надеюсь, что когда-нибудь NJS позволит подключать внешние модули, что сделает функционал Nginx практически неограниченным. Но есть NGINX Plus и каких-то фич скорее всего не будет!
puna: www.habr.com