我使用授权和 S3 创建了自己的 PyPI 存储库。 在 Nginx 上

在这篇文章中,我想分享我使用 NJS 的经验,NJS 是 Nginx Inc 开发的 Nginx JavaScript 解释器,并通过一个真实的例子描述了它的主要功能。 NJS 是 JavaScript 的一个子集,允许您扩展 Nginx 的功能。 对于问题 为什么你自己的翻译??? 德米特里·沃林采夫详细回答了。 简而言之:NJS 是 nginx 方式,而 JavaScript 更先进、“原生”并且没有 GC,与 Lua 不同。

很久以前…

在我的上一份工作中,我继承了 gitlab 以及许多杂乱的 CI/CD 管道,包括 docker-compose、dind 和其他有趣的东西,这些管道被转移到了 kaniko Rails。 之前在 CI 中使用的图像按其原始形式移动。 他们一直正常工作,直到有一天我们的 gitlab IP 改变了,CI 变成了南瓜。 问题是参与 CI 的 docker 镜像之一有 git,它通过 ssh 拉取 Python 模块。 对于 ssh,您需要一个私钥并且...它与known_hosts 一起位于图像中。 由于真实 IP 与known_hosts 中指定的 IP 不匹配,任何 CI 都会失败并出现密钥验证错误。 从现有的 Dockfile 中快速组装了一个新映像,并添加了该选项 StrictHostKeyChecking no。 但不好的味道仍然存在,并且希望将库移动到私有 PyPI 存储库。 切换到私有 PyPI 后的额外好处是更简单的管道和requirements.txt 的正常描述

先生们,选择已经做出了!

我们在云和 Kubernetes 中运行所有内容,最终我们希望获得一个小型服务,它是具有外部存储的无状态容器。 嗯,既然用S3,就优先考虑它。 并且,如果可能的话,在 gitlab 中进行身份验证(如果需要,您可以自己添加)。

快速搜索产生了几个结果:s3pypi、pypicloud 以及“手动”创建萝卜 html 文件的选项。 最后一个选项自行消失。

s3pypi:这是使用 S3 托管的 cli。 我们上传文件,生成 html 并将其上传到同一个存储桶。 适合家庭使用。

pypicloud:这似乎是一个有趣的项目,但阅读文档后我很失望。 尽管有良好的文档并且能够扩展以满足您的需求,但实际上它是多余的并且难以配置。 根据当时的估计,纠正代码以适应您的任务需要 3 到 5 天的时间。 该服务还需要一个数据库。 我们把它留下了,以防我们找不到其他东西。

更深入的搜索产生了 Nginx 的模块 ngx_aws_auth。 他的测试结果是浏览器中显示的 XML,其中显示了 S3 存储桶的内容。 搜索时的最后一次提交是在一年前。 该存储库看起来被遗弃了。

通过访问源代码并阅读 PEP-503 我意识到 XML 可以即时转换为 HTML 并提供给 pip。 在谷歌上搜索了更多关于 Nginx 和 S3 的信息后,我发现了一个用 JS 为 Nginx 编写的 S3 身份验证示例。 我就是这样认识NJS的。

以这个例子为基础,一个小时后,我在浏览器中看到了与使用 ngx_aws_auth 模块时相同的 XML,但所有内容都已经用 JS 编写了。

我真的很喜欢 nginx 解决方案。 首先,良好的文档和许多示例,其次,我们获得了 Nginx 处理文件的所有优点(开箱即用),第三,任何知道如何为 Nginx 编写配置的人都能够弄清楚是什么。 与 Python 或 Go(如果从头开始编写)相比,极简主义对我来说也是一个优点,更不用说关系了。

TL;DR 2天后,PyPi的测试版本已经在CI中使用。

它是如何工作的呢?

