Crawler un grand volume de pages web

Temps de lecture ~6 minutes

Crawler et scraper les données présentes sur le net est une tâche amusante. C’est relativement facile et gratifiant puisque vous obtenez très vite des résultats concrêts. Cependant, passer d’un crawler basique (écrit sous forme de petit script Python par exemple) à un crawler rapide permettant d’acquérir un grand volume de données, n’est pas quelque chose de simple. Je vais tenter de vous décrire deux ou trois défis que vous rencontrerez lorsque vous vous lancerez dans ce type de projet.

Concurrence

Introduire de la concurrence est absolument central dans la plupart des applications modernes, et c’est particulièrement vrai en ce qui concerne les applications exigeant un grand nombre d’accès réseaux tels que les crawlers. En effet, puisque chaque requête HTTP que vous déclenchez prend un temps monstre avant de répondre, vous avez tout intérêt à les lancer en parallèle plutôt qu’en séquentiel. En gros cela signifie que si vous crawlez 10 pages prenant 1 seconde chacune, cela vous prendra approximativement 1 seconde au total plutôt que 10 secondes.

La concurrence est donc critique, mais comment la mettre en place ?

L’approche naïve, qui fonctionne parfaitement pour une petite application, est de coder vous-même le déclenchement des jobs en parallèle, attendre les résultats, et les traiter. Typiquement en Python vous lanceriez plusieurs processus, et en Go (qui se prête mieux à ce type de travail) vous devriez créer des goroutines. Mais gérer tout cela manuellement peut vite devenir compliqué : comme vos ressources en RAM et CPU sont limitées, vous ne pouvez pas indéfiniment lancer vos jobs en parallèle, donc comment gérer les queues, et comment gérer les retries lorsque des jobs échouent (et vous pouvez être certain que cela arrivera) ou lorsque votre serveur s’arrête pour différentes raisons ?

L’approche la plus robuste est de mettre à profit un système de messaging tel que RabbitMQ. Chaque nouvelle URL récupérée lors du parsing peut désormais être mise en queue dans RabbitMQ, et toute nouvelle page que votre application souhaite crawler doit être prise dans la queue de RabbitMQ. Le nombre de requêtes concurrentes que vous souhaitez atteindre est simplement un paramètre à déclarer dans RabbitMQ.

Bien entendu, même lorsque l’on utilise un système de messaging, le choix du langage de programmation reste important : déclencher 100 jobs en parallèle en Go vous coûtera beaucoup moins de ressources qu’en Python par exemple (raison en partie pour laquelle j’apprécie beaucoup Go !).

Scalabilité

A un certain point, peu importe le degré d’optimisation que votre crawler aura atteint, vous serez limité par des ressources hardware.

La première solution est d’upgrader les performances de votre serveur (ce que l’on appelle “scalabilité verticale”). C’est facile à faire, mais une fois que vous aurez atteint un certain niveau de RAM ou CPU, cela s’avérera moins coûteux de favoriser la “scalabilité horizontale”.

Le principe de la scalabilité horizontale est d’ajouter plusieurs serveurs de taille modeste à votre infrastructure, plutôt que de transformer votre serveur en supercalculateur. Réaliser cela est plus difficile sur le plan technique car vos serveurs auront certainement besoin de communiquer à propos d’un état commun à tous, et un refactoring de votre application peut s’avérer nécessaire. La bonne nouvelle est qu’un crawler peut facilement devenir “stateless” : plusieurs instances de votre application peuvent tourner en parallèle et les informations à partager seront certainement situées dans votre outil de messaging et/ou votre base de données. Il est alors facile d’augmenter/diminuer le nombre de serveurs en fonction de la vitesse que vous souhaitez atteindre. Chaque serveur doit gérer un certain nombre de requêtes concurrentes consommées depuis le serveur de messaging. C’est votre rôle de définir combien de requêtes concurrentes chaque serveur peut absorber en fonction de ses ressources en RAM/CPU.

Les orchestrateurs de conteneurs tels que Kubernetes rendent la scalabilité horizontale plus aisée. Il est plus simple d’accroître le nombre d’instances en quelques clics, et Kubernetes peut même se charger de scaler les instances automatiquement pour vous (en revanche mettez toujours en place des limites pour éviter aux dépenses de déraper).

Si vous souhaitez avoir une connaissance plus pointue des challenge liés à la scalabilité, je vous recommande de lire cet excellent ouvrage de Martin Kleppmann: Data Intensive Applications.

Data Intensive Applications book

Rapporter les erreurs intelligemment

