Tein oman PyPI-arkiston valtuutuksen ja S3:n kanssa. Nginxillä

Tässä artikkelissa haluan jakaa kokemukseni NJS:n, Nginx Inc:n kehittämän Nginxin JavaScript-tulkin kanssa, ja kuvailla sen tärkeimpiä ominaisuuksia todellisella esimerkillä. NJS on JavaScriptin osajoukko, jonka avulla voit laajentaa Nginxin toimintoja. Kysymykseen miksi oma tulkkisi??? Dmitri Volyntsev vastasi yksityiskohtaisesti. Lyhyesti: NJS on nginx-suuntainen ja JavaScript on progressiivisempi, "natiivi" ja ilman GC:tä, toisin kuin Lua.

Kauan sitten…

Viime työpaikallani perin gitlabin, jossa oli lukuisia kirjavia CI/CD-piippuja docker-compose-, dind- ja muita herkkuja, jotka siirrettiin kaniko-raileille. Aiemmin CI:ssä käytetyt kuvat siirrettiin alkuperäisessä muodossaan. Ne toimi kunnolla siihen päivään asti, jolloin gitlab-IP-osoite vaihtui ja CI muuttui kurpitsaksi. Ongelmana oli, että yhdessä CI:hen osallistuneista docker-kuvista oli git, joka veti Python-moduuleja ssh:n kautta. Ssh:lle ​​tarvitset yksityisen avaimen ja... se oli kuvassa yhdessä tunnettujen_isäntien kanssa. Ja mikä tahansa CI epäonnistui avaimen vahvistusvirheen vuoksi, koska todellinen IP ja tiedossa_isännät määritetyt IP-osoitteet eivät täsmää. Uusi kuva koottiin nopeasti olemassa olevista Dock-tiedostoista ja vaihtoehto lisättiin StrictHostKeyChecking no. Mutta huono maku säilyi ja haluttiin siirtää libsit yksityiseen PyPI-arkistoon. Lisäbonuksena yksityiseen PyPI:hen vaihtamisen jälkeen oli yksinkertaisempi putkisto ja normaali kuvaus vaatimuksista.txt

Valinta on tehty, herrat!

Suoritamme kaiken pilvissä ja Kubernetesissa, ja lopulta halusimme saada pienen palvelun, joka oli tilaton kontti ulkoisella tallennustilalla. No, koska käytämme S3:a, sille annettiin etusija. Ja jos mahdollista, todennuksella gitlabissa (voit lisätä sen itse tarvittaessa).

Pikahaku tuotti useita tuloksia: s3pypi, pypicloud ja vaihtoehto "manuaalisella" html-tiedostojen luomisella nauriille. Viimeinen vaihtoehto katosi itsestään.

s3pypi: Tämä on Cli S3-isännöinnille. Lataamme tiedostot, luomme html:n ja lataamme sen samaan ämpäriin. Sopii kotikäyttöön.

pypicloud: Se vaikutti mielenkiintoiselta projektilta, mutta dokumentaation luettuani olin pettynyt. Huolimatta hyvästä dokumentaatiosta ja mahdollisuudesta laajentaa tarpeitasi vastaavaksi, se osoittautui todellisuudessa tarpeettomaksi ja vaikeasti konfiguroitavaksi. Koodin korjaaminen tehtäviisi sopivaksi olisi tuolloisten arvioiden mukaan kestänyt 3-5 päivää. Palvelu tarvitsee myös tietokannan. Jätimme sen siltä varalta, että emme löytäneet mitään muuta.

Tarkempi haku tuotti moduulin Nginxille, ngx_aws_auth. Hänen testauksensa tuloksena oli selaimessa näkyvä XML, joka näytti S3-ämpärin sisällön. Edellinen sitoumus etsintähetkellä oli vuosi sitten. Varasto näytti hylätyltä.

Menemällä lähteeseen ja lukemalla PEP-503 Ymmärsin, että XML voidaan muuntaa HTML:ksi lennossa ja antaa pipiin. Googlattuani hieman enemmän Nginxistä ja S3:sta, löysin esimerkin todennusta S3:ssa, joka oli kirjoitettu JS:llä Nginxille. Näin tapasin NJS:n.

Tämän esimerkin perusteella näin tunnin kuluttua selaimessani saman XML:n kuin ngx_aws_auth-moduulia käytettäessä, mutta kaikki oli jo kirjoitettu JS:llä.

Pidin todella nginx-ratkaisusta. Ensinnäkin hyvä dokumentaatio ja monia esimerkkejä, toiseksi, saamme kaikki Nginxin hyödyt tiedostojen kanssa työskentelyyn (pakkauksesta), kolmanneksi jokainen, joka osaa kirjoittaa Nginxin asetuksia, voi selvittää, mikä on mitä. Minimalismi on myös minulle plussaa verrattuna Pythoniin tai Goon (jos kirjoitetaan tyhjästä), puhumattakaan nexusista.

