В тази статия ще споделя опит с NJS, интерпретатора на JavaScript за Nginx, разработван от компанията Nginx Inc, описвайки на реален пример основните му възможности. NJS е подмножество на JavaScript, което позволява разширяване на функционалността на Nginx. На въпроса подробно отговаря Дмитрий Волынцев. В краткост: NJS е nginx-начин, а JavaScript е по-прогресивен, „роден“ и без GC в сравнение с Lua.
Отдавна…
На предишната работа ми остана gitlab с няколко разнообразни CI/CD пайплайна с docker-compose, dind и други атракции, които бяха прехвърлени на канали. Образите, които преди се използваха в CI, остнаха в оригиналния си вид. Те работеха безпроблемно до деня, когато нашият gitlab смени IP адреса и CI се превърна в тиква. Проблемът беше, че в един от docker-образите, участващи в CI, имаше git, който по ssh теглеше Python модули. За ssh е необходим личен ключ и … той беше в образа заедно с known_hosts. И всеки CI приключваше с грешка при проверка на ключа поради несъответствие на реалния IP адрес и указаното в known_hosts. От наличните Dockfile-ове бързо беше сглобен нов образ и добавена опцията StrictHostKeyChecking no. Но неприятният привкус остана и се появи желание да се прехвърлят библиотеките в частен PyPI репозиторий. Допълнителен бонус приключи с преминаването на частен PyPI, който позволяваше по-прост пайплайн и нормално описание на requirements.txt.
Изборът е направен, Господа!
Ние всичко въртим в облаците и Kubernetes и в крайна сметка искахме да получим малък сервиз, който представляваше статeless-контейнер с външно хранилище. А тъй като използваме S3, приоритетът беше на него. И по възможност с аутентификация в gitlab (може и сами да допишете по необходимост).
Бързото търсене даде няколко резултата s3pypi, pypicloud и вариант с „ръчно“ създаване на html-файлове за репозиторий. Последният вариант отпадна сам по себе си.
s3pypi: Това cli за използване на хостинг на S3. Качваме файлове, генерираме html и ги качваме в същия бакет. Подходящо за домашна употреба.
pypicloud: Проектът изглеждаше интересен, но след прочитането на документацията дойде разочарование. Въпреки че документацията е добра и предоставя възможности за разширяване, на практика се оказа излишен и сложен за настройка. Поправянето на кода за нашите нужди, според ранните оценки, би отнело 3-5 дни. Освен това, услугата изисква база данни. Оставихме го за всеки случай, в случай че не намерим друго.
По-дълбокото търсене даде модул за Nginx, ngx_aws_auth. Резултатът от тестването му беше XML, показван в браузъра, от който беше видимо съдържанието на S3 бакета. Последният комит, към момента на търсенето, беше преди година. Репозиторият изглеждаше изоставен.
Обращайки се към първоизточника и прочитайки , разбрах, че 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 образ. Импортирваме нашия скрипт с помощта на директивата 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/(?.*) {
internal;
proxy_set_header X-amz-date $s3_datetime;
proxy_set_header Authorization $s3_auth;
proxy_pass $s3_endpoint/$s3_path;
}
location ~ "^/(?[w-]*)[\/]?(?[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, например:
Списък на директорийте
myback-space
10000
/
false
new/
old/От списъка с директории ще са нужни само елементите CommonPrefixes.
Добавяйки в браузъра нужната ни директория към адреса, ще получим нейното съдържание също в XML формат:
Списък на файловете в директорията
myback-space
old/
10000
false
old/giphy.mp4
2020-08-21T20:27:46.000Z
"00000000000000000000000000000000-1"
1350084
02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
STANDARD
old/hsd-k8s.jpg
2020-08-31T16:40:01.000Z
"b2d76df4aeb4493c5456366748218093"
93183
02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
STANDARDОт списъка с файлове ще вземем само елементите Ключ.
Остава полученият 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 ~ "^/(?[w-]*)[/?]?(?[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') е и 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 и вероятно нито една от функциите няма да бъде налична!
и
/ Выступление Дмитрия Волныева на Saint HighLoad++ 2019
/ Выступление Василия Сошникова на HighLoad++ 2019
Източник: habr.com
