Movemos nossas imagens para um CDN e o servidor de origem continuou a servi-las
Nossa documentação é carregada de capturas de tela. Cada recurso do portal, capturado em claro e escuro, em duas larguras, em dez idiomas, o que soma bem mais de cem megabytes de imagens. Tudo isso estava no próprio bundle estático da aplicação, servido a partir da origem, que é o lugar errado para cem megabytes de imagens que nunca mudam. Então as movemos para o Cloudflare R2 e colocamos um CDN na frente, um por ambiente: cdn.authagonal.io para produção e um host de CDN correspondente para o nosso site de dev.
A aplicação escolhe o host de CDN certo em tempo de execução olhando o nome de host a partir do qual está sendo servida. Se a página está em authagonal.io, as imagens vêm de cdn.authagonal.io. Simples, sem configuração em tempo de build, uma única referência de imagem que se resolve corretamente em qualquer um dos dois ambientes. Nós implantamos, vimos o navegador puxar cada imagem de forma limpa do CDN, e então olhamos o tráfego da origem. A origem ainda estava servindo as imagens. Cada uma delas, no primeiro carregamento, exatamente como antes.
A página que um rastreador vê não é a página que um navegador constrói
Para explicar por quê, tenho que explicar o que nossas páginas de marketing realmente são. Elas são uma aplicação de página única, mas uma aplicação de página única não passa de uma casca vazia até o JavaScript rodar, e uma casca vazia é ruim para os mecanismos de busca e ruim para o tempo que a primeira coisa significativa leva para aparecer. Então, em tempo de build, nós pré-renderizamos: iniciamos o site, abrimos cada página em um navegador headless, deixamos que ela renderize por completo e salvamos o HTML resultante. É esse HTML pré-renderizado que servimos primeiro. Ele já traz o conteúdo dentro, o rastreador vê uma página de verdade, o usuário vê texto e imagens imediatamente, e então o JavaScript assume.
Aqui está o problema escondido nessa descrição. A pré-renderização roda o site em 127.0.0.1, um endereço de loopback na máquina de build. Então, quando a aplicação pergunta «em qual nome de host eu estou, para poder escolher um CDN», a resposta durante a pré-renderização é «um endereço IP local», que não corresponde nem a authagonal.io nem ao host de dev. A dedução do host em tempo de execução, a parte esperta, cai silenciosamente para sua única opção restante: o caminho relativo à origem. E é esse caminho de origem que fica congelado no HTML pré-renderizado.
Assim, cada visitante recebia HTML com as URLs de imagem da origem embutidas, e o navegador fazia o óbvio: buscava-as, na origem, na primeira pintura. Um instante depois, o JavaScript re-renderizava a página, deduzia de novo o nome de host, desta vez obtendo o real, e trocava as imagens para o CDN. Duas buscas para cada imagem, a primeira atingindo exatamente o servidor que tentávamos poupar, e completamente invisível a menos que você estivesse observando os logs da origem, porque a página parecia e funcionava perfeitamente.
A correção que piorou as coisas por onze minutos
A primeira correção era a tentadora. Se o problema é que uma URL errada fica congelada no HTML pré-renderizado, então não emita URL nenhuma durante a pré-renderização. Deixe a fonte da imagem em branco no snapshot, e deixe o JavaScript preencher a URL de CDN correta assim que ele rodar e souber o host real. Nenhuma URL errada, nenhuma busca à origem.
Foi para produção, e estava errado, e o substituímos cerca de onze minutos depois. Deixar a fonte da imagem em branco no HTML pré-renderizado derruba toda a razão pela qual pré-renderizamos. O rastreador que lê a página estática agora não vê imagem nenhuma, e o navegador não tem nada a mostrar até o JavaScript ter rodado, que é justamente o caminho lento que construímos o HTML pré-renderizado para evitar. Tínhamos removido a busca dupla removendo a imagem da única versão da página que existe antes do JavaScript. Isso troca um problema de tráfego por um problema de busca e de primeira pintura, o que é uma troca pior.
A lição desses onze minutos: o HTML pré-renderizado tem que carregar uma referência de imagem real e funcional. O bug nunca foi a URL estar presente. Foi a URL errada estar presente, e deixá-la em branco só troca um defeito por outro. O que de fato precisávamos era que a URL correta pudesse ser conhecida em tempo de pré-renderização, e ela não pode, porque em tempo de pré-renderização ninguém sabe qual ambiente vai servir a página.
Decidir no último momento possível
A URL correta depende do host da requisição, e o host da requisição não é conhecido até haver uma requisição. A pré-renderização acontece uma vez, no build, e produz um arquivo que é servido em ambos os ambientes. Então a parte da URL específica do ambiente não pode ser decidida no build. Ela tem que ser decidida por requisição, e a única coisa na pilha que vê cada requisição e o seu host é o servidor web no edge.
Então a pré-renderização não escreve mais uma URL nem um vazio. Ela escreve um marcador, uma string sentinela literal, https://cdn.authagonal.io, no lugar do host do CDN. O HTML pré-renderizado que sai do build diz https://cdn.authagonal.io/screenshots/whatever.webp, que não é nem uma URL de origem nem uma fonte vazia. É uma URL com um buraco dentro.
O nginx preenche o buraco na saída. Ele mapeia o host da requisição para a base de CDN certa, o host que não é de produção para o seu próprio CDN de dev, authagonal.io para https://cdn.authagonal.io, qualquer outra coisa para vazio, de modo que a URL volta a ser relativa à origem como um recuo local seguro. Então uma única diretiva reescreve a sentinela para esse valor enquanto o HTML é transmitido ao cliente:
map $host $asset_cdn_base {
default "";
"~*example\.dev$" "https://cdn.example.dev";
"~*authagonal\.io$" "https://cdn.authagonal.io";
}
sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;
O HTML estático agora sai da origem já carregando URLs de CDN reais, corretas para qualquer host que tenha pedido. O navegador busca direto do CDN na primeira pintura. Não há nenhuma primeira busca errada a desfazer, porque não há nenhuma URL errada, e nunca houve uma vazia também. O rastreador vê uma página completa com links de imagem reais. Um único arquivo serve ambos os ambientes, e nenhum dos dois precisa de um build separado.
Há uma pequena elegância em quais partes da resposta são reescritas. A reescrita é restrita apenas ao HTML, que é o padrão do nginx. Isso importa porque a mesma string sentinela aparece também no bundle de JavaScript, como o ramo de recuo do código de dedução do host. No cliente esse ramo é código morto, já que o navegador tem um nome de host real e nunca emite a sentinela, então especificamente não queremos que o nginx toque no bundle. Deixar a reescrita no seu padrão de apenas HTML nos dá isso de graça. Aprendemos, aliás, que escrever o padrão de forma explícita é pior do que deixá-lo de fora, porque uma declaração redundante faz o nginx avisar sobre uma duplicata.
Por que não simplesmente reescrever o arquivo uma vez na inicialização
A objeção óbvia é que isso é muito trabalho por requisição para um valor que tem apenas duas possibilidades. Por que não reescrever o HTML uma única vez quando o contêiner sobe, embutir o host certo e servir um arquivo estático sem nenhum filtro?
Porque o contêiner não consegue escrever em si mesmo. O site estático roda no nginx como um usuário não root, com um sistema de arquivos raiz somente leitura e cada capacidade do Linux removida, com um único e pequeno diretório de trabalho gravável para os arquivos temporários do próprio nginx. Essa é uma postura de endurecimento deliberada: um servidor web que serve tráfego não confiável não deveria conseguir modificar os arquivos que serve, para que um atacante que encontre uma brecha não encontre nada para reescrever. Uma reescrita do HTML na inicialização precisaria exatamente do acesso de escrita que tiramos de propósito. O sistema de arquivos somente leitura não é um obstáculo a contornar, é uma propriedade que queremos, e ele descarta a reescrita na inicialização de forma limpa. A reescrita por requisição não toca em arquivo nenhum, e é por isso que ela é compatível com um servidor que não possui nada que possa mudar.
Também não quisemos embutir o host do CDN no build e produzir dois bundles de imagens diferentes, um por ambiente, porque isso reintroduz o build específico do ambiente do qual acabáramos de nos livrar. O propósito da dedução do host era um único artefato para os dois ambientes. A sentinela mantém essa promessa: um build, um arquivo, o ambiente resolvido no edge no último momento possível.
A pré-renderização congela valores de ambiente, então resolva-os no edge
A pré-renderização congela um momento. Tudo que a sua aplicação decide olhando o seu ambiente fica decidido, em tempo de pré-renderização, contra o ambiente do build, que é um endereço de loopback e o host real de ninguém. Então um valor que é correto em tempo de execução torna-se errado no instante em que é fotografado, e falha em silêncio, porque a página pré-renderizada ainda renderiza, apenas apontando para o lugar errado.
A correção geral é não resolver de jeito nenhum valores específicos do ambiente durante a pré-renderização. Emita um marcador, carregue-o intacto através do artefato estático, e resolva-o na camada que de fato vê o ambiente, que para um valor por requisição é o edge. É ligação tardia, aplicada a uma string em um arquivo HTML: deixe o buraco aberto por cada etapa que não sabe a resposta, e preencha-o na única etapa que sabe.
Se você prefere que a própria documentação e os recursos do seu provedor de identidade já sejam servidos corretamente a partir do edge, essa é uma das muitas pequenas coisas que a Authagonal já resolveu para que você possa reservar o debate para o seu próprio produto.