Base de connaissances

Comment mettre à l'échelle des charges de scraping avec proxys résidentiels via Kubernetes

Scraping sur Kubernetes : identifiants de proxy en Secrets, identité de session par pod via la Downward API, autoscaling piloté par la file, et arrêt propre.

Chris Collins

Chris Collins

28 juillet 2026 · 11 min de lecture

Une charge de scraping est proche du locataire idéal de Kubernetes : elle est massivement parallèle, sans état pour la plupart par tâche, et en rafales d’une façon qui réclame l’autoscaling. Une fois que vous avez des proxys qui fonctionnent dans un conteneur, passer à un cluster est l’étape naturelle suivante pour quiconque fait de la collecte à volume réel. Mais la couche proxy change quelques-uns des réglages par défaut vers lesquels vous iriez autrement : comment l’identité est assignée par pod, sur quel signal vous autoscalez, et pourquoi ajouter des pods n’est pas gratuit face aux sites que vous frappez.

C’est la couche d’orchestration au-dessus du montage à conteneur unique. Elle suppose que vous savez déjà pointer un scraper vers un gateway résidentiel ; ici nous nous concentrons sur en faire tourner beaucoup à la fois, en sécurité, sur Kubernetes.

Partout, le gateway est celui de Shifter : un point de terminaison, p.shifter.io:443, avec le ciblage encodé dans le nom d’utilisateur (customer-USERNAME-country-us-sid-abc123-ttl-600). Changez l’hôte et les identifiants pour un autre fournisseur ; les motifs sont identiques.

La forme de la charge

Deux motifs couvrent presque tout :

  • Un Deployment de workers de longue durée qui tirent des tâches d’une file. C’est le défaut pour la collecte continue. Les pods restent en place, drainent une file de travail et scalent avec le backlog.
  • Un Job ou CronJob pour des lots finis : un crawl nocturne, un backfill ponctuel. Kubernetes fait tourner N pods jusqu’à complétion et s’arrête.

Les deux partagent les mêmes briques ci-dessous. Le Deployment piloté par file est ce que la plupart des équipes veulent, donc les manifestes ici visent cela, avec des notes là où un Job diffère.

Les identifiants du proxy vont dans un Secret, jamais dans l’image

La première règle est ennuyeuse et non négociable : les identifiants du proxy ne vont pas dans l’image, dans une ConfigMap, ni codés en dur dans un manifeste. Ils vont dans un Secret, monté comme variables d’environnement (ou un fichier) au runtime.

apiVersion: v1
kind: Secret
metadata:
name: proxy-credentials
type: Opaque
stringData:
SHIFTER_USER: "customer-yourname"
SHIFTER_PASS: "your-gateway-password"

Cuire les identifiants dans une image signifie que quiconque peut pull l’image a votre compte proxy. Un Secret les garde hors de votre registre et de votre historique git, et vous laisse tourner les identifiants sans reconstruire. En production, adossez cela à un vrai gestionnaire de secrets (External Secrets Operator, Vault ou le driver CSI de votre cloud) plutôt qu’un manifeste brut que vous pourriez committer par accident.

Donnez à chaque pod sa propre identité avec la Downward API

Voici la partie spécifique au proxy. Si chaque pod s’authentifie avec le même nom d’utilisateur exact et sans jeton de session, ils tirent tous du pool de la même façon, ce qui convient d’ordinaire pour de la pure rotation. Mais dès que vous voulez que chaque worker tienne une session cohérente et distincte, une identité par pod plutôt qu’un même bloc partagé, vous devez faire varier le sid dans le nom d’utilisateur de chaque pod.

La façon propre de faire cela sur Kubernetes est la Downward API : injectez le nom propre du pod comme variable d’environnement, et dérivez-en le jeton de session.

apiVersion: apps/v1
kind: Deployment
metadata:
name: scraper
spec:
replicas: 6
selector:
matchLabels: { app: scraper }
template:
metadata:
labels: { app: scraper }
spec:
terminationGracePeriodSeconds: 90 # laisse finir les requetes en vol (voir plus bas)
containers:
- name: scraper
image: your-registry/scraper:1.4.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name # p. ex. 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" }

Dans le worker, construisez le nom d’utilisateur du proxy à partir de POD_NAME pour que chaque pod soit une session stable pendant sa vie :

import os, hashlib
user = os.environ["SHIFTER_USER"]
pod = os.environ.get("POD_NAME", "local")
# Id de session deterministe par pod, stable pendant la vie du 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"
# passe `proxy` a requests / httpx / le client de ton choix

