侧边栏壁纸
  • 累计撰写 269 篇文章
  • 累计收到 2 条评论

为了实现零停机发布把全站迁移到 Kubernetes(K8s)容器化与 Ingress 滚动更新,搜索收录与关键词排名却断崖式暴跌?排查 Pod 终止竞态致蜘蛛频报 502、缺少 preStop 优雅停机打爆长连接、就绪探针缺失致流量涌入冷启动节点投毒软 404 与 Ingress 真实 IP 丢失触发 WAF 误杀我踩过的几个深坑

2026-11-14 / 0 评论 / 9 阅读
AI 摘要由 AI 生成

将全站迁移至Kubernetes后,尽管实现了零停机发布,但搜索收录和关键词排名却大幅下降,原因在于Pod终止竞态、缺少优雅停机机制、就绪探针缺失导致流量涌入冷启动节点和Ingress真实IP丢失等问题。通过深入排查,团队成功修复了这些问题,并总结了相关经验。

上个月我们把支撑全站核心内容、技术社区与 SSR 渲染服务的底层基础设施做了一次彻底的改造。之前老架构一直跑在十几台云服务器上,靠自写的 Ansible 脚本配合 systemd 与 PM2 管理进程。每次版本发布,运维都要逐台切流量、拉代码、重启服务,不仅耗时耗力,稍微手抖就容易引发局部雪崩。

为了彻底摆脱人肉运维,实现开发团队心心念念的自动化持续交付与弹性扩缩容,我们把整套前端 SSR 与业务系统全量容器化,迁移到了 Kubernetes 1.28 集群。外部流量入口统一收敛到 Ingress-Nginx Controller,配合 Deployment 的 RollingUpdate 滚动更新策略。

在测试环境验收和内部压测时,整套容器编排表现得堪称完美:CI/CD 流水线几分钟就能完成镜像构建与滚动上线,发版期间我们用测试脚本打出持续并发,监控大盘上的错误率始终保持在 0%,大家一致认为终于实现了真正的零停机发布(Zero-Downtime Deployment)。

然而全量切换到 K8s 运行两周后,监控警报和站长后台的数据直接给了我们当头一棒。

百度搜索资源平台和 Google Search Console 里的抓取健康度指标全面沦陷。全站十几万存量页面的搜索展现量在两周内腰斩,新发表的文章抓取收录率更是断崖式跌到不足 5%。Google Search Console 爆出了三千多个“5xx 服务器错误”与四千多条“已抓取 - 网页未编入索引(软 404)”,百度抓取诊断里也密密麻麻全都是抓取超时与连接被重置。

搜索引擎爬虫(Baiduspider、Googlebot)跟普通浏览器用户的行为模式有着本质区别。普通用户刷新一次遇到瞬时抖动,通常会下意识按一下 F5;而搜索引擎爬虫是高并发、重度依赖 HTTP Keep-Alive 连接复用、且对响应状态码极度严苛的无状态程序。在密集的日常灰度发版过程中,哪怕每次滚动更新只漏出几十个 502、或者有几个冷启动节点把半截骨架屏返回给爬虫,算法就会迅速判定站点基础设施不稳定,立即大幅砍掉分配给站点的抓取配额(Crawl Budget),并直接下调核心词权重。

我和运维团队花了一整周时间在抓包日志、Ingress 访问记录与内核网络参数里反复排查,复盘出这套 K8s 滚动更新体系在技术 SEO 上捅出的几个隐蔽深坑与最终落地生效的修复方案。


坑一:Pod 销毁与 Ingress 摘除的异步时间差,导致蜘蛛频繁撞上 502 Bad Gateway

为了追求敏捷发布,团队把前端 SSR 镜像的发布频率放得很高,前端修一个小样式或者调整一个接口字段,都会触发一次 Deployment 滚动上线,一天往往要发布五六次。

在分析 Ingress-Nginx 的历史访问日志时,我们把 Baiduspider 和 Googlebot 的 User-Agent 过滤出来,发现了非常刺眼的数据特征:

