لقد أنشأت مستودع PyPI الخاص بي بتفويض و S3. على Nginx

أود في هذه المقالة أن أشارك تجربتي في العمل مع NJS، وهو مترجم JavaScript لـ Nginx تم تطويره بواسطة Nginx Inc، واصفًا إمكانياته الرئيسية باستخدام مثال حقيقي. NJS هي مجموعة فرعية من JavaScript تسمح لك بتوسيع وظائف Nginx. للسؤال لماذا مترجمك الخاص؟؟؟ أجاب ديمتري فولينتسيف بالتفصيل. باختصار: NJS هو طريقة nginx، وجافا سكريبت أكثر تقدمية، "أصلية" وبدون GC، على عكس Lua.

منذ وقت طويل…

في وظيفتي الأخيرة، ورثت gitlab مع عدد من خطوط أنابيب CI/CD المتنوعة مع docker-compose وdind وغيرها من المسرات، والتي تم نقلها إلى kaniko Rails. تم نقل الصور التي تم استخدامها سابقًا في CI بشكلها الأصلي. لقد عملوا بشكل صحيح حتى اليوم الذي تغير فيه عنوان IP الخاص بـ gitlab وتحول CI إلى قرع. كانت المشكلة أن إحدى صور عامل الإرساء التي شاركت في CI كانت تحتوي على بوابة، والتي تسحب وحدات بايثون عبر ssh. بالنسبة لـ ssh، تحتاج إلى مفتاح خاص و... كان موجودًا في الصورة معknown_hosts. وفشل أي CI بسبب خطأ في التحقق من المفتاح بسبب عدم التطابق بين عنوان IP الحقيقي والمحدد فيknown_hosts. تم تجميع صورة جديدة بسرعة من ملفات Dockfiles الموجودة وتمت إضافة الخيار StrictHostKeyChecking no. ولكن بقي الذوق السيئ وكانت هناك رغبة في نقل libs إلى مستودع PyPI خاص. كانت المكافأة الإضافية، بعد التحول إلى PyPI الخاص، عبارة عن خط أنابيب أبسط ووصف عادي لملف require.txt

لقد تم الاختيار أيها السادة!

نحن ندير كل شيء في السحابة وKubernetes، وفي النهاية أردنا الحصول على خدمة صغيرة عبارة عن حاوية عديمة الحالة مع وحدة تخزين خارجية. حسنًا، نظرًا لأننا نستخدم S3، فقد أعطيت الأولوية له. وإذا أمكن، مع المصادقة في gitlab (يمكنك إضافتها بنفسك إذا لزم الأمر).

أسفر البحث السريع عن عدة نتائج: s3pypi، وpypicloud، وخيار الإنشاء "اليدوي" لملفات html الخاصة بملفات اللفت. اختفى الخيار الأخير من تلقاء نفسه.

s3pypi: هذا هو cli لاستخدام استضافة S3. نقوم بتحميل الملفات وإنشاء HTML وتحميلها إلى نفس المجموعة. مناسبة للاستخدام المنزلي.

pypicloud: بدا وكأنه مشروع مثير للاهتمام، ولكن بعد قراءة الوثائق شعرت بخيبة أمل. على الرغم من التوثيق الجيد والقدرة على التوسع ليناسب احتياجاتك، فقد تبين في الواقع أنها زائدة عن الحاجة ويصعب تكوينها. كان تصحيح الكود ليناسب مهامك، وفقًا للتقديرات في ذلك الوقت، سيستغرق من 3 إلى 5 أيام. تحتاج الخدمة أيضًا إلى قاعدة بيانات. لقد تركناها في حالة عدم العثور على أي شيء آخر.

أسفر البحث المتعمق عن وحدة Nginx، ngx_aws_auth. وكانت نتيجة اختباره هي عرض XML في المتصفح، والذي أظهر محتويات مجموعة S3. آخر التزام في وقت البحث كان قبل عام. بدا المستودع مهجورًا.

من خلال الذهاب إلى المصدر والقراءة بيب-503 أدركت أنه يمكن تحويل XML إلى HTML بسرعة وإعطاؤه للنقطة. بعد البحث في Google عن المزيد حول Nginx وS3، عثرت على مثال للمصادقة في S3 مكتوب بلغة JS لـ Nginx. هكذا التقيت بـ NJS.