TL;DR Kahden päivän kuluttua PyPi:n testiversio oli jo käytössä CI:ssä.

Miten se toimii?

Moduuli ladataan Nginxiin ngx_http_js_module, sisältyy viralliseen Docker-kuvaan. Tuomme skriptimme käyttämällä direktiiviä js_importNginx-kokoonpanoon. Funktiota kutsutaan käskyllä js_content. Direktiiviä käytetään muuttujien asettamiseen js_set, joka ottaa argumentiksi vain skriptissä kuvatun funktion. Mutta voimme suorittaa alikyselyitä NJS:ssä vain käyttämällä Nginxiä, emme mitään XMLHttpRequestiä. Tätä varten vastaava sijainti on lisättävä Nginx-kokoonpanoon. Ja skriptin on kuvattava alipyyntö tähän paikkaan. Jotta toimintoa voidaan käyttää Nginx-kokoonpanosta, funktion nimi on vietävä itse skriptiin 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}

Selaimessa pyydettäessä http://localhost:8080/ pääsemme sisään location /jossa direktiivi js_content kutsuu funktiota request kuvataan käsikirjoituksessamme script.js. Puolestaan ​​funktiossa request alikysely tehdään location = /sub-query, argumentista saadulla menetelmällä (nykyisessä esimerkissä GET). (r), välitetään implisiittisesti, kun tätä funktiota kutsutaan. Alipyyntövastaus käsitellään funktiossa call_back.

Kokeile S3:a

Jotta voimme tehdä pyynnön yksityiselle S3-tallennustilalle, tarvitsemme:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Käytetystä http-menetelmästä, nykyisestä päivämäärästä/ajasta, S3_NAME ja URI, luodaan tietyntyyppinen merkkijono, joka allekirjoitetaan (HMAC_SHA1) SECRET_KEY:llä. Seuraava on rivi kuten AWS $ACCESS_KEY:$HASH, voidaan käyttää valtuutusotsikossa. Sama päivämäärä/aika, jota käytettiin luomaan merkkijono edellisessä vaiheessa, on lisättävä otsikkoon X-amz-date. Koodissa se näyttää tältä:

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 -valtuutusesimerkki, muutettu vanhentuneeksi)

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}

Vähän selitystä aiheesta _subrequest_uri: tämä on muuttuja, joka alkuperäisestä uri:sta riippuen muodostaa pyynnön S3:lle. Jos haluat saada "juuren" sisällön, sinun on luotava uri-pyyntö, joka osoittaa erottimen delimiter, joka palauttaa luettelon kaikista CommonPrefixes xml -elementeistä, jotka vastaavat hakemistoja (PyPI:n tapauksessa luettelon kaikista paketeista). Jos haluat saada tietyn hakemiston sisältöluettelon (luettelo kaikista pakettiversioista), uri-pyynnössä on oltava etuliitekenttä, jossa hakemiston (paketin) nimi päättyy välttämättä kauttaviivaan /. Muuten törmäykset ovat mahdollisia esimerkiksi hakemiston sisältöä pyydettäessä. On hakemistoja aiohttp-request ja aiohttp-request ja jos pyyntö määrittää /?prefix=aiohttp-request, niin vastaus sisältää molempien hakemistojen sisällön. Jos lopussa on kauttaviiva, /?prefix=aiohttp-request/, vastaus sisältää vain vaaditun hakemiston. Ja jos pyydämme tiedostoa, tuloksena oleva uri ei saa erota alkuperäisestä.

Tallenna ja käynnistä Nginx uudelleen. Kirjoitamme selaimeen Nginx-osoitteemme, pyynnön tulos on XML, esimerkiksi:

Luettelo hakemistoista

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

Hakemistoluettelosta tarvitset vain elementit CommonPrefixes.

Lisäämällä tarvitsemamme hakemiston osoitteeseemme selaimessa, saamme myös sen sisällön XML-muodossa:

Lista tiedostoista hakemistossa

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

Tiedostoluettelosta otamme vain elementit Key.

Jäljelle jää vain jäsentää tuloksena oleva XML ja lähettää se HTML-muodossa, kun Content-Type-otsikko on ensin korvattu tekstillä/html:llä.

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="/fi/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Kokeile PyPI:tä

Tarkistamme, ettei toimivissa olevissa paketeissa mikään hajoa missään.

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

Toistamme libsillämme.

# Создаем для тестов новое окружение
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:ssä paketin luominen ja lataaminen näyttää tältä:

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}"

todennus

