Buzeli
buzeliSoluções Digitais
Kubernetes

Next.js ISR多Pod部署:为什么revalidatePath只使2个Pod中的1个缓存失效——以及通过Headless Service实现的fan-out模式

发布于 2026年5月4日

问题:ISR缓存是按进程的

这是一个无头WordPress + Next.js在Kubernetes上运行的架构。WordPress更新文章,向外部负载均衡器触发webhook,LB转发到其中一个Next.js Pod,Pod调用revalidatePath。从WordPress的角度来看,失效操作成功了——HTTP 200,成功日志。

问题在生产环境中以间歇性方式出现:有时用户立即看到新内容,有时需要数小时才能显示。在运行2个Pod时,规律变得清晰:一半访问看到新内容,一半看到旧缓存。

Next.js ISR缓存存在于Node.js进程的内存中。revalidatePath使收到调用的进程的缓存失效。另一个Pod不知道失效发生了——继续提供缓存版本,直到ISR超时自然到期。

这不是Next.js的bug。这是按进程系统的正确行为。问题在于大多数架构隐式假设只有一个进程。当有多个Pod时,每个Pod都有自己的缓存,它们之间没有原生的同步机制。

为什么显而易见的解决方案不起作用

第一个想法是让WordPress直接调用所有Pod。但存在网络问题:Pod使用Flannel overlay,IP在10.244.x.x范围内——这些IP在集群外部不可路由。

复制
# 网络拓扑(Flannel overlay)
# WordPress VM(OCI):10.1.0.x(VCN网络,可路由)
# Pod A(Next.js):  10.244.1.5(Flannel overlay,在集群外部不可路由)
# Pod B(Next.js):  10.244.2.8(Flannel overlay,在集群外部不可路由)
# OCI外部LB:         <OCI_LB_PUBLIC>(可路由,分发到Pod)

# WordPress → 10.244.1.5:3000  ← 失败:IP在集群外部不可路由
# WordPress → <OCI_LB_PUBLIC>   ← 成功:通过LB访问1个随机Pod

其他方法也被排除了:在WordPress VM上使用NodePort加dnsmasq被Worker节点的NSG规则阻止;Valkey pub/sub可以解决Pod发现问题,但仍然需要回调HTTP到Pod来执行revalidatePath——复杂度相同,但组件更多。

解决方案必须使用集群网络。 Pod可以通过Flannel IP相互调用。接收WordPress调用的Pod是入口点——它可以访问集群内部网络,将失效操作传播到其他Pod。

解决方案:使用Headless Service进行Pod发现

普通的带ClusterIP的Kubernetes Service维护一个虚拟IP,kube-proxy在Pod之间分配连接。要发现所有Pod的真实IP,我们需要一个Headless Service——设置clusterIP: None后,集群DNS为每个活跃的Pod返回一个A记录。

复制
# 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

创建此Service后,对nextjs-headless.myapp.svc.cluster.local的DNS查询会为每个标签为app=nextjs且处于Running状态的Pod返回一个A记录。如果有2个Pod,返回2个IP。如果HPA扩展到3个Pod,自动返回3个IP——无需额外配置。

复制
# 验证:有多少Pod已注册?
kubectl get endpoints nextjs-headless -n myapp
# NAME               ENDPOINTS                         AGE
# nextjs-headless   10.244.1.5:3000,10.244.2.8:3000   2d

Fan-out模式:主Pod将操作传播给其他Pod

接收WordPress调用的Pod在此事务中是主Pod。它依次执行三件事:

1. 在本地执行revalidatePath/revalidateTag(使自己的缓存失效)。

2. 通过DNS解析nextjs-headless.myapp.svc.cluster.local → 获取所有Pod IP的列表。

3. 过滤自己的IP(通过Downward API注入),并以?fanout=1并行调用每个其他Pod(即发即弃)。

接收带有fanout=1调用的Pod只执行本地重新验证,不再传播——这可以防止无限循环。

复制
// app/src/app/siteapi/revalidate/route.js(简化版)
import dns from "dns/promises";
import { revalidatePath, revalidateTag } from "next/cache";

