kelvin@cluxnei:~/blog$ cat 2gb/03-a-porta-de-entrada.md
Um IP, uma porta 443, várias aplicações
No fim da parte 2 sua aplicação rodava, voltava sozinha e tinha teto de memória.
E continuava invisível, porque escuta em 127.0.0.1:3000 e ninguém
digita porta 3000 no navegador. Falta a peça que recebe o mundo inteiro numa
porta só e distribui. Esta parte é sobre ela, com quatro experimentos medidos,
incluindo um que explica por que a conta de 2 GB fecha.
Neste texto
- Por que um proxy, mesmo com uma app só
- Vários sites, um IP
- A ordem de match não é de cima para baixo
- O que a sua aplicação deixa de saber
- WebSocket e a conexão que não pode ser fechada
- Quem serve o arquivo estático
- 2000 conexões, 0,8 MB
- Onde o TLS termina
- Os limites que ninguém configura
- O checklist
Por que um proxy, mesmo com uma app só
A pergunta honesta é essa. Se você tem uma aplicação, por que não deixá-la escutando direto na porta 443 e acabou?
Porque a porta 443 é um recurso único da máquina e tudo que precisa dela vai brigar. No momento em que aparecer a segunda aplicação, ou um segundo domínio, ou uma pasta de arquivos estáticos, você vai ter que colocar alguma coisa na frente de qualquer jeito. Mas mesmo com uma app só, o proxy resolve cinco coisas que você teria que escrever você mesmo:
- TLS. Certificado, renovação, protocolos aceitos, tudo num lugar só, em vez de dentro de cada aplicação.
- Portas privilegiadas. Abrir a 443 exige root. Com proxy, o root é
dele, e a sua aplicação segue rodando como
deploynuma porta alta. - Arquivo estático. Medido mais abaixo: o proxy serve quase o dobro de requisições por segundo e libera memória.
- Clientes lentos. O nginx recebe a requisição inteira antes de repassar. Uma conexão de 3G ruim ocupa o nginx, não um worker da sua aplicação.
- Limites. Tamanho de upload, timeout, taxa por IP, cabeçalhos de segurança. Tudo fora do código.
Vários sites, um IP
Um IP só, e o navegador diz no cabeçalho Host qual site ele
quer. O nginx escolhe o bloco server pelo server_name.
Um arquivo por site em /etc/nginx/sites-available, com link
simbólico em sites-enabled:
server { listen 443 ssl; server_name app.meusite.dev; location / { proxy_pass http://127.0.0.1:3000; } } server { listen 443 ssl; server_name site.meusite.dev; root /var/www/site; }
Duas coisas que economizam uma tarde:
O primeiro bloco que escuta numa porta é o servidor padrão dela. Quem
chegar com um Host desconhecido, ou direto pelo IP, cai nesse bloco.
Se isso te incomoda, declare um bloco explícito com
default_server devolvendo 444, que fecha a conexão sem
responder nada.
E nginx -t antes de todo reload. Ele valida a
configuração inteira sem aplicar. O reload com configuração quebrada
não derruba o nginx, ele recusa e continua com a versão antiga, mas você fica
achando que aplicou alguma coisa.
A ordem de match não é de cima para baixo
Esta seção é a que mais causa dano, porque o sintoma não aponta para a causa.
A intuição de todo mundo é que o nginx lê os blocos location na
ordem do arquivo e usa o primeiro que casar. Está errado. A ordem real é por
tipo de location, e o arquivo não tem nada a ver com isso:
1. location = /exato casamento exato, ganha de todos 2. location ^~ /prefixo prefixo que bloqueia regex 3. location ~ regex regex, na ordem do arquivo location ~* regex idem, ignorando maiúsculas 4. location /prefixo prefixo comum, o perdedor
Leia o item 3 e o item 4 juntos. Uma regex ganha de um prefixo comum, esteja ela onde estiver no arquivo. E agora considere a configuração mais comum do mundo, que é cache longo para assets mais um proxy para a aplicação:
location ~ \.js$ { expires 1y; } location /app/ { proxy_pass http://127.0.0.1:8100; }
Montei exatamente isso num nginx de verdade, com um backend que responde
qualquer caminho, e pedi /app/client.js. Coloquei um cabeçalho em
cada bloco para ver quem pegou:
# com location /app/ HTTP/1.1 404 Not Found X-Quem-Pegou: regex .js # com location ^~ /app/ HTTP/1.1 200 OK X-Quem-Pegou: prefixo /app/ corpo: veio do backend: /app/client.js
Na primeira versão o arquivo dá 404. A regex \.js$ venceu, o
nginx foi procurar client.js no disco, não achou, e devolveu 404.
O proxy nunca foi consultado. A aplicação carrega, o HTML chega, e só os scripts
somem. Nenhum log da aplicação registra nada, porque a requisição não chegou
nela.
Dois caracteres consertam. ^~ quer dizer se este prefixo
casar, pare aqui e não considere nenhuma regex.
A regra prática: toda location de prefixo que faz
proxy_pass leva ^~. Sem exceção. O custo é zero e o
dia em que alguém adicionar uma regex de assets no arquivo, nada quebra.
O que a sua aplicação deixa de saber
Atravessar o proxy custa informação. A aplicação passa a conversar com o nginx, não com o navegador, e por padrão ela perde quase tudo sobre quem chamou.
Subi um backend que imprime os cabeçalhos que recebe e passei por um
proxy_pass sem mais nada configurado, pedindo o site
meusite.dev:
# proxy_pass sozinho Host: 127.0.0.1:8100 cliente TCP visto pelo backend: 127.0.0.1
É só isso que chega. Repare no Host: a aplicação acredita que ela
é 127.0.0.1:8100. Todo link absoluto que ela montar, todo
redirecionamento, todo e-mail de confirmação de cadastro vai apontar para
http://127.0.0.1:8100. E o IP do visitante é sempre
127.0.0.1, então seu rate limit por IP, seu log de acesso e seu
antifraude enxergam o mundo inteiro como uma pessoa só.
Com as quatro linhas que faltavam:
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# mesmo pedido, agora Host: meusite.dev X-Real-IP: 127.0.0.1 X-Forwarded-For: 127.0.0.1 X-Forwarded-Proto: http
X-Forwarded-Proto merece um parágrafo só dele, porque é a origem
de um bug que consome tardes inteiras. O TLS termina no nginx. Do nginx para a
sua aplicação o tráfego é HTTP puro, e é isso mesmo que se espera. Só que a
aplicação, olhando a própria conexão, conclui com razão que está em HTTP, e gera
link http:// num site https://. O navegador então
bloqueia conteúdo misto, ou o HSTS força HTTPS, o servidor redireciona, e você
ganha um loop de redirecionamento sem causa aparente.
Mandar o cabeçalho é metade do conserto. A outra metade é a aplicação acreditar nele, e isso é explícito em todo framework:
| stack | o que ligar |
|---|---|
| Express | app.set('trust proxy', 1) |
| Laravel | TrustProxies, ou fastcgi_param HTTPS on no FPM |
| Django | SECURE_PROXY_SSL_HEADER |
| Rails | config.force_ssl com assume_ssl |
Cuidado com a confiança. Só confie em
X-Forwarded-* se o único caminho até a sua aplicação for o proxy.
Se ela também escuta em 0.0.0.0, qualquer um manda o cabeçalho que
quiser e o seu rate limit por IP vira decoração. É o outro motivo, além do da
parte 2, para escutar só no 127.0.0.1.
WebSocket e a conexão que não pode ser fechada
Nos testes acima apareceu um detalhe que vale explicar: o backend recebeu
Connection: close. Por padrão, o nginx conversa com a aplicação em
HTTP/1.0 e fecha a conexão a cada requisição, mesmo que o navegador
esteja em keep-alive do lado de fora.
Para tráfego normal isso custa um handshake TCP por requisição. Para WebSocket, mata: a conexão precisa ser promovida, e uma conexão que o proxy fecha não tem como ser promovida a nada.
location ^~ /ws/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }
As três primeiras linhas fazem o upgrade acontecer. A quarta é a que quase
sempre falta: o padrão de proxy_read_timeout é 60 segundos, e uma
conexão WebSocket ociosa por mais de um minuto é derrubada pelo proxy. O sintoma
é um chat que desconecta sozinho quando ninguém fala, e a culpa cai no cliente,
que não tem nada com isso.
Quem serve o arquivo estático
Quase todo framework consegue servir a pasta public. A pergunta
é se deveria.
Medi com um arquivo de 120 KB, tamanho de bundle realista, 4000 requisições com 20 conexões simultâneas, duas rodadas de cada:
| quem serve | rodada 1 | rodada 2 | custo em memória |
|---|---|---|---|
| nginx, direto do disco | 2827 req/s | 2527 req/s | nenhum a mais |
| nginx encaminhando para o Node | 1529 req/s | 1640 req/s | 28,6 MB do processo |
Cerca de 1,7 vez mais rápido, e sem os 28,6 MB. O cliente de teste
rodava na mesma máquina, então os valores absolutos dizem pouco. A razão entre
eles é o que importa, e ela vem de uma diferença estrutural: o nginx entrega o
arquivo com sendfile, que copia do page cache para o socket dentro
do kernel, sem os dados passarem por espaço de usuário. O Node lê o arquivo para
a memória dele, monta a resposta e escreve. Na segunda vez em diante os dois
leem do cache, e ainda assim o caminho mais curto ganha.
Na prática isso é uma location:
location ^~ /assets/ { alias /srv/minhaapp/current/public/assets/; expires 1y; add_header Cache-Control "public, immutable"; access_log off; }
O immutable serve para arquivo com hash no nome, e diz ao
navegador para não revalidar nem quando o usuário aperta recarregar. Se o seu
build não coloca hash nos nomes, não use um ano de cache, porque você não
vai conseguir publicar correção nenhuma.
2000 conexões, 0,8 MB
Aqui está a medição que sustenta a série inteira.
O nginx não usa uma thread por conexão. Cada worker é um processo só, com um
laço de eventos em cima de epoll, e uma conexão ociosa é um
descritor de arquivo mais algumas centenas de bytes de estado. Dizer isso é
fácil. Eu abri 2000 conexões keep-alive simultâneas contra um nginx real e medi
a memória própria dele antes e durante:
antes: 1 conexão nginx com 5,9 MB durante: 2001 conexões nginx com 6,7 MB 2000 conexões custaram 0,8 MB, ou cerca de 0,4 KB cada
Compare com o modelo oposto, que é o do PHP-FPM da parte 1: um processo por requisição simultânea, a 25 ou 40 MB cada. Atender 2000 requisições ao mesmo tempo nesse modelo pediria dezenas de gigabytes. Não é uma questão de ajuste fino, é a arquitetura.
Essa é a razão de a conta de 2 GB fechar. A camada que fala com o mundo, que é a que recebe todo pico e toda conexão lenta, é a mais barata da pilha. Os processos caros ficam atrás dela, em número pequeno e controlado.
Existe um teto, e vale saber onde ele está:
worker_processes auto; # um por vCPU. Com 4 vCPUs, 4 workers events { worker_connections 768; # padrão do Ubuntu, por worker } # teto = 4 x 768 = 3072 conexões # cada requisição proxiada usa duas: navegador e backend
Numa VPS de 2 GB dá para subir worker_connections para 4096 sem
dor, porque o custo por conexão é aquele 0,4 KB. O que trava antes disso é o
limite de descritores de arquivo do processo, que se ajusta com
LimitNOFILE na unidade do systemd, exatamente como na parte 2.
Onde o TLS termina
Termina no nginx. O certificado é dele, a negociação é dele, e a partir dali
para dentro o tráfego é HTTP puro em 127.0.0.1, que é a razão de
toda a seção dos cabeçalhos existir.
Certificado gratuito com renovação automática:
apt install certbot python3-certbot-nginx certbot --nginx -d meusite.dev -d www.meusite.dev # a renovação já vem como timer do systemd: systemctl list-timers | grep certbot certbot renew --dry-run
O certbot edita o seu arquivo do nginx e instala um timer. Vale rodar o
--dry-run uma vez, porque descobrir que a renovação nunca funcionou
noventa dias depois, com o site fora do ar, é uma experiência evitável.
O redirecionamento de HTTP para HTTPS fica num bloco separado, e a porta 80 precisa continuar aberta para a validação da renovação funcionar:
server { listen 80; server_name meusite.dev www.meusite.dev; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }
Sobre HSTS, uma frase de aviso. O cabeçalho
Strict-Transport-Security manda o navegador nunca mais tentar HTTP
naquele domínio, e ele é lembrado pelo tempo do max-age. É uma boa
ideia, e é uma porta que tranca por fora: enquanto o prazo não vence, nenhum
visitante que já passou pelo site consegue acessá-lo sem TLS válido, nem que você
queira. Comece com um max-age curto.
Os limites que ninguém configura
Quatro linhas que evitam quatro incidentes:
client_max_body_size 10m; # padrão é 1m. Upload maior devolve 413 client_body_timeout 15s; send_timeout 15s; proxy_read_timeout 60s; # o tempo que o proxy espera a sua app gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; # abaixo disso comprimir custa mais do que rende
O client_max_body_size padrão de 1 MB é o campeão de chamados. O
usuário tenta subir uma foto de celular, recebe 413, e não existe
nenhum registro disso no log da aplicação, porque o nginx recusou antes de
repassar.
E o proxy_read_timeout de 60 segundos combinado com uma rota
lenta gera 504 que some do log da aplicação por motivo oposto: ela
respondeu, só que tarde demais e ninguém estava mais ouvindo.
O checklist
| item | como conferir |
|---|---|
| a configuração é válida | nginx -t antes de todo reload |
prefixo com proxy tem ^~ | grep -n "location" no arquivo |
| os quatro cabeçalhos estão lá | grep proxy_set_header |
| a app confia no proxy | gere um link absoluto e veja o esquema |
| estático não passa pela app | curl -I e olhe o Server |
| a renovação do TLS funciona | certbot renew --dry-run |
| upload grande passa | mande um arquivo maior que 1 MB |
| ninguém alcança a app direto | curl http://SEU_IP:3000 de fora |
O que vem na parte 4
A máquina está lida, o processo está supervisionado e a porta de entrada distribui o tráfego. Falta a pergunta que você vem adiando desde o começo: como o código chega até lá.
A parte 4 é o deploy. O que você sobe não é a sua pasta de trabalho, e a
diferença entre as duas coisas é a classe de bug mais traiçoeira que existe,
porque ela nunca reproduz local. Releases numeradas com troca atômica, rollback
em um comando, onde buildar quando o build não cabe na máquina, segredos com
permissão, e a conta de max_connections do Postgres que a parte 1
deixou em aberto.
kelvin@cluxnei:~/blog$