Lab 20 — SPA na borda ou SSR no contêiner
O problema, e a empresa que o tem
A Cadência é a mesma equipe de duas pessoas do L01, do L03 e do L05. Depois de resolver domínio, TLS e estático na borda, o time decidiu publicar um catálogo de 1.200 produtos — a aposta de aquisição da empresa para o próximo ano, porque não há orçamento para anúncio pago. A ideia é simples: se as páginas de produto rankearem organicamente, o custo de trazer visitante cai para perto de zero.
O time de front reaproveitou o que já tinha: o mesmo pacote React que serve o painel administrativo, agora apontado também para as rotas de produto. Publicou, testou no navegador — parecia perfeito, rápido, sem reload de página — e esperou o tráfego orgânico chegar. Três semanas depois, o Search Console mostrava as páginas indexadas, mas sem título nem descrição: só a URL nua.
Quem investigou rodou curl contra uma das páginas e viu o problema inteiro em uma linha: a resposta é sempre o mesmo index.html, com um `<div id="root"></div>` vazio. O nome do produto, o preço, a descrição — nada disso está na resposta que qualquer coisa que não execute JavaScript recebe. O app funciona perfeitamente para quem já está com o navegador aberto; para quem ainda não chegou, ele não existe.
O que este laboratório NÃO é
Não é decisão de autenticação: o painel já usa o padrão de sessão do L12, e este módulo não mexe nisso. Também não é proteção de borda — WAF e limite de taxa são o L47, que depende do L05 assim como este. E não é escolha de framework de front-end: React, Vue ou Blazor mudam a sintaxe de onde o HTML é montado, não a decisão arquitetural sobre ONDE ele é montado, que é o assunto inteiro daqui.
O que você vai conseguir fazer
Cada objetivo se prova com um comando na seção de implantação. Nenhum deles se prova com a sensação de ter entendido.
- Explicar a diferença entre TTFB e tempo até o conteúdo aparecer, e por que a SPA melhora um piorando o outro.
- Provar, com curl, que um rastreador sem JavaScript recebe conteúdo vazio numa SPA e conteúdo completo numa página renderizada no servidor.
- Configurar uma política de cache customizada que compartilha o mesmo objeto entre visitantes diferentes, sem incluir cookie na chave.
- Explicar por que uma política gerenciada com TTL mínimo maior que zero ignora Cache-Control: no-store enviado pela origem.
- Medir taxa de acerto por comportamento de cache, separando o custo do hit do custo do miss.
- Derivar o TTL do catálogo a partir da tolerância de atualidade declarada, em vez de copiar um valor padrão.
- Justificar por que o painel administrativo continua sendo SPA depois da migração do catálogo para SSR.
- Publicar as duas versões — SPA e SSR — e comparar custo e TTFB entre elas com número, não com impressão.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Renderização client-side vs. no servidor | SAA-C03, DVA-C02 | a mesma URL montada no navegador ou no contêiner | que TTFB e tempo até o conteúdo são métricas diferentes, e uma arquitetura pode melhorar uma piorando a outra |
| Custom error response para SPA | SAA-C03, DVA-C02 | fallback de 403/404 para index.html, só no comportamento do painel | que rota de cliente não é arquivo real no bucket, e por que o catálogo SSR não precisa desse fallback |
| Cache policy vs. o que é encaminhado à origem | SAA-C03, DVA-C02 | chave de cache sem cookie no comportamento do catálogo | que a chave decide o que é COMPARTILHADO, não o que a origem recebe |
| TTL mínimo de política gerenciada | SAA-C03 | CachingOptimized sobre uma resposta com Cache-Control: no-store | que TTL mínimo maior que zero vence a diretiva da origem — é documentado, não é bug |
| Origem HTTP customizada vs. origem S3 | SAA-C03, DVA-C02 | comportamento do catálogo aponta para o ALB; o do painel, para o bucket | que origem customizada aceita cabeçalho e método que uma origem S3 não aceita da mesma forma |
| Dois comportamentos, um domínio | SAA-C03 | path pattern decide política de cache e origem por caminho | que o comportamento mais específico vence, e o padrão funciona como coringa |
| Cabeçalho de resposta e indexação | SAA-C03, DVA-C02 | X-Robots-Tag: noindex no comportamento do painel | que robots.txt impede rastreamento, não indexação de URL já conhecida por outro caminho |
| Eficiência de performance no Well-Architected | SAA-C03, SAP-C02 | escolher onde renderizar por rota, não por aplicação inteira | que a pergunta certa é "que página, com qual restrição", não "qual renderização é melhor" |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve um site que "ficou mais lento para robôs de busca" depois de migrar para uma SPA, e oferece "aumentar a capacidade do servidor" como distrator. Capacidade não é o problema: o servidor responde rápido, com pouco conteúdo. A resposta certa está em ONDE o HTML é montado, não em quanta computação existe por trás dele.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca — e nenhuma peça da arquitetura de produção existe fora dela.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| HTML pronto para o rastreador, sem depender de JS | obrigatório no catálogo | obriga renderização no servidor (SSR) nas rotas de produto — não pré-render único no build |
| Atualização de preço e estoque | no máximo 5 min de atraso | limita o TTL do comportamento do catálogo a 300 s; descarta SSG puro sem pipeline de revalidação |
| Conteúdo idêntico para todo visitante anônimo | hipótese declarada | autoriza cache COMPARTILHADO no catálogo — chave sem cookie |
| TTFB público, p95 | abaixo de 600 ms | força cache na borda em vez de renderizar a cada visita; o miss ainda paga banco + render |
| Painel administrativo não pode ser indexado | obrigatório | cabeçalho X-Robots-Tag no comportamento do painel, além de robots.txt — as duas camadas |
| Navegação do painel sem recarregar a página | requisito de UX interno | mantém o painel como SPA; SSR ali não se justifica por nenhum requisito, é adorno |
| Custo do catálogo previsível mesmo com rajada de rastreador | obrigatório, orçamento pequeno | o cache absorve o rastreamento; sem ele, cada página visitada pelo robô vira uma renderização paga |
| Nenhum dado de sessão nas páginas de catálogo | hipótese declarada | remove a necessidade de Cookie ou Authorization na chave de cache dessas rotas |
| Publicar o painel sem afetar o catálogo, e vice-versa | obrigatório, equipe pequena | dois pipelines independentes: sync no S3 para o painel, rollout do ECS para o catálogo (reaproveita o L03) |
O requisito mais fácil de esquecer que existe
A hipótese "nenhuma página varia por visitante" não é permanente — é uma decisão de produto que pode mudar. No dia em que existir preço por contrato ou promoção segmentada, a rota correspondente para de caber neste cache compartilhado e precisa sair para um comportamento sem cache, ou para um com a chave incluindo o que identifica o contrato. Ignorar essa mudança é o caminho mais direto para um visitante ver o preço de outro.
Arquitetura mínima: SPA na borda, servindo tudo
Este é o desenho que a Cadência publicou por reaproveitar a pipeline do painel. Ele é legítimo como ponto de partida — implanta de verdade, com poucas linhas — e é exatamente por isso que o defeito não aparece em nenhum teste manual: quem abre o navegador vê a página funcionando.
- → sync do build: index.html + bundle com hash
- → GET /produtos/tenis-x, sem executar o que vem depois
- → GET /produtos/tenis-x
- → serve o MESMO index.html para qualquer caminho
- → miss de /api/*: a chamada é do navegador, não da borda
- → encaminha ao contêiner
- → SELECT preço e estoque, a cada chamada
- Fora da AWS
- Rede e entrega
- Armazenamento
- Compute
- Banco de dados
O rastreador e o visitante recebem o MESMO HTML vazio — só muda o que acontece depois, no navegador de um deles. Percorra os passos: a borda até acerta o cache, mas do objeto errado, e nenhum alarme dispara quando o conteúdo nunca chega a existir para quem não executa o JavaScript.
- O rastreador recebe uma casca, não o produto. A resposta é literalmente o mesmo `index.html` que o visitante humano recebe: um `<div id="root"></div>` vazio e uma tag de script. O que preenche essa casca depende de executar o pacote JS depois — e a CloudFront não sabe, nem precisa saber, se isso vai acontecer.
- A borda acerta o cache do objeto errado. Como toda rota devolve o mesmo `index.html`, a taxa de acerto deste comportamento pode chegar perto de 100% — e essa métrica, sozinha, sugere que está tudo bem. Ela mede se o INVÓLUCRO foi reaproveitado, não se o produto apareceu para quem pediu.
- O conteúdo chega numa segunda viagem, se chegar. Depois que o HTML "carregou" do ponto de vista da rede, o navegador ainda precisa baixar, interpretar e executar o pacote JS, e só então chamar a API para buscar o produto. O primeiro byte foi rápido; o conteúdo que importa vem numa etapa inteiramente separada.
- Cada visita paga a consulta, sempre. Nada neste desenho guarda a resposta da API. Cada chamada do navegador — de um humano ou de um rastreador que execute JS — refaz a consulta ao banco. O custo de computar a página cresce de forma linear com CADA visita, sem nenhuma economia de escala.
- Nada aqui gera um alarme. Não há erro 4xx nem 5xx: o servidor respondeu 200 com um HTML válido. A ausência do produto só aparece, semanas depois, como queda de tráfego orgânico num painel que fica fora do console da AWS — nenhuma métrica deste desenho aponta para a causa.
- Por que o time builda assim, e não por descuido. É a mesma pipeline que já serve o painel administrativo desde o L05: um build, um bucket, um comportamento. Reaproveitar foi a decisão certa para o painel — e virou a decisão errada quando alguém apontou o MESMO pipeline para o catálogo que precisa ser encontrado.
A prova abaixo reproduz o que um rastreador que não executa JavaScript recebe. Não é simulação: é a resposta real, sem nenhum agente especial — só o que HTTP entrega.
# medir-o-vazio.sh — prova que o conteudo do produto nao chega ao rastreador
ALVO="https://catalogo-antigo.cadencia.com.br" # o desenho MINIMO deste laboratorio
# Um rastreador tipico busca o HTML e NAO executa o pacote JS depois.
# curl reproduz exatamente essa condicao, sem precisar simular um bot.
curl -s "$ALVO/produtos/tenis-x" | grep -c "Tênis X"
# Na Cadencia: 0. O nome do produto simplesmente nao esta na resposta — so
# existe depois que o navegador roda o JS e chama a API.
curl -s "$ALVO/produtos/tenis-x" | wc -c
# Na Cadencia: 612 bytes. E o mesmo index.html de sempre, nao uma pagina de
# produto — o tamanho sozinho ja denuncia que nada foi renderizado.
A falha mais perigosa deste laboratório não lança nenhum alarme
O servidor respondeu 200, com HTML sintaticamente válido. Não há erro para o CloudWatch registrar, não há 4xx nem 5xx, nenhum health check falha. A única consequência observável é uma queda de tráfego orgânico ao longo de semanas, medida num painel — o Search Console — que fica inteiramente fora do console da AWS. Um defeito que não aciona nenhum dos alarmes que este time já sabe configurar é o tipo de defeito que sobrevive mais tempo.
Arquitetura para produção
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → push da imagem do renderizador
- → sync do bundle do painel, nomes com hash
- → GET /produtos/tenis-x, sem depender do que vem depois
- → GET /produtos/tenis-x
- → GET /admin, sessão autenticada
- → miss de /produtos/*: chave sem cookie
- → renderiza o HTML completo com o preço atual
- → consulta preço e estoque, só no miss
- → todo /admin/*: casca da SPA + bundle
- → taxa de acerto por comportamento, por minuto
- Fora da AWS
- Compute
- Rede e entrega
- Armazenamento
- Banco de dados
- Gestão e governança
O catálogo passa a nascer pronto no contêiner, e a borda guarda essa cópia PARA TODOS enquanto ela valer — não porque alguém pediu, mas porque a chave de cache não inclui o que varia por visitante. O painel não muda: continua SPA, porque nenhum rastreador o visita. Percorra os passos.
- O rastreador recebe o produto, não a promessa de um. A mesma URL agora devolve, na primeira resposta, o título, o preço e a disponibilidade dentro do HTML — nenhuma execução de script é necessária para esse conteúdo existir. Isso vale tanto para quem executa JavaScript quanto para quem não executa.
- A chave de cache decide o que pode ser compartilhado. A política deste comportamento não inclui cookie, cabeçalho nem query string na chave — só o caminho. Como a hipótese declarada diz que o catálogo não varia por visitante anônimo, dois pedidos do MESMO produto, de pessoas diferentes, podem compartilhar a mesma cópia.
- O contêiner só entra em cena no miss. Quando a borda tem uma cópia válida, ela responde sozinha — o ALB, a task e o banco nunca são alcançados. O custo de RENDERIZAR a página é pago uma vez por janela de tempo por produto, não uma vez por visita.
- A armadilha: TTL mínimo de política gerenciada vence o no-store. Se este comportamento usasse a política gerenciada CachingOptimized por hábito — é a sugestão padrão em todo assistente —, a distribuição guardaria a resposta por pelo menos o TTL mínimo dela, mesmo que a origem mandasse `Cache-Control: no-store`. É documentado pela AWS como comportamento esperado, não como falha.
- O painel continua no mesmo lugar de sempre. Requisições de `/admin/*` caem noutro comportamento, apontado para o bucket do painel — o mesmo desenho do L05, sem alteração. Nada na lista de requisitos pediu para mudar isso, e mudar seria pagar renderização por navegação que já era rápida.
- A observação nunca é agregada entre os dois. A taxa de acerto é lida por comportamento. Somar `/produtos/*` (que deveria ter acerto alto) com `/admin/*` (que não guarda nada compartilhado por decisão) esconderia uma queda real num dos dois atrás da média do outro.
- Por que este desenho custa mais peça, e por que vale a pena. Duas políticas de cache, dois comportamentos, um contêiner ligado — é genuinamente mais do que o desenho anterior. A justificativa não é abstrata: é o requisito de aparecer em busca, que é o canal de aquisição real desta empresa.
A diferença estrutural em relação ao desenho anterior não é "acrescentar um contêiner": é que o HTML deixa de nascer sempre no mesmo lugar. O catálogo nasce no contêiner e pode viver na borda por um tempo; o painel continua nascendo no bucket, sem cache compartilhado nenhum, porque nenhum requisito pediu para mudar isso.
TTL mínimo de política gerenciada vence Cache-Control: no-store
A AWS documenta isto explicitamente sobre as políticas gerenciadas com TTL mínimo maior que zero, como a CachingOptimized (mínimo de 1 s): "CloudFront will cache content for at least the duration specified in the cache policy minimum TTL, even if the Cache-Control: no-cache, no-store, or private directives are present in the origin headers." Não é um bug a ser reportado — é o comportamento projetado. Para uma rota que às vezes precisa honrar literalmente um no-store da origem, a política precisa ter TTL mínimo em 0, o que nenhuma política gerenciada de cache agressivo oferece.
O TTL nasce do requisito, não de um valor que "parece razoável"
A tolerância declarada de até 5 minutos de atraso no preço não é uma sugestão de estilo: ela É o max_ttl da política. Um TTL maior economizaria mais renderização e violaria o requisito; um TTL menor cumpriria o requisito com folga e pagaria renderização que não precisava pagar. O valor certo é o que encosta no limite declarado, não o que "parece seguro".
Como funciona ponta a ponta
O caminho de uma requisição ao catálogo tem uma bifurcação — hit ou miss — que decide se o banco é consultado ou não. Os dois caminhos devolvem o MESMO HTML: a diferença é só quem o montou, e há quanto tempo.
O que o renderizador embute na resposta não é só texto visível: é também dado estruturado, no formato que buscadores usam para entender do que a página trata sem precisar inferir a partir do layout.
// produto.jsonld — parte da PRIMEIRA resposta, não de uma segunda chamada.
// Isto é o que o SSR entrega de graça: o rastreador nao precisa executar
// nada para achar este bloco, porque ele ja esta no HTML que chegou.
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Tênis X",
"description": "Tênis de corrida, entressola em espuma EVA, cabedal em tela.",
"sku": "TX-4471",
"offers": {
"@type": "Offer",
"price": "349.90",
"priceCurrency": "BRL",
"availability": "https://schema.org/InStock"
}
}
// Na SPA (arquitetura minima), este bloco so existiria depois da chamada
// client-side — e um rastreador que nao executa JS nunca o veria.
O hit não é "uma versão mais pobre" da resposta
A cópia que a borda guarda é byte a byte a mesma coisa que o contêiner renderizou — incluindo o bloco de dado estruturado. Servir do cache não degrada nada que importa para quem lê ou para quem rastreia; degrada só a atualidade, dentro do limite que o TTL impõe.
As decisões, e o que se perde em cada uma
📋 Cadência precisa que 1.200 páginas de produto rankeiem organicamente, manter um painel interno rápido para 15 pessoas, preço e estoque atualizados a partir do ERP a cada hora, com duas pessoas no time e sem orçamento para um serviço de renderização gerenciado.
Nenhuma das duas coisas separadamente resolveria o problema real, que tem duas metades diferentes: o catálogo precisa ser encontrado por quem não roda JavaScript, e o painel precisa continuar rápido para gente que já está autenticada e não compete em busca nenhuma. Tratar as duas rotas com a mesma renderização — qualquer uma das duas — resolveria uma metade e pioraria a outra sem necessidade.
Alt: SPA único, também no catálogo — É o desenho que abre este laboratório: o rastreador recebe casca vazia, e a queda de tráfego orgânico não gera alarme nenhum — só aparece semanas depois, num painel fora da AWS.
Alt: SSR universal, inclusive no painel — Paga renderização e consulta ao banco a cada navegação interna que não precisa de SEO nem de HTML pronto, e troca a navegação instantânea que o SPA já entregava por uma viagem ao servidor a cada clique.
Alt: Pré-renderização total no build (SSG) — Com 1.200 SKUs e preço mudando a cada hora, publicar o catálogo inteiro de novo a cada atualização — ou aceitar até uma hora de preço desatualizado sem pipeline de revalidação incremental — vira o próprio gargalo. Fica para o nível 4 da evolução, quando existir esse pipeline.
Alt: Dynamic rendering (HTML diferente por User-Agent) — Depende de identificar corretamente o rastreador — um cabeçalho que qualquer cliente declara do jeito que quiser —, duplica a superfície a manter (duas respostas para a mesma URL) e deixou de ser a orientação recomendada como solução definitiva, e não apenas como paliativo.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Onde as páginas de catálogo são montadas | SSR no contêiner | SPA total; pré-render estático no build | HTML completo já na primeira resposta, sem depender de execução de JS por quem está lendo | o contêiner precisa ficar ligado; visita que ultrapassa o cache paga renderização |
| Onde o painel administrativo é montado | SPA, sem mudança | SSR também no painel | nenhuma página do painel precisa competir em busca; navegação sem recarregar é o requisito real | nenhuma perda relevante — é o desenho que já funcionava |
| O que entra na chave de cache do catálogo | caminho + host, sem cookie e sem query string | incluir cookie de sessão (padrão da política UseOriginCacheControlHeaders) | permite compartilhar o mesmo objeto entre visitantes anônimos diferentes | se um dia existir preço por contrato, essa rota precisa sair deste comportamento |
| TTL do catálogo | derivado da tolerância declarada (300 s) | TTL longo padrão (24 h, CachingOptimized); TTL zero (CachingDisabled) | equilibra atualidade de preço e estoque com taxa de acerto | preço pode ficar até 5 min desatualizado — é o próprio requisito, não um bug |
| Pipeline de publicação | dois pipelines independentes | um pipeline único publicando os dois juntos | equipe pequena não coordena dois motivos de mudança no mesmo deploy | risco de as duas versões divergirem sem ninguém notar; mitigado com tag de versão |
| Como impedir indexação do painel | cabeçalho noindex no comportamento, mais robots.txt | confiar só no robots.txt | duas camadas, porque robots.txt não remove URL já indexada por outro caminho | nenhuma perda relevante |
| Estratégia para atualidade de preço | TTL curto com revalidação natural | invalidação síncrona a cada mudança de preço | evita rajada de invalidação coincidindo com a atualização em lote do ERP | até 5 minutos de defasagem — exatamente o que o requisito autoriza |
A dívida que dois pipelines independentes criam, e que este módulo não paga
Nada impede que alguém publique uma versão do painel que espera um campo novo da API do renderizador antes de o renderizador publicar esse campo. Os dois pipelines evitam um deploy acoplado às custas de exigir disciplina de compatibilidade entre eles — e isso é exatamente o problema de versionamento de contrato que o L18 trata para o banco, aplicado agora à borda.
Construir: a política de cache, feita à mão
As políticas gerenciadas resolvem a maioria dos casos, e por isso a maioria dos tutoriais nunca sai delas. Esta rota é a exceção: precisa de um TTL específico e de uma chave sem cookie, e nenhuma política gerenciada oferece as duas coisas juntas para conteúdo dinâmico.
# cache-policy.tf — a chave de cache do catalogo, decidida a mao
# As politicas GERENCIADAS da CloudFront resolvem quase todo caso, e e por
# isso que a maioria dos tutoriais para por ai. Nenhuma delas serve para esta
# rota, pelos dois motivos que os comentarios abaixo explicam.
resource "aws_cloudfront_cache_policy" "catalogo" {
name = "${var.projeto}-catalogo"
comment = "Compartilhada entre visitantes anonimos; TTL derivado da tolerancia de atualidade do preco"
# TTL minimo em 0 e o OPOSTO do padrao da politica gerenciada
# CachingOptimized (1 s). A AWS documenta que TTL minimo maior que zero faz
# a distribuicao ignorar Cache-Control: no-store da origem — que aqui seria
# o comportamento ERRADO: se a task um dia mandar no-store (por exemplo,
# numa promocao que exige preco sempre fresco), queremos que a distribuicao
# OBEDECA, e nao force um segundo de cache mesmo assim.
min_ttl = 0
default_ttl = 300 # tolerancia declarada: ate 5 min de atraso no preco
max_ttl = 300 # teto igual ao padrao — a origem nunca manda mais que isso
parameters_in_cache_key_and_forwarded_to_origin {
# Nenhum cookie na chave. E a decisao central desta secao: com cookie de
# sessao ou de rastreamento na chave, cada visitante vira uma entrada de
# cache SUA, e a taxa de acerto desaba para perto de zero sem nenhum erro
# visivel — e exatamente o que a politica gerenciada
# UseOriginCacheControlHeaders faria aqui, porque ela inclui TODOS os
# cookies por padrao.
cookies_config {
cookie_behavior = "none"
}
# Sem query string na chave: /produtos/tenis-x?utm_source=... e
# /produtos/tenis-x sao o MESMO produto. Incluir a query fragmentaria o
# cache por parametro de campanha de marketing.
query_strings_config {
query_string_behavior = "none"
}
headers_config {
header_behavior = "none"
}
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
}
}
# O cabecalho que marca o painel como nao-indexavel. Robots.txt sozinho nao
# basta: ele impede RASTREAMENTO, nao impede que uma URL ja conhecida por
# outro caminho (um link, por exemplo) seja indexada.
resource "aws_cloudfront_response_headers_policy" "noindex" {
name = "${var.projeto}-painel-noindex"
custom_headers_config {
items {
header = "X-Robots-Tag"
value = "noindex, nofollow"
override = true
}
}
}
A política que parece a mais inteligente é a que mais vaza
UseOriginCacheControlHeaders parece a escolha certa para "deixar a origem decidir o TTL" — e decide o TTL corretamente. O problema é outro campo do mesmo documento da AWS: essa política inclui TODOS os cookies na chave de cache. Com um cookie de sessão ou de rastreamento presente em toda requisição — o caso mais comum de aplicação web —, cada visitante recebe sua própria entrada de cache, e o compartilhamento que era o objetivo inteiro deste desenho nunca acontece. Nada quebra visivelmente: a distribuição continua respondendo, só que sem economizar nada.
Construir: o comportamento de borda que decide onde o HTML nasce
Duas origens, dois comportamentos, uma decisão por caminho. O comportamento mais específico — `/produtos/*` — vence sobre o padrão, que continua servindo o painel exatamente como antes deste laboratório.
# distribuicao.tf — dois comportamentos, duas politicas, duas origens
resource "aws_cloudfront_distribution" "app" {
enabled = true
comment = "${var.projeto}: catalogo SSR cacheavel + painel SPA"
# Origem 1: o renderizador, atras do ALB do L01/L03. E uma origem HTTP
# customizada, nao um bucket — a distincao importa porque origem
# customizada aceita metodo diferente de GET e cabecalho de autenticacao, o
# que uma origem S3 nao aceita da mesma forma.
origin {
domain_name = aws_lb.app.dns_name
origin_id = "renderizador"
custom_origin_config {
origin_protocol_policy = "https-only"
http_port = 80
https_port = 443
origin_ssl_protocols = ["TLSv1.2"]
}
# So a origem entende este cabecalho; prova que a requisicao veio da
# borda e nao de alguem falando direto com o ALB. Nao substitui rede
# privada — e defesa complementar, reaproveitada do L05.
custom_header {
name = "X-Origem-Borda"
value = var.segredo_origem
}
}
# Origem 2: o bucket do painel, privado, com OAC — o mesmo padrao do L05,
# aplicado agora so ao bundle administrativo.
origin {
domain_name = aws_s3_bucket.painel.bucket_regional_domain_name
origin_id = "painel"
origin_access_control_id = aws_cloudfront_origin_access_control.painel.id
}
# Comportamento padrao: o painel. Fica como default porque e o que ja
# existia antes deste laboratorio — o catalogo e que entra como excecao.
default_cache_behavior {
target_origin_id = "painel"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
# ID documentado pela AWS para a politica gerenciada CachingOptimized.
# Serve bem aqui: o bundle do painel e estatico, com hash no nome —
# exatamente o caso para o qual essa politica foi desenhada.
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
response_headers_policy_id = aws_cloudfront_response_headers_policy.noindex.id
}
# O catalogo: caminho mais especifico, politica propria, origem diferente.
ordered_cache_behavior {
path_pattern = "/produtos/*"
target_origin_id = "renderizador"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = aws_cloudfront_cache_policy.catalogo.id
}
# O painel E uma SPA de verdade: /pedidos/42 nao existe como arquivo no
# bucket. Este fallback, no nivel da DISTRIBUICAO, devolve index.html com
# 200 para qualquer 403/404 — e so se aplica ao comportamento padrao,
# porque o catalogo tem rota real por produto e nao precisa dele.
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = var.certificado_borda_arn # us-east-1, herdado do L05
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
O fallback de SPA só existe onde ainda faz sentido
O painel não tem arquivo real para cada rota — `/pedidos/42` só existe porque o JavaScript decide mostrá-lo. Por isso o comportamento padrão precisa do fallback de 403/404 para `index.html`. O catálogo NÃO precisa dele: cada produto tem uma rota real, entendida pelo contêiner, e devolver 404 de verdade para um produto que não existe é o comportamento correto — inclusive para SEO, que trata 404 genuíno como sinal válido.
Construir: a página renderizada no contêiner
O contêiner recebe uma rota por produto e devolve HTML completo — não JSON para o navegador montar depois. É a mudança que faz o rastreador parar de receber casca vazia.
// Program.cs — a pagina nasce aqui, e a codificacao nao e opcional
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContextPool<AppDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Padrao")));
var app = builder.Build();
// So esta rota decide o que a borda pode compartilhar entre visitantes.
// Nada aqui le Cookie nem Authorization — e por isso que a resposta PODE
// ser cacheada sem risco de misturar dado de um visitante com o de outro.
app.MapGet("/produtos/{slug}", async (string slug, AppDb db, HttpContext ctx) =>
{
var produto = await db.Produtos.SingleOrDefaultAsync(p => p.Slug == slug);
if (produto is null) return Results.NotFound();
// HtmlEncoder e a linha que substitui o que o React fazia sozinho no
// desenho anterior. Nome e descricao vem do catalogo, escrito por
// fornecedor — dado que ATRAVESSA um limite de confianca. Interpolar
// sem encodar aqui e o jeito mais direto de introduzir um XSS
// armazenado que a SPA nunca teve, porque la cada valor passava pelo
// encoder automatico do React antes de virar DOM.
var enc = System.Text.Encodings.Web.HtmlEncoder.Default;
var nome = enc.Encode(produto.Nome);
var descricao = enc.Encode(produto.Descricao);
var jsonLd = System.Text.Json.JsonSerializer.Serialize(new
{
context = "https://schema.org",
type = "Product",
name = produto.Nome, // o serializador JSON encoda por conta propria
sku = produto.Sku,
offers = new
{
type = "Offer",
price = produto.Preco.ToString("F2"),
priceCurrency = "BRL",
availability = produto.EmEstoque
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
});
var html = $"""
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>{nome} — Cadência</title>
<meta name="description" content="{descricao}">
<script type="application/ld+json">{jsonLd}</script>
</head>
<body>
<h1>{nome}</h1>
<p>{descricao}</p>
<p>R$ {produto.Preco:F2} — {(produto.EmEstoque ? "em estoque" : "indisponível")}</p>
</body>
</html>
""";
// O TTL que a borda respeita nasce AQUI, nao na politica. A politica so
// define o TETO (max_ttl = 300) e o PISO (min_ttl = 0); quem manda o
// valor real e a origem — exatamente o padrao ja usado no L05.
ctx.Response.Headers["Cache-Control"] = "public, max-age=300";
return Results.Content(html, "text/html; charset=utf-8");
});
// Vivacidade sem dependencia — mesmo padrao do L03. Nao entra no caminho do
// cache: a borda nunca guarda a resposta desta rota.
app.MapGet("/health/live", () => Results.Ok());
app.Run();
Renderizar no servidor reabre um risco que a SPA fechava sozinha
Numa SPA, o React encoda automaticamente qualquer dado interpolado no DOM — é parte do funcionamento normal da biblioteca, e a maioria dos times nunca precisa pensar nisso. Ao montar HTML como string no servidor, essa proteção desaparece: interpolar nome ou descrição de produto sem `HtmlEncoder` introduz um XSS armazenado que simplesmente não existia no desenho anterior. A migração para SSR não é neutra em segurança — ela move a responsabilidade de codificar para o seu código, e o seu código precisa assumi-la explicitamente.
# Dockerfile — multi-stage, o mesmo racional do L03
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["Renderizador/Renderizador.csproj", "Renderizador/"]
RUN dotnet restore "Renderizador/Renderizador.csproj"
COPY . .
RUN dotnet publish "Renderizador/Renderizador.csproj" -c Release -o /app/publish /p:UseAppHost=false
# O SDK nao vai para producao: e superficie de ataque e bytes que cada task
# nova baixaria de graca, sem servir a nenhum visitante.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
USER app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "Renderizador.dll"]
Implantar, e provar as duas versões
Cinco provas. As duas primeiras atacam o entregável direto — conteúdo e TTFB —, as outras três atacam os mecanismos que sustentam a decisão de cache.
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece que funcionou"
CATALOGO="https://cadencia.com.br" # desenho de PRODUCAO (SSR + cache seletivo)
ANTIGO="https://catalogo-antigo.cadencia.com.br" # desenho MINIMO (SPA), mantido so para a prova 1
# ── Prova 1: o rastreador ve o produto, ou ve casca? ─────────────────────────
echo "SPA: $(curl -s "$ANTIGO/produtos/tenis-x" | grep -c 'Tênis X') ocorrencias do nome"
echo "SSR: $(curl -s "$CATALOGO/produtos/tenis-x" | grep -c 'Tênis X') ocorrencias do nome"
# Esperado: SPA = 0, SSR = 1. Qualquer coisa diferente de "SSR = 1" reprova
# o laboratorio inteiro — e o entregavel central.
# ── Prova 2: TTFB nos tres estados, nao so em dois ───────────────────────────
echo "SPA (casca, sempre no cache do bucket):"
curl -s -o /dev/null -w ' time_starttransfer=%{time_starttransfer}s\n' "$ANTIGO/"
echo "SSR em MISS (forca expirando o TTL com um produto novo):"
curl -s -o /dev/null -w ' time_starttransfer=%{time_starttransfer}s\n' \
"$CATALOGO/produtos/produto-recem-cadastrado"
echo "SSR em HIT (produto ja visitado, dentro do TTL):"
curl -s -o /dev/null -w ' time_starttransfer=%{time_starttransfer}s\n' "$CATALOGO/produtos/tenis-x"
# Medido na aplicacao de exemplo: casca 0,045 s · miss 0,31 s · hit 0,052 s.
# O HIT chega perto do TTFB da casca, mas com o produto inteiro dentro —
# e essa e a comparacao que o entregavel pede.
# ── Prova 3: taxa de acerto depois de aquecer ────────────────────────────────
acertos=0; total=100
for i in $(seq 1 $total); do
produto="produto-$(( (i % 20) + 1 ))" # 20 produtos distintos, repetidos
cache=$(curl -s -o /dev/null -D - "$CATALOGO/produtos/$produto" \
| grep -i '^x-cache:' | tr -d '\r')
[[ "$cache" == *"Hit from cloudfront"* ]] && acertos=$((acertos+1))
done
echo "taxa de acerto: $acertos/$total"
# Esperado, depois do aquecimento inicial (as 20 primeiras chamadas de cada
# produto sao miss por definicao): por volta de 94/100. Abaixo de ~80/100
# com este padrao de repeticao indica TTL curto demais ou cookie na chave.
# ── Prova 4: a chave de cache ignora cookie, de proposito ───────────────────
h1=$(curl -s -o /dev/null -D - -H 'Cookie: sessao=visitante-a' \
"$CATALOGO/produtos/tenis-x" | grep -i '^x-cache:')
h2=$(curl -s -o /dev/null -D - -H 'Cookie: sessao=visitante-b' \
"$CATALOGO/produtos/tenis-x" | grep -i '^x-cache:')
echo "visitante A: $h1"
echo "visitante B: $h2"
# Esperado: os DOIS acusam Hit from cloudfront (apos a primeira chamada
# aquecer o cache). Se um deles vier Miss, algo no cabecalho ou no cookie
# esta entrando na chave sem que a politica customizada devesse permitir.
# ── Prova 5: o preco atualiza dentro da janela prometida ────────────────────
INICIO=$(date +%s)
# mude o preco diretamente no banco aqui (fora deste script)
while true; do
preco=$(curl -s "$CATALOGO/produtos/tenis-x" | grep -oE 'R\$ [0-9]+,[0-9]{2}')
[[ "$preco" == *"329"* ]] && break # o novo preco que voce definiu
sleep 5
done
echo "preco atualizado em $(( $(date +%s) - INICIO )) segundos"
# Medido na aplicacao de exemplo: 187 s. O requisito declarado e ate 300 s
# (o max_ttl da politica); qualquer valor acima disso reprova a prova.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · O rastreador vê o produto | grep pelo nome do produto na resposta crua | SPA = 0 ocorrências, SSR = 1 ocorrência | se o SSR também vier 0, o renderizador não está montando o HTML — confira a rota |
| 2 · TTFB nos três estados | curl -w com time_starttransfer nos três caminhos | hit próximo da casca da SPA, miss visivelmente mais alto | hit muito acima da casca indica que o comportamento não está cacheando de verdade |
| 3 · Taxa de acerto após aquecer | laço de 100 requisições em 20 produtos, lendo X-Cache | por volta de 94/100 hits | abaixo de ~80/100 com este padrão de repetição indica TTL curto ou cookie na chave |
| 4 · Cookie não fragmenta o cache | duas requisições, cookies diferentes, mesma URL | as duas acusam Hit from cloudfront | se uma vier Miss, algo está entrando na chave que a política deveria excluir |
| 5 · Preço atualiza dentro do prazo | poll até o novo preço aparecer, cronometrado | atualização dentro de 300 s | acima de 300 s viola o requisito declarado de atualidade — reveja o max_ttl |
Meça de fora da região da aplicação
Rodar as provas de dentro da mesma região da AWS onde o ALB vive mede a rede da AWS, não a experiência de quem acessa de fora. A diferença entre hit e miss ainda aparece, mas os valores absolutos de TTFB saem otimistas demais para orientar decisão de produto.
Quebrar de propósito: três falhas e o diagnóstico
A falha que vaza para todo mundo, não só para quem a causou
As duas primeiras falhas desta seção afetam quem as provoca. A terceira é diferente: um insumo que a origem usa para variar a resposta, mas que a chave de cache não inclui, permite que a PRIMEIRA requisição envenene o cache para TODOS os visitantes seguintes — é a classe de falha chamada cache poisoning, e é a mais grave das três porque não fica contida em quem errou a configuração.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Cache key inclui cookie de sessão sem querer | trocar a política customizada pela gerenciada UseOriginCacheControlHeaders | taxa de acerto próxima de zero mesmo com tráfego repetido no mesmo produto | métrica CacheHitRate do comportamento, e a política anexada ao behavior | trocar por política customizada sem cookies na chave |
| TTL mínimo de política gerenciada sobre resposta no-store | aplicar CachingOptimized no comportamento do catálogo por hábito | preço desatualizado aparece para todo visitante por pelo menos o TTL mínimo, mesmo a origem mandando no-store | cabeçalho Age da resposta comparado ao Cache-Control que a origem realmente enviou | política customizada com TTL mínimo em 0 quando o requisito é honrar literalmente a origem |
| Página do painel aparece indexada no Google | publicar o comportamento do painel sem o cabeçalho noindex | funcionário descobre página interna numa busca pública | cabeçalho de resposta do comportamento /admin/*, e o robots.txt | X-Robots-Tag: noindex na política de resposta desse comportamento, mais robots.txt como segunda camada |
O comportamento /produtos/* está com a política gerenciada CachingOptimized anexada. A origem responde com Cache-Control: no-store em toda requisição, mas os visitantes ainda ocasionalmente recebem um preço um segundo desatualizado. Qual é a explicação correta?
Segurança: o que muda quando a página nasce no servidor
Mover a montagem do HTML para o contêiner troca um conjunto de riscos por outro. Nenhum deles é exclusivo deste laboratório — mas todos aparecem porque este laboratório é o primeiro a renderizar HTML no servidor nesta aplicação.
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| XSS armazenado por interpolação sem codificação | média | alto | HtmlEncoder em todo dado que atravessa a fronteira de confiança do catálogo | teste automatizado com payload conhecido sobre nome e descrição de produto | corrigir o ponto de interpolação, reimplantar, revisar todos os campos vindos de fonte externa |
| Cache poisoning por insumo fora da chave | baixa | alto | garantir que tudo que a origem usa para variar a resposta esteja na chave, ou não varie a resposta por aquele insumo | auditoria periódica comparando o que a rota lê com o que a cache policy inclui | invalidar o caminho afetado, corrigir a chave, revisar logs de acesso pelo período exposto |
| Painel indexado publicamente | média | médio | cabeçalho noindex na política de resposta do comportamento do painel, mais robots.txt | monitorar aparição de /admin/* em busca ou em log de rastreador conhecido | solicitar remoção de índice, corrigir o cabeçalho, confirmar em nova rastreada |
| Cache compartilhado sobrevive a uma mudança na hipótese de personalização | baixa hoje, alta se o requisito mudar | alto | revisar a hipótese declarada antes de qualquer alteração de preço personalizado | teste de contrato: dois usuários diferentes não deveriam ver o mesmo preço quando a regra mudar | desligar o cache da rota imediatamente, invalidar, investigar a extensão do vazamento |
| Papel de execução do ECS com privilégio amplo demais | baixa | médio | papéis separados de execução e de task, reaproveitados do L01/L03 | IAM Access Analyzer sobre uso real | derivar a política do uso medido — é o L41 |
Observabilidade: as perguntas que o painel tem de responder
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| A borda está absorvendo o tráfego do catálogo? | CacheHitRate do comportamento /produtos/* | queda súbita indica TTL curto demais, ou chave variando sem necessidade | ≥ 85% após aquecimento |
| O contêiner está sendo acionado além do esperado? | invocações por minuto no alvo vs. CacheHitRate | hit ratio baixo aparece correlacionado a pico de invocação | investigar sempre que a correlação sumir |
| O TTFB do catálogo está dentro do orçamento? | TargetResponseTime do ALB somado ao tempo de borda | hit e miss precisam ser separados, senão a média mente | p95 miss < 400 ms · p95 hit < 100 ms (medido, não padrão) |
| O preço está fresco dentro do prometido? | cabeçalho Age comparado ao TTL configurado | Age próximo do TTL máximo indica conteúdo no limite da tolerância | Age < 300 s |
| Alguém indexou o painel por engano? | log de acesso do comportamento /admin/* por user-agent de rastreador | acesso de robô conhecido a uma rota que deveria ser invisível para ele | qualquer ocorrência |
| A renderização está gerando erro sem derrubar a cópia antiga da borda? | 5xx do alvo no comportamento /produtos/* | contêiner respondendo mal enquanto a borda ainda serve a última cópia boa | > 1% das renderizações em 5 min |
A métrica que engana quando lida agregada
A taxa de acerto da DISTRIBUIÇÃO inteira mistura um comportamento que deveria estar perto de 100% com outro que foi desenhado para nunca compartilhar nada. Uma média de 40% pode ser dois números saudáveis — 85% no catálogo, quase 0% no painel — ou pode ser o catálogo quebrado escondido atrás do painel funcionando como esperado. Só a métrica por comportamento distingue os dois casos.
Escala: 10, 10 mil, 1 milhão, e o rastreador em rajada
| Volume | O que acontece com a renderização | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 req/min, 1.200 produtos | quase tudo cabe no cache depois do aquecimento inicial | nada real; o TTL de 300 s garante frescor | nada |
| 10 mil req/min, pico de campanha | o hit ratio já alto absorve a maior parte; o miss ainda soma | o primeiro miss de cada produto após expirar o TTL gera renderização simultânea se muitos TTLs vencerem juntos | escalonar os TTLs com um pequeno jitter, para não expirar tudo no mesmo segundo |
| 1 milhão de req/dia, rastreador em rajada | um rastreador pode varrer milhares de páginas em minutos | páginas raramente visitadas por humano viram a maior fatia do custo de renderização, pagas só pelo robô | priorizar TTL mais longo para as páginas de cauda longa observada nos logs — é o nível 6 da evolução |
| Atualização em massa do ERP (1.200 SKUs de uma vez) | se cada mudança invalidasse a página na hora, seriam 1.200 renders quase simultâneos | a rajada de renderização coincidiria com o próprio evento que a motivou | manter o TTL natural em vez de invalidação síncrona total — é exatamente a decisão já tomada nesta seção |
| Falha de AZ durante um miss | a task da AZ saudável responde; a borda não sabe nem precisa saber quantas tasks existem | nada específico deste desenho — herda a resiliência do L01/L03 | manter pelo menos 2 tasks em 2 AZs, como já estabelecido |
O tráfego que mais se beneficia do cache não é humano
Um rastreador de busca não tem paciência nem lealdade: ele revisita a mesma URL em intervalos próprios, muitas vezes mais rápido que qualquer humano voltaria. É exatamente esse padrão que um cache compartilhado absorve melhor — e é exatamente esse padrão que, sem cache nenhum, mais penaliza a fatura do contêiner.
Custo: o que este laboratório muda na fatura
A pergunta certa não é "quanto custa SSR" — é "quanto da fatura do catálogo depende da taxa de acerto". A fórmula abaixo é a mesma conta da seção de escala, escrita como custo em vez de como carga.
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| CloudFront no hit | requisições e bytes servidos na borda | muito mais barato que um miss, mas ainda soma em alto volume — mande ao Pricing Calculator |
| ALB + Fargate | hora ligada mais computação | cobra independente do tráfego: o contêiner fica de pé mesmo quando a borda absorve 95% das visitas |
| RDS | consulta por MISS, não por visita | já que hit não toca o banco, o custo do banco cresce com o INVERSO da taxa de acerto |
| S3 do painel | armazenamento do bundle e requisições de GET | baixo, com poucos usuários internos e pouco tráfego repetido |
| CloudWatch | métricas extras de taxa de acerto por comportamento | valor pequeno; não é onde se economiza |
| Cenário | Volume | O que domina o custo | Tendência | Otimização |
|---|---|---|---|---|
| Catálogo recém-publicado | cache frio, todo produto em miss na primeira visita | renderização e consulta ao banco em quase toda requisição | cai rápido conforme o cache aquece | nenhuma; é o comportamento esperado do primeiro dia |
| Catálogo maduro | hit ratio estabilizado por volta de 90% | ALB e Fargate ligados, com pouca renderização de fato | previsível, dominado pelo custo fixo de manter o contêiner de pé | avaliar se o dimensionamento do contêiner pode cair, já que ele atende só a fração de miss |
| Pico de campanha com muitos produtos novos | muitos misses simultâneos | renderização concentrada numa janela curta | sobe junto com o lançamento, depois volta ao padrão maduro | publicar produtos novos escalonados no tempo, não todos de uma vez |
O ganho de custo que não está na fatura de CloudFront
Um hit ratio maduro por volta de 90% significa que o contêiner atende só a uma fração do tráfego total — o que abre espaço para dimensioná-lo por esse volume residual, não pelo tráfego bruto do catálogo. É uma decisão de capacidade que só faz sentido depois de medir a taxa de acerto real, não antes.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | dois pipelines independentes, cada um publicando sua parte sem afetar a outra | divergência de contrato entre painel e renderizador sem aviso automático | teste de contrato entre os dois pipelines | média |
| Segurança | HTML codificado no servidor, painel com cabeçalho noindex, chave de cache sem cookie | cache poisoning se um insumo novo passar a variar a resposta sem entrar na chave | auditoria periódica dos insumos lidos pela rota contra a chave declarada | alta |
| Confiabilidade | catálogo sobrevive a falha de AZ herdando o desenho do L01/L03 | rajada de misses simultâneos se muitos TTLs vencerem no mesmo instante | jitter no TTL de invalidação | média |
| Eficiência de performance | TTFB do hit próximo ao da casca estática, com conteúdo completo | o miss ainda paga a soma de render mais consulta ao banco | cache de segundo nível para a consulta, se o miss um dia dominar o TTFB | média |
| Otimização de custos | renderização paga só no miss, não em toda visita | contêiner dimensionado para o tráfego bruto, não para o residual pós-cache | redimensionar depois de medir o hit ratio real em produção | média |
| Sustentabilidade | cada hit evita uma renderização e uma consulta que já tinham sido feitas | produto de cauda longa raramente cacheado continua pagando renderização a cada visita rara | TTL mais longo para produtos com baixo tráfego humano, guiado pelo nível 6 da evolução | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro — e aqui a "opção intermediária" entre SSR puro e pré-render total finalmente ganha um lugar, no nível 4.
SPA total, sem preocupação com SEO. É onde a Cadência estava, e continua legítimo para um painel interno sem rastreador.SSR simples no contêiner para as rotas públicas, sem cache na borda para o HTML — cada visita renderiza do zero.Cache seletivo por comportamento, chave sem cookie, TTL derivado da tolerância de atualidade declarada.Geração estática no build (SSG) para o que muda pouco — páginas de categoria, institucionais — combinada com SSR só para o que muda por hora, como preço e estoque.Revalidação incremental orientada a evento — o ERP muda o preço, e só aquela página é invalidada na borda, em vez de esperar o TTL — mais renderização em múltiplas regiões.Decidir, a partir do padrão de tráfego e de rastreamento observado nos logs, qual página merece TTL curto e qual merece TTL longo — em vez de um único valor para o catálogo inteiro.A ordem não é uma corrida, é uma escada de requisito
Nenhum nível é "melhor" isoladamente — cada um resolve o risco que o nível anterior deixou em aberto, e nenhum deles é gratuito. A Cadência, com 1.200 SKUs e duas pessoas, para no nível 3 por decisão, não por limitação: os níveis 4 a 6 resolvem problemas que ela ainda não tem.
Onde IA entra nesta arquitetura, e onde não entra
A decisão central deste laboratório — renderizar cada rota onde o requisito manda — é determinística: SEO e TTFB público empurram para SSR cacheável; autenticação e navegação interna empurram para SPA. Um modelo não melhora essa decisão, porque ela já tem resposta certa a partir de fatos conhecidos sobre a rota.
Há um lugar honesto para IA, e é modesto: o nível 6 da evolução, decidir QUAL TTL cada página individual merece a partir do padrão de tráfego observado — em vez de um valor único para o catálogo inteiro. Mesmo aí, a resposta certa começa por regra simples, não por modelo.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | ajustar o TTL por página, em vez de um valor único, a partir do padrão de tráfego e rastreamento observado |
| Por que uma regra não bastaria? | uma regra simples cobre a maior parte: "página com pouco tráfego humano e muito rastreador usa TTL mais longo". IA só se justifica depois que essa regra mostrar seu limite |
| De onde viriam os dados? | logs de acesso da distribuição, já existentes — sem coleta nova |
| Qual o risco? | classificar errado uma página que na verdade é sensível a preço, sem um caminho manual de correção visível |
| Por que não agora? | o catálogo tem 1.200 produtos e um TTL único já cumpre o requisito declarado; não há problema não resolvido que justifique o modelo hoje |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "decidir se esta página deveria ser SPA ou SSR" troca uma classificação determinística e estável — a rota é pública ou é autenticada, isso não muda a cada requisição — por uma decisão probabilística que precisaria ser recalculada, auditada e monitorada. Onde a resposta certa já é conhecida a partir de um fato fixo sobre a rota, IA só acrescenta latência e uma nova fonte de erro.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Servir tudo como SPA, inclusive rota pública que precisa ranquear | reaproveitar a pipeline que já existe é a menor mudança possível | o rastreador que não executa JS recebe casca vazia, e nada avisa | páginas indexadas sem título nem descrição; tráfego orgânico plano apesar de conteúdo novo | SSR nas rotas públicas que dependem de busca | aplicação inteiramente autenticada, sem nenhuma rota pública |
| Servir tudo como SSR, inclusive o painel autenticado | parece a correção óbvia depois de resolver o catálogo — "SSR é melhor, então em tudo" | paga renderização e round-trip ao banco em navegação interna que não precisa de SEO nem de HTML pronto | navegação do painel fica perceptivelmente mais lenta que antes, sem ganho equivalente | manter SPA onde não há requisito de indexação nem de HTML pronto para quem não executa JS | nunca, dentro do escopo deste laboratório — não há requisito que justifique |
| Aplicar política gerenciada de cache agressivo (CachingOptimized) numa rota dinâmica | é a sugestão padrão de todo assistente e console; parece a escolha "recomendada" | o TTL mínimo dessa política vence Cache-Control: no-store, o que é documentado, não é bug | preço ou estoque desatualizado sem explicação aparente, mesmo com a origem tentando evitar cache | política customizada com TTL mínimo em 0 quando o requisito é honrar literalmente a origem | conteúdo genuinamente estático, sem variação — como o bundle do painel |
| Usar UseOriginCacheControlHeaders sem checar o que ela inclui na chave | parece resolver tudo de uma vez: "deixa a origem decidir" | ela inclui TODOS os cookies na chave, fragmentando o cache numa cópia por visitante | taxa de acerto próxima de zero mesmo com tráfego repetido no mesmo produto | política customizada com cookies_config explicitamente "none" | origem que realmente varia por cookie e não deveria compartilhar nada |
| Dynamic rendering: HTML diferente por User-Agent detectado | parece um atalho que não exige mudar a arquitetura de renderização | depende de um cabeçalho que qualquer cliente declara do jeito que quiser, e duplica a superfície a manter | divergência entre o que o bot vê e o que o humano vê, descoberta tarde | servir o MESMO HTML para todo mundo — é o que o SSR deste laboratório já faz | paliativo temporário enquanto uma migração para SSR está em andamento |
| Pré-renderizar o catálogo inteiro no build, ignorando a cadência de mudança de preço | parece a solução "mais simples": nada de contêiner, só arquivo estático | republicar 1.200 páginas a cada mudança de preço é caro e lento, ou aceitar defasagem maior que o requisito permite | build que demora cada vez mais conforme o catálogo cresce, ou preço visivelmente desatualizado | SSR para o que muda com frequência; SSG só para o que muda raramente — é o nível 4 da evolução | catálogo pequeno com preço que muda raramente, sem requisito de atualidade apertado |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Página indexada sem título nem descrição | rota ainda caindo no comportamento de SPA, não no de SSR | confira qual origem respondeu, pelo cabeçalho de origem ou pelo corpo da resposta | path pattern do comportamento do catálogo na distribuição | garantir que /produtos/* aponta para o renderizador, não para o bucket |
| Taxa de acerto travada perto de zero | cookie ou cabeçalho entrando na chave de cache | compare a política anexada ao comportamento com a lista de cookies_config/headers_config esperada | CacheHitRate do comportamento, e a cache policy anexada | trocar por política customizada sem cookie nem cabeçalho na chave |
| Preço desatualizado além do prometido | TTL mínimo de política gerenciada vencendo no-store da origem | compare o Cache-Control que a origem realmente envia com o que a distribuição está respeitando | cabeçalho Age da resposta, e o min_ttl da política anexada | política customizada com TTL mínimo em 0 |
| Painel aparece em busca pública | ausência do cabeçalho noindex no comportamento do painel | inspecione os cabeçalhos de resposta do comportamento /admin/* | response_headers_policy anexada ao comportamento padrão | adicionar X-Robots-Tag: noindex, mais robots.txt |
| Rota de produto existente devolve 404 | slug não encontrado no banco, ou rollout do renderizador em andamento | confira se o produto existe na consulta direta ao banco | logs da task e o estado do rollout do ECS (L03) | se o produto existe, investigar o rollout; se não existe, o 404 está correto |
| XSS refletido num campo de produto | interpolação sem HtmlEncoder num campo vindo do catálogo | teste com um payload conhecido no nome ou na descrição de um produto de teste | o ponto de montagem do HTML no Program.cs | aplicar HtmlEncoder.Default.Encode em todo dado interpolado |
| Custo do Fargate sobe mais que o esperado | hit ratio caiu, e mais requisições estão chegando ao contêiner | compare CacheHitRate ao longo do tempo com o custo do ALB/Fargate no período | CloudWatch do comportamento do catálogo | investigar a causa da queda de hit ratio antes de aumentar capacidade |
A pergunta que resolve metade destes casos
Antes de mexer em política de cache, pergunte: o problema é de RENDERIZAÇÃO (o HTML que sai do contêiner está errado) ou de CACHE (o HTML certo existe, mas a borda não está guardando ou servindo do jeito esperado)? As duas famílias de sintoma se parecem — "o site está com informação errada" — e pedem investigação em lugares completamente diferentes.
Limpeza: o que o destroy não leva
Este laboratório acrescenta duas origens e duas políticas à distribuição já criada no L05. A distribuição em si tem um detalhe que os outros laboratórios desta série não têm: ela não desliga na hora.
# 1. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 2. DISTRIBUICAO CLOUDFRONT: desabilitar e propagar para todos os pontos
# de presenca leva minutos, nao segundos. O destroy espera, mas se voce
# interrompeu no meio, a distribuicao pode continuar ATIVA, servindo e
# cobrando por requisicao.
aws cloudfront get-distribution --id "$DIST_ID" \
--query "Distribution.{Status:Status,Enabled:DistributionConfig.Enabled}"
# 3. BUCKET DO PAINEL: se tiver objeto dentro, o destroy do bucket falha.
aws s3 rm "s3://${PROJETO}-painel" --recursive
# 4. CACHE POLICY E RESPONSE HEADERS POLICY customizadas: nao cobram, mas
# ficam orfas se a distribuicao que as usava foi apagada por fora do
# Terraform em algum momento.
aws cloudfront list-cache-policies --type custom \
--query "CachePolicyList.Items[].CachePolicy.CachePolicyConfig.Name"
# 5. E o que vem do L01/L03/L05 e continua cobrando por hora: ALB, RDS,
# contêiner do renderizador, e o Elastic IP orfao do NAT do L02.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Cache policy e response headers policy customizadas | sim | não | sem custo próprio; ficam órfãs só se apagadas fora do Terraform |
| Distribuição CloudFront | sim, mas com atraso | sim, enquanto ativa | desabilitar e propagar para os pontos de presença leva minutos — interromper no meio deixa tudo de pé |
| Bucket do painel | só se vazio | sim, GB-mês | o destroy falha com objeto dentro; interrupção no meio deixa o bucket cobrando armazenamento |
| Serviço ECS do renderizador | sim | não | segue o mesmo padrão de limpeza do L03 |
| ALB e RDS herdados do L01 | sim | sim, por hora | nada muda aqui; conferir se este era o único consumidor antes de derrubar |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Rastreador recebe HTML vazio | renderização no contêiner, por requisição | o conteúdo existe na primeira resposta, sem depender de execução de JS por quem lê |
| Renderizar a cada visita é caro | cache seletivo com chave sem cookie | compartilha a mesma cópia entre visitantes anônimos, pagando renderização só no miss |
| Preço precisa ficar atual | TTL de 300 s derivado da tolerância declarada | equilibra atualidade com taxa de acerto, em vez de copiar um valor padrão |
| Política gerenciada pode vazar cache velho | política customizada com TTL mínimo 0 | a única forma de honrar Cache-Control: no-store da origem quando ele existir |
| Cookie fragmenta o cache sem avisar | cookies_config "none" na política do catálogo | garante que dois visitantes anônimos compartilham a mesma entrada de cache |
| Painel não pode competir em busca | cabeçalho noindex, mais robots.txt | duas camadas, porque robots.txt não remove URL já indexada por outro caminho |
| Painel precisa de navegação sem reload | SPA, sem mudança | nenhum requisito pediu SSR ali; mudar seria pagar computação sem ganho |
| HTML montado como string reabre XSS | HtmlEncoder em todo dado interpolado | restitui a proteção que o encoder automático do React dava de graça na SPA |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Rastreador não vê o produto | renderização no servidor | painel, que continua sem essa proteção — e não precisa dela |
| Custo cresce linear com o tráfego | cache com chave sem cookie | a primeira visita a cada produto — o miss sempre paga |
| Preço desatualizado além do prometido | TTL derivado da tolerância | rota que um dia deixar de ser anônima e continuar no mesmo comportamento |
| Cache vazando entre visitantes | chave sem cookie e sem query string | insumo novo que passe a variar a resposta sem entrar na chave — é cache poisoning |
| XSS armazenado | HtmlEncoder na interpolação | dado que entra por outro caminho sem passar por essa rota |
| Painel indexado | cabeçalho noindex mais robots.txt | URL do painel compartilhada manualmente por um link direto |
- A requisição chega à borda, para um caminho de produto ou de painel.
- A distribuição decide, pelo caminho, qual comportamento e qual origem valem.
- No catálogo, a chave de cache é montada sem cookie, sem cabeçalho e sem query string.
- Se há cópia válida (HIT), a borda responde sozinha — nenhum outro recurso é acionado.
- Se não há (MISS), a borda encaminha ao ALB, que roteia à task disponível.
- A task consulta preço e estoque atuais e monta o HTML completo, com dado estruturado embutido.
- A resposta volta com Cache-Control informando por quanto tempo pode ficar na borda.
- A borda guarda essa cópia; o próximo pedido ao MESMO caminho, de qualquer visitante, recebe-a.
- No painel, a requisição sempre cai no bucket, sem cache compartilhado — desenho inalterado desde o L05.
- A taxa de acerto por comportamento prova, com número, se a decisão de cache está funcionando.
Perguntas frequentes
❓ Um rastreador de busca realmente não executa JavaScript?
❓ Preciso remover cookie de toda página para o CloudFront cachear alguma coisa?
❓ Por que não pré-renderizar tudo no build e nunca mais tocar em contêiner?
❓ SSR é sempre a escolha certa para SEO?
❓ O que acontece se eu usar a política gerenciada CachingOptimized na origem do catálogo?
❓ Como impedir que o painel apareça no Google, já que está atrás do CloudFront?
❓ Preciso de servidor sempre ligado para servir HTML pronto?
❓ Qual a diferença entre TTFB e o tempo até o conteúdo aparecer?
Fixando
Depois de migrar o catálogo para SSR cacheável, alguém sugere migrar também o painel administrativo, argumentando que "HTML pronto é sempre melhor para performance". Qual é a resposta tecnicamente correta?
A taxa de acerto do comportamento /produtos/* está em 2%, mesmo com tráfego repetido nos mesmos produtos ao longo do dia. A política anexada é a gerenciada UseOriginCacheControlHeaders. Qual é a causa mais provável?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L05 no ar (domínio, ACM, CloudFront, S3 do estático); L01/L03 (ECS Fargate, ALB, rolling update); Docker e Terraform básicos |
| Conhecimentos adquiridos | diferença entre TTFB e tempo até o conteúdo; o que uma chave de cache decide sobre compartilhamento; a armadilha do TTL mínimo de política gerenciada sobre no-store; por que renderizar no servidor reabre um risco de codificação que a SPA fechava sozinha |
| Limitação que fica | a defasagem de até 5 minutos no preço é aceita por decisão, não eliminada; e dois pipelines independentes exigem disciplina de compatibilidade que nenhuma ferramenta aqui garante automaticamente |
| Próximo exemplo recomendado | L17 — upload direto ao S3 sem passar pela aplicação. Mesma dependência do L05, e resolve outra rota que também não deveria acionar o contêiner à toa |
| Também habilitado por este módulo | L94 (busca de produto com IA, híbrida e com rerank) herda o catálogo renderizável e indexável construído aqui — sem HTML pronto, não haveria conteúdo confiável para indexar antes de buscar |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Use managed cache policies (Amazon CloudFront Developer Guide) — TTL mínimo, máximo e padrão de cada política gerenciada, o que cada uma inclui na chave de cache, e o aviso explícito de que TTL mínimo maior que zero faz a distribuição ignorar Cache-Control: no-cache, no-store e private da origem; e Optimize load balancer health check parameters for Amazon ECS — a matemática de tempo de health check reaproveitada do L03, aplicada aqui ao mesmo contêiner do renderizador.
O que não foi verificado, e você deve conferir na sua conta
Os números de TTFB e taxa de acerto citados nas provas — 45 ms, 310 ms, 52 ms, 94/100 — são os medidos na aplicação de exemplo, e servem como ordem de grandeza, não como referência. O comportamento de rastreadores específicos quanto a executar JavaScript muda por fornecedor e por período, e não foi verificado numa fonte oficial e estável o bastante para citar como fato permanente — trate a incerteza declarada nas Perguntas frequentes como o estado real do conhecimento, não como lacuna a preencher depois. Meça o hit ratio e o TTFB da sua aplicação antes de copiar qualquer TTL deste módulo.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…