const SECRET = process.env.REVALIDATE_SECRET;
const POD_IP = process.env.POD_IP;  // 通过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";

  // 使本地缓存失效
  revalidatePath(`/${slug}/`);

  // 如果这是fan-out调用,停在这里
  if (isFanout) {
    return Response.json({ ok: true, pod: POD_IP, fanout: true });
  }

  // 主Pod:传播到其他Pod
  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");

    // 即发即弃:不等待响应
    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到 ${ip} 失败`);
      });
    }

    console.log(`[revalidate] 主Pod=${POD_IP} fan-out到 [${otherPods.join(", ")}]`);
  } catch (err) {
    console.error("[revalidate] fan-out错误:", err.message);
  }

  return Response.json({ ok: true, pod: POD_IP });
}

通过Downward API注入POD_IP

Pod IP默认情况下不作为环境变量提供。Kubernetes Downward API允许将Pod元数据(IP、名称、命名空间)作为环境变量注入,无需调用API服务器。

复制
# k8s/deployment.yaml — 相关环境变量
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

通过这个配置,Next.js容器内的process.env.POD_IP返回当前Pod的Flannel IP——这正是我们过滤Headless Service返回的IP列表所需要的。

实施后的完整流程

实现fan-out后,缓存失效流程如下:

复制
# WordPress发布后的事件序列:
#
# 1. WordPress保存文章 → 向外部LB发出GET请求
#    GET https://www.mysite.com.br/siteapi/revalidate?secret=TOKEN&slug=my-post
#
# 2. LB路由到随机Pod(例如:Pod A,10.244.1.5)
#
# 3. Pod A(主Pod):
#    - revalidatePath("/my-post/")  ← 本地缓存已失效
#    - dns.resolve4("nextjs-headless.myapp.svc.cluster.local")
#      → [10.244.1.5, 10.244.2.8]
#    - 过滤 10.244.1.5(自己的IP)
#    - fetch("http://10.244.2.8:3000/siteapi/revalidate?...&fanout=1")  ← 即发即弃
#    - 向WordPress返回200
#
# 4. Pod B(fanout):
#    - revalidatePath("/my-post/")  ← 本地缓存已失效
#    - 返回200(不再传播)
#
# 结果:save_post后<100ms内两个Pod都完成失效

save_post后Pod中预期的日志:

复制
# kubectl logs -f -n myapp -l app=nextjs --prefix=true | grep revalidate
[pod/nextjs-abc] [revalidate] 主Pod=10.244.1.5 slug=my-post
[pod/nextjs-abc] [revalidate] fan-out从10.244.1.5到[10.244.2.8]
[pod/nextjs-abc] [revalidate] fan-out到10.244.2.8 → 200
[pod/nextjs-xyz] [revalidate] fanout Pod=10.244.2.8 slug=my-post

关键细节:SECRET被编译进bundle

这个架构中有一个重要的陷阱。重新验证端点的验证SECRET被编译进了Next.js bundle——而不是作为运行时环境变量。在Next.js中,没有NEXT_PUBLIC_前缀的环境变量在用于静态编译的Route Handler时默认在构建时被替换。

实际影响:如果需要轮换重新验证令牌,仅更新Kubernetes Secret并重启Pod是不够的。需要用编译了新令牌的Next.js镜像进行完整重建。

正确的解决方案是将SECRET移动到Kubernetes Secret中,并确保Route Handler在运行时读取变量——在服务器端函数中使用process.env,而不是在静态模块作用域中。最安全的方法是配置Route Handler使用export const dynamic = 'force-dynamic'来保证运行时执行。

推广这个模式

按进程缓存问题不仅仅出现在Next.js ISR中。任何需要在多个Pod之间同步的内存状态都面临同样的挑战:

内存限流器: 按IP/用户的计数器在每个Pod中是隔离的。

本地Session存储: 在Pod A上创建的会话在Pod B上不存在。

配置缓存: 从数据库读取并存储在内存中的feature flags或配置在Pod之间会异步变得过时。

只要操作可以通过HTTP逐个触发每个Pod,通过Headless Service的fan-out模式适用于上述任何情况。对于需要真正共享状态的情况(分布式计数器、会话),正确的解决方案是将状态移动到外部存储(Redis/Valkey、数据库)——而不是在Pod之间同步。

两个Pod为同一用户提供不同内容是生产中最难调试的问题之一——因为它本质上是间歇性的(取决于LB将每个请求路由到哪个Pod)。诊断从检查是否存在应该共享但实际上没有共享的内存状态开始。

诊断参考

使用此模式在生产中调查过时缓存:

复制
# 1. 检查Headless Service是否有端点
kubectl get endpoints nextjs-headless -n myapp
# 如果为空:Pod不处于Running状态或标签选择器不匹配

# 2. 保存文章后检查fan-out日志
kubectl logs -f -n myapp -l app=nextjs --prefix=true | grep revalidate

# 3. 直接从Pod内部测试端点
kubectl exec -n myapp deployment/nextjs -- sh -c   "wget -qO- 'http://localhost:3000/siteapi/revalidate?secret=TOKEN&slug=SLUG'"

# 4. 检查POD_IP是否被正确注入
kubectl exec -n myapp deployment/nextjs -- env | grep POD_IP