Gumawa ako ng sarili kong repository ng PyPI na may pahintulot at S3. Sa Nginx

Sa artikulong ito, nais kong ibahagi ang aking karanasan sa NJS, isang JavaScript interpreter para sa Nginx na binuo ng Nginx Inc, na naglalarawan sa mga pangunahing kakayahan nito gamit ang isang tunay na halimbawa. Ang NJS ay isang subset ng JavaScript na nagbibigay-daan sa iyong palawigin ang functionality ng Nginx. Sa tanong bakit sarili mong interpreter??? Si Dmitry Volyntsev ay sumagot nang detalyado. Sa madaling salita: Ang NJS ay nginx-way, at ang JavaScript ay mas progresibo, "katutubo" at walang GC, hindi katulad ni Lua.

Matagal na panahon…

Sa aking huling trabaho, minana ko ang gitlab na may maraming motley CI/CD pipelines na may docker-compose, dind at iba pang mga delight, na inilipat sa kaniko rails. Ang mga imahe na dati nang ginamit sa CI ay inilipat sa kanilang orihinal na anyo. Nagtrabaho sila ng maayos hanggang sa araw na nagbago ang aming gitlab IP at naging kalabasa ang CI. Ang problema ay ang isa sa mga docker na imahe na lumahok sa CI ay may git, na hinila ang mga module ng Python sa pamamagitan ng ssh. Para sa ssh kailangan mo ng isang pribadong key at... ito ay nasa larawan kasama ng mga kilalang_host. At ang anumang CI ay nabigo sa isang pangunahing error sa pag-verify dahil sa isang mismatch sa pagitan ng tunay na IP at ang tinukoy sa known_hosts. Ang isang bagong imahe ay mabilis na binuo mula sa umiiral na Dockfiles at ang opsyon ay idinagdag StrictHostKeyChecking no. Ngunit nanatili ang masamang lasa at nagkaroon ng pagnanais na ilipat ang mga libs sa isang pribadong repositoryo ng PyPI. Ang karagdagang bonus, pagkatapos lumipat sa pribadong PyPI, ay isang mas simpleng pipeline at isang normal na paglalarawan ng requirements.txt

Ang pagpili ay ginawa, mga ginoo!

Pinapatakbo namin ang lahat sa cloud at Kubernetes, at sa huli gusto naming makakuha ng isang maliit na serbisyo na isang stateless container na may external na storage. Well, dahil gumagamit kami ng S3, binigyan ito ng priority. At, kung maaari, na may pagpapatunay sa gitlab (maaari mo itong idagdag sa iyong sarili kung kinakailangan).

Ang isang mabilis na paghahanap ay nagbunga ng ilang mga resulta: s3pypi, pypicloud at isang opsyon na may "manual" na paglikha ng mga html file para sa mga singkamas. Ang huling pagpipilian ay nawala nang mag-isa.

s3pypi: Ito ay isang cli para sa paggamit ng S3 hosting. Ina-upload namin ang mga file, nabuo ang html at ina-upload ito sa parehong bucket. Angkop para sa gamit sa bahay.

pypicloud: Tila isang kawili-wiling proyekto, ngunit pagkatapos basahin ang dokumentasyon ay nabigo ako. Sa kabila ng mahusay na dokumentasyon at kakayahang palawakin upang umangkop sa iyong mga pangangailangan, sa katotohanan ito ay naging kalabisan at mahirap i-configure. Ang pagwawasto sa code upang umangkop sa iyong mga gawain, ayon sa mga pagtatantya noong panahong iyon, ay tumagal ng 3-5 araw. Ang serbisyo ay nangangailangan din ng isang database. Iniwan namin ito kung sakaling wala kaming mahanap na iba.

Ang isang mas malalim na paghahanap ay nagbunga ng isang module para sa Nginx, ngx_aws_auth. Ang resulta ng kanyang pagsubok ay XML na ipinakita sa browser, na nagpakita ng mga nilalaman ng S3 bucket. Ang huling commit sa oras ng paghahanap ay isang taon na ang nakalipas. Ang imbakan ay mukhang inabandona.

