Vytvoril som svoje úložisko PyPI s autorizáciou a S3. Na Nginx

V tomto článku by som sa chcel podeliť o svoje skúsenosti s NJS, interpreterom JavaScriptu pre Nginx, ktorý vyvinula spoločnosť Nginx Inc, a opísať jeho hlavné možnosti na skutočnom príklade. NJS je podmnožina JavaScriptu, ktorá vám umožňuje rozšíriť funkčnosť Nginx. K otázke prečo vlastný tlmočník??? Dmitrij Volyncev odpovedal podrobne. Stručne povedané: NJS je nginx-way a JavaScript je na rozdiel od Lua progresívnejší, „natívny“ a bez GC.

Pred dlhým časom…

Pri mojej poslednej práci som zdedil gitlab s množstvom pestrých CI/CD potrubí s docker-compose, dind a inými lahôdkami, ktoré sa preniesli na kaniko raily. Obrázky, ktoré sa predtým používali v CI, boli presunuté v pôvodnej podobe. Fungovali správne až do dňa, keď sa nám zmenila IP adresa gitlabu a CI sa zmenila na tekvicu. Problém bol v tom, že jeden z obrazov dockerov, ktoré sa podieľali na CI, mal git, ktorý stiahol moduly Pythonu cez ssh. Pre ssh potrebujete súkromný kľúč a... bol na obrázku spolu so známymi_hostiteľmi. A akékoľvek CI zlyhalo s chybou overenia kľúča v dôsledku nesúladu medzi skutočnou IP a IP uvedenou v známych_hostiteľoch. Nový obrázok bol rýchlo zostavený z existujúcich Dockfiles a bola pridaná možnosť StrictHostKeyChecking no. Ale zlý vkus zostal a bola tu túžba presunúť knižnice do súkromného úložiska PyPI. Dodatočným bonusom po prechode na súkromné ​​PyPI bol jednoduchší kanál a normálny popis súboru requirements.txt

Voľba bola urobená, páni!

Všetko prevádzkujeme v cloude a Kubernetes a nakoniec sme chceli získať malú službu, ktorou bol bezstavový kontajner s externým úložiskom. No keďže používame S3, prednosť dostala práve tá. A ak je to možné, s autentifikáciou v gitlabe (v prípade potreby si ju môžete pridať sami).

Rýchle vyhľadávanie prinieslo niekoľko výsledkov: s3pypi, pypicloud a možnosť s „ručným“ vytváraním html súborov pre repy. Posledná možnosť zmizla sama od seba.

s3pypi: Toto je cli pre používanie S3 hostingu. Nahráme súbory, vygenerujeme html a nahráme ho do rovnakého vedra. Vhodné na domáce použitie.

pypicloud: Vyzeralo to ako zaujímavý projekt, ale po prečítaní dokumentácie som bol sklamaný. Napriek dobrej dokumentácii a možnosti rozšírenia podľa vašich potrieb sa v skutočnosti ukázalo, že je nadbytočný a ťažko konfigurovateľný. Oprava kódu, aby vyhovoval vašim úlohám, by podľa vtedajších odhadov trvala 3 až 5 dní. Služba potrebuje aj databázu. Nechali sme to pre prípad, že by sme nič iné nenašli.

Hlbšie vyhľadávanie prinieslo modul pre Nginx, ngx_aws_auth. Výsledkom jeho testovania bolo zobrazenie XML v prehliadači, ktoré zobrazovalo obsah vedra S3. Posledný záväzok v čase pátrania bol pred rokom. Úložisko vyzeralo opustené.

Prejdením k zdroju a prečítaním PEP-503 Uvedomil som si, že XML je možné previesť na HTML za behu a dať do pipu. Keď som si trochu viac vygooglil o Nginx a S3, narazil som na príklad autentifikácie v S3 napísaný v JS pre Nginx. Tak som spoznal NJS.

Keď vezmem tento príklad ako základ, o hodinu neskôr som v prehliadači videl rovnaké XML ako pri použití modulu ngx_aws_auth, ale všetko už bolo napísané v JS.

Veľmi sa mi páčilo riešenie nginx. Po prvé, dobrá dokumentácia a veľa príkladov, po druhé, získame všetky vymoženosti Nginxu na prácu so súbormi (po vybalení), po tretie, každý, kto vie, ako písať konfigurácie pre Nginx, bude schopný zistiť, čo je čo. Minimalizmus je pre mňa tiež plus v porovnaní s Pythonom alebo Go (ak je napísaný od začiatku), nehovoriac o nexuse.

TL;DR Po 2 dňoch bola testovacia verzia PyPi už použitá v CI.

Ako to funguje?

