У цій статті хочу поділитися досвідом роботи з 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="/uk/${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