Sa pamamagitan ng pagpunta sa pinagmulan at pagbabasa PEP-503 Napagtanto ko na ang XML ay maaaring ma-convert sa HTML sa mabilisang at ibigay sa pip. Pagkatapos mag-googling ng kaunti pa tungkol sa Nginx at S3, nakita ko ang isang halimbawa ng pagpapatunay sa S3 na nakasulat sa JS para sa Nginx. Ayun nakilala ko si NJS.

Isinasaalang-alang ang halimbawang ito bilang batayan, makalipas ang isang oras nakita ko sa aking browser ang parehong XML gaya noong ginagamit ang module ng ngx_aws_auth, ngunit nakasulat na ang lahat sa JS.

Talagang nagustuhan ko ang solusyon ng nginx. Una, mahusay na dokumentasyon at maraming mga halimbawa, pangalawa, nakukuha namin ang lahat ng mga goodies ng Nginx para sa pagtatrabaho sa mga file (sa labas ng kahon), pangatlo, sinuman na marunong sumulat ng mga config para sa Nginx ay magagawang malaman kung ano. Ang Minimalism ay isang plus din para sa akin, kumpara sa Python o Go (kung isinulat mula sa simula), hindi banggitin ang nexus.

TL;DR Pagkalipas ng 2 araw, nagamit na ang pansubok na bersyon ng PyPi sa CI.

Paano ito gumagana?

Ang module ay na-load sa Nginx ngx_http_js_module, kasama sa opisyal na larawan ng docker. Ini-import namin ang aming script gamit ang direktiba js_importsa pagsasaayos ng Nginx. Ang function ay tinatawag ng isang direktiba js_content. Ang direktiba ay ginagamit upang magtakda ng mga variable js_set, na tumatagal bilang argumento lamang ang function na inilarawan sa script. Ngunit maaari kaming magsagawa ng mga subquery sa NJS gamit lamang ang Nginx, hindi anumang XMLHttpRequest. Upang gawin ito, ang kaukulang lokasyon ay dapat idagdag sa configuration ng Nginx. At dapat ilarawan ng script ang isang subrequest sa lokasyong ito. Upang ma-access ang isang function mula sa Nginx config, ang pangalan ng function ay dapat na i-export sa mismong script 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}

Kapag hiniling sa browser http://localhost:8080/ pumasok kami location /kung saan ang direktiba js_content tumatawag sa isang function request inilarawan sa aming script script.js. Sa turn, sa function request isang subquery ang ginawa sa location = /sub-query, na may isang pamamaraan (sa kasalukuyang halimbawa GET) na nakuha mula sa argumento (r), ipinasa nang tahasan kapag tinawag ang function na ito. Ang tugon sa subrequest ay ipoproseso sa function call_back.

Sinusubukan ang S3

Para humiling sa pribadong storage ng S3, kailangan namin:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Mula sa ginamit na paraan ng http, ang kasalukuyang petsa/oras, S3_NAME at URI, isang partikular na uri ng string ang nabuo, na nilagdaan (HMAC_SHA1) gamit ang SECRET_KEY. Susunod ay isang linya tulad ng AWS $ACCESS_KEY:$HASH, ay maaaring gamitin sa header ng pahintulot. Ang parehong petsa/oras na ginamit upang bumuo ng string sa nakaraang hakbang ay dapat idagdag sa header X-amz-date. Sa code ay ganito ang hitsura:

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(Halimbawa ng awtorisasyon ng AWS Sign v2, binago sa hindi na ginagamit na katayuan)

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}

