من مخزن PyPI خودم را با مجوز و S3 ساختم. در Nginx

در این مقاله می‌خواهم تجربه‌ام را با NJS، یک مفسر جاوا اسکریپت برای Nginx که توسط Nginx Inc توسعه یافته است، به اشتراک بگذارم و قابلیت‌های اصلی آن را با استفاده از یک مثال واقعی شرح دهد. NJS زیرمجموعه ای از جاوا اسکریپت است که به شما امکان می دهد عملکرد Nginx را گسترش دهید. به سوال چرا مترجم خودت؟؟؟ دیمیتری وولینتسف با جزئیات پاسخ داد. به طور خلاصه: NJS nginx-way است و جاوا اسکریپت برخلاف Lua پیشروتر، "بومی" و بدون GC است.

مدت ها پیش…

در آخرین کارم، gitlab را با تعدادی خط لوله CI/CD متنوع با docker-compose، dind و دیگر دلخوشی ها به ارث بردم که به ریل های کانیکو منتقل شدند. تصاویری که قبلاً در CI استفاده می شدند به شکل اصلی خود منتقل شدند. آنها درست کار کردند تا روزی که IP gitlab ما تغییر کرد و CI به کدو تنبل تبدیل شد. مشکل این بود که یکی از تصاویر داکری که در CI شرکت کرد، git داشت که ماژول‌های پایتون را از طریق ssh می‌کشید. برای ssh شما نیاز به کلید خصوصی و ... در تصویر بود به همراه شناخته شده_هاست. و هر CI با یک خطای تأیید کلید به دلیل عدم تطابق بین IP واقعی و IP مشخص شده درknown_hosts ناموفق بود. یک تصویر جدید به سرعت از Dockfiles موجود جمع آوری شد و گزینه اضافه شد StrictHostKeyChecking no. اما طعم بد باقی ماند و تمایل به انتقال libs به یک مخزن خصوصی PyPI وجود داشت. یک امتیاز اضافی، پس از تغییر به PyPI خصوصی، یک خط لوله ساده تر و یک توصیف معمولی از requirements.txt بود.

انتخاب انجام شده است، آقایان!

ما همه چیز را در ابرها و Kubernetes اجرا می کنیم و در نهایت می خواستیم یک سرویس کوچک که یک ظرف بدون حالت با حافظه خارجی بود، دریافت کنیم. خب چون از S3 استفاده می کنیم اولویت با آن بود. و در صورت امکان با احراز هویت در gitlab (در صورت لزوم می توانید خودتان آن را اضافه کنید).

یک جستجوی سریع چندین نتیجه را به همراه داشت: s3pypi، pypicloud و گزینه ای با ایجاد "دستی" فایل های html برای شلغم. آخرین گزینه به خودی خود ناپدید شد.

s3pypi: این یک کلیپ برای استفاده از هاست S3 است. ما فایل ها را آپلود می کنیم، html را تولید می کنیم و در همان سطل آپلود می کنیم. مناسب برای مصارف خانگی.

pypicloud: پروژه جالبی به نظر می رسید، اما پس از خواندن مستندات ناامید شدم. علیرغم مستندات خوب و توانایی گسترش مطابق با نیازهای شما، در واقعیت مشخص شد که پیکربندی آن اضافی و دشوار است. طبق برآوردهای آن زمان، تصحیح کد متناسب با وظایف شما، 3 تا 5 روز طول می کشد. این سرویس به یک پایگاه داده نیز نیاز دارد. اگر چیز دیگری پیدا نکردیم آن را گذاشتیم.

جستجوی عمیق تر، ماژولی برای Nginx، ngx_aws_auth به دست آورد. نتیجه آزمایش او XML نمایش داده شده در مرورگر بود که محتویات سطل S3 را نشان می داد. آخرین کامیت در زمان جستجو یک سال پیش بود. مخزن متروکه به نظر می رسید.

با مراجعه به منبع و مطالعه PEP-503 من متوجه شدم که XML را می توان به سرعت به HTML تبدیل کرد و به pip داد. پس از جستجوی کمی بیشتر در مورد Nginx و S3، با نمونه ای از احراز هویت در S3 مواجه شدم که در JS برای Nginx نوشته شده بود. اینگونه بود که با NJS آشنا شدم.

با در نظر گرفتن این مثال، یک ساعت بعد در مرورگر خود همان XML را دیدم که هنگام استفاده از ماژول ngx_aws_auth بود، اما همه چیز قبلاً در JS نوشته شده بود.

راه حل nginx را خیلی دوست داشتم. اولاً، مستندات خوب و مثال‌های زیاد، ثانیاً، ما تمام خوبی‌های Nginx را برای کار با فایل‌ها دریافت می‌کنیم (خارج از جعبه)، ثالثاً، هرکسی که می‌داند چگونه تنظیمات را برای Nginx بنویسد، می‌تواند بفهمد چه چیزی چیست. مینیمالیسم در مقایسه با Python یا Go (اگر از ابتدا نوشته شده باشد) برای من یک امتیاز مثبت است.

TL;DR پس از 2 روز، نسخه آزمایشی PyPi قبلاً در CI استفاده شده بود.

چگونه کار می کند؟