# Ingress-Nginx access.log 中的爬虫报错记录
116.179.32.14 - - [03/Nov/2026:14:22:18 +0800] "GET /article/8921.html HTTP/1.1" 502 552 "-" "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" 0.002 [default-frontend-ssr-3000] 10.244.3.48:3000 502 0.002
66.249.66.1 - - [03/Nov/2026:14:22:19 +0800] "GET /topic/k8s-ingress.html HTTP/1.1" 502 552 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" 0.001 [default-frontend-ssr-3000] 10.244.3.48:3000 502 0.001

每次发生 502 的时间点,都与 CI/CD 流水线触发滚动更新的时间完全吻合。

很多人以为 Kubernetes 只要配置了 RollingUpdate 默认就是平滑无感知的,但其实 Pod 的终止流程和负载均衡的路由摘除在底层是两条完全解耦的异步链路:

Kube-apiserver 触发更新
   ├── 分支 A (Pod 销毁): 向旧 Pod 发送 SIGTERM -> 容器内应用立即退出进程
   └── 分支 B (路由摘除): 更新 Endpoints -> 通知 Ingress Controller -> Ingress 更新 upstream

当新 Pod 启动并被标记为可用后,API Server 开始清理旧 Pod。此时 API Server 会向旧 Pod 发送 SIGTERM 信号;于此同时,API Server 会把该 Pod 的 IP 从 Service 关联的 Endpoints / EndpointSlices 中剔除。

问题就出在这里:从 API Server 变更 Endpoints,到 Ingress-Nginx Controller 监听并感知到变更、再到 Nginx 真正把该 IP 从 Upstream 列表中移除,中间存在 1 到 3 秒的网络与事件处理延迟。

我们容器里跑的 Node.js SSR 进程收到 SIGTERM 信号后,默认就直接退出了进程。而在退出发生的那 2 秒内,Ingress-Nginx 依然认为这个 Pod 存活,依然源源不断地把外部请求转发过去。刚巧顺着连接并发爬取数十个页面的 Baiduspider 撞在这个窗口期,Ingress 连不上后端端口,只能硬生生向爬虫吐出 502 Bad Gateway。

日积月累下来,爬虫在几天内撞到了数百次 502。对搜索引擎来说,502 是最高级别的服务故障标识,直接促使抓取系统踩下了紧急刹车。

生产止血方案:显式注入 preStop 钩子阻断竞态

解决这个竞态的核心原则:强制让旧 Pod 的应用进程多活一段时间,给 Ingress Controller 预留出足够的时间把 Upstream 节点干净地摘除掉。

我们在 Deployment 的容器配置中补充了 lifecycle.preStop 钩子,先执行一段休眠:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend-ssr
  namespace: production
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: frontend-ssr
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: ssr-app
          image: registry.example.com/frontend/ssr:v2.4.1
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]
          ports:
            - containerPort: 3000

在这里做了三处关键约束:

  1. maxUnavailable: 0:滚动更新时严禁提前减少可用副本数,必须新 Pod 就绪后才允许动旧 Pod。
  2. preStop: sleep 15:旧 Pod 收到停止通知后,先原地休眠 15 秒。在这 15 秒内,应用进程依然正常响应所有存量请求,而 Ingress Controller 早就把该 Pod 的 IP 从路由表里剔除了,绝不再有新流量进来。
  3. terminationGracePeriodSeconds: 60:给 Pod 足够的宽限期,避免 sleep 还没结束就被 Kubernetes 强制 SIGKILL 杀死。

坑二:应用层缺少连接排空机制,蜘蛛 HTTP Keep-Alive 被强制发送 TCP RST

加上 preStop 之后,502 错误确实锐减了 90%,但抓取日志里仍然能看到少量针对旧页面的抓取超时与中断,百度站长平台的抓取报错类型变成了“连接被对端重置(Connection Reset)”。

排查抓包文件后发现,这是长连接没有做连接排空(Connection Draining)引起的。

