Chaque crawler distribué finit tôt ou tard par vivre le même mauvais après-midi. Un parseur ralentit parce qu’un site s’est mis à envoyer des pages énormes. Les fetchers continuent à récupérer des pages à pleine vitesse, parce que rien ne leur a dit de ralentir. La file entre les deux grossit de millions d’éléments, la mémoire grimpe, un nœud tombe, son travail est remis en file sur les autres, et les nouvelles tentatives les font tomber à leur tour. Pendant ce temps, le site à l’origine de tout ça reçoit plus de trafic que jamais.
Rien de tout cela n’est un bug dans un composant en particulier. C’est l’absence de contrôle de flux entre eux. Un crawler est un pipeline d’étapes à des vitesses très différentes, et sans moyen pour une étape lente de dire à une étape rapide d’attendre, la rapide l’emporte jusqu’à ce que tout le monde perde.
Cet essai porte sur la construction de ce retour d’information : d’où viennent les signaux, où ils doivent aller, et la poignée de règles qui font qu’un crawler se dégrade en douceur plutôt que de s’effondrer.
Un crawler est un pipeline, et les étapes ne s’accordent pas sur la vitesse
En enlevant les détails, la plupart des crawlers ont quatre étapes :
| Étape | Ce qui la limite | Comment elle échoue en surcharge |
|---|---|---|
| Frontier et scheduler | Presque rien, elle ne fait que choisir des URLs | Émet du travail plus vite que ce que peut absorber la suite |
| Fetchers | Sites cibles, capacité des proxys, limites du plan | Timeouts, throttling, blocages |
| Parsing et rendu | CPU, mémoire, slots de navigateur headless | Latence croissante, puis épuisement mémoire |
| Dédoublonnage et stockage | Capacité d’écriture de la base de données | Écritures lentes, contention de verrous, arriérés |
Le frontier est presque gratuit à exécuter, il sera donc toujours l’étape la plus rapide. Les trois autres sont chacune limitées par quelque chose de différent, et la limite se déplace : un site devient plus lent, un type de page devient plus lourd, une base de données commence une compaction. Le contrôle de flux signifie que l’étape la plus lente à l’instant présent fixe le rythme pour toutes les autres.
L’alternative, c’est que le rythme soit fixé par l’étape qui manque de mémoire en premier.
Règle 1 : chaque file est bornée
Une file non bornée entre deux étapes n’est pas un tampon. C’est une promesse d’absorber n’importe quelle quantité de travail excédentaire, promesse qu’aucune machine ne peut tenir. Cela masque également le problème. L’étape en amont voit un succès à chaque mise en file pendant que la file grossit silencieusement, et quand quelqu’un finit par regarder, le plus ancien élément attend depuis des heures.
Une file bornée transforme la même situation en signal. Quand elle se remplit, le producteur bloque, ou reçoit un rejet explicite. Le ralentissement se propage en amont, étape par étape, jusqu’à atteindre le frontier, qui arrête tout simplement de distribuer des URLs pendant un moment. Rien n’est perdu et rien n’explose.
Dans un seul processus, c’est un simple argument au constructeur de la file :
import asyncio
async def fetcher(frontier: asyncio.Queue, parsed: asyncio.Queue, fetch):
while True:
url = await frontier.get()
try:
page = await fetch(url)
# Blocks when the parse queue is full: a slow parser slows fetching.
await parsed.put(page)
finally:
frontier.task_done()
async def parser(parsed: asyncio.Queue, store):
while True:
page = await parsed.get()
try:
await store(page)
finally:
parsed.task_done()
async def run(urls, fetch, store, fetchers=16, parsers=4):
frontier = asyncio.Queue(maxsize=1000)
parsed = asyncio.Queue(maxsize=200)
workers = [asyncio.create_task(fetcher(frontier, parsed, fetch)) for _ in range(fetchers)]
workers += [asyncio.create_task(parser(parsed, store)) for _ in range(parsers)]
for url in urls:
await frontier.put(url) # blocks when the frontier is full
await frontier.join()
await parsed.join()
for w in workers:
w.cancel()
Entre plusieurs machines, le principe est le même, seul le mécanisme change : une file de broker avec une limite de longueur et une politique de rejet, un flux dont on agit réellement sur le lag des consommateurs, ou un frontier adossé à une base de données qui ne libère des URLs qu’aux workers disposant de capacité libre. Dimensionnez les bornes en fonction de la latence que vous pouvez tolérer, pas de la mémoire dont vous disposez. Si l’étape de parsing traite 200 pages par seconde et que vous acceptez 10 secondes de mise en attente, la file contient environ 2 000 éléments. Une taille plus grande ne fait que reporter le moment où vous découvrez le problème.
Règle 2 : tirer, pas pousser
Le backpressure le plus propre est celui que l’on obtient gratuitement. Si les workers demandent du travail quand ils ont de la capacité, plutôt qu’un coordinateur qui leur assigne du travail, un worker lent en demande simplement moins souvent. Le système ne peut pas lui en envoyer plus qu’il ne peut gérer, puisqu’il n’en a jamais demandé plus.
Pousser du travail oblige le coordinateur à deviner. Il doit suivre la charge de chaque worker, et il devinera mal exactement au moment où la charge change. Les designs basés sur le tirage, que les workers louent des éléments d’une file ou accordent des crédits explicites à leur amont, déplacent cette décision vers le seul composant qui connaît la réponse.
Les baux ont besoin d’une expiration. Un worker qui meurt en détenant du travail ne doit pas le détenir indéfiniment, les éléments loués doivent donc retourner dans la file après un délai. Fixez ce délai à partir du fetch légitime le plus lent, pas de la moyenne, sinon du travail sain mais lent finit par être distribué deux fois.
Règle 3 : une file par hôte, pas une file globale unique
Une file de fetch globale unique présente un mode de défaillance que les crawlers rencontrent constamment : le head-of-line blocking. Si les mille prochaines URLs appartiennent toutes à un site lent ou en throttling, tous les fetchers finissent par attendre sur ce site pendant que le travail pour des sites rapides et sains reste bloqué derrière.
Partitionnez l’étape de fetch par hôte, ou par toute unité sur laquelle une cible applique une limite de débit. Chaque hôte a sa propre file et sa propre limite de concurrence, et les fetchers piochent dans les hôtes qui ont actuellement de la place. Un site en difficulté ne ralentit alors que lui-même.
C’est également là que réside la politesse. Une limite par hôte est simultanément un contrôle de flux pour vous et une retenue envers le site. Fixer cette limite au départ, et ce que les signaux propres du site vous en disent, est traité dans rate limiting et régulation des requêtes.
Règle 4 : rendre les limites par hôte adaptatives
Une concurrence par hôte fixe se trompe dans les deux sens. Trop basse, elle gaspille de la capacité sur un site qui pourrait en absorber davantage. Trop haute, elle continue de solliciter un site qui a commencé à se rebiffer, ce qui transforme un throttling temporaire en blocage.
La réponse bien éprouvée est celle qu’utilise TCP : additive increase, multiplicative decrease. Chaque succès pousse la limite un peu vers le haut. Chaque throttle ou timeout la divise par deux. La limite se stabilise juste en dessous de ce que le site va tolérer, et bouge quand le site change.
import asyncio
class HostLimiter:
"""Adaptive concurrency for one host: additive increase, multiplicative decrease."""
def __init__(self, start=4, floor=1, ceiling=32):
self.limit = start
self.floor = floor
self.ceiling = ceiling
self.in_flight = 0
self._cond = asyncio.Condition()
async def acquire(self):
async with self._cond:
await self._cond.wait_for(lambda: self.in_flight < int(self.limit))
self.in_flight += 1
async def release(self, outcome):
async with self._cond:
self.in_flight -= 1
if outcome == "ok":
self.limit = min(self.ceiling, self.limit + 1 / max(1, int(self.limit)))
elif outcome in ("throttled", "timeout"):
self.limit = max(self.floor, self.limit / 2)
self._cond.notify_all()
L’augmentation est délibérément lente, environ un slot supplémentaire par cycle complet de succès, et la diminution est délibérément brutale. L’asymétrie est voulue : dépasser la tolérance d’un site coûte bien plus cher que de rester en dessous. Gardez un plafond strict par hôte que vous fixez vous-même, afin qu’une limite adaptative ne puisse jamais s’imposer au-delà de ce que vous jugez raisonnable.
Règle 5 : savoir d’où vient chaque signal
Tous les « ralentis » ne veulent pas dire la même chose, et les traiter de façon identique envoie la pression au mauvais endroit.
| Signal | Où il prend son origine | Ce qu’il doit ralentir |
|---|---|---|
429, latence en hausse, pages de challenge d’un site | La cible | Le limiteur de cet hôte uniquement |
| File de parsing pleine, stockage en retard | Votre propre pipeline | Le fetch globalement, puis le frontier |
| Plafond de concurrence sur une API de scraping | Votre plan | Le total des requêtes en cours vers cette API |
| Quota ou crédits épuisés | Votre plan | Tout, jusqu’à la réinitialisation du cycle ou un réapprovisionnement |
L’API Web Scraping de Shifter, par exemple, renvoie 429 Too Many Requests quand vous dépassez le plafond de concurrence de votre plan, et 509 quand les crédits sont épuisés. Le premier est un signal de contrôle de flux qui vous concerne, pas une cible, il appartient donc à un limiteur global sur les appels à l’API, pas à un limiteur par hôte. Le second est une condition d’arrêt. Confondre l’un ou l’autre avec le 429 propre d’un site fait qu’un crawler freine des sites sains pour un problème qui n’a rien à voir avec eux.
Les retries sont de la charge
La façon la plus courante dont un crawler défait son propre contrôle de flux, c’est par les retries. Un fetch échoue, le worker retente immédiatement, la nouvelle tentative échoue aussi parce que la cause n’a pas disparu, et chaque worker qui fait la même chose multiplie le trafic exactement au moment où une cible, ou votre propre étape, est la moins capable de l’absorber.
- Les retries passent par le même contrôle d’admission que les premières tentatives. Un retry qui contourne le limiteur par hôte est un contournement de votre propre contrôle de flux.
- Reculez avec du jitter, pour qu’un lot de workers ayant échoué ensemble ne retente pas ensemble.
- Donnez à chaque tâche un budget de retries, et suivez les retries comme une part du total des requêtes. Quand cette part grimpe, le système dépense sa capacité sur l’échec.
- N’empilez pas les couches de retry. L’API Web Scraping retente déjà les fetchs échoués, les CAPTCHAs et les erreurs transitoires de la cible jusqu’à trois fois avec des proxys différents avant de renvoyer une erreur, sans frais supplémentaires. Des retries agressifs côté client par-dessus multiplient les tentatives plutôt que d’ajouter de la résilience.
- Ne retentez jamais ce qui ne peut pas réussir. Les erreurs d’authentification et de configuration échouent de la même façon à chaque fois.
Quand vous ne pouvez pas suivre, délestez volontairement
Parfois, la réponse honnête est que le crawler a plus de travail qu’il ne peut en faire. Le contrôle de flux ralentit alors tout de façon uniforme, ce qui est souvent le pire résultat : chaque tâche finit en retard, y compris celles qui comptent.
Le délestage de charge rend le choix explicite. Donnez une priorité au travail, et quand les files restent pleines au-delà d’un seuil, abandonnez ou différez les éléments de plus basse priorité au niveau du frontier, avant qu’ils ne coûtent un fetch. Rafraîchir une page de prix volatile vaut plus que revérifier une page d’archive qui n’a pas changé depuis un an. Quelles pages méritent le budget est un problème à part entière, traité dans planification de crawl sensible aux coûts.
Mesurez la pression, pas seulement le débit
Les graphiques de débit ont l’air corrects jusqu’à l’effondrement. Les métriques qui montrent la pression qui monte sont différentes :
- La profondeur de file et l’âge du plus ancien élément, par étape. L’âge compte plus que la profondeur : une file profonde qui se vide rapidement est saine, une file peu profonde pleine d’éléments périmés ne l’est pas.
- Les requêtes en cours et la limite adaptative actuelle, par hôte. Une limite qui divise sans arrêt par deux signale un site qui se rebiffe.
- Les rejets d’admission et les mises en file bloquées, par étape. C’est le backpressure qui fait son travail. Une hausse soudaine indique quelle étape est désormais le goulot d’étranglement.
- La part de retries, par hôte et globalement.
Une limite par hôte qui baisse combinée à des taux de blocage et de challenge en hausse signifie généralement qu’un site se retourne contre vous plutôt qu’il n’est simplement lent. Transformer ces signaux en un jugement unique par site est traité dans construire un score de santé de cible. L’ensemble plus large des métriques de pipeline se trouve dans surveiller un pipeline de web scraping.
Faire évoluer l’infrastructure ne supprime pas le besoin de contrôle de flux
Ajouter des workers relève le plafond de l’étape de fetch. Cela ne relève ni la tolérance d’une cible, ni votre capacité de parsing, ni les limites de votre plan. Un crawler qui fait évoluer automatiquement ses fetchers sur la profondeur de file, sans limites par hôte ni files bornées en aval, va monter en charge droit dans toutes les contraintes à la fois. Si vous fonctionnez sur Kubernetes, faites évoluer sur les signaux ci-dessus plutôt que sur le seul CPU ; la mécanique est traitée dans faire évoluer le scraping via proxy résidentiel avec Kubernetes.
Le même principe s’applique entre régions. Des files durables et du backpressure entre régions sont ce qui empêche une défaillance régionale de se transformer en défaillance globale, comme décrit dans failover de proxy résidentiel pour des pipelines multi-régions.
En résumé
Le contrôle de flux n’est pas une optimisation à ajouter une fois qu’un crawler est devenu grand. C’est ce qui décide si un crawler sous tension ralentit ou s’effondre. Les règles sont peu nombreuses : bornez chaque file, laissez les workers tirer, partitionnez par hôte, adaptez les limites par hôte en baisse brutale et en hausse lente, dirigez chaque signal de ralentissement vers l’étape qu’il décrit, traitez les retries comme de la charge, et délestez délibérément le travail le moins précieux.
Un crawler construit ainsi possède une propriété supplémentaire qui vaut la peine. Quand un site commence à peiner, le crawler le remarque et lève le pied de lui-même, ce qui est bon pour le site et, ce n’est pas une coïncidence, bon pour les chances du crawler d’être encore le bienvenu la semaine prochaine.