Modul sa načíta do Nginx ngx_http_js_module, ktorý je súčasťou oficiálneho obrázka dockera. Náš skript importujeme pomocou smernice js_importna konfiguráciu Nginx. Funkcia sa volá direktívou js_content. Direktíva sa používa na nastavenie premenných js_set, ktorý berie ako argument iba funkciu opísanú v skripte. Ale môžeme vykonať poddotazy v NJS iba pomocou Nginx, nie pomocou XMLHttpRequest. Aby ste to dosiahli, musíte do konfigurácie Nginx pridať príslušné umiestnenie. A skript musí opísať čiastkovú požiadavku na toto miesto. Ak chcete získať prístup k funkcii z konfigurácie Nginx, názov funkcie musí byť exportovaný v samotnom skripte 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}

Na požiadanie v prehliadači http://localhost:8080/ dostaneme sa do location /v ktorej je smernica js_content volá funkciu request popísané v našom scenári script.js. Na druhej strane vo funkcii request je vytvorený poddotaz location = /sub-query, s metódou (v aktuálnom príklade GET) získanou z argumentu (r), odovzdané implicitne pri volaní tejto funkcie. Odpoveď čiastkovej požiadavky bude spracovaná vo funkcii call_back.

Skúšam S3

Aby sme mohli požiadať o súkromné ​​úložisko S3, potrebujeme:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Z použitej http metódy, aktuálneho dátumu/času, S3_NAME a URI sa vygeneruje určitý typ reťazca, ktorý sa podpíše (HMAC_SHA1) pomocou SECRET_KEY. Ďalej je riadok ako AWS $ACCESS_KEY:$HASH, možno použiť v hlavičke autorizácie. Do hlavičky je potrebné pridať rovnaký dátum/čas, ktorý bol použitý na vygenerovanie reťazca v predchádzajúcom kroku X-amz-date. V kóde to vyzerá 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/(?<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(Príklad autorizácie AWS Sign v2 sa zmenil na zastaraný stav)

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}

Malé vysvetlenie o _subrequest_uri: toto je premenná, ktorá v závislosti od počiatočného uri vytvára požiadavku na S3. Ak potrebujete získať obsah „root“, musíte vytvoriť požiadavku uri s oddeľovačom delimiter, ktorý vráti zoznam všetkých CommonPrefixes xml prvkov, zodpovedajúcich adresárom (v prípade PyPI zoznam všetkých balíkov). Ak potrebujete získať zoznam obsahu v konkrétnom adresári (zoznam všetkých verzií balíka), potom požiadavka uri musí obsahovať pole prefixu s názvom adresára (balíka) nevyhnutne končiaceho lomkou /. V opačnom prípade môže dôjsť ku kolíziám napríklad pri vyžiadaní obsahu adresára. Existujú adresáre aiohttp-request a aiohttp-requests a ak to požiadavka špecifikuje /?prefix=aiohttp-request, potom bude odpoveď obsahovať obsah oboch adresárov. Ak je na konci lomka, /?prefix=aiohttp-request/, potom bude odpoveď obsahovať len požadovaný adresár. A ak požadujeme súbor, potom by sa výsledné uri nemalo líšiť od pôvodného.

Uložte a reštartujte Nginx. V prehliadači zadáme adresu nášho Nginxu, výsledkom požiadavky bude XML, napríklad:

Zoznam adresárov

<?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>

Zo zoznamu adresárov budete potrebovať iba prvky CommonPrefixes.

Pridaním adresára, ktorý potrebujeme na našu adresu v prehliadači, dostaneme aj jeho obsah vo forme XML:

Zoznam súborov v adresári

<?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>&#34;00000000000000000000000000000000-1&#34;</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>&#34;b2d76df4aeb4493c5456366748218093&#34;</ETag>
    <Size>93183</Size>
    <Owner>
      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>
      <DisplayName></DisplayName>
    </Owner>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
</ListBucketResult>

Zo zoznamu súborov vezmeme iba prvky Key.