搜索引擎爬虫为了最大化利用带宽,单次握手后会保持长连接(Keep-Alive)。旧 Pod 在 sleep 15 结束后,操作系统向主进程发送 SIGTERM。我们原本的 Node.js 启动脚本写得非常简单粗暴:

// 错误示范:缺乏主动连接排空与超时保护
const http = require('http');
const app = require('./server');

const server = http.createServer(app);
server.listen(3000);

process.on('SIGTERM', () => {
  console.log('收到 SIGTERM,直接退出');
  process.exit(0); // 致命错误:暴力关闭进程
});

当执行到 process.exit(0) 时,Node.js 运行时直接强行终结。此时底层的 TCP 套接字被操作系统内核直接回收,如果 Ingress-Nginx 与该 Pod 之间刚好有一条未完成数据发送的 Keep-Alive 连接,内核会直接给对端发送一个 TCP RST 数据包,而不是正常的四次挥手 FIN。

结果就是:正在接收网页正文 HTML 的百度爬虫突然遭遇连接掐断,解析器拿到的是残缺了一半的 DOM 结构;或者刚好发起下一个 GET 请求的谷歌爬虫直接被 RST 甩在脸上,被迫记为一次网络连接失败。

生产止血方案:应用层实现连接平滑排空

我们重构了前端 SSR 的信号监听逻辑,接入真正的优雅停机流程:

// 正确做法:优雅处理 SIGTERM,等待在途请求处理完毕
const http = require('http');
const app = require('./server');

const server = http.createServer(app);
server.listen(3000);

let isShuttingDown = false;

process.on('SIGTERM', () => {
  if (isShuttingDown) return;
  isShuttingDown = true;
  console.log('收到 SIGTERM,开始排空存量连接...');

  // 1. 停止接收新的 HTTP 连接
  server.close((err) => {
    if (err) {
      console.error('关闭 HTTP 服务异常', err);
      process.exit(1);
    }
    console.log('所有在途请求已处理完毕,安全退出');
    process.exit(0);
  });

  // 2. 兜底保护:如果某些长连接挂住,最多等待 25 秒后强制退出
  setTimeout(() => {
    console.warn('连接排空超时,强制退出');
    process.exit(1);
  }, 25000);
});

同时在 Ingress-Nginx 的 ConfigMap 中,调整了针对 Upstream 的连接超时参数:

data:
  # 避免 Ingress 对后端的长连接保留时间过长,错开优雅停机周期
  upstream-keepalive-timeout: "60"
  upstream-keepalive-requests: "100"

通过这一步改造,旧 Pod 退出时能把所有爬虫已经建立的在途请求完整传输完毕,再正常通过 FIN 断开连接,爬虫端再也没有出现过半截快照或连接重置。


坑三:就绪探针(readinessProbe)缺失,流量涌入冷启动节点投毒软 404

前两个坑解决的是“旧 Pod 怎么退”,而第三个坑则是“新 Pod 怎么进”。

在 Google Search Console 的索引覆盖率报告中,我们发现有近四千个高权重文章 URL 被归类为“已抓取 - 网页未编入索引”,翻看谷歌抓取时的快照,整页内容竟然只有通用的导航栏和一个灰色的骨架屏,正文区域是空白的。

这是典型的“软 404(Soft 404)”:页面返回给搜索引擎的状态码明明是 200 OK,但页面里根本没有实质性内容。搜索引擎会认为你在用空页面欺骗爬虫,轻则不予收录,重则直接降权整个目录。

追查发现,这完全是 Kubernetes 默认的健康检查机制引发的乌龙。

老配置里我们只写了 livenessProbe,或者把 readinessProbe 简单配置为检测端口连通性:

# 错误示范:仅探测根路径或端口,未验证业务真正就绪
readinessProbe:
  tcpSocket:
    port: 3000
  initialDelaySeconds: 2
  periodSeconds: 5

