Tony Wang13 min de lecturaCómo hacer scraping de 10 millones de sitios web en 10 minutos
Diseño de sistema paso a paso: la cola, la flota de workers y el ajuste de recursos de un scraper distribuido de 16,000 req/s.
Diez millones de sitios web en diez minutos equivalen a unas 16,700 solicitudes por segundo, sostenidas durante los diez minutos completos. Ninguna máquina sola logra eso, y ninguna solicitud individual más ingeniosa te acerca a la meta. Lo único que lo consigue es una flota de workers baratos e idénticos que extraen de una única cola compartida — donde el throughput total sube con el número de workers, y cualquier worker puede desaparecer sin que se pierda trabajo.
Este artículo construye ese sistema de punta a punta: la arquitectura desacoplada, la elección de lenguaje que abarata la concurrencia, el puñado de ajustes por worker que deciden si añadir una caja realmente añade throughput, y el manejo de fallos que lo mantiene funcionando mientras tanto. El worker es un pequeño binario open source que puedes correr en tu propia laptop, así que cada paso es reproducible en lugar de quedarse en teoría.
Es un problema de throughput, no de solicitudes
Empieza por la aritmética, porque condiciona todo lo demás: 10,000,000 URLs ÷ 600 segundos ≈ 16,667 solicitudes/segundo. Una sola caja bien ajustada, sosteniendo ~500 sondas concurrentes limitadas por I/O — cada una tardando uno o dos segundos — llega a unos pocos miles de solicitudes por segundo. Así que el objetivo son aproximadamente entre cuatro y diez cajas, si el throughput escala de forma limpia al agregarlas. Todo el trabajo del diseño que sigue consiste en ganarse ese «si»: hacer que la flota escale casi linealmente, y mantenerla viva mientras corre.
La arquitectura: un pipeline desacoplado
Todo se sostiene sobre una idea — desacoplar las etapas para que nunca se bloqueen entre sí, y mantener todo el estado durable en un solo lugar. Los workers se vuelven desechables; la cola se convierte en el sistema de registro.
- Seed loader. Transmite la lista de URLs hacia la cola en fragmentos — nunca carga 10M de filas en memoria. Hazlo idempotente (
SADDen un set de Redis, o push a una lista) para que una reejecución retome en lugar de duplicar encolados. - Cola de trabajo (Redis). El corazón del sistema. Desacopla a los productores de los consumidores, absorbe ráfagas (backpressure), y hace que todo el sistema sea al menos una vez: un worker saca un job de la cola, y si muere a mitad de vuelo, otro worker simplemente lo vuelve a sacar. Añade un
SETo un filtro Bloom para deduplicar URLs visitadas, y una cola de mensajes muertos (dead-letter) para los jobs que agotan sus reintentos. - Flota de workers. Pods sin estado ejecutando código idéntico. Cada uno acumula un pequeño lote (~100–200 jobs), los procesa con concurrencia interna, escribe los resultados en bloque, y sincroniza sus estadísticas periódicamente y al apagarse. Escalar es simplemente «añadir pods». Un crash es simplemente «los demás siguen adelante».
- Sink de resultados. Cualquier almacén que acepte escrituras en lote (Elasticsearch, Postgres
COPY, archivos Parquet) con clave en un id idempotente comodomain|run_id, para que un job reprocesado sobrescriba en su lugar en vez de duplicarse.
Nota que no hay un «coordinador» central haciendo planificación en tiempo real. La cola es la coordinación. Eso es lo que permite que la flota escale horizontalmente sin un cerebro frágil en el medio.
Elige el lenguaje según el cuello de botella
El eje que decide esto no es la velocidad bruta de una sola solicitud. El fetching está limitado por I/O — pasas todo el tiempo esperando la red — así que lo que importa es qué tan barato mantiene un runtime miles de solicitudes en vuelo a la vez, qué tan bien usa cada núcleo, y qué tan pequeño se despliega. La gente primero pregunta «¿cuál usa menos memoria?», así que empezaremos ahí — y la respuesta no es la que predicen los memes.
- Go es el camino de menor resistencia aquí — aunque no por la razón que normalmente se da. Las goroutines son hilos verdes (~2–8 KB de stack cada una, multiplexadas sobre hilos del sistema operativo con un poller de red incorporado), así que escribes código de apariencia síncrona y sencilla, y el scheduler las estaciona en I/O sin ocupar un hilo; tener 100k+ solicitudes en vuelo por caja no llama la atención. Sus verdaderas ventajas son el multi-core real y gratuito (sin GIL, sin flota de procesos) y el despliegue — un único binario estático en una imagen scratch de ~10–20 MB con arranque en frío de milisegundos, lo que abarata «añadir un pod». Lo que no es una ventaja automática es la memoria; ver la medición más abajo.
- Python es más competitivo de lo que sugiere su reputación.
asyncio+aiohttpofrecen concurrencia de I/O real y, según resulta, una huella de memoria genuinamente ligera. Sus costos están en otro lado: es un event loop por núcleo (cooperativo — una sola llamada bloqueante extraviada detiene todo el loop), así que usar todos tus núcleos implica correr una flota de procesos, y la imagen es intérprete-más-dependencias (cientos de MB) con un arranque en frío lento. Sobre el GIL: la compilación libre de GIL (free-threaded) se volvió oficialmente soportada (aún opcional) en Python 3.14, pero ayuda sobre todo al multithreading limitado por CPU — para el abanico de I/O el GIL nunca fue el limitante, así que cambia poco para un fetcher.
| Runtime | Primitiva de concurrencia | Multi-core | Imagen / arranque en frío | Mejor cuando |
|---|---|---|---|---|
| Go | goroutine (hilo verde + netpoller) | integrado | binario estático, ~10–20 MB, ms | abanico de I/O de alta densidad (esta build) |
| Rust | tarea async (tokio) | integrado | pequeña, ms, sin GC | exprimir al máximo la curva de recursos |
| Python | tarea asyncio (1 loop / núcleo) | multi-proceso | intérprete + deps, cientos de MB, lento | pipelines de extracción o intensivos en ML |
| Node | promesa de event-loop | worker_threads | runtime + deps, medio | un equipo tipo JS-shop, escala moderada |
| JVM (Loom) | hilo virtual | integrado | JRE + jar, grande, warmup | una plataforma JVM existente |
Entonces, ¿cuál usa realmente menos memoria? Lo medimos
La sabiduría popular dice «las goroutines son diminutas, así que Go consume poca RAM». Eso es cierto para una goroutine desnuda y falso para una conexión. Mantuvimos 10,000 solicitudes HTTP concurrentes abiertas contra un servidor local y registramos el pico de memoria residente (HTTP plano, sin TLS — una prueba limpia de huella, no un scraping completo):
| 10,000 conexiones concurrentes | RSS pico |
|---|---|
Go — por defecto (GOGC=100) | 319 MB |
Go — GOMEMLIMIT=200MiB | 218 MB |
Go — GOGC=20 | 201 MB |
Python — asyncio + aiohttp | 200 MB |
De fábrica, Go usó ~1.6× la memoria de Python, no menos. Cada conexión en Go carga un stack de goroutine crecido más los buffers de lectura y escritura de net/http, y el garbage collector mantiene residente, por defecto, más o menos el doble del heap vivo — esa última parte es el «la memoria residente se duplica» que volverás a encontrar en la sección de ajuste. La corrección es la misma palanca: limitar el GC (GOMEMLIMIT, o un GOGC más bajo) y Go cae a paridad con Python.
Así que la memoria no es la razón para elegir Go aquí — sin ajustar, es una razón en contra. Elige Go por la eficiencia de CPU y multi-core, la imagen estática diminuta, y la baja huella base (8 MB en reposo aquí, contra 28 MB de Python); solo no lo despliegues con el GC completamente abierto.
La salida honesta: si la etapa cara es la extracción — pandas, BeautifulSoup, una llamada a un LLM — el ecosistema de Python puede dominar, y la jugada correcta es dividir el pipeline (fetchers en Go alimentando extractores en Python) en vez de forzar a un solo lenguaje a hacer ambas cosas. Empareja el lenguaje con la etapa que realmente es el cuello de botella.
Y aquí está la parte que realmente ha cambiado. Esta decisión solía resolverse con «escribe lo que el equipo ya sabe», porque ponerse al día en Go para un solo componente de alto throughput costaba semanas — así que un equipo de Python se quedaba en Python incluso donde un runtime más liviano encajaba mejor con el trabajo. La programación asistida por LLM ha disuelto en gran medida esa curva de aprendizaje. Un modelo te esboza el pool de goroutines, el http.Transport ajustado, el backpressure con canal y semáforo, y el Dockerfile de forma idiomática, incluso para un equipo que nunca ha desplegado Go. Elegir la herramienta correcta para el trabajo ahora es una opción realista por defecto, no un lujo.
El worker: una sonda barata y honesta
La unidad de trabajo es deliberadamente pequeña. Aquí está la línea base, corriendo crawlora-deadweb — la sonda abierta en Go que usaremos como worker — en una sola máquina antes de distribuirla:
cat domains.txt | crawlora-deadweb --json --concurrency 50 > out.ndjson
Lo que hay dentro de un buen worker son solo dos cosas hechas con cuidado:
- Alta concurrencia, porque el trabajo está limitado por I/O. Un pool acotado de goroutines (o tareas de asyncio) mantiene cientos de solicitudes en vuelo por caja. La CPU está mayormente inactiva; la red es el trabajo.
- Un veredicto honesto y barato por URL. DNS → conexión TCP → un
GET /, luego etiqueta el resultado y sigue adelante. Crucialmente, no quemes el presupuesto reintentando hosts que genuinamente ya no existen: sin DNS o una conexión rechazada significa muerto — descártalo; un403/429significa vivo pero rechazando a este cliente — un problema completamente distinto que un reintento tampoco resuelve. Distinguir entre ambos casos es la mayor parte de lo que evita que una corrida grande desperdicie horas en cadáveres.
Los cinco ajustes que hacen rápido a un worker
Una flota solo escala linealmente si cada caja está ajustada; de lo contrario añades cajas y todas se amontonan en el mismo cuello de botella compartido. Cinco perillas explican casi todo:
| Ajuste | Típico | Por qué importa |
|---|---|---|
| Concurrencia / worker | 50–500 en vuelo | Limitado por I/O: la caja espera a la red, no a la CPU |
| Reutilización de conexión | subir MaxIdleConnsPerHost, keep-alive activo | Evita un handshake TCP + TLS nuevo en cada solicitud |
| DNS | caché + varios resolvers | Millones de lookups derretirán a un único resolver — este es el cuello de botella oculto |
| Timeout por intento | pocos segundos, 1 reintento transitorio | Un host lento nunca debe retener un slot de concurrencia |
| Lote de escritura | bloque cada ~100 resultados / ~2s | El sink es el segundo cuello de botella después del DNS — nunca escribas una fila a la vez |
Si un crawl grande está misteriosamente lento, la respuesta es DNS mucho más a menudo de lo que la gente espera. Cachea agresivamente, distribuye los lookups entre varios resolvers, y corre un resolver con caché local en cada nodo.
Escalar hacia afuera: añade cajas, observa el cuello de botella
Con un worker ajustado, escalar es sobre todo un conteo de réplicas. En una corrida real, subir la flota de fetch de 4 a 10 pods vació la cola en horas en vez de un día. La forma que buscas:
En Kubernetes la higiene es pequeña pero determinante: pon imagePullPolicy: Always para que un :latest reempujado realmente se despliegue, dale a los pods requests/limits reales, y opcionalmente maneja un HPA a partir de la profundidad de la cola en lugar de la CPU. Y no sobre-particiones: el throughput está acotado por el recurso compartido más lento, así que si la curva se está aplanando, añadir cajas escala el cuello de botella, no el trabajo — arregla primero el DNS o el sink.
Mantenerlo activo: el fallo es el caso normal
En diez millones de solicitudes, los eventos raros ocurren constantemente — un worker será OOMKilled, un deploy hará rollout a mitad de la corrida, un host empezará a dar timeout. La alta disponibilidad no es una funcionalidad que se añade encima; es el conjunto de valores por defecto que convierte cada uno de esos casos en algo intrascendente:
| Cuando ocurre esto | Qué te salva |
|---|---|
| Un worker se cae a mitad de un job | La cola al-menos-una-vez vuelve a asignar el job a otro worker |
| La misma URL se procesa dos veces | Escritura idempotente con clave en un id estable domain + run_id — el reintento sobrescribe, sin duplicados |
| Un rolling deploy reinicia cada pod | Drenado con SIGTERM elegante: dejar de extraer, terminar lo que está en vuelo, volcar estadísticas |
| Una URL envenenada atasca a un worker | Timeout por intento + presupuesto de reintentos → cola de mensajes muertos |
| Un host o el sink falla en cada llamada | Salta el circuit breaker; el worker se recicla después de N tareas |
| La ráfaga de 10M amenaza al resto de tu infra | Namespace propio + cuota de recursos + clase de prioridad no-preemptible |
La idea central: workers sin estado más una cola durable significa que no hay un único punto de fallo. La cola es lo único que tiene que sobrevivir, y es lo único que replicas.
La parte que realmente se rompe: los recursos
En la práctica, un sistema a esta escala rara vez falla por lógica. Falla por memoria — un bucle de reinicios por OOMKills (exit 137). La causa es casi siempre la misma: el runtime no es consciente del cgroup, así que dentro de un contenedor ve los núcleos y la RAM del nodo en vez del límite del pod. Para Go antes de la versión 1.25, GOMAXPROCS toma por defecto el conteo de núcleos del nodo (sobre-paralelizando el GC y los handshakes de TLS contra un límite de CPU fraccional), y no hay techo de memoria, así que el heap puede casi duplicarse por encima del límite antes de que corra el GC.
Las correcciones, en orden de cuánto nos beneficiaron:
| Ajuste | Valor | Efecto |
|---|---|---|
GOMAXPROCS | = el límite de CPU del contenedor | Deja de sobre-paralelizar el GC + los handshakes contra un límite de CPU fraccional. La palanca de mayor impacto — terminó con el bucle de OOM y bajó el RSS por pod de ~730 MiB a ~150–420 MiB. |
GOMEMLIMIT | ~85% del límite de memoria | Un techo suave que hace que el GC corra antes de que el kernel mate el pod |
automaxprocs + automemlimit | importación en blanco de ambos | El binario lee el cgroup al arrancar y se autoajusta en ambos aspectos, para cualquier límite |
--concurrency | ajustado al límite de CPU, no más alto | Pasado el límite, más concurrencia es solo contención |
GOGC=off | no | Se vuelve en contra con un asignador de alta rotación como este |
La lección se generaliza más allá de Go: cualquiera que sea el runtime, dimensiona su paralelismo y memoria según la cuota del contenedor, no la del host. (Para Python: dimensiona el pool de procesos/workers según la cuota de CPU, no os.cpu_count(), y vigila el RSS por worker.)
Sabe cuándo realmente terminaste
Dos trampas de conteo muerden a todos en la recta final:
Y no escales la flota a cero en el instante en que la cola queda vacía. Los workers acumulan trabajo por delante, así que matarlos en queue == 0 les manda SIGTERM a mitad de su búfer y descarta trabajo en vuelo — un subconteo. Espera hasta que procesado ≈ objetivo (confirmado contra el sink), y solo entonces reduce la escala.
Sé un buen ciudadano
Nada de esto es un permiso para martillar la web. Mantén límites de tasa por host, respeta el robots.txt y los términos de servicio, envía solo GETs sin autenticar, e identifica a tu bot. Una lista de diez millones de hosts distintos distribuye la carga de forma natural; diez millones de páginas en un solo sitio necesita una limitación real por host. Y no corras un abanico amplio desde una única IP de nube pelada — conectarte a muchos hosts distintos desde una sola dirección no gestionada parece un escaneo de red y te gana reportes de abuso. Usa una salida controlada o un pool gestionado.
El worker es open source
crawlora-deadweb es la pequeña sonda en Go usada como worker arriba — DNS, TCP, un GET honesto, y luego un veredicto. Córrela sobre tu propia lista, o lee su paquete classify para ver cómo está construida la unidad de trabajo. El mismo patrón de escalar-solo-lo-necesario es lo que Crawlora corre como API alojada.
Preguntas frecuentes
How do you scrape 10 million websites in 10 minutes?
Not from one machine and not with a cleverer request — you run a horizontally-scaled fleet of stateless workers pulling URLs from a shared queue. Ten million in ten minutes is about 16,700 requests per second; with each tuned box holding a few hundred concurrent I/O-bound requests, that is roughly four to ten workers, provided throughput scales cleanly with the box count.
What architecture is used for large-scale web scraping?
A decoupled pipeline: a loader streams the URL list into a durable queue (Redis), a fleet of stateless worker pods pulls jobs and probes each URL, and results are bulk-written to a sink with idempotent ids. The queue is the only stateful piece, so workers are disposable and the system is at-least-once — a crashed worker's jobs simply re-pop to another worker.
Is Go or Python better for web scraping at scale?
For high-concurrency, I/O-bound fetching, Go's real strengths are true multi-core use (no GIL) and a single small static binary with fast cold start — not memory. In a 10,000-connection test, Go's default net/http used about 1.6x the RAM of Python's asyncio + aiohttp (319 MB vs 200 MB) until its garbage collector was capped with GOMEMLIMIT, which brought it to parity. Python needs a process per core but is lean on memory; if extraction or ML is the heavy stage, its ecosystem can win — so split the pipeline with Go fetchers and Python extractors.
Why does my distributed scraper keep getting OOMKilled?
Usually because the runtime is not cgroup-aware: inside a container it sees the node's cores and RAM, not the pod's limit. For Go, set GOMAXPROCS to the CPU limit and GOMEMLIMIT to about 85% of the memory limit, or blank-import automaxprocs and automemlimit to self-tune. Setting GOMAXPROCS to the cap is typically the single biggest fix — it ended a restart loop and dropped per-pod memory from ~730 MiB to ~150–420 MiB in our run.
How do you make a web scraper highly available?
Keep workers stateless and put all durable state in the queue, so any worker can die without losing work (at-least-once). Make writes idempotent (key on domain|run_id) so re-processing is safe, drain gracefully on SIGTERM, send permanently-failing jobs to a dead-letter queue, and run the fleet in an isolated namespace with a non-preempting priority class so a burst never starves the rest of your infrastructure.