Isang maliit na paliwanag tungkol sa _subrequest_uri: ito ay isang variable na, depende sa paunang uri, ay bumubuo ng isang kahilingan sa S3. Kung kailangan mong makuha ang mga nilalaman ng "ugat", pagkatapos ay kailangan mong lumikha ng isang kahilingan sa uri na nagpapahiwatig ng delimiter delimiter, na magbabalik ng listahan ng lahat ng CommonPrefixes xml na elemento, na tumutugma sa mga direktoryo (sa kaso ng PyPI, isang listahan ng lahat ng mga pakete). Kung kailangan mong makakuha ng isang listahan ng mga nilalaman sa isang partikular na direktoryo (isang listahan ng lahat ng mga bersyon ng package), ang kahilingan sa uri ay dapat maglaman ng isang patlang ng prefix na may pangalan ng direktoryo (package) na kinakailangang nagtatapos sa isang slash /. Kung hindi, ang mga banggaan ay posible kapag humihiling ng mga nilalaman ng isang direktoryo, halimbawa. Mayroong mga direktoryo na aiohttp-request at aiohttp-request at kung ang kahilingan ay tumutukoy /?prefix=aiohttp-request, pagkatapos ang tugon ay maglalaman ng mga nilalaman ng parehong mga direktoryo. Kung may slash sa dulo, /?prefix=aiohttp-request/, pagkatapos ang tugon ay maglalaman lamang ng kinakailangang direktoryo. At kung humiling kami ng isang file, ang resultang uri ay hindi dapat mag-iba mula sa orihinal.

I-save at i-restart ang Nginx. Sa browser na ipinasok namin ang address ng aming Nginx, ang resulta ng kahilingan ay XML, halimbawa:

Listahan ng mga direktoryo

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

Mula sa listahan ng mga direktoryo kakailanganin mo lamang ang mga elemento CommonPrefixes.

Sa pamamagitan ng pagdaragdag ng direktoryo na kailangan namin sa aming address sa browser, matatanggap din namin ang mga nilalaman nito sa XML form:

Listahan ng mga file sa isang direktoryo

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

Mula sa listahan ng mga file kukuha kami ng mga elemento lamang Key.

Ang natitira na lang ay i-parse ang nagreresultang XML at ipadala ito bilang HTML, na pinalitan muna ang Content-Type header ng 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="/tl/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Sinusubukan ang PyPI

Sinusuri namin na walang masira kahit saan sa mga pakete na kilala na gumagana.

# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ для тСстов Π½ΠΎΠ²ΠΎΠ΅ ΠΎΠΊΡ€ΡƒΠΆΠ΅Π½ΠΈΠ΅
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

Ulitin namin sa aming mga libs.

# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ для тСстов Π½ΠΎΠ²ΠΎΠ΅ ΠΎΠΊΡ€ΡƒΠΆΠ΅Π½ΠΈΠ΅
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

Sa CI, ang paggawa at pag-load ng isang package ay ganito ang hitsura:

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

Pagpapatunay

Sa Gitlab posible na gumamit ng JWT para sa pagpapatunay/pagpapahintulot ng mga panlabas na serbisyo. Gamit ang auth_request directive sa Nginx, ire-redirect namin ang authentication data sa isang subrequest na naglalaman ng function call sa script. Ang script ay gagawa ng isa pang subrequest sa Gitlab url at kung ang data ng pagpapatunay ay tinukoy nang tama, ibabalik ng Gitlab ang code 200 at ang pag-upload/pag-download ng package ay papayagan. Bakit hindi gumamit ng isang subquery at agad na ipadala ang data sa Gitlab? Dahil pagkatapos ay kailangan nating i-edit ang file ng pagsasaayos ng Nginx sa tuwing gagawa tayo ng anumang mga pagbabago sa awtorisasyon, at ito ay isang medyo nakakapagod na gawain. Gayundin, kung gumagamit ang Kubernetes ng read-only na patakaran sa root filesystem, nagdaragdag ito ng mas kumplikado kapag pinapalitan ang nginx.conf sa pamamagitan ng configmap. At nagiging ganap na imposibleng i-configure ang Nginx sa pamamagitan ng configmap habang sabay-sabay na gumagamit ng mga patakarang nagbabawal sa koneksyon ng mga volume (pvc) at read-only na root filesystem (nangyayari rin ito).

Gamit ang intermediate ng NJS, nagkakaroon kami ng pagkakataong baguhin ang mga tinukoy na parameter sa nginx config gamit ang mga variable ng kapaligiran at gumawa ng ilang mga pagsusuri sa script (halimbawa, isang hindi wastong tinukoy na 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}