Zostáva len analyzovať výsledný XML a odoslať ho ako HTML, pričom najprv nahradíte hlavičku Content-Type textom/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="/sk/${reValue.groups.v}">${a_text}</a>`);
    }
  }

  return '<html><body>n' + out.join('</br>n') + 'n</html></body>'
}

Skúšam PyPI

Kontrolujeme, či sa nikde nič nerozbije na balíkoch, o ktorých je známe, že fungujú.

# Создаем для тестов новое окружение
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

Opakujeme s našimi libami.

# Создаем для тестов новое окружение
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

V CI vyzerá vytvorenie a načítanie balíka 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}"

overenie pravosti

V Gitlabe je možné použiť JWT na autentifikáciu/autorizáciu externých služieb. Pomocou direktívy auth_request v Nginx presmerujeme autentifikačné údaje na čiastkovú požiadavku obsahujúcu volanie funkcie v skripte. Skript odošle ďalšiu podpožiadavku na url Gitlabu a ak boli autentifikačné údaje zadané správne, Gitlab vráti kód 200 a povolí sa nahrávanie/sťahovanie balíka. Prečo nepoužiť jeden poddotaz a hneď neposlať dáta do Gitlabu? Pretože potom budeme musieť upraviť konfiguračný súbor Nginx zakaždým, keď vykonáme akékoľvek zmeny v autorizácii, a to je dosť únavná úloha. Ak Kubernetes používa politiku koreňového súborového systému iba na čítanie, pridáva to ešte väčšiu zložitosť pri nahrádzaní nginx.conf cez konfiguračnú mapu. A stáva sa absolútne nemožné konfigurovať Nginx cez konfiguračnú mapu a súčasne používať zásady zakazujúce pripojenie zväzkov (pvc) a koreňového súborového systému iba na čítanie (to sa tiež stáva).

Pomocou medziproduktu NJS dostaneme možnosť zmeniť zadané parametre v konfigurácii nginx pomocou premenných prostredia a vykonať nejaké kontroly v skripte (napríklad nesprávne 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 ~ "^/(?<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}

Najpravdepodobnejšie je otázka varenia: -Prečo nepoužívať hotové moduly? Všetko sa tam už urobilo! Napríklad var AWS = require('aws-sdk') a nie je potrebné písať „bicykel“ s autentifikáciou S3!

Prejdime k mínusom

Nepríjemnou, no očakávanou funkciou sa pre mňa stala nemožnosť importovať externé JS moduly. Popísané vo vyššie uvedenom príklade require('crypto') is vstavané moduly a vyžadujú iba prácu pre nich. Neexistuje ani spôsob, ako znova použiť kód zo skriptov a musíte ho skopírovať a vložiť do rôznych súborov. Dúfam, že raz bude táto funkcia implementovaná.

Kompresia musí byť zakázaná aj pre aktuálny projekt v Nginx gzip off;

Pretože v NJS nie je modul gzip a nie je možné ho pripojiť, preto neexistuje spôsob, ako pracovať s komprimovanými údajmi. Pravda, v tomto prípade to naozaj nie je mínus. Textu je málo a prenesené súbory sú už komprimované a dodatočná kompresia im veľmi nepomôže. Tiež to nie je taká nabitá alebo kritická služba, aby ste sa museli obťažovať doručovaním obsahu o niekoľko milisekúnd rýchlejšie.

Ladenie skriptu trvá dlho a je možné ho iba prostredníctvom „tlačí“ v error.log. V závislosti od nastavenej úrovne logovania info, varovania alebo chyby je možné použiť 3 metódy r.log, r.warn, r.error resp. Skúšam odladiť nejaké skripty v Chrome (v8) alebo konzolovom nástroji njs, ale nie všetko sa tam dá skontrolovať. Pri ladení kódu, známeho ako funkčné testovanie, história vyzerá asi takto:

docker-compose restart nginx
curl localhost:8080/
docker-compose logs --tail 10 nginx

a takýchto sekvencií môžu byť stovky.

Písanie kódu pomocou poddotazov a premenných pre ne sa mení na zamotanú spleť. Niekedy sa začnete ponáhľať okolo rôznych okien IDE a snažíte sa zistiť postupnosť akcií vášho kódu. Nie je to ťažké, ale niekedy je to veľmi otravné.

Neexistuje úplná podpora pre ES6.

Môžu tam byť aj iné nedostatky, ale na nič iné som sa nestretol. Zdieľajte informácie, ak máte negatívne skúsenosti s používaním NJS.

Záver

NJS je ľahký interpret s otvoreným zdrojom, ktorý vám umožňuje implementovať rôzne skripty JavaScript v Nginx. Pri jeho vývoji bola veľká pozornosť venovaná výkonu. Samozrejme, stále toho veľa chýba, ale projekt vyvíja malý tím a aktívne pridáva nové funkcie a opravuje chyby. Dúfam, že jedného dňa vám NJS umožní pripojiť externé moduly, vďaka ktorým bude funkčnosť Nginx takmer neobmedzená. Ale je tu NGINX Plus a s najväčšou pravdepodobnosťou nebudú žiadne funkcie!

Úložisko s úplným kódom článku

njs-pypi s podporou AWS Sign v4

Popis direktív modulu ngx_http_js_module

Oficiálne úložisko NJS и dokumentáciu

Príklady použitia NJS od Dmitrija Volintseva

njs - natívne skriptovanie JavaScriptu v nginx / Prejav Dmitrija Volnyeva na Saint HighLoad++ 2019

NJS vo výrobe / Prejav Vasilija Soshnikova na HighLoad++ 2019

Podpisovanie a overovanie požiadaviek REST v AWS

Zdroj: hab.com