ماژول در Nginx بارگذاری می شود ngx_http_js_module، در تصویر رسمی docker گنجانده شده است. ما اسکریپت خود را با استفاده از دستورالعمل وارد می کنیم js_importبه پیکربندی Nginx. تابع توسط یک دستورالعمل فراخوانی می شود js_content. این دستورالعمل برای تنظیم متغیرها استفاده می شود js_set، که فقط تابع توضیح داده شده در اسکریپت را به عنوان آرگومان می گیرد. اما ما می‌توانیم پرس و جوهای فرعی را در NJS فقط با استفاده از Nginx اجرا کنیم، نه با استفاده از XMLHttpRequest. برای انجام این کار، مکان مربوطه باید به پیکربندی Nginx اضافه شود. و اسکریپت باید یک درخواست فرعی را برای این مکان توصیف کند. برای اینکه بتوان به یک تابع از پیکربندی 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، نوع خاصی از رشته تولید می‌شود که با استفاده از SECRET_KEY (HMAC_SHA1) امضا می‌شود. بعد یک خط مانند است 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، تغییر به وضعیت منسوخ شده)

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 باید حاوی یک فیلد پیشوندی باشد که نام دایرکتوری (بسته) لزوماً با علامت / ختم می شود. در غیر این صورت، برای مثال، هنگام درخواست محتویات یک فهرست، برخورد ممکن است. دایرکتوری های 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>&#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>

از لیست فایل ها فقط عناصر را می گیریم 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="/fa/${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 از یک خط‌مشی سیستم فایل ریشه فقط خواندنی استفاده می‌کند، هنگام جایگزینی nginx.conf از طریق configmap، پیچیدگی بیشتری می‌افزاید. و پیکربندی Nginx از طریق configmap در حالی که همزمان از سیاست‌های منع اتصال حجم‌ها (pvc) و سیستم فایل روت فقط خواندنی استفاده می‌کند، کاملاً غیرممکن می‌شود (این نیز اتفاق می‌افتد).

با استفاده از NJS intermediate، ما این فرصت را داریم که پارامترهای مشخص شده در پیکربندی 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 خارجی به یک ویژگی ناخوشایند، اما مورد انتظار تبدیل شد. توضیح داده شده در مثال بالا نیازمند ('crypto') است ماژول های داخلی و فقط برای آنها کار می کند. همچنین هیچ راهی برای استفاده مجدد از کدها از اسکریپت ها وجود ندارد و باید آن را در فایل های مختلف کپی و پیست کنید. امیدوارم روزی این قابلیت اجرا شود.

فشرده‌سازی نیز باید برای پروژه فعلی در Nginx غیرفعال باشد gzip off;

زیرا در NJS ماژول gzip وجود ندارد و اتصال آن غیرممکن است؛ بنابراین هیچ راهی برای کار با داده های فشرده وجود ندارد. درست است، این واقعاً منهای این مورد نیست. متن زیادی وجود ندارد و فایل های منتقل شده از قبل فشرده شده اند و فشرده سازی اضافی کمک زیادی به آنها نخواهد کرد. همچنین، این سرویس آنقدر بارگذاری یا حیاتی نیست که مجبور شوید با ارائه محتوا چند میلی ثانیه سریعتر زحمت بکشید.

اشکال زدایی اسکریپت زمان زیادی می برد و فقط از طریق "چاپ" در error.log امکان پذیر است. بسته به اطلاعات سطح گزارش، هشدار یا خطا، می توان به ترتیب از 3 روش r.log، r.warn، r.error استفاده کرد. من سعی می کنم برخی از اسکریپت ها را در کروم (v8) یا ابزار کنسول njs اشکال زدایی کنم، اما همه چیز را نمی توان در آنجا بررسی کرد. هنگام اشکال زدایی کد، با نام آزمایشی عملکردی، تاریخچه چیزی شبیه به این است:

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

و صدها مورد از این قبیل می تواند وجود داشته باشد.

نوشتن کد با استفاده از پرس و جوها و متغیرها برای آنها به یک درهم پیچیده تبدیل می شود. گاهی اوقات شما شروع به هجوم به اطراف پنجره های مختلف IDE می کنید و سعی می کنید دنباله ای از اقدامات کد خود را بفهمید. کار سختی نیست، اما گاهی اوقات بسیار آزاردهنده است.

هیچ پشتیبانی کاملی از ES6 وجود ندارد.

ممکن است کاستی های دیگری نیز وجود داشته باشد، اما من به چیز دیگری برخورد نکرده ام. اگر تجربه منفی در استفاده از NJS دارید، اطلاعات را به اشتراک بگذارید.

نتیجه

NJS یک مفسر متن باز سبک وزن است که به شما امکان می دهد اسکریپت های مختلف جاوا اسکریپت را در Nginx پیاده سازی کنید. در طول توسعه آن، توجه زیادی به عملکرد معطوف شد. البته هنوز چیزهای زیادی از دست رفته است، اما این پروژه توسط یک تیم کوچک در حال توسعه است و آنها فعالانه در حال اضافه کردن ویژگی های جدید و رفع اشکال هستند. امیدوارم روزی NJS به شما امکان اتصال ماژول های خارجی را بدهد که عملکرد Nginx را تقریباً نامحدود می کند. اما NGINX Plus وجود دارد و به احتمال زیاد هیچ ویژگی وجود نخواهد داشت!

مخزن با کد کامل مقاله

njs-pypi با پشتیبانی از AWS Sign v4

شرح دستورالعمل های ماژول ngx_http_js_module

مخزن رسمی NJS и مستندات

نمونه هایی از استفاده از NJS از دیمیتری وولینتسف

njs - برنامه نویسی بومی جاوا اسکریپت در nginx / سخنرانی دیمیتری ولنیف در Saint HighLoad++ 2019

NJS در حال تولید / سخنرانی واسیلی سوشنیکوف در HighLoad++ 2019

امضا و احراز هویت درخواست های REST در AWS

منبع: www.habr.com