该模块被加载到Nginx中 ngx_http_js_module,包含在官方 docker 镜像中。 我们使用指令导入脚本 js_import到 Nginx 配置。 该函数由指令调用 js_content。 该指令用于设置变量 js_set,它仅将脚本中描述的函数作为参数。 但是我们只能使用 Nginx 来执行 NJS 中的子查询,而不能使用任何 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 的请求。 如果您需要获取“root”的内容,那么您需要创建一个指示分隔符的uri请求 delimiter,它将返回所有 CommonPrefixes xml 元素的列表,对应于目录(对于 PyPI,是所有包的列表)。 如果您需要获取特定目录中的内容列表(所有包版本的列表),则 uri 请求必须包含一个前缀字段,该前缀字段的目录(包)名称必须以斜杠 / 结尾。 否则,例如在请求目录内容时可能会发生冲突。 有目录aiohttp-request和aiohttp-requests,如果请求指定 /?prefix=aiohttp-request,那么响应将包含两个目录的内容。 如果末尾有斜线, /?prefix=aiohttp-request/,那么响应将仅包含所需的目录。 如果我们请求一个文件,那么生成的 uri 不应与原始 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 发送出去,首先用 text/html 替换 Content-Type 标头。

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="/zh-CN/${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 进行外部服务的身份验证/授权。 使用 Nginx 中的 auth_request 指令,我们将身份验证数据重定向到包含脚本中函数调用的子请求。 该脚本将向 Gitlab url 发出另一个子请求,如果正确指定了身份验证数据,则 Gitlab 将返回代码 200 并且允许上传/下载包。 为什么不使用一个子查询并立即将数据发送到 Gitlab? 因为这样每次修改授权时我们都必须编辑Nginx配置文件,这是一个相当繁琐的任务。 此外,如果 Kubernetes 使用只读根文件系统策略,那么在通过 configmap 替换 nginx.conf 时,这会增加更多的复杂性。 并且绝对不可能通过 configmap 配置 Nginx,同时使用禁止连接卷 (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 模块成为一个令人不快但又是预料之中的功能。 上面示例中描述的 require('crypto') 是 内置模块 并要求仅适用于他们。 也无法重用脚本中的代码,您必须将其复制并粘贴到不同的文件中。 我希望有一天这个功能能够实现。

Nginx 中的当前项目还必须禁用压缩 gzip off;

由于NJS中没有gzip模块,无法连接,因此无法处理压缩数据。 确实,对于这种情况来说,这并不是真正的缺点。 文本不多,并且传输的文件已经被压缩,额外的压缩对它们没有太大帮助。 此外,这并不是一项负载或关键的服务,您不必费心以更快的速度交付内容。

调试脚本需要很长时间,并且只能通过 error.log 中的“打印”来实现。 根据设置的日志记录级别 info、warn 或 error,可以分别使用 3 种方法 r.log、r.warn、r.error。 我尝试在 Chrome (v8) 或 njs 控制台工具中调试一些脚本,但并不是所有内容都可以在那里检查。 调试代码(又称功能测试)时,历史记录看起来像这样:

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

这样的序列可能有数百个。

使用子查询和变量编写代码会变得一团乱麻。 有时,您开始在不同的 IDE 窗口中奔波,试图找出代码的操作顺序。 这并不困难,但有时很烦人。

不完全支持 ES6。

可能还有其他一些缺点,但我没有遇到过其他的。 如果您在使用 NJS 时有负面体验,请分享信息。

结论

NJS 是一个轻量级的开源解释器,允许您在 Nginx 中实现各种 JavaScript 脚本。 在其开发过程中,非常注重性能。 当然,还有很多缺失,但该项目是由一个小团队开发的,他们正在积极添加新功能并修复错误。 我希望有一天 NJS 允许你连接外部模块,这将使 Nginx 的功能几乎不受限制。 但有了 NGINX Plus,很可能就没有任何功能了!

包含文章完整代码的存储库

njs-pypi 具有 AWS Sign v4 支持

ngx_http_js_module模块的指令说明

NJS 官方存储库 и 文件

Dmitry Volintsev 提供的使用 NJS 的示例

njs - nginx 中的本机 JavaScript 脚本 / Dmitry Volnyev 在 Saint HighLoad++ 2019 上的演讲

NJS 生产中 / Vasily Soshnikov 在 HighLoad++ 2019 上的演讲

在 AWS 中签署和验证 REST 请求

来源: habr.com