Chrome 无头浏览器规模化(三):Kubernetes HPA 为什么管不了浏览器
CPU 指标不可靠、内存占用有延迟、启动需要 5 秒而销毁只需要 1 秒——浏览器集群的扩缩容和普通微服务完全不同。
HPA 失效的第一个原因:CPU 启动尖峰
标准的 Kubernetes HPA 基于 CPU 或内存指标来做扩缩容。逻辑很简单:平均 CPU 使用率超过 80% 就扩容,低于 40% 就缩容。
但这个逻辑在浏览器实例上不成立。原因是浏览器启动时的 CPU 行为和稳定运行时完全不一样。
Chrome 启动时需要:
- 加载二进制文件(约 200MB-300MB)
- 初始化 V8 引擎
- 创建 GPU 上下文(即使是无头模式)
- 建立 CDP WebSocket 监听
- 加载初始页面
这个过程在 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 进程占用的内存不会在页面关闭后立即归还给操作系统:
-
V8 的内存池:V8 的堆空间不会在页面卸载时立刻收缩。V8 会把释放的内存标记为可用,但不会归还给操作系统,因为重新申请的开销更大。
-
操作系统页面缓存:Chrome 使用的大量共享库(libc, libstdc++, zlib)被操作系统缓存在内存中。即使 Chrome 退出了,这些缓存页面也不会立即释放——它们只是被标记为"可回收",只有在其他进程申请内存时才会被真正回收。
-
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 在浏览器集群面前基本失效,因为:
- CPU 启动尖峰误导扩缩容决策
- 内存释放存在数分钟的延迟
- 浏览器是有状态服务,不能随时被杀
解决方案不是调整 CPU/内存阈值,而是换到业务指标(队列深度)、配置冷却时间、维护预热池。把浏览器当有状态服务来管理,而不是无状态微服务。
A 系列总结:规模化不是把 1 个实例复制 1000 份。孤儿进程、连接池耗尽、HPA 失效——每当你觉得"再开一个实例就好了"的时候,这三个问题会在不同的层面等着你。三篇文章的核心建议其实都一样:一个浏览器实例只做一个任务,做完就关,关干净。 简单但有效。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。