باستخدام هذا المثال كأساس، بعد ساعة رأيت في متصفحي نفس XML كما هو الحال عند استخدام وحدة ngx_aws_auth، ولكن كل شيء مكتوب بالفعل في JS.

لقد أحببت حقًا حل nginx. أولاً، التوثيق الجيد والعديد من الأمثلة، ثانيًا، نحصل على كل مزايا Nginx للعمل مع الملفات (خارج الصندوق)، ثالثًا، أي شخص يعرف كيفية كتابة تكوينات Nginx سيكون قادرًا على معرفة ما هو. تعد البساطة أيضًا ميزة إضافية بالنسبة لي، مقارنةً بـ Python أو Go (إذا كانت مكتوبة من الصفر)، ناهيك عن الترابط.

TL;DR بعد يومين، تم استخدام الإصدار التجريبي من PyPi بالفعل في CI.

كيف يعمل؟

يتم تحميل الوحدة في Nginx ngx_http_js_module، مضمنة في صورة عامل الإرساء الرسمية. نقوم باستيراد البرنامج النصي الخاص بنا باستخدام التوجيه 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، يتم إنشاء نوع معين من السلسلة، والتي يتم توقيعها (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، تم تغييره إلى حالة مهملة)

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، بعد استبدال رأس نوع المحتوى أولاً بنص/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="/ar/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

محاولة بايبي

نحن نتأكد من عدم تعطل أي شيء في أي مكان على الحزم المعروفة بأنها تعمل.

# Создаем для тестов новое окружение
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، نحصل على فرصة تغيير المعلمات المحددة في تكوين 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 الخارجية ميزة مزعجة ولكنها متوقعة. الموضح في المثال أعلاه يتطلب ("التشفير") هو وحدات البناء ولا يتطلب إلا العمل لهم. لا توجد أيضًا طريقة لإعادة استخدام التعليمات البرمجية من البرامج النصية ويجب عليك نسخها ولصقها في ملفات مختلفة. آمل أن يتم تنفيذ هذه الوظيفة يومًا ما.

يجب أيضًا تعطيل الضغط للمشروع الحالي في Nginx gzip off;

نظرًا لعدم وجود وحدة gzip في NJS ومن المستحيل توصيلها، لذلك لا توجد طريقة للعمل مع البيانات المضغوطة. صحيح أن هذا ليس ناقصًا حقًا لهذه الحالة. لا يوجد الكثير من النص، والملفات المنقولة مضغوطة بالفعل ولن يساعدها الضغط الإضافي كثيرًا. كما أن هذه ليست خدمة محملة أو مهمة بحيث يتعين عليك الاهتمام بتقديم المحتوى بشكل أسرع ببضعة أجزاء من الثانية.

يستغرق تصحيح البرنامج النصي وقتًا طويلاً ولا يمكن تحقيقه إلا من خلال "المطبوعات" في error.log. اعتمادًا على معلومات مستوى التسجيل المحددة، التحذير أو الخطأ، من الممكن استخدام 3 طرق r.log، r.warn، r.error على التوالي. أحاول تصحيح بعض البرامج النصية في Chrome (الإصدار 8) أو أداة وحدة التحكم njs، ولكن لا يمكن التحقق من كل شيء هناك. عند تصحيح أخطاء التعليمات البرمجية، والمعروف أيضًا باسم الاختبار الوظيفي، يبدو السجل كما يلي:

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

ويمكن أن يكون هناك المئات من هذه التسلسلات.

تتحول كتابة التعليمات البرمجية باستخدام الاستعلامات الفرعية والمتغيرات الخاصة بها إلى تشابك متشابك. في بعض الأحيان تبدأ في الاندفاع حول نوافذ IDE المختلفة في محاولة لمعرفة تسلسل إجراءات التعليمات البرمجية الخاصة بك. الأمر ليس صعبًا، لكنه مزعج جدًا في بعض الأحيان.

لا يوجد دعم كامل لـ ES6.

قد يكون هناك بعض أوجه القصور الأخرى، ولكن لم أواجه أي شيء آخر. شارك المعلومات إذا كانت لديك تجربة سلبية في استخدام NJS.

اختتام

NJS هو مترجم خفيف الوزن ومفتوح المصدر يسمح لك بتنفيذ العديد من نصوص JavaScript النصية في 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