Gitlabissa on mahdollista käyttää JWT:tä ulkoisten palveluiden todentamiseen/valtuutukseen. Käyttämällä Nginxin auth_request-komentoa ohjaamme todennustiedot alipyyntöön, joka sisältää funktiokutsun komentosarjassa. Skripti tekee uuden alipyynnön Gitlabin URL-osoitteeseen ja jos todennustiedot on määritetty oikein, Gitlab palauttaa koodin 200 ja paketin lataus/lataus sallitaan. Mikset käytä yhtä alikyselyä ja lähetä tiedot välittömästi Gitlabille? Koska silloin meidän on muokattava Nginx-määritystiedostoa joka kerta, kun teemme muutoksia valtuutukseen, ja tämä on melko työläs tehtävä. Lisäksi, jos Kubernetes käyttää vain luku -tyyppistä juuritiedostojärjestelmäkäytäntöä, tämä lisää entisestään monimutkaisuutta, kun nginx.conf korvataan configmapin kautta. Ja on täysin mahdotonta määrittää Nginx configmapin kautta samalla kun käytetään käytäntöjä, jotka kieltävät asemien (pvc) ja vain luku -oleman juuritiedostojärjestelmän yhdistämisen (tätä myös tapahtuu).

NJS-välituotteen avulla saamme mahdollisuuden muuttaa määritettyjä parametreja nginx-konfiguraatiossa käyttämällä ympäristömuuttujia ja tehdä joitakin tarkistuksia komentosarjassa (esimerkiksi väärin määritetty URL-osoite).

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}

Todennäköisesti kysymys herää: -Miksi et käytä valmiita moduuleja? Siellä on jo kaikki tehty! Esimerkiksi var AWS = request('aws-sdk'), eikä sinun tarvitse kirjoittaa "pyörää" S3-todennusta käyttäen!

Jatketaan haitoihin

Minulle ulkoisten JS-moduulien tuonnin kyvyttömyydestä tuli epämiellyttävä, mutta odotettu ominaisuus. Yllä olevassa esimerkissä kuvattu request('crypto') on sisäänrakennetut moduulit ja vaativat vain töitä heille. Skriptien koodia ei myöskään voi käyttää uudelleen, vaan se on kopioitava ja liitettävä eri tiedostoihin. Toivon, että tämä toiminto otetaan joskus käyttöön.

Nginxin nykyisen projektin pakkaus on myös poistettava käytöstä gzip off;

Koska NJS:ssä ei ole gzip-moduulia ja sen yhdistäminen on mahdotonta; siksi pakatun tiedon kanssa ei voi työskennellä. Totta, tämä ei todellakaan ole miinus tässä tapauksessa. Tekstiä ei ole paljon, ja siirretyt tiedostot on jo pakattu, eikä lisäpakkaus auta niitä paljon. Tämä ei myöskään ole niin ladattu tai kriittinen palvelu, että joutuisit vaivautumaan sisällön toimittamiseen muutaman millisekuntia nopeammin.

Skriptin virheenkorjaus kestää kauan, ja se on mahdollista vain error.login "tulosteilla". Asetetusta lokitason tiedoista, varoituksesta tai virheestä riippuen on mahdollista käyttää 3 menetelmää r.log, r.warn ja r.error. Yritän korjata joitain skriptejä Chromessa (v8) tai njs-konsolityökalussa, mutta kaikkea ei voi tarkistaa sieltä. Virheenkorjauksessa koodia eli toiminnallista testausta historia näyttää suunnilleen tältä:

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

ja tällaisia ​​sarjoja voi olla satoja.

Koodin kirjoittaminen alikyselyillä ja niille muuttuvilla muuttujilla muuttuu sotkuiseksi sotkuksi. Joskus alat kiirehtiä eri IDE-ikkunoissa yrittäessäsi selvittää koodisi toimintojen järjestystä. Se ei ole vaikeaa, mutta joskus se on erittäin ärsyttävää.

ES6:lle ei ole täyttä tukea.

Saattaa olla muitakin puutteita, mutta en ole havainnut mitään muuta. Jaa tietoja, jos sinulla on negatiivisia kokemuksia NJS:n käytöstä.

Johtopäätös

NJS on kevyt avoimen lähdekoodin tulkki, jonka avulla voit toteuttaa erilaisia ​​JavaScript-skriptejä Nginxissä. Sen kehittämisen aikana suorituskykyyn on kiinnitetty paljon huomiota. Paljon toki vielä puuttuu, mutta projektia kehittää pieni tiimi ja he lisäävät aktiivisesti uusia ominaisuuksia ja korjaavat bugeja. Toivon, että jonain päivänä NJS mahdollistaa ulkoisten moduulien liittämisen, mikä tekee Nginxistä lähes rajattoman. Mutta on olemassa NGINX Plus ja todennäköisesti siinä ei ole ominaisuuksia!

Arkisto, jossa on artikkelin täydellinen koodi

njs-pypi AWS Sign v4 -tuella

Kuvaus ngx_http_js_module-moduulin käskyistä

Virallinen NJS-arkisto и dokumentointi

Esimerkkejä NJS:n käytöstä Dmitry Volintsevilta

njs - alkuperäinen JavaScript-komentosarja nginxissä / Dmitri Volnyevin puhe Saint HighLoad++:ssa 2019

NJS tuotannossa / Vasily Soshnikovin puhe HighLoad++ 2019 -tapahtumassa

REST-pyyntöjen allekirjoittaminen ja todentaminen AWS:ssä

Lähde: will.com