Next.js ISR com múltiplos pods: por que revalidatePath invalida só 1 dos 2 pods — e o padrão fan-out via Headless Service que resolve
Publicado em 4 de maio de 2026
O problema: cache ISR é por processo
A arquitetura era headless WordPress + Next.js rodando no Kubernetes. O WordPress atualizava um post, disparava um webhook para o Load Balancer externo, o LB encaminhava para um dos pods Next.js, e o pod chamava revalidatePath. Do ponto de vista do WordPress, a invalidação havia funcionado — HTTP 200, log de sucesso.
O problema aparecia em produção de forma intermitente: às vezes o usuário via o conteúdo novo imediatamente, às vezes demorava horas para aparecer. O padrão ficou claro com 2 pods rodando: metade das visitas via conteúdo novo, metade via o cache antigo.
O cache ISR do Next.js vive na memória do processo Node.js. revalidatePath invalida o cache do processo que recebeu a chamada. O outro pod não sabe que a invalidação aconteceu — e continua servindo a versão em cache até o timeout ISR expirar naturalmente.
Isso não é um bug do Next.js. É o comportamento correto de um sistema por processo. O problema é que a maioria das arquiteturas assume implicitamente que existe um único processo. Quando há múltiplos pods, cada um tem seu próprio cache e não existe mecanismo nativo de sincronização entre eles.
Por que a solução óbvia não funciona
A primeira ideia seria fazer o WordPress chamar todos os pods diretamente. Mas há um problema de rede: os pods usam Flannel overlay e têm IPs no range 10.244.x.x — esses IPs não são roteáveis fora do cluster.
# Topologia de rede (Flannel overlay)
# WordPress VM (OCI): 10.1.0.x (rede VCN, roteável)
# Pod A (Next.js): 10.244.1.5 (Flannel overlay, NÃO roteável fora do cluster)
# Pod B (Next.js): 10.244.2.8 (Flannel overlay, NÃO roteável fora do cluster)
# OCI LB externo: <OCI_LB_PUBLIC> (roteável, distribui para pods)
# WordPress → 10.244.1.5:3000 ← FALHA: IP não roteável fora do cluster
# WordPress → <OCI_LB_PUBLIC> ← OK: acessa 1 pod aleatório via LBOutras abordagens também foram descartadas: um NodePort com dnsmasq na VM WordPress era bloqueado pelas regras de NSG do worker node; Valkey pub/sub resolveria a descoberta dos pods mas ainda precisaria de uma chamada HTTP de volta ao pod para executar revalidatePath — a mesma complexidade, mais componentes.
A solução precisa usar a rede do cluster. Os pods podem se chamar entre si via IPs Flannel. O pod que recebe a chamada do WordPress é o ponto de entrada — e ele tem acesso à rede interna do cluster para propagar a invalidação para os outros pods.
A solução: Headless Service para descoberta de pods
Um Kubernetes Service normal com ClusterIP mantém um IP virtual e o kube-proxy distribui as conexões entre os pods. Para descobrir os IPs reais de todos os pods, precisamos de um Headless Service — com clusterIP: None, o DNS do cluster retorna um registro A por pod ativo.
# k8s/service-headless.yaml
apiVersion: v1
kind: Service
metadata:
name: nextjs-headless
namespace: myapp
spec:
clusterIP: None
selector:
app: nextjs
ports:
- name: http
port: 3000
targetPort: 3000Com esse Service criado, uma query DNS para nextjs-headless.myapp.svc.cluster.local retorna um registro A para cada pod com label app=nextjs que esteja Running. Se há 2 pods, retorna 2 IPs. Se o HPA escalar para 3 pods, retorna 3 IPs — automaticamente, sem nenhuma configuração adicional.
# Verificação: quantos pods estão registrados?
kubectl get endpoints nextjs-headless -n myapp
# NAME ENDPOINTS AGE
# nextjs-headless 10.244.1.5:3000,10.244.2.8:3000 2dO padrão fan-out: o pod primário propaga para os outros
O pod que recebe a chamada do WordPress é o pod primário nesta transação. Ele executa três coisas em sequência:
1. Executa revalidatePath/revalidateTag localmente (invalida seu próprio cache).
2. Resolve nextjs-headless.myapp.svc.cluster.local via DNS → obtém lista de todos os IPs de pods.
3. Filtra seu próprio IP (injetado via Downward API) e chama cada outro pod com ?fanout=1 em paralelo (fire-and-forget).
Pods que recebem a chamada com fanout=1 executam apenas a revalidação local e não propagam — isso evita loops infinitos.
// app/src/app/siteapi/revalidate/route.js (simplificado)
import dns from "dns/promises";
import { revalidatePath, revalidateTag } from "next/cache";
const SECRET = process.env.REVALIDATE_SECRET;
const POD_IP = process.env.POD_IP; // injetado via Downward API
export async function GET(request) {
const { searchParams } = new URL(request.url);
if (searchParams.get("secret") !== SECRET) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const slug = searchParams.get("slug");
const isFanout = searchParams.get("fanout") === "1";
// Invalida cache local
revalidatePath(`/${slug}/`);
// Se é chamada de fan-out, para aqui
if (isFanout) {
return Response.json({ ok: true, pod: POD_IP, fanout: true });
}
// Pod primário: propaga para os outros pods
try {
const addresses = await dns.resolve4("nextjs-headless.myapp.svc.cluster.local");
const otherPods = addresses.filter((ip) => ip !== POD_IP);
const fanoutUrl = new URL(request.url);
fanoutUrl.searchParams.set("fanout", "1");
// Fire-and-forget: não aguarda resposta
for (const ip of otherPods) {
const url = fanoutUrl.toString().replace(fanoutUrl.hostname, ip).replace(fanoutUrl.port, "3000");
fetch(url, { signal: AbortSignal.timeout(5000) }).catch(() => {
console.error(`[revalidate] fan-out to ${ip} failed`);
});
}
console.log(`[revalidate] primary pod=${POD_IP} fan-out to [${otherPods.join(", ")}]`);
} catch (err) {
console.error("[revalidate] fan-out error:", err.message);
}
return Response.json({ ok: true, pod: POD_IP });
}Injetando POD_IP via Downward API
O IP do pod não está disponível como variável de ambiente por padrão. O Kubernetes Downward API permite injetar metadados do pod (IP, nome, namespace) como variáveis de ambiente sem nenhuma chamada à API do cluster.
# k8s/deployment.yaml — env vars relevantes
apiVersion: apps/v1
kind: Deployment
metadata:
name: nextjs
namespace: myapp
spec:
template:
spec:
containers:
- name: nextjs
image: registry.example.com/myapp/nextjs:latest
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: REVALIDATE_SECRET
valueFrom:
secretKeyRef:
name: nextjs-secrets
key: revalidate-secretCom essa configuração, process.env.POD_IP dentro do container Next.js retorna o IP Flannel do pod atual — exatamente o que precisamos para filtrar a lista de IPs retornada pelo Headless Service.
O fluxo completo após a implementação
Com o fan-out implementado, o fluxo de invalidação de cache ficou assim:
# Sequência de eventos após publish no WordPress:
#
# 1. WordPress salva post → dispara GET para LB externo
# GET https://www.meusite.com.br/siteapi/revalidate?secret=TOKEN&slug=meu-post
#
# 2. LB roteia para um pod aleatório (ex: Pod A, 10.244.1.5)
#
# 3. Pod A (pod primário):
# - revalidatePath("/meu-post/") ← cache local invalidado
# - dns.resolve4("nextjs-headless.myapp.svc.cluster.local")
# → [10.244.1.5, 10.244.2.8]
# - filtra 10.244.1.5 (próprio IP)
# - fetch("http://10.244.2.8:3000/siteapi/revalidate?...&fanout=1") ← fire-and-forget
# - retorna 200 para o WordPress
#
# 4. Pod B (fanout):
# - revalidatePath("/meu-post/") ← cache local invalidado
# - retorna 200 (não propaga)
#
# Resultado: ambos os pods invalidados em <100ms após o save_postOs logs esperados nos pods após um save_post:
# kubectl logs -f -n myapp -l app=nextjs --prefix=true | grep revalidate
[pod/nextjs-abc] [revalidate] primary pod=10.244.1.5 slug=meu-post
[pod/nextjs-abc] [revalidate] fan-out from 10.244.1.5 to [10.244.2.8]
[pod/nextjs-abc] [revalidate] fan-out to 10.244.2.8 → 200
[pod/nextjs-xyz] [revalidate] fanout pod=10.244.2.8 slug=meu-postO detalhe crítico: SECRET compilado no bundle
Há uma armadilha importante nessa arquitetura. O SECRET de validação do endpoint de revalidação estava compilado no bundle Next.js — não como variável de ambiente de runtime. Em Next.js, variáveis de ambiente sem o prefixo NEXT_PUBLIC_ são substituídas em build time por padrão quando usadas em Route Handlers compilados estaticamente.
Consequência prática: se o token de revalidação precisar ser rotacionado, não basta atualizar o Kubernetes Secret e reiniciar os pods. É necessário fazer rebuild completo da imagem Next.js com o novo token compilado.
A solução correta é mover o SECRET para um Kubernetes Secret e garantir que o Route Handler leia a variável em runtime — usando process.env em uma função server-side, não no escopo de módulo estático. A forma mais segura é configurar o Route Handler com export const dynamic = 'force-dynamic' para garantir execução em runtime.
Generalizando o padrão
O problema do cache por processo aparece em outros contextos além do ISR do Next.js. Qualquer estado em memória que precisa de sincronização entre múltiplos pods tem o mesmo desafio:
Rate limiters em memória: contadores por IP/usuário ficam isolados por pod.
Session stores locais: sessão criada no Pod A não existe no Pod B.
Caches de configuração: feature flags ou configs lidos do banco e armazenados em memória ficam desatualizados de forma assíncrona entre pods.
O padrão fan-out via Headless Service funciona para qualquer um desses casos desde que a operação possa ser disparada via HTTP para cada pod individualmente. Para casos onde o estado precisa ser verdadeiramente compartilhado (contadores distribuídos, sessões), a solução correta é mover o estado para um armazenamento externo (Redis/Valkey, banco de dados) — não sincronizar entre pods.
Dois pods servindo conteúdo diferente para o mesmo usuário é um dos problemas mais difíceis de debugar em produção — porque é intermitente por natureza (depende de qual pod o LB roteia cada requisição). O diagnóstico começa verificando se há algum estado em memória que deveria ser compartilhado mas não é.
Referência de diagnóstico
Para investigar cache stale em produção com esse padrão:
# 1. Verificar se o Headless Service tem endpoints
kubectl get endpoints nextjs-headless -n myapp
# Se vazio: pods não estão Running ou label selector não bate
# 2. Verificar logs de fan-out após salvar um post
kubectl logs -f -n myapp -l app=nextjs --prefix=true | grep revalidate
# 3. Testar o endpoint diretamente de dentro do pod
kubectl exec -n myapp deployment/nextjs -- sh -c "wget -qO- 'http://localhost:3000/siteapi/revalidate?secret=TOKEN&slug=SLUG'"
# 4. Verificar se POD_IP está sendo injetado
kubectl exec -n myapp deployment/nextjs -- env | grep POD_IP


