Neste artigo, quero compartilhar minha experiência com o NJS, o interpretador JavaScript para Nginx desenvolvido pela empresa Nginx Inc., descrevendo suas principais funcionalidades por meio de um exemplo prático. NJS é um subconjunto da linguagem de programação JavaScript que permite expandir a funcionalidade do Nginx. A pergunta é respondeu detalhadamente Dmitry Volyntsev. Resumindo: NJS é o jeito nginx, enquanto JavaScript é mais progressivo, 'nativo' e sem GC, diferentemente do Lua.
Há muito tempo...
No meu emprego anterior, herdei um gitlab com uma série de pipelines CI/CD variados usando docker-compose, dind e outras maravilhas, que foram transformados para kaniko. As imagens que eram usadas anteriormente no CI foram mantidas em seu estado original. Elas funcionaram bem até o dia em que nosso gitlab mudou de IP, e o CI se tornou um caos. O problema era que, em uma das imagens docker que participava do CI, havia o git, que por ssh puxava módulos Python. Para o ssh, é necessário uma chave privada e... ela estava na imagem junto com known_hosts. E qualquer CI terminava com um erro de verificação da chave devido à discrepância entre o IP real e o especificado em known_hosts. A partir dos Dockfiles disponíveis, rapidamente foi criada uma nova imagem e adicionada a opção StrictHostKeyChecking no. Mas o gosto amargo ficou e surgiu a vontade de mover as bibliotecas para um repositório PyPI privado. Um bônus adicional, após a migração para um PyPI privado, é que o pipeline se tornou mais simples e a descrição do requirements.txt ficou normal.
A escolha foi feita, senhores!
Estamos todos rodando na nuvem e Kubernetes e, no final das contas, queríamos criar um pequeno serviço que consistisse em um contêiner stateless com armazenamento externo. E como usamos o S3, ele foi priorizado. E, se possível, com autenticação no gitlab (podemos adicionar isso se necessário).
Uma busca rápida produziu alguns resultados: s3pypi, pypicloud e uma opção de criação 'manual' de arquivos html para o repositório. A última opção foi descartada.
s3pypi: É um cli para uso de hospedagem no S3. Carregamos arquivos, geramos html e subimos para o mesmo bucket. Para uso doméstico, é adequado.
pypicloud: Parecia um projeto interessante, mas após ler a documentação, veio a decepção. Apesar da boa documentação e possibilidades de personalização, na prática, se mostrou excessivo e complicado de configurar. Ajustar o código para as minhas necessidades levaria, segundo estimativas da época, de 3 a 5 dias. Além disso, o serviço requer um banco de dados. Optamos por mantê-lo caso não encontrássemos uma alternativa melhor.
Uma busca mais aprofundada resultou no módulo para Nginx, ngx_aws_auth. O resultado dos testes foi um XML exibido no navegador, que mostrava o conteúdo do bucket S3. O último commit, no momento da busca, foi há um ano. O repositório parecia abandonado.
Ao me voltar para a fonte original e ler , percebi que o XML pode ser convertido em HTML automaticamente e enviado pelo pip. Depois de pesquisar um pouco mais sobre Nginx e S3, encontrei um exemplo de autenticação em S3 escrito em JS para Nginx. Foi assim que conheci o NJS.
Tomando este exemplo como base, em uma hora vi no meu navegador o mesmo XML que aparecia ao usar o módulo ngx_aws_auth, mas agora tudo estava escrito em JS.
A solução em Nginx me agradou bastante. Primeiro, a documentação é boa e há muitos exemplos; segundo, obtemos todos os benefícios do Nginx ao lidar com arquivos (pronto para uso); terceiro, qualquer pessoa que saiba escrever configurações para Nginx conseguirá entender como funciona. Além disso, para mim, o minimalismo é um ponto positivo em comparação com Python ou Go (se escrito do zero), sem mencionar o nexus.
TL;DR: Em 2 dias, a versão de teste do PyPi já estava sendo usada no CI.
Como funciona?
No Nginx, o módulo é carregado ngx_http_js_module, incluído na imagem oficial do docker. Importamos nosso script usando a diretiva js_importna configuração do Nginx. A chamada da função é feita pela diretiva js_content. Para definir variáveis, usamos a diretiva js_set, que aceita como argumento apenas funções definidas no script. Porém, para executar subconsultas no NJS, precisamos usar o Nginx, e nada de XMLHTTPRequest. Para isso, é necessário adicionar uma localização correspondente na configuração do Nginx. E no script, deve ser descrita uma subconsulta (subrequest) para essa localização. Para poder chamar a função a partir da configuração do Nginx, no próprio script é necessário exportar o nome da função 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) {
// código do manipulador
r.return(resp.status, resp.responseBody);
}
r.subrequest('/sub-query', { method: r.method }, call_back);
}
export default {request}Ao fazer uma solicitação no navegador http://localhost:8080/ chegamos em location /onde a diretiva js_content chama a função request descrita em nosso script script.js. Por sua vez, na função request é feita uma subsolicitação para location = /sub-query, com o método (neste exemplo GET) obtido do argumento (r), implicitamente passado ao chamar essa função. O tratamento da resposta da subsolicitação será feito na função call_back.
Testamos S3
Para fazer uma solicitação para um armazenamento S3 privado, precisamos de:
ACCESS_KEY
SECRET_KEY
S3_BUCKET
A partir do método http utilizado, a data/hora atual, S3_NAME e o URI, é gerada uma string de determinado formato, que é assinada (HMAC_SHA1) usando o SECRET_KEY. Em seguida, a string do tipo AWS $ACCESS_KEY:$HASH, pode ser utilizada no cabeçalho de autorização. A mesma data/hora que foi usada para gerar a string na etapa anterior deve ser adicionada ao cabeçalho X-amz-date. No código, isso parece assim:
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/(?.*) {
internal;
proxy_set_header X-amz-date $s3_datetime;
proxy_set_header Authorization $s3_auth;
proxy_pass $s3_endpoint/$s3_path;
}
location ~ "^/(?[w-]*)[\/]?(?[w-.]*)$" {
js_content s3.request;
}
}s3.js(exemplo de autorização AWS Sign v2, traduzido para o status deprecated)
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}Um pouco de explicação sobre _subrequest_uri: esta é uma variável que, dependendo do uri original, forma a solicitação para o S3. Se precisar obter o conteúdo da "raiz", será necessário formar a solicitação uri com a indicação do delimitador delimiter, que retornará a lista de todos os elementos XML CommonPrefixes, correspondendo aos diretórios (no caso do PyPI, a lista de todos os pacotes). Se precisar obter a lista do conteúdo em um diretório específico (lista de todas as versões dos pacotes), então a solicitação uri deve conter o campo prefix com o nome do diretório (pacote), obrigatoriamente terminando com uma barra \. Caso contrário, podem ocorrer colisões ao solicitar o conteúdo do diretório, por exemplo. Existem diretórios aiohttp-request e aiohttp-requests e, se a solicitação for feita /?prefix=aiohttp-request, então a resposta conterá o conteúdo de ambos os diretórios. Se houver uma barra no final, /?prefix=aiohttp-request/, a resposta conterá apenas o diretório necessário. E se solicitarmos um arquivo, a URI resultante não deve diferir da original.
Salvamos, reiniciamos o Nginx. No navegador, digitamos o endereço do nosso Nginx, e o resultado da solicitação será XML, por exemplo:
Lista de diretórios
myback-space
10000
/
false
new/
old/Da lista de diretórios, precisaremos apenas dos elementos CommonPrefixes.
Adicionando, no navegador, o diretório que precisamos ao nosso endereço, obteremos seu conteúdo também em formato XML:
Lista de arquivos no diretório
myback-space
old/
10000
false
old/giphy.mp4
2020-08-21T20:27:46.000Z
"00000000000000000000000000000000-1"
1350084
02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
STANDARD
old/hsd-k8s.jpg
2020-08-31T16:40:01.000Z
"b2d76df4aeb4493c5456366748218093"
93183
02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
STANDARDDa lista de arquivos, pegaremos apenas os elementos Chave.
Resta analisar o XML obtido e enviá-lo como HTML, substituindo previamente o cabeçalho Content-Type para 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="/${reValue.groups.v}">${a_text}</a>`);
}
}
return '<html><body>n' + out.join('</br>n') + 'n</html></body>'
}Testamos o PyPI
Verificamos se nada está quebrado em pacotes que sabemos que funcionam.
# Создаем для тестов новое окружение
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:8080Repetimos com nossas bibliotecas.
# Создаем для тестов новое окружение
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:8080No CI, a criação e o upload do pacote acontecem assim:
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}"Autenticação
No GitLab é possível usar JWT para autenticação/autorização de serviços externos. Ao aproveitar a diretiva auth_request no Nginx, redirecionaremos os dados de autenticação em uma subrequisição que contém a chamada de uma função no script. No script, será feita uma nova subrequisição para a URL do GitLab e, se os dados de autenticação foram fornecidos corretamente, o GitLab retornará o código 200 e permitirá o upload/download do pacote. Por que não usar uma única subrequisição e enviar os dados diretamente para o GitLab? Isso porque teríamos que alterar o arquivo de configuração do Nginx toda vez que tivéssemos mudanças na autorização, e isso é uma tarefa bastante trabalhosa. Além disso, se o Kubernetes utiliza a política de sistema de arquivos root apenas para leitura, isso adiciona ainda mais complexidade ao substituir o nginx.conf através do configmap. E se torna absolutamente impossível configurar o Nginx através do configmap ao mesmo tempo em que se utilizam políticas que proíbem a conexão de volumes (pvc) e o sistema de arquivos root apenas para leitura (isso também pode acontecer).
Usando o NJS como um intermediário, conseguimos alterar os parâmetros especificados na configuração do Nginx por meio de variáveis de ambiente e fazer algumas verificações no script (por exemplo, uma URL indicada incorretamente).
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 ~ "^/(?[w-]*)[\/]?(?[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}Provavelmente surge a questão: - Por que não usar módulos prontos? Afinal, tudo já está feito! Por exemplo, var AWS = require('aws-sdk') e não é necessário reinventar a roda com autenticação S3!
Vamos passar para os contras
Para mim, a impossibilidade de importar módulos JS externos foi uma característica desagradável, mas esperada. O require('crypto') descrito no exemplo acima, é e o require funciona apenas para eles. Também não há como reutilizar código de scripts e é necessário copiar e colar em diferentes arquivos. Espero que um dia essa funcionalidade seja implementada.
Para o projeto atual no Nginx, a compressão deve ser desativada gzip off;
Porque não há módulo gzip no NJS e não é possível conectá-lo, portanto, não há como trabalhar com dados comprimidos. Na verdade, isso não é um grande problema para esse caso. O texto não é muito e os arquivos transferidos já estão comprimidos, então uma compressão adicional não ajudaria muito. Além disso, não é um serviço tão carregado ou crítico para se preocupar em entregar conteúdo alguns milissegundos mais rápido.
A depuração do script é longa e só pode ser feita através de "prints" no error.log. Dependendo do nível de log configurado (info, warn ou error), é possível usar 3 métodos: r.log, r.warn, r.error, respectivamente. Tento depurar alguns scripts no Chrome (v8) ou na ferramenta de console njs, mas nem tudo pode ser verificado ali. Ao depurar o código, que é uma forma de teste funcional, o histórico parece mais ou menos assim:
docker-compose restart nginx
curl localhost:8080/
docker-compose logs --tail 10 nginxe tais sequências podem ser centenas.
Escrever código usando subconsultas e variáveis para elas se torna um emaranhado complicado. Às vezes, você começa a pular entre diferentes janelas do IDE tentando entender a sequência de ações do seu código. Isso não é difícil, mas às vezes é bastante frustrante.
Não há suporte completo para ES6.
Pode haver outras desvantagens, mas não encontrei mais nada. Compartilhe informações se você tiver uma experiência negativa com o NJS.
Conclusão
O NJS é um interpretador open-source leve que permite implementar diferentes cenários em Nginx usando a linguagem JavaScript. Durante seu desenvolvimento, foi dada grande atenção ao desempenho. Certamente há muitas coisas que ainda faltam, mas o projeto está evoluindo com uma pequena equipe e eles estão ativamente adicionando novos recursos e corrigindo bugs. Eu espero que algum dia o NJS permita a conexão de módulos externos, o que tornará a funcionalidade do Nginx praticamente ilimitada. Mas existe o NGINX Plus, e provavelmente não haverá alguns recursos!
e
/ Выступление Дмитрия Волныева на Saint HighLoad++ 2019
/ Выступление Василия Сошникова на HighLoad++ 2019
Fonte: habr.com