Des tonnes de vilaines choses peuvent arriver au cours d’un crawl : des problèmes de connectivité (côté client comme côté serveur), des congestions réseaux, une page HTML trop lourde, une limite de mémoire…

Il est crucial de gérer ces erreurs correctement et de les rapporter avec discernement afin de ne pas crouler sous les erreurs.

Une bonne pratique est de centraliser toutes les erreurs dans Sentry. Certaines erreurs ne sont jamais envoyées à Sentry car nous ne les considérons pas comme critiques. Par exemple nous souhaitons être alerté lorsqu’une instance connaît un problème de mémoire, mais nous ne voulons pas être prévenu lorsqu’une page ne peut pas être téléchargée à cause d’un site en timeout (ce type d’erreur fait partie du fonctionnement normal d’un crawler). Décidez donc intelligement de quelles erreurs valent le coup d’être rapportées, et lesquelles ne le valent pas.

File descriptors et utilisation mémoire

Lorsque l’on touche au sujet des crawlers, il est intéressant de se familiariser avec le concept de file descriptors. Chaque requête HTTP que vous lancez ouvre un file descriptor, et ce dernier consomme de la mémoire.

Sur les systèmes Linux, le nombre maximal de file descriptors ouverts en parallèle est plafonné par l’OS afin de ne pas mettre le système à genoux. Une fois que cette limite est atteinte, il n’est plus possible d’ouvrir une seule nouvelle page web.

Vous pouvez décider d’augmenter cette limite mais procédez avec attention car cela peut mener à une consommation mémoire excessive.

Eviter certains pièges

Voici 2 pièges typiques dans lesquels il faut éviter de tomber si vous souhaiter améliorer la performance de votre crawler :

  • stopper la requête si la page à télécharger est trop lourde : c’est important non seulement pour des questions de stabilité (vous n’avez certainement pas envie de remplir tout votre disque) mais aussi pour des questions d’efficacité.
  • paramétrer les timeouts avec attention : une requête web peut être en timeout pour plusieurs raisons et il est important de comprendre les concepts qu’il y a là-dessous afin d’adopter différent niveaux de timeouts. Jetez un oeil à cet excellent article de Cloudflare pour plus de détails. En Go vous pouvez mettre en place un timeout lorsque vous créez un client net/http, mais une façon plus idiomatique (et peut-être plus moderne) de faire est d’utiliser les contexts.

DNS

Lorsque l’on crawl des millions de pages web, le serveur DNS que l’on utilise par défaut a des chances de finir par rejeter les requêtes. Il devient alors intéressant de commencer à utiliser un serveur DNS plus robuste comme celui de Google ou de Cloudflare, ou même d’effectuer une rotation sur ces différents serveurs.

Rafraichir les données

Crawler des données une seule fois est souvent de peu d’intérêt. Les données doivent être rafraichies de façon asynchrone régulièrement en utilisant des tâches périodique, ou de façon synchrone à chaque requête utilisateur.

Une application sur laquelle j’ai travaillée récemment rafraichissait les données de façon asynchrone. Chaque fois que l’on crawlait un domaine, la date du crawl était stockée en base de données, et par la suite chaque jour une tâche périodique scannait en base tous les domaines nécessitant un update. Comme Cron est très limité, nous utilisions cet lib permettant de faire du Cron plus avancé dans notre application Go: https://github.com/robfig/cron.

Etre juste

Crawler le web doit être fait respectueusement. Cela signifie en gros 2 choses :

  • ne pas crawler une page si le robots.txt l’interdit
  • ne pas harceler de requêtes un seul serveur : baissez drastiquement votre niveau de concurrence lorsque vous crawlez plusieurs page d’un même domaine, et faites des pauses entre chaque requête.

Conclusion

Mettre en place un crawler permettant de crawler un grand volume de pages web est une aventure fascinante qui demande des réflexions avancées à la fois sur le code et sur l’infrastructure.

Dans ce poste je me contente d’aborder les bases mais j’espère malgré tout que ces concepts vous aideront lors de votre prochain projet ! Si vous avec des remarques ou des questions n’hésitez pas, ce sera avec plaisir.

Also available in English

Rate Limiting d'API avec Traefik, Docker, Go, et la mise en cache

Limiter l'utilisation de l'API en fonction d'une règle avancée de limitation du débit n'est pas si facile. Pour y parvenir derrière l'API NLP Cloud, nous utilisons une combinaison de Docker, Traefik (comme proxy inverse) et la mise en cache locale dans un script Go. Si cela est fait correctement, vous pouvez améliorer considérablement les performances de votre limitation de débit et étrangler correctement les demandes d'API sans sacrifier la vitesse des demandes. Continuer de lire