Chrome 无头浏览器规模化(三):Kubernetes HPA 为什么管不了浏览器

CPU 指标不可靠、内存占用有延迟、启动需要 5 秒而销毁只需要 1 秒——浏览器集群的扩缩容和普通微服务完全不同。

亿牛云技术团队2026年4月4日5 分钟阅读

HPA 失效的第一个原因:CPU 启动尖峰

标准的 Kubernetes HPA 基于 CPU 或内存指标来做扩缩容。逻辑很简单:平均 CPU 使用率超过 80% 就扩容,低于 40% 就缩容。

但这个逻辑在浏览器实例上不成立。原因是浏览器启动时的 CPU 行为和稳定运行时完全不一样。

Chrome 启动时需要:

  1. 加载二进制文件(约 200MB-300MB)
  2. 初始化 V8 引擎
  3. 创建 GPU 上下文(即使是无头模式)
  4. 建立 CDP WebSocket 监听
  5. 加载初始页面

这个过程在 1-3 秒内 CPU 使用率冲到 100%,然后迅速回落到 5-15%。如果 HPA 基于 CPU 做扩容,会出现以下情况:

  • Pod 刚启动 → CPU 100% → HPA 认为需要扩容 → 再启动一个 Pod
  • 新 Pod 也在启动 → CPU 100% → HPA 继续扩容
  • 第一批 Pod 启动完成 → CPU 降到 5% → HPA 认为需要缩容
  • 杀死一批 Pod,留在系统的服务线程堆积

这种现象叫做启动风暴(Startup Storm)——HPA 被启动时的 CPU 尖峰误导,不断扩容,然后又在 Pod 就绪后迅速缩容。

时间线:
  T0: 当前 10 个 Pod,CPU 均值 15%
  T1: 新任务到达,HPA 检测到 CPU 升至 45%
  T2: HPA 扩容至 12 个 Pod
  T3: 新 Pod 启动中,CPU 100%(启动尖峰)
  T4: HPA 检测到 CPU 升至 65%,继续扩容至 15 个 Pod
  T5: 所有 Pod 就绪,CPU 降至 12%
  T6: HPA 缩容至 12 个 Pod
  T7: 被杀的 Pod 中还有正在执行的任务,失败率上升

HPA 失效的第二个原因:内存不是实时释放的

用内存指标做 HPA 也有问题。Chrome 进程占用的内存不会在页面关闭后立即归还给操作系统:

  1. V8 的内存池:V8 的堆空间不会在页面卸载时立刻收缩。V8 会把释放的内存标记为可用,但不会归还给操作系统,因为重新申请的开销更大。

  2. 操作系统页面缓存:Chrome 使用的大量共享库(libc, libstdc++, zlib)被操作系统缓存在内存中。即使 Chrome 退出了,这些缓存页面也不会立即释放——它们只是被标记为"可回收",只有在其他进程申请内存时才会被真正回收。

  3. Swap 延迟:如果系统使用了交换分区,Chrome 进程的一部分内存可能已经被换出到磁盘。close() 之后,这部分内存需要等到被换回才能被正确释放,导致 free 命令看到的可用内存不会立刻增加。

这意味着基于内存的 HPA 会有几分钟的滞后——Pod 实际上已经不需要了,但内存指标还在高位。HPA 不会缩容,资源被浪费。

HPA 失效的第三个原因:浏览器不是无状态服务

HPA 设计的基本假设是:任何 Pod 都可以被随时杀死,请求会被重新路由到其他 Pod。

但浏览器实例携带状态。如果在执行中途杀死一个浏览器 Pod:

  • 已经创建的 CDP 会话直接中断
  • 正在运行的页面导航永远不会返回
  • 已经保存的 Cookie 和 localStorage 丢失
  • 如果目标网站要求登录,下次需要重新认证

这不像无状态 Web 服务——前端挂了一个请求,转发到另一个实例重试就好了。浏览器的状态是进程级别的,K8s 的优雅关闭(Graceful Shutdown)机制在浏览器场景中的效果有限:发送 SIGTERM 后,Chrome 需要时间来关闭所有标签页,这个过程如果在 terminationGracePeriodSeconds(默认 30 秒)内没完成,Pod 会被强制杀死。

实际可用的扩缩容方案

方案一:使用自定义指标做 HPA

不要用 CPU 和内存。使用更直接的业务指标:

# 基于队列深度的 HPA(推荐)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  metrics:
  - type: External
    external:
      metric:
        name: sqs_queue_depth
      target:
        type: AverageValue
        averageValue: 5  # 每个 Pod 处理的任务数

或者使用 Keda 的事件驱动自动缩放:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
  triggers:
  - type: rabbitmq
    metadata:
      queueName: browser-tasks
      queueLength: "10"  # 每个 Pod 处理 10 个任务

这避免了 CPU 和内存指标的所有问题。Pod 的数量由待处理任务数决定,与每个 Pod 内部的资源消耗无关。

方案二:启动延迟处理

在 HPA 中配置扩缩容的冷却时间,避免启动风暴:

behavior:
  scaleDown:
    stabilizationWindowSeconds: 300  # 等待 5 分钟再缩容
    policies:
    - type: Percent
      value: 10                      # 每次最多缩容 10%
      periodSeconds: 60
  scaleUp:
    stabilizationWindowSeconds: 60   # 等待 1 分钟再扩容
    policies:
    - type: Pods
      value: 2                       # 每次最多增加 2 个 Pod
      periodSeconds: 30

这种配置让扩缩容变得缓慢但稳定,防止了启动风暴。

方案三:带预热池的部署策略

对于对延迟敏感的场景,维护一个预热池——已经启动好但还没分配任务的浏览器实例:

# 两个 Deployment:预热池和任务池
apiVersion: apps/v1
kind: Deployment
metadata:
  name: browser-warm-pool
spec:
  replicas: 5  # 始终保持 5 个预热实例
  # ... 正常配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: browser-worker
spec:
  # 根据任务量扩缩容,不带预热逻辑

预热池的 Pod 加载好 Chrome 后执行一个空页面保持。任务到来时直接从预热池取出,而不是从零启动。

方案四:优雅关闭的合理配置

增加 terminationGracePeriodSeconds 给 Chrome 足够的关闭时间:

apiVersion: v1
kind: Pod
spec:
  terminationGracePeriodSeconds: 60  # 而非默认的 30
  containers:
  - name: browser
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "curl -X POST http://localhost:9222/json/close || true"]

preStop hook 在 K8s 发送 SIGTERM 前执行,先尝试通过 CDP 优雅关闭所有标签页,然后才让 K8s 发送终止信号。

总结

Kubernetes HPA 在浏览器集群面前基本失效,因为:

  1. CPU 启动尖峰误导扩缩容决策
  2. 内存释放存在数分钟的延迟
  3. 浏览器是有状态服务,不能随时被杀

解决方案不是调整 CPU/内存阈值,而是换到业务指标(队列深度)、配置冷却时间、维护预热池。把浏览器当有状态服务来管理,而不是无状态微服务。

A 系列总结:规模化不是把 1 个实例复制 1000 份。孤儿进程、连接池耗尽、HPA 失效——每当你觉得"再开一个实例就好了"的时候,这三个问题会在不同的层面等着你。三篇文章的核心建议其实都一样:一个浏览器实例只做一个任务,做完就关,关干净。 简单但有效。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。