Malamang na ang tanong ay gumagawa ng serbesa: -Bakit hindi gumamit ng mga yari na module? Nagawa na ang lahat doon! Halimbawa, var AWS = require('aws-sdk') at hindi na kailangang magsulat ng "bike" na may S3 authentication!

Lumipat tayo sa cons

Para sa akin, ang kawalan ng kakayahang mag-import ng mga panlabas na JS module ay naging isang hindi kasiya-siya, ngunit inaasahang tampok. Ang inilarawan sa halimbawa sa itaas ay nangangailangan('crypto') ay mga built-in na module at nangangailangan lamang ng mga gawa para sa kanila. Wala ring paraan upang magamit muli ang code mula sa mga script at kailangan mong kopyahin at i-paste ito sa iba't ibang mga file. Umaasa ako na balang araw ay maipapatupad ang pagpapaandar na ito.

Dapat ding hindi pinagana ang compression para sa kasalukuyang proyekto sa Nginx gzip off;

Dahil walang gzip module sa NJS at imposibleng ikonekta ito; samakatuwid, walang paraan upang gumana sa naka-compress na data. Totoo, hindi talaga ito isang minus para sa kasong ito. Walang maraming teksto, at ang mga nailipat na file ay naka-compress na at ang karagdagang compression ay hindi makakatulong sa kanila nang malaki. Gayundin, hindi ito isang punong-puno o kritikal na serbisyo na kailangan mong mag-abala sa paghahatid ng nilalaman nang ilang millisecond nang mas mabilis.

Ang pag-debug sa script ay tumatagal ng mahabang panahon at posible lamang sa pamamagitan ng "mga print" sa error.log. Depende sa nakatakdang impormasyon sa antas ng pag-log, babala o error, posibleng gumamit ng 3 pamamaraang r.log, r.warn, r.error ayon sa pagkakabanggit. Sinusubukan kong i-debug ang ilang mga script sa Chrome (v8) o ang njs console tool, ngunit hindi lahat ay maaaring suriin doon. Kapag nagde-debug ng code, aka functional testing, ganito ang hitsura ng kasaysayan:

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

at maaaring magkaroon ng daan-daang ganoong pagkakasunod-sunod.

Ang pagsulat ng code gamit ang mga subquery at variable para sa kanila ay nagiging gusot na gusot. Minsan nagsisimula kang magmadali sa iba't ibang mga window ng IDE na sinusubukang malaman ang pagkakasunud-sunod ng mga aksyon ng iyong code. Hindi naman mahirap, pero minsan nakakainis.

Walang ganap na suporta para sa ES6.

Maaaring may iba pang mga pagkukulang, ngunit wala akong nakatagpo na iba. Magbahagi ng impormasyon kung mayroon kang negatibong karanasan sa paggamit ng NJS.

Konklusyon

Ang NJS ay isang magaan na open-source na interpreter na nagbibigay-daan sa iyong ipatupad ang iba't ibang mga script ng JavaScript sa Nginx. Sa panahon ng pag-unlad nito, ang mahusay na pansin ay binabayaran sa pagganap. Siyempre, marami pa ring kulang, ngunit ang proyekto ay binuo ng isang maliit na koponan at sila ay aktibong nagdaragdag ng mga bagong tampok at nag-aayos ng mga bug. Umaasa ako na balang araw ay papayagan ka ng NJS na kumonekta sa mga panlabas na module, na gagawing halos walang limitasyon ang pag-andar ng Nginx. Ngunit mayroong NGINX Plus at malamang na walang mga tampok!

Imbakan na may buong code para sa artikulo

njs-pypi na may suporta sa AWS Sign v4

Paglalarawan ng mga direktiba ng ngx_http_js_module module

Opisyal na imbakan ng NJS ΠΈ ang babasahin

Mga halimbawa ng paggamit ng NJS mula kay Dmitry Volintsev

njs - katutubong JavaScript scripting sa nginx / Talumpati ni Dmitry Volnyev sa Saint HighLoad++ 2019

NJS sa produksyon / Talumpati ni Vasily Soshnikov sa HighLoad++ 2019

Paglagda at Pagpapatunay ng Mga Kahilingan sa REST sa AWS

Pinagmulan: www.habr.com