Buzeli
buzeliSoluções Digitais
Kubernetes

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.

Copiar
# 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 LB

Outras 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.

Copiar
# 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: 3000

Com 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.

Copiar
# 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   2d

O 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.

Copiar
// 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.

Copiar
# 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-secret

Com 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:

Copiar
# 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_post

Os logs esperados nos pods após um save_post:

Copiar
# 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-post

O 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:

Copiar
# 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