У дадзеным артыкуле жадаю падзеліцца досведам працы з NJS, інтэрпрэтатара JavaScript для Nginx распрацоўванага ў кампаніі Nginx Inc, апісаўшы на рэальным прыкладзе яго асноўныя магчымасці. NJS гэта падмноства ЯП JavaScript, якое дазваляе пашырыць функцыянальнасць Nginx. На пытанне
Даўным даўно…
На мінулай працы ў спадчыну мне дастаўся 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.
Як гэта працуе?
У 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/(?<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="/be/${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'), гэта
Гэтак жа для бягучага праекту ў 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 і нейкіх фіч хутчэй за ўсё не будзе!
Крыніца: habr.com