Maintenant le pod scraper-7c9f-abcde est une session différente de scraper-7c9f-fghij, de façon déterministe, sans aucune coordination entre eux. Tournez au sein d’un pod en changeant le sid par unité de travail plutôt que par pod ; la distinction sticky vs rotatif s’applique exactement comme partout ailleurs. (Pour le code du worker qui va dans le conteneur, le guide Python couvre la configuration du client et la rotation.)

Autoscalez sur la profondeur de file, pas sur le CPU

Le HorizontalPodAutoscaler par défaut scale sur le CPU. Pour un scraper qui est presque entièrement I/O-bound, attendant des allers-retours réseau à travers le proxy, le CPU est quasi plat quelle que soit la quantité de travail en file. Scaler dessus est presque inutile : vous resterez à CPU bas avec une montagne de backlog et ne scalerez jamais vers le haut.

Scalez sur le backlog lui-même. L’outil le plus propre est KEDA, qui scale un Deployment sur des métriques externes comme la longueur d’une liste Redis ou la profondeur d’une file :

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: scraper-scaler
spec:
scaleTargetRef:
name: scraper
minReplicaCount: 2
maxReplicaCount: 40
triggers:
- type: redis
metadata:
address: redis.default.svc:6379
listName: scrape:queue
listLength: "50" # vise ~50 taches en file par pod

Cela vous donne la propriété que vous voulez vraiment : les pods apparaissent quand il y a du travail et se drainent quand la file se vide, donc vous ne payez pas pour des workers inactifs ni n’affamez un backlog. Un plafond maxReplicaCount n’est pas optionnel, c’est votre garde-fou principal contre le problème suivant.

La concurrence scale avec les pods, et c’est le piège

La chose la plus importante à intérioriser : la pression totale sur une cible est la concurrence par pod multipliée par le nombre de pods. Chaque pod peut poliment se plafonner à 5 requêtes concurrentes, mais 40 pods font 200 requêtes concurrentes contre ce que vous scrapez. L’autoscaling qui semblait une victoire de débit devient un marteau distribué qui fait marquer tout votre pool.

Deux défenses, utilisées ensemble :

  • Plafonnez la concurrence par pod et gardez-la modeste. Un petit sémaphore par worker.
  • Bornez la concurrence totale par hôte cible à travers la flotte, pas seulement par pod. C’est une préoccupation de niveau cluster qu’un seul pod ne peut pas voir, donc elle appartient à la file de travail : shardez ou limitez le débit des tâches par domaine cible pour qu’aucun site ne reçoive plus qu’il ne tolère peu importe combien de pods tournent. C’est la version distribuée de la même logique dans le load balancing de proxy, et c’est pourquoi un bon pool résidentiel est un outil pour répartir la charge entre IP plutôt qu’une licence pour l’intensifier.

Si vous sautez cela, l’autoscaling ne vous rend pas plus rapide, il vous rend bloqué. Plus de parallélisme au-delà de la tolérance d’une cible achète des défis, pas des données.

Arrêt propre : terminer ou remettre en file, jamais laisser tomber

Kubernetes tue des pods de façon routinière : scale-down, mises à jour rolling, drains de nœud, reprises de spot. Quand il le fait, il envoie SIGTERM, attend terminationGracePeriodSeconds, puis envoie SIGKILL. Un scraper qui ignore SIGTERM perd chaque requête en vol à l’instant où il est tué, ce qui apparaît comme des trous mystérieux dans vos données et de la bande passante gaspillée sur des fetches à moitié finis.

Gérez le signal. Sur SIGTERM, arrêtez de tirer de nouvelles tâches, laissez les requêtes en vol finir dans la période de grâce, et remettez en file tout ce que vous ne pouvez pas compléter à temps pour qu’un autre pod le reprenne :

import signal
draining = False
def handle_sigterm(signum, frame):
global draining
draining = True # arrete de tirer du travail neuf; finis ce qui est en vol
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining:
task = queue.pull()
if task is None:
continue
try:
process(task) # fetch a travers le proxy, stocke le resultat
except Exception:
queue.requeue(task) # au-moins-une-fois: laisse un autre pod reessayer

Mettez terminationGracePeriodSeconds confortablement au-dessus de votre requête la plus longue attendue, et appuyez-vous sur une file au-moins-une-fois pour qu’une tâche tuée soit réessayée au lieu d’être perdue. C’est le pendant au niveau pod du failover dans les pipelines multi-région : la file est le tampon durable qui rend n’importe quel pod individuel jetable.