Node.js SSR 服务在容器启动后,第一秒就能把 3000 端口拉起并监听。Kubernetes 一看端口通了,立刻认定该 Pod 处于“Ready”状态,Ingress-Nginx 随即把大量外部流量导入这个新 Pod。

然而,SSR 框架在真正渲染页面之前,需要做一系列冷启动预热工作:从远端微服务拉取全局路由字典、初始化 Redis 连接池、预热模板编译缓存。

在这个耗时 5 到 8 秒的冷启动阶段,外部请求打进来,SSR 逻辑发现底层缓存不可用、配置数据还没加载完毕,错误处理逻辑被触发,但它的兜底行为却设计得极不合理:

// 错误示例:依赖未初始化时返回空数据与 200 状态码
export async function getServerSideProps(context) {
  const { id } = context.params;

  if (!globalConfig.isLoaded || !redisClient.isReady) {
    // 危险:服务未就绪,却返回空 props 走客户端骨架屏渲染
    return {
      props: {
        fallback: true,
        article: null
      }
    };
  }

  const article = await fetchArticleDetail(id);
  return { props: { article } };
}

普通浏览器用户打开网页,前端 JS 会在后续客户端补齐请求;但爬虫抓取只认服务端返回的首屏 HTML。爬虫抓到了这批返回 200 OK 却空无一物的骨架屏页面,当成了正式内容存进快照,瞬间把全站优质文章洗成了垃圾低质页。

生产止血方案:深度业务就绪探针 + 503 兜底状态码

解决此问题必须双管齐下:既要让 Kubernetes 真正知道何时才能放量,也要让未就绪时的兜底响应合乎 SEO 规范。

第一步,在应用内增加深度健康检查路由 /api/health/readiness,只有核心依赖全部连通、字典全部加载完毕才返回 200:

readinessProbe:
  httpGet:
    path: /api/health/readiness
    port: 3000
  initialDelaySeconds: 8
  periodSeconds: 3
  successThreshold: 1
  failureThreshold: 3

第二步,在 SSR 服务端兜底逻辑中,坚决干掉“返回空数据 + 200”的危险做法。如果偶发遇到依赖未就绪,必须严格返回 503 Service Unavailable 状态码,并附带 Retry-After 响应头:

// 正确示例:服务未就绪时明确返回 503 与重试头
export async function getServerSideProps({ res }) {
  if (!globalConfig.isLoaded || !redisClient.isReady) {
    res.statusCode = 503;
    res.setHeader('Retry-After', '10');
    res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');

    return {
      props: {
        error: 'Service Initializing'
      }
    };
  }
  // 正常业务逻辑...
}

对搜索引擎而言,503 + Retry-After 是标准的服务临时维护信号,爬虫会理解为“系统正在短暂维护,过一会儿再来”,既不会把脏快照存进索引库,也不会判定为软 404 死链。


坑四:Ingress-Nginx 丢失客户端真实 IP,蜘蛛流量被 WAF 误杀拉黑抛出 403

最后一个坑最为致命,直接导致百度爬虫的主动抓取配额在三天内被扣除近 80%。

在百度站长平台的抓取频次大盘里,我们发现抓取曲线不是平缓下降,而是在特定时段突然出现断崖式归零,抓取报错记录里充斥着海量的 403 Forbidden。

我们查看容器内的应用日志,压根没有这批 403 的记录,说明请求在入口层就被拦死了。顺着调用链往上查,我们在 Ingress-Nginx Controller 的日志里找到了真凶:

10.0.4.18 - - [05/Nov/2026:16:40:12 +0800] "GET /category/tech HTTP/1.1" 403 162 "-" "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"

日志显示,所有被拦截的请求,其来源 IP 竟然全都是内网私网地址 10.0.4.18。

我们的 K8s 集群部署在公有云上,外部流量经过云厂商的四层负载均衡器(SLB / ALB),再分发到集群节点的 NodePort,最后转发给 Ingress-Nginx Pod。

