抓取工作负载接近于 Kubernetes 的理想租户:它高度并行、每个任务基本无状态,而且以一种简直在呼唤自动扩缩的方式呈突发性。一旦你让代理在容器里跑起来,转向集群就是任何以真实规模做采集的人自然的下一步。但代理层改变了你本会伸手去取的几个默认值:身份是怎么按 Pod 分配的、你按什么信号扩缩,以及为什么加 Pod 面对你所击打的站点并非免费午餐。
这是单容器搭建之上的编排层。它假定你已经知道如何把一个抓取器指向一个住宅 gateway;这里我们专注于在 Kubernetes 上同时、安全地跑很多个抓取器。
自始至终,gateway 都是 Shifter 的:一个端点 p.shifter.io:443,定位编码在用户名里(customer-USERNAME-country-us-sid-abc123-ttl-600)。换成别家供应商,就换掉主机和凭据;模式是一样的。
工作负载的形态
两种模式几乎覆盖一切:
- 一个长期运行 worker 的
Deployment,从一个队列里拉取任务。这是持续采集的默认。Pod 保持在线,排空一个工作队列,并随积压而扩缩。 - 一个
Job或CronJob,用于有限批次:一次夜间爬取、一次一次性回填。Kubernetes 让 N 个 Pod 运行到完成然后停止。
两者共享下面同样的构件。按队列驱动的 Deployment 是大多数团队想要的,所以这里的清单瞄准它,并在 Job 有差异处加注。
代理凭据属于 Secret,绝不放进镜像
第一条规则乏味且不可商量:代理凭据不放进镜像、不放进 ConfigMap、也不硬编码进清单。它们放进一个 Secret,在运行时作为环境变量(或一个文件)挂载。
apiVersion: v1kind: Secretmetadata: name: proxy-credentialstype: OpaquestringData: SHIFTER_USER: "customer-yourname" SHIFTER_PASS: "your-gateway-password"把凭据烤进镜像意味着任何能 pull 到镜像的人都拿到了你的代理账户。一个 Secret 把它们挡在你的镜像仓库和 git 历史之外,并让你无需重建就能轮换。在生产中,用一个真正的密钥管理器(External Secrets Operator、Vault 或你云的 CSI 驱动)来支撑它,而不是一个你可能不小心提交的纯清单。
用 Downward API 给每个 Pod 它自己的身份
这里是代理特有的部分。如果每个 Pod 都用完全相同的用户名、且不带会话令牌来认证,它们都以同样的方式从池里取——对纯轮换来说这通常没问题。但一旦你想让每个 worker 持有一个连贯、独立的会话——每个 Pod 一个身份,而不是一个共享的整块——你就需要让每个 Pod 在它的用户名里改变 sid。
在 Kubernetes 上干净的做法是 Downward API:把 Pod 自己的名字注入为一个环境变量,并从中派生出会话令牌。
apiVersion: apps/v1kind: Deploymentmetadata: name: scraperspec: replicas: 6 selector: matchLabels: { app: scraper } template: metadata: labels: { app: scraper } spec: terminationGracePeriodSeconds: 90 # 让在途请求完成(见下文) containers: - name: scraper image: your-registry/scraper:1.4.0 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name # 例如 scraper-7c9f-abcde - name: SHIFTER_USER valueFrom: secretKeyRef: { name: proxy-credentials, key: SHIFTER_USER } - name: SHIFTER_PASS valueFrom: secretKeyRef: { name: proxy-credentials, key: SHIFTER_PASS } resources: requests: { cpu: "100m", memory: "192Mi" } limits: { cpu: "500m", memory: "384Mi" }在 worker 里,从 POD_NAME 构建代理用户名,好让每个 Pod 在其生命周期内是一个稳定的会话:
import os, hashlib
user = os.environ["SHIFTER_USER"]pod = os.environ.get("POD_NAME", "local")# 每个 Pod 的确定性会话 id,在 Pod 生命周期内稳定。sid = hashlib.sha1(pod.encode()).hexdigest()[:10]
proxy_user = f"{user}-country-us-sid-{sid}-ttl-600"proxy = f"http://{proxy_user}:{os.environ['SHIFTER_PASS']}@p.shifter.io:443"# 把 `proxy` 交给 requests / httpx / 你选的客户端现在 Pod scraper-7c9f-abcde 是一个不同于 scraper-7c9f-fghij 的会话,确定性地、无需它们之间任何协调。在一个 Pod 之内轮换,靠按工作单元、而不是按 Pod 来改变 sid;粘性 vs 轮换的区分在这里的适用和别处完全一样。(关于进容器里的 worker 代码,Python 指南涵盖了客户端设置和轮换。)
按队列深度自动扩缩,而不是按 CPU
默认的 HorizontalPodAutoscaler 按 CPU 扩缩。对一个几乎全是 I/O 受限的抓取器——在等经过代理的网络往返——不论排队了多少工作,CPU 都近乎平坦。按它扩缩几乎没用:你会守着低 CPU 和一座积压之山,永远不向上扩。
按积压本身扩缩。最干净的工具是 KEDA,它按外部指标——比如一个 Redis 列表的长度或一个队列的深度——来扩缩一个 Deployment:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: scraper-scalerspec: scaleTargetRef: name: scraper minReplicaCount: 2 maxReplicaCount: 40 triggers: - type: redis metadata: address: redis.default.svc:6379 listName: scrape:queue listLength: "50" # 目标为每个 Pod 约 50 个排队任务这给了你真正想要的性质:有工作时 Pod 出现、队列清空时排空,所以你既不为闲置 worker 付费、也不饿死一个积压。一个 maxReplicaCount 上限不是可选项,它是你对下一个问题的首要护栏。
并发随 Pod 数量扩大,而这就是陷阱
最重要的一件要内化的事:对一个目标的总压力,是每 Pod 并发乘以 Pod 数量。 每个 Pod 也许礼貌地把自己封顶在 5 个并发请求,但 40 个 Pod 就是对你所抓取的东西的 200 个并发请求。那个感觉像吞吐胜利的自动扩缩,变成了一柄让你整个池被标记的分布式大锤。
两道防线,一起使用:
- 给每 Pod 并发封顶并保持适度。每个 worker 一个小信号量。
- 在整个机队范围内按目标主机约束总并发,而不只是按 Pod。这是一个单个 Pod 看不见的集群级关切,所以它属于工作队列:按目标域名对任务分片或限速,好让任何单一站点无论多少 Pod 在跑都不会收到超过它所容忍的量。这是代理负载均衡中同一逻辑的分布式版本,也是为什么一个好的住宅池是一个把负载分摊到各 IP 上的工具,而不是一张加剧它的许可证。
如果你跳过这个,自动扩缩不会让你更快,它会让你被封。越过一个目标的容忍度之后,更多的并行买来的是挑战,而不是数据。
优雅关停:完成或重新入队,绝不丢弃
Kubernetes 例行地杀 Pod:缩容、滚动更新、节点排空、竞价实例回收。当它这么做时,它发 SIGTERM、等 terminationGracePeriodSeconds、然后发 SIGKILL。一个无视 SIGTERM 的抓取器,在被杀的那一刻就失去每一个在途请求,这会表现为你数据里的神秘缺口,以及花在半完成 fetch 上的浪费带宽。
处理这个信号。收到 SIGTERM 时,停止拉取新任务,让在途请求在宽限期内完成,并把你无法及时完成的任何东西重新入队,好让另一个 Pod 接手:
import signal
draining = False
def handle_sigterm(signum, frame): global draining draining = True # 停止拉取新工作;完成在途的
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining: task = queue.pull() if task is None: continue try: process(task) # 经代理 fetch,存储结果 except Exception: queue.requeue(task) # 至少一次:让另一个 Pod 重试把 terminationGracePeriodSeconds 设得从容地高于你预期的最长请求,并依赖一个至少一次的队列,好让一个被杀的任务被重试、而不是丢失。这是多区域管线中故障切换在 Pod 层面的对应物:队列是那个让任何单个 Pod 变得可弃的持久缓冲。
懂代理的健康探针
liveness 和 readiness 探针决定一个 Pod 是否被保留、以及它是否接收工作。对一个抓取器,把它们绑到真实的东西上。一个代理出口坏掉的 Pod——出口 IP 错误、反复认证失败、会话死掉——应当让 readiness 失败,好让调度器停止给它发任务;如果它一直坏着就让 liveness 失败,好让它带着一个新鲜身份被重启。
让探针廉价而诚实:一个轻量的内部检查,看最近几个请求是否成功,而不是每次探针都对代理做一次实时调用(那会花带宽、也可能自己超时)。目标是在一个 Pod 悄悄不再经一个健康 IP 出口、还没烧掉你队列的一块却什么也不返回之前,就注意到它。
把资源调对:抓取器是 I/O 受限的
不要过度分配。一个抓取 Pod 一生的大部分时间都阻塞在经代理的网络 I/O 上,而不是在烧 CPU。适度的 requests(一小块核、几百 MB)让调度器每个节点塞进许多 Pod,而那正是你对一支轻量、并发的 worker 机队所想要的——集群形态下的无限并发连接模型。设 limits 以抓住失控,但保持 requests 精瘦,好让自动扩缩真的能放置 Pod。盯内存多过盯 CPU:一个缓冲大响应的抓取器,撞上 OOM 上限的可能性远大于 CPU 上限。
发布时别把目标震到
一次一口气替换 40 个 Pod 的滚动更新,会造出一阵全都同时击打你目标的新鲜会话——一次自找的流量尖峰。用一个保守的 maxSurge/maxUnavailable,好让替换是渐进的,并倚靠同样的队列加封顶纪律,好让一次发布不翻译成一次请求尖峰。目标不该能知道你发布了。
验证并观测
有两件事值得从第一天就接上。第一,一次 Pod 启动时的出口 IP 检查,记录该 Pod 的出站 IP 和国家,好让一个配置错的 Secret 或一个卡在本地出口的 Pod 立刻显而易见,而不是在一次浪费的运行之后。第二,按 Pod 的成功率和延迟,因为在一支机队里失败很少是均匀的:一个坏节点、一个耗尽的会话、或一个开始挑战你的目标,会表现为单个 Pod 的指标发生偏离。池的质量驱动你到底会不会频繁重试,所以追踪它——有良好信誉的干净 IP 意味着每 Pod 更少的重试、以及为同样数据更少的总负载——并盯住延迟和超时率,作为一个目标姿态已变的早期预警。
底线
Kubernetes 很适合抓取,因为工作负载是并行且突发的,而平台的自动扩缩、自愈和发布机制干净地映射到一支 worker 机队上。代理层在其上加了一份简短的规则清单:把凭据放在一个 Secret 里,用 Downward API 给每个 Pod 一个独立会话,按队列深度而不是按 CPU 自动扩缩,记住对一个目标的总压力是每 Pod 并发乘以 Pod 数量、并在机队范围内给它封顶,以及处理 SIGTERM、好让一个被杀的 Pod 重新入队而不是丢弃工作。把这些做对,你就能把采集从 6 个 Pod 扩到 60 个、而目标从不感觉到——这就是全部意义所在。
至于其下的那个池,我们的住宅代理就是为把一支机队的负载分摊到许多干净 IP 上而建的,而且因为它们按 GB 计费,扩 Pod 会让你的吞吐翻倍、却不让你的代理账单翻倍——只有你真正移动的数据才计数。如果你在做大规模采集,那正是那个让它既快又静的组合。