V tomto článku se chci podělit o své zkušenosti s NJS, interpretem JavaScriptu pro Nginx vyvíjeným společností Nginx Inc, a na skutečném příkladu popsat jeho hlavní možnosti. NJS je podmnožina programovacího jazyka JavaScript, která umožňuje rozšíření funkčnosti Nginx. Na otázku podrobně odpověděl Dmitrij Volyncev. Ve zkratce: NJS je nginx-way, zatímco JavaScript je progresivnější, 'nativní' a bez GC na rozdíl od Lua.
Kdysi dávno...
Na mé předchozí práci jsem zdědil gitlab s některým množstvím různorodých CI/CD pipeline s docker-compose, dind a dalšími vymoženostmi, které byly převedeny na kaniko. Obrázky, které se dříve používaly v CI, zůstaly ve své původní podobě. Fungovaly správně až do dne, kdy se našemu gitlabu změnila IP adresa a CI se proměnil v dýni. Problém byl v tom, že v jednom z docker obrázků, který se účastnil CI, byl git, který přes ssh stahoval Python moduly. Pro ssh byl potřeba privátní klíč a ... ten byl v obrázku spolu s known_hosts. Jakýkoli CI končil chybou ověření klíče kvůli neshodě mezi skutečnou IP a tím, co bylo uvedeno v known_hosts. Z dostupných Dockfile byl rychle sestaven nový obrázek a přidána volba StrictHostKeyChecking no. Ale nepříjemný pocit zůstal a objevila se touha přesunout knihovny do privátního PyPI repozitáře. Dalším bonusem, po přechodu na privátní PyPI, byl jednodušší pipeline a normální popis requirements.txt.
Výběr je učiněn, Pánové!
Všechno točíme v oblacích a Kubernetes a nakonec jsme chtěli získat malou službu, která by představovala stateless kontejner s externím úložištěm. A jelikož používáme S3, priority byly pochopitelně pro něj. A pokud možno s autentizací v gitlabu (může se dodatečně napsat podle potřeby).
Rychlé hledání dalo několik výsledků s3pypi, pypicloud a varianta s 'ručním' vytvářením html souborů pro repozitář. Poslední možnost sama odpadla.
s3pypi: Toto je CLI pro používání hostingu na S3. Vklady soubory, generujeme html a nahráváme do stejného bucketu. Pro domácí použití to bude vyhovovat.
pypicloud: Působilo to jako zajímavý projekt, ale po přečtení dokumentace jsem byl zklamán. Navzdory dobré dokumentaci a možnostem přizpůsobení se ukázal jako přehnaný a obtížný na nastavení. Upravování kódu pro moje potřeby by podle tehdejších odhadů trvalo 3-5 dní. Také služba vyžaduje databázi. Nechal jsem ho na případ, že nic jiného nenajdu.
Hloubkovější vyhledávání vrátilo modul pro Nginx, ngx_aws_auth. Výsledkem jeho testování byl XML zobrazený v prohlížeči, kde bylo vidět obsah S3 bucketu. Poslední commit, v době hledání, byl před rokem. Repozitář vypadal opuštěně.
Obrátil jsem se na primární zdroj a přečetl si , pochopil jsem, že XML lze na místě převést na HTML a předat ho pip. Ještě jsem chvíli googlil s Nginx a S3 a narazil na příklad autentizace v S3 napsaný v JS pro Nginx. Tak jsem se seznámil s NJS.
Vzniklý příklad jsem vzal za základ a za hodinu jsem v prohlížeči pozoroval stejný XML jako při použití modulu ngx_aws_auth, ale už to bylo napsáno v JS.
Řešení na nginx se mi velmi líbilo. Za prvé, dobrá dokumentace a spousta příkladů, za druhé získáváme všechny výhody Nginx pro práci se soubory (out of the box), a za třetí, každý, kdo umí psát konfigurace pro Nginx, se dokáže zorientovat. Dalším plusem pro mě je minimalismus v porovnání s Pythonem nebo Go (pokud bych to psal od nuly), nemluvě o nexus.
TL;DR, po 2 dnech byla testovací verze PyPi již využita v CI.
Jak to funguje?
V Nginx se načítá modul ngx_http_js_module, který je zahrnut v oficiálním docker obrazu. Importujeme náš skript pomocí direktivy js_importdo konfigurace Nginx. Volání funkce se provádí pomocí direktivy js_content. Pro nastavení proměnných se používá direktiva js_set, která jako argument přijímá pouze funkci uvedenou ve skriptu. Ale provádět subdotazy v NJS můžeme pouze pomocí Nginx, žádný XMLHttpRequest. Do konfigurace Nginx musí být přidán odpovídající lokace. A ve skriptu musí být popsán subdotaz (subrequest) na tuto lokaci. Abychom mohli volat funkci z konfigurace Nginx, musí být v samotném skriptu název funkce exportován. 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}Při požadavku v prohlížeči http://localhost:8080/ se dostaneme do location /ve kterém direktiva js_content vyvolává funkci request popisovanou v našem skriptu script.js. Na druhé straně ve funkci request se provádí subdotaz na location = /sub-query, s metodou (v současném příkladu GET) získanou z argumentu (r), automaticky předávaným při volání této funkce. Zpracování odpovědi subdotazu bude provedeno ve funkci call_back.
Zkoušíme S3
Abychom provedli požadavek na soukromé S3 úložiště, potřebujeme:
ACCESS_KEY
SECRET_KEY
S3_BUCKET
Z používané http metody, aktuální datum/čas, S3_NAME a URI se generuje řetězec určitého formátu, který se podepisuje (HMAC_SHA1) pomocí SECRET_KEY. Dále lze v hlavičce autorizace použít řetězec ve formátu AWS $ACCESS_KEY:$HASH, který musí být použit v autorizační hlavičce. To samé datum/čas, které bylo použito pro generaci řetězce v předchozím kroku, musí být přidáno do hlavičky X-amz-date. V kódu to vypadá takto:
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(příklad autorizace AWS Sign v2, převeden do stavu 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}Trochu vysvětlení o _subrequest_uri: je to proměnná, která v závislosti na původním uri vytváří požadavek na S3. Pokud je potřeba získat obsah „kořene“, je nutné vytvořit uri-dotaz s označením oddělovače delimiter, který vrátí seznam všech xml prvků CommonPrefixes, což odpovídá adresářům (v případě PyPI seznam všech balíčků). Pokud je třeba získat seznam obsahu v určitém adresáři (seznam všech verzí balíčků), pak uri-dotaz musí obsahovat pole prefix s názvem adresáře (balíčku), které končí na lomítko /. Jinak mohou nastat kolize při požadavku na obsah adresáře, například. Existují adresáře aiohttp-request a aiohttp-requests a pokud je v dotazu uvedeno /?prefix=aiohttp-request, pak bude odpovědí obsah obou adresářů. Pokud na konci bude lomítko, /?prefix=aiohttp-request/, pak v odpovědi bude pouze požadovaný adresář. A pokud žádáme o soubor, pak výsledný URI nesmí být od původního odlišný.
Uložíme, restartujeme Nginx. V prohlížeči zadáme adresu našeho Nginx, výsledkem žádosti bude XML, například:
Seznam adresářů
myback-space
10000
/
false
new/
old/Ze seznamu adresářů potřebujeme pouze prvky CommonPrefixes.
Přidáním požadovaného adresáře k naší adrese v prohlížeči získáme jeho obsah také ve formátu XML:
Seznam souborů v adresáři
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
STANDARDZe seznamu souborů vybereme pouze prvky Klíč.
Zbývá získaný XML zpracovat a vrátit ve formátu HTML, přičemž je třeba nahradit hlavičku Content-Type na 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>'
}Zkoušíme PyPI
Kontrolujeme, že nikde nic nepadá na prokázaných balíčcích.
# Создаем для тестов новое окружение
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:8080Opakujeme s našimi knihovnami.
# Создаем для тестов новое окружение
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:8080V CI se vytváření a nahrávání balíčku provádí takto:
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}"Autentizace
V Gitlab je možné použít JWT k autentizaci/autorizaci externích služeb. Pomocí direktivy auth_request v Nginx přesměrujeme autentizační údaje na poddotaz obsahující volání funkce ve skriptu. Ve skriptu bude proveden další poddotaz na URL Gitlabu a pokud byly autentizační údaje uvedeny správně, Gitlab vrátí kód 200 a povolí nahrávání/stahování balíčku. Proč nevyužít pouze jeden poddotaz a nevyslat údaje rovnou do Gitlabu? Protože by pak bylo nutné každým způsobem upravit konfigurační soubor Nginx, kdykoli dojde k nějakým změnám v autorizaci, a to je dost zdlouhavá činnost. Také pokud v Kubernetes používáme politiku read-only root filesystem, přidává to další komplikace při nahrazování nginx.conf přes configmap. A konfigurace Nginx prostřednictvím configmap se stává zcela nemožnou, pokud současně používáme politiky zakazující připojení objemů (pvc) a read-only root filesystem (což se také stává).
Použitím meziproduktu NJS získáváme možnost měnit uvedené parametry v konfiguraci nginx pomocí environmentálních proměnných a provádět nějaké kontroly ve skriptu (například, nesprávně zadané 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}Pravděpodobně se objevuje otázka: - A proč nepoužívat hotové moduly? Vždyť je to už všechno hotové! Například var AWS = require(‘aws-sdk’) a není třeba psát „kolo“ s S3-autentizací!
Přejdeme k nevýhodám
Pro mě byla nemožnost importovat externí JS-moduly nepříjemnou, ale očekávanou vlastností. Popsané v příkladu výše require(‘crypto’) je a require funguje pouze pro ně. Také není možné znovu používat kód ze skriptů a musím ho kopírovat mezi různými soubory. Doufám, že se někdy tato funkčnost realizuje.
Pro aktuální projekt v Nginxu musí být zakázáno kompresi. gzip off;
Protože v NJS není modul gzip a není možné ho připojit, není možné pracovat se zkomprimovanými daty. Na druhou stranu to není velký problém pro tento případ. Textu není mnoho a přenášené soubory jsou již zkomprimované, takže další komprese by příliš nepomohla. Také to není tak zatížená nebo kritická služba, abychom se trápili s dodáváním obsahu o několik milisekund rychleji.
Ladění skriptu je časově náročné a možné pouze prostřednictvím „tisků“ do error.log. V závislosti na nastavené úrovni logování info, warn nebo error je možné použít 3 metody r.log, r.warn, r.error. Některé skripty se snažím ladit v Chromu (v8) nebo konzolovém nástroji njs, ale ne všechno je možné tam zkontrolovat. Při ladění kódu, což je vlastně funkční testování, vypadá historie přibližně takto:
docker-compose restart nginx
curl localhost:8080/
docker-compose logs --tail 10 nginxa takových sekvencí může být stovky.
Psát kód s použitím poddotazů a proměnných pro ně se stává zamotaným klubkem. Někdy se začnete mísit mezi různými okny IDE, abyste pochopili sekvenci akcí vašeho kódu. Není to těžké, ale někdy to velmi zatěžuje.
Není plná podpora ES6.
Možná existují i nějaké další nedostatky, ale s ničím jiným jsem se již nesetkal. Podělte se o informace, pokud máte negativní zkušenosti s použitím NJS.
Závěr
NJS je lehký open-source interpreter, který umožňuje realizovat v Nginxu různé scénáře v jazyce JavaScript. Při jeho vývoji byla věnována velká pozornost výkonu. Samozřejmě, že mu ještě mnoho chybí, ale projekt se vyvíjí s podporou malého týmu, který aktivně přidává nové funkce a opravuje chyby. Doufám, že jednou NJS umožní připojovat externí moduly, což učiní funkčnost Nginxu prakticky neomezenou. Ale je tu NGINX Plus a některé funkce pravděpodobně nebudou!
a
/ Выступление Дмитрия Волныева на Saint HighLoad++ 2019
/ Выступление Василия Сошникова на HighLoad++ 2019
Zdroj: habr.com