在这个转发链路上,存在两个破坏真实 IP 的致命环节:

  1. NodePort 的 SNAT 行为:Service 的 externalTrafficPolicy 默认值是 Cluster。当 SLB 把请求发到 Node-A,而 Ingress Pod 刚好落在 Node-B 上时,Node-A 会对数据包做 SNAT 转换,把源 IP 替换成 Node-A 的集群内网 IP。
  2. Ingress-Nginx 缺少真实 IP 模块配置:Controller 没有配置信任来自云 SLB 传递的 X-Forwarded-For 请求头,直接把 TCP 连接对端的私网 IP 当成了客户端的真实 IP。

结果可想而知:分散在全国各地的数百台百度爬虫服务器,每次发起的高频抓取流量经过 SLB 转发后,在 Ingress 看来全部变成了同一个内网 IP 10.0.4.18 在短时间内疯狂刷量。

我们在 Ingress 入口层挂载的 limit_req 频率限制和防 CC 规则瞬间被击穿:“单个 IP 每秒请求超过 50 次,直接拉入黑名单拦截 10 分钟”。规则一触发,整台机器上的所有爬虫请求瞬间全部遭到 403 Forbidden 绝杀。

百度爬虫刚想来抓取新内容,直接被一锅端拦在门外,自然判定该网站设置了访问权限或发生严重拒绝服务,抓取配额应声腰斩。

生产止血方案:保留源 IP 并配置双向 rDNS 放行机制

要彻底解决 IP 穿透与误杀问题,需要从网络路由与爬虫识别两个维度入手。

首先,修改 Ingress-Nginx 的 Service 配置,将外部流量策略修改为 Local,避免跨节点转发时的 SNAT 覆盖:

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local

其次,在 Ingress-Nginx 的 ConfigMap 中开启真实 IP 提取,并指定信任云负载均衡器的网段:

apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  use-forwarded-headers: "true"
  compute-full-forwarded-for: "true"
  # 填入云厂商 SLB 的回源网段或 VPC 地址段
  proxy-real-ip-cidr: "100.64.0.0/10,10.0.0.0/8"

最后,针对防护规则,不能单纯依据 User-Agent 放行(防止伪造 UA 盗刷),必须在网关层配合双向反向 DNS(Reverse DNS)做爬虫校验。只有同时满足真实爬虫域名后缀(如 *.baidu.com、*.googlebot.com)且 IP 解析一致的请求,才跳过严格的频率限流模块,确保搜索引擎蜘蛛拥有顺畅的抓取通道。


落地检查清单与长效监控

在把上面这套方案完整推上线后,我们用模拟爬虫持续打量两小时,并在随后的日常 CI/CD 发版中密切观察指标。

两周后,站长后台的数据逐渐恢复常态:百度抓取频次曲线恢复到改造前的水平,Google Search Console 里的软 404 与 5xx 错误逐步清除,核心栏目和长尾关键词的排名也陆续回弹。

对于依赖搜索引擎流量的内容站和 SSR 架构,迁移到云原生与 Kubernetes 时不能只盯着“业务能否跑通”或者“容器副本数够不够”,必须把网络生命周期细化到每一个 TCP 包与状态码:

  1. 发布前必查生命周期钩子:所有承接外部流量的 Deployment 必须配置 preStop: sleep 15,必须匹配合理的 terminationGracePeriodSeconds,严禁容器进程收到信号立即暴毙。
  2. 连接排空是刚需:应用层必须捕获 SIGTERM 信号并调用 server.close(),给在途的长连接留足完成传输的窗口,避免向爬虫抛出 TCP RST。
  3. 就绪探针不搞形式主义:探针路由必须检测真实的依赖数据与缓存准备情况,未就绪阶段严禁返回 200 骨架屏,必须返回标准的 503 + Retry-After。
  4. 验证真实 IP 穿透:上线前查看 Ingress 日志,确认 remote_addr 是真实的公网 IP 而不是私网集群地址,避免防 CC 规则造成大面积蜘蛛误杀。

评论一下?

OωO
取消