Una carga de scraping se acerca al inquilino ideal de Kubernetes: es vergonzosamente paralela, en su mayoría sin estado por tarea, y a ráfagas de un modo que pide a gritos autoescalado. Una vez que tienes proxies funcionando dentro de un contenedor, pasar a un clúster es el siguiente paso natural para cualquiera que corra recolección a volumen real. Pero la capa de proxy cambia algunos de los valores por defecto a los que recurrirías de otro modo: cómo se asigna la identidad por pod, sobre qué señal autoescalas, y por qué añadir pods no es gratis frente a los sitios que golpeas.
Esta es la capa de orquestación por encima del montaje de un solo contenedor. Da por hecho que ya sabes apuntar un scraper a un gateway residencial; aquí nos centramos en correr muchos de ellos a la vez, con seguridad, sobre Kubernetes.
En todo momento, el gateway es el de Shifter: un endpoint, p.shifter.io:443, con el targeting codificado en el nombre de usuario (customer-USERNAME-country-us-sid-abc123-ttl-600). Cambia host y credenciales por otro proveedor; los patrones son idénticos.
La forma de la carga
Dos patrones cubren casi todo:
- Un
Deploymentde workers de larga duración que sacan tareas de una cola. Es el valor por defecto para recolección continua. Los pods se mantienen arriba, drenan una cola de trabajo y escalan con el backlog. - Un
JoboCronJobpara lotes finitos: un crawl nocturno, un backfill puntual. Kubernetes corre N pods hasta completarse y para.
Ambos comparten los mismos bloques de abajo. El Deployment guiado por cola es lo que la mayoría de los equipos quiere, así que los manifiestos aquí apuntan a eso, con notas donde un Job difiere.
Las credenciales del proxy van en un Secret, nunca en la imagen
La primera regla es aburrida e innegociable: las credenciales del proxy no van en la imagen, en un ConfigMap ni hardcodeadas en un manifiesto. Van en un Secret, montado como variables de entorno (o un fichero) en tiempo de ejecución.
apiVersion: v1kind: Secretmetadata: name: proxy-credentialstype: OpaquestringData: SHIFTER_USER: "customer-yourname" SHIFTER_PASS: "your-gateway-password"Hornear las credenciales en una imagen significa que cualquiera que pueda hacer pull de la imagen tiene tu cuenta de proxy. Un Secret las mantiene fuera de tu registro y de tu historial de git, y te deja rotar sin reconstruir. En producción, respalda esto con un gestor de secretos real (External Secrets Operator, Vault o el driver CSI de tu nube) en lugar de un manifiesto plano que podrías commitear por accidente.
Dale a cada pod su propia identidad con la Downward API
Aquí está la parte específica del proxy. Si cada pod se autentica con el mismo nombre de usuario exacto y sin token de sesión, todos sacan del pool de la misma forma, lo cual suele estar bien para pura rotación. Pero en cuanto quieras que cada worker sostenga una sesión coherente y distinta, una identidad por pod en lugar de un mismo bloque compartido, necesitas que cada pod varíe el sid en su nombre de usuario.
La forma limpia de hacer eso en Kubernetes es la Downward API: inyecta el propio nombre del pod como variable de entorno, y deriva el token de sesión de él.
apiVersion: apps/v1kind: Deploymentmetadata: name: scraperspec: replicas: 6 selector: matchLabels: { app: scraper } template: metadata: labels: { app: scraper } spec: terminationGracePeriodSeconds: 90 # deja terminar las peticiones en vuelo (ver abajo) containers: - name: scraper image: your-registry/scraper:1.4.0 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name # p. ej. 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" }En el worker, construye el nombre de usuario del proxy a partir de POD_NAME para que cada pod sea una sesión estable durante su vida:
import os, hashlib
user = os.environ["SHIFTER_USER"]pod = os.environ.get("POD_NAME", "local")# Id de sesión determinista por pod, estable durante la vida del 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"# entrega `proxy` a requests / httpx / el cliente que prefierasAhora el pod scraper-7c9f-abcde es una sesión distinta del scraper-7c9f-fghij, de forma determinista, sin ninguna coordinación entre ellos. Rota dentro de un pod cambiando el sid por unidad de trabajo en lugar de por pod; la distinción sticky vs rotativo aplica exactamente igual que en cualquier otro sitio. (Para el código de worker que va dentro del contenedor, la guía de Python cubre la configuración del cliente y la rotación.)
Autoescala por profundidad de cola, no por CPU
El HorizontalPodAutoscaler por defecto escala por CPU. Para un scraper que es casi enteramente I/O-bound, esperando round-trips de red a través del proxy, la CPU está casi plana sin importar cuánto trabajo haya en cola. Escalar sobre ella es casi inútil: te quedarás con CPU baja y una montaña de backlog, y nunca escalarás hacia arriba.
Escala sobre el propio backlog. La herramienta más limpia es KEDA, que escala un Deployment sobre métricas externas como la longitud de una lista de Redis o la profundidad de una cola:
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" # apunta a ~50 tareas en cola por podEsto te da la propiedad que de verdad quieres: los pods aparecen cuando hay trabajo y se drenan cuando la cola se vacía, así que no pagas por workers ociosos ni matas de hambre a un backlog. Un techo de maxReplicaCount no es opcional, es tu principal salvaguarda contra el siguiente problema.
La concurrencia escala con los pods, y ahí está la trampa
Lo más importante que interiorizar: la presión total sobre un destino es la concurrencia por pod multiplicada por el número de pods. Cada pod puede limitarse educadamente a 5 peticiones concurrentes, pero 40 pods son 200 peticiones concurrentes contra lo que sea que scrapees. El autoescalado que parecía una victoria de throughput se convierte en un martillo distribuido que hace que marquen todo tu pool.
Dos defensas, usadas juntas:
- Limita la concurrencia por pod y mantenla modesta. Un semáforo pequeño por worker.
- Acota la concurrencia total por host de destino a lo largo de la flota, no solo por pod. Esto es una preocupación de nivel de clúster que un solo pod no puede ver, así que pertenece a la cola de trabajo: shardea o limita el ritmo de las tareas por dominio de destino para que ningún sitio reciba más de lo que tolera sin importar cuántos pods corran. Esta es la versión distribuida de la misma lógica del balanceo de carga de proxy, y es por lo que un buen pool residencial es una herramienta para repartir la carga entre IP en lugar de una licencia para intensificarla.
Si te saltas esto, el autoescalado no te hace más rápido, te hace bloqueado. Más paralelismo más allá de la tolerancia de un destino compra desafíos, no datos.
Apagado ordenado: termina o reencola, nunca dejes caer
Kubernetes mata pods de forma rutinaria: scale-down, actualizaciones rolling, drenajes de nodo, reclamos de spot. Cuando lo hace, envía SIGTERM, espera terminationGracePeriodSeconds, y luego envía SIGKILL. Un scraper que ignora SIGTERM pierde cada petición en vuelo en el instante en que lo matan, lo que aparece como huecos misteriosos en tus datos y ancho de banda malgastado en fetches a medio terminar.
Maneja la señal. Ante SIGTERM, deja de sacar tareas nuevas, deja que las peticiones en vuelo terminen dentro del periodo de gracia, y reencola cualquier cosa que no puedas completar a tiempo para que otro pod la recoja:
import signal
draining = False
def handle_sigterm(signum, frame): global draining draining = True # deja de sacar trabajo nuevo; termina lo que está en vuelo
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining: task = queue.pull() if task is None: continue try: process(task) # fetch a través del proxy, guarda el resultado except Exception: queue.requeue(task) # al-menos-una-vez: deja que otro pod reintentePon terminationGracePeriodSeconds cómodamente por encima de tu petición más larga esperada, y confía en una cola de al-menos-una-vez para que una tarea matada se reintente en lugar de perderse. Este es el compañero a nivel de pod del failover en pipelines multi-región: la cola es el búfer duradero que hace desechable a cualquier pod individual.
Sondas de salud que entienden el proxy
Las sondas de liveness y readiness deciden si un pod se mantiene y si recibe trabajo. Para un scraper, átalas a algo real. Un pod cuya salida de proxy está rota, IP de salida equivocada, fallos de auth repetidos, una sesión muerta, debería fallar readiness para que el scheduler deje de enviarle tareas, y fallar liveness si sigue roto para que se reinicie con una identidad fresca.
Mantén la sonda barata y honesta: una comprobación interna ligera de que las últimas peticiones tuvieron éxito, no una llamada en vivo al proxy en cada sonda (eso gasta ancho de banda y puede él mismo hacer timeout). El objetivo es notar un pod que ha dejado de salir en silencio por una IP sana antes de que queme un trozo de tu cola devolviendo nada.
Dimensiona bien los recursos: los scrapers son I/O-bound
No sobreaprovisiones. Un pod de scraping pasa la mayor parte de su vida bloqueado en I/O de red a través del proxy, no quemando CPU. requests modestos (una fracción de core, un par de cientos de MB) dejan al scheduler empaquetar muchos pods por nodo, que es exactamente lo que quieres para una flota de workers ligeros y concurrentes, el modelo de conexiones concurrentes ilimitadas en forma de clúster. Pon limits para atrapar descontroles, pero mantén requests esbeltos para que el autoescalado pueda de hecho colocar pods. Vigila la memoria más que la CPU: un scraper que bufferea respuestas grandes es mucho más probable que golpee un límite de OOM que uno de CPU.
Despliega sin atronar al destino
Una actualización rolling que reemplaza 40 pods a la vez crea una ráfaga de sesiones frescas todas golpeando tus destinos simultáneamente, un pico de tráfico autoinfligido. Usa un maxSurge/maxUnavailable conservador para que el reemplazo sea gradual, y apóyate en la misma disciplina de cola-y-límite para que un despliegue no se traduzca en un pico de peticiones. El destino no debería poder saber que desplegaste.
Verifica y observa
Dos cosas vale la pena cablear desde el día uno. Primero, una comprobación de IP de salida al arranque del pod que registre la IP saliente y el país del pod, para que un Secret mal configurado o un pod atascado en salida local sea obvio de inmediato en lugar de tras una corrida malgastada. Segundo, tasa de éxito y latencia por pod, porque en una flota los fallos rara vez son uniformes: un nodo malo, una sesión agotada, o un destino que empezó a desafiarte aparece como las métricas de un solo pod divergiendo. La calidad del pool marca con qué frecuencia reintentas siquiera, así que trackéala, IP limpias con buena reputación significan menos reintentos por pod y menos carga total por los mismos datos, y mantén un ojo en la latencia y las tasas de timeout como tu aviso temprano de que la postura de un destino cambió.
En resumen
Kubernetes es un buen encaje para el scraping porque la carga es paralela y a ráfagas, y la maquinaria de autoescalado, autorreparación y despliegue de la plataforma se mapea limpiamente sobre una flota de workers. La capa de proxy añade una lista corta de reglas encima: mantén las credenciales en un Secret, dale a cada pod una sesión distinta vía la Downward API, autoescala por profundidad de cola en lugar de por CPU, recuerda que la presión total sobre un destino es la concurrencia por pod por el número de pods y acótala a nivel de flota, y maneja SIGTERM para que un pod matado reencole en lugar de dejar caer trabajo. Haz eso bien y podrás escalar la recolección de seis pods a sesenta sin que el destino lo sienta nunca, que es el objetivo entero.
Para el pool que hay debajo, nuestros proxies residenciales están construidos para distribuir la carga de una flota entre muchas IP limpias, y como están tarifados por gigabyte, escalar pods multiplica tu throughput sin multiplicar tu factura de proxy, solo cuentan los datos que de verdad mueves. Si corres recolección a gran escala, esa es la combinación que la mantiene rápida y silenciosa a la vez.