Des sondes de santé qui comprennent le proxy

Les sondes de liveness et readiness décident si un pod est gardé et s’il reçoit du travail. Pour un scraper, liez-les à quelque chose de réel. Un pod dont la sortie proxy est cassée, mauvaise IP de sortie, échecs d’auth répétés, une session morte, devrait échouer la readiness pour que le scheduler cesse de lui envoyer des tâches, et échouer la liveness s’il reste cassé pour qu’il soit redémarré avec une identité fraîche.

Gardez la sonde bon marché et honnête : une vérification interne légère que les dernières requêtes ont réussi, pas un appel en direct au proxy à chaque sonde (ça dépense de la bande passante et peut lui-même faire timeout). Le but est de repérer un pod qui a discrètement cessé de sortir par une IP saine avant qu’il ne brûle un morceau de votre file en ne rapportant rien.

Dimensionnez bien les ressources : les scrapers sont I/O-bound

Ne surprovisionnez pas. Un pod de scraping passe le plus clair de sa vie bloqué sur de l’I/O réseau à travers le proxy, pas à brûler du CPU. Des requests modestes (une fraction de cœur, quelques centaines de Mo) laissent le scheduler empaqueter beaucoup de pods par nœud, ce qui est exactement ce que vous voulez pour une flotte de workers légers et concurrents, le modèle des connexions concurrentes illimitées en forme de cluster. Mettez des limits pour attraper les emballements, mais gardez les requests sveltes pour que l’autoscaling puisse effectivement placer des pods. Surveillez la mémoire plus que le CPU : un scraper qui bufferise de grosses réponses est bien plus susceptible de heurter une limite OOM qu’une de CPU.

Déployez sans faire tonner la cible

Une mise à jour rolling qui remplace 40 pods d’un coup crée une rafale de sessions fraîches frappant toutes vos cibles simultanément, un pic de trafic auto-infligé. Utilisez un maxSurge/maxUnavailable conservateur pour que le remplacement soit graduel, et appuyez-vous sur la même discipline file-et-plafond pour qu’un déploiement ne se traduise pas en un pic de requêtes. La cible ne devrait pas pouvoir savoir que vous avez déployé.

Vérifiez et observez

Deux choses valent la peine d’être câblées dès le premier jour. D’abord, une vérification d’IP de sortie au démarrage du pod qui journalise l’IP sortante et le pays du pod, pour qu’un Secret mal configuré ou un pod coincé sur la sortie locale soit évident immédiatement plutôt qu’après une exécution gaspillée. Ensuite, taux de succès et latence par pod, car dans une flotte les échecs sont rarement uniformes : un mauvais nœud, une session épuisée, ou une cible qui a commencé à vous défier apparaît comme les métriques d’un seul pod qui divergent. La qualité du pool détermine à quelle fréquence vous réessayez tout court, alors suivez-la, des IP propres avec une bonne réputation signifient moins de réessais par pod et moins de charge totale pour les mêmes données, et gardez un œil sur la latence et les taux de timeout comme votre alerte précoce que la posture d’une cible a changé.

En résumé

Kubernetes convient bien au scraping parce que la charge est parallèle et en rafales, et que la machinerie d’autoscaling, d’auto-réparation et de rollout de la plateforme se mappe proprement sur une flotte de workers. La couche proxy ajoute une courte liste de règles par-dessus : gardez les identifiants dans un Secret, donnez à chaque pod une session distincte via la Downward API, autoscalez sur la profondeur de file plutôt que sur le CPU, souvenez-vous que la pression totale sur une cible est la concurrence par pod fois le nombre de pods et bornez-la à l’échelle de la flotte, et gérez SIGTERM pour qu’un pod tué remette en file au lieu de laisser tomber du travail. Réussissez cela et vous pourrez mettre à l’échelle la collecte de six pods à soixante sans que la cible ne le sente jamais, ce qui est tout l’intérêt.

Pour le pool en dessous, nos proxys résidentiels sont construits pour distribuer la charge d’une flotte entre de nombreuses IP propres, et parce qu’ils sont tarifés au gigaoctet, mettre les pods à l’échelle multiplie votre débit sans multiplier votre facture de proxy, seules les données que vous déplacez vraiment comptent. Si vous faites de la collecte à grande échelle, c’est la combinaison qui la garde à la fois rapide et silencieuse.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de $0.75/GB.

Commencer