Building a Resilient Web Crawl System: Design, Implementation, and Observability
Building a Resilient Web Crawl System: Design, Implementation, and Observability
Web crawling is a foundational capability for search engines, data platforms, and competitive intelligence. A robust crawl system must be scalable, fault-tolerant, respectful of site policies, and easy to operate. This tutorial walks through the end-to-end process of designing and implementing a resilient web crawler, with concrete code examples, deployment tips, and practical guidance you can adapt to your organization.
1) Define the crawl goals and constraints
Before touching code, clarify what you’re trying to achieve and what you won’t do.
-
Goals
- Maximize coverage of target domains within a given latency bound.
- Respect robots.txt, rate limits, and politeness policies.
- Store structured metadata (URL, status, fetch time, response codes, error messages).
- Provide observability dashboards for operator sanity and alerting.
-
Constraints
- Crawl budget per domain (requests per second, total daily requests).
- Maximum depth and breadth from seed URLs.
- Handling of dynamic content (e.g., JavaScript-rendered pages) vs. static content.
- Data freshness requirements (e.g., re-crawl every 24-72 hours).
-
Success metrics
- Coverage (percentage of target URLs visited).
- Freshness (time since last successful fetch).
- Error rate (non-2xx responses, hard 4xx/5xx errors).
- Latency (time from seed to crawl completion per URL).
Illustration: Think of a crawl system as a fleet of robots with different routes. You need a map (crawl plan), traffic rules (politeness), and a control tower (observability) to keep them running smoothly.
2) High-level architecture
A practical, modular architecture helps you scale and operate safely.
-
Scheduler/Queue
- Manages work units (URLs) with priorities and per-domain quotas.
- Supports backoff, retries, and politeness windows.
-
Fetcher
- Performs HTTP requests with robust error handling.
- Applies per-domain rate limiting and user-agent strategies.
- Handles redirects, compression, and timeouts.
-
Parser/Normalizer
- Extracts links, metadata, and relevant content.
- Normalizes URLs to avoid duplicates.
-
Storage
- Durable store for crawl metadata (URL, status, timestamp, content hash).
- Optional content storage for fetched HTML or extracted data.
-
Deduplication and Canonicalization
- Avoids re-crawling identical URLs and handles canonical links.
-
Observability
- Metrics, logs, dashboards, and alerting.
-
Compliance and Ethics
- robots.txt fetcher, crawl-delay respect, and opt-out handling.
Visualizing: A message-driven pipeline where a URL message flows through fetch, parse, store, and metrics reporters, with per-domain throttling and error handling woven in.
3) Data model essentials
A lightweight schema helps keep the system maintainable.
-
CrawlJob (URL to fetch)
- id: string (UUID)
- url: string
- domain: string
- priority: int
- created_at: timestamp
- updated_at: timestamp
- status: enum (QUEUED, FETCHING, PARSING, SUCCESS, RETRY, BLOCKED, FAILED)
- retry_count: int
- max_retries: int
- last_fetched_at: timestamp
- http_status: int
- content_hash: string
- content_type: string
- redirect_count: int
- error_message: string
-
DomainPolicy
- domain: string
- rate_limit_per_s: float
- concurrency_limit: int
- robots_txt_last_checked: timestamp
- robots_txt_disallow: boolean
- crawl_delay_seconds: float
- user_agent: string
-
CrawlContent (optional)
- job_id: string
- html: text/blob
- extracted_links: list
- title: string
- meta_description: string
Choose a storage approach you’re comfortable with (PostgreSQL for relational integrity, or a NoSQL store like Redis for speed with a separate metadata store).
4) Core components: code sketches
Below are concise Python-centric sketches to illustrate key components. You can adapt to your language of choice (Go, Rust, Java, etc.).
- URL queue with per-domain rate limiting (simplified)
### crawl_queue.py
import time
from collections import defaultdict, deque
class CrawlQueue:
def __init__(self):
self.domain_queues = defaultdict(deque)
self.domain_last_access = defaultdict(float)
self.domain_limits = {} # domain -> (limit_per_sec, max_concurrency)
def add(self, job):
domain = self._extract_domain(job.url)
self.domain_queues[domain].append(job)
def next_batch(self, batch_size=32):
now = time.time()
batch = []
for domain, q in list(self.domain_queues.items()):
limit = self.domain_limits.get(domain, (1.0, 2))
last = self.domain_last_access.get(domain, 0)
if len(batch) >= batch_size:
break
if now - last < 1.0 / max(limit, 1e-6):
continue
if q:
batch.append(q.popleft())
self.domain_last_access[domain] = now
return batch
@staticmethod
def _extract_domain(url):
from urllib.parse import urlparse
return urlparse(url).netloc
- Fetching with timeout and polite headers
### fetcher.py
import requests
from urllib.parse import urlparse
DEFAULT_HEADERS = {
"User-Agent": "MyCrawlBot/1.0 (+https://example.com/crawler)"
}
def fetch(url, timeout=15, headers=None, max_redirects=5):
h = dict(DEFAULT_HEADERS)
if headers:
h.update(headers)
try:
resp = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, timeout_connect=5, max_redirects=max_redirects)
return {
"status": resp.status_code,
"content_type": resp.headers.get("Content-Type", ""),
"content": resp.content,
"final_url": resp.url,
"elapsed": resp.elapsed.total_seconds(),
}
except requests.RequestException as e:
return {"status": None, "error": str(e)}
- Simple link extractor
### parser.py
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def extract_links(html, base_url):
soup = BeautifulSoup(html, "html.parser")
links = set()
for a in soup.find_all("a", href=True):
href = a["href"]
if href.startswith("#"):
continue
href = urljoin(base_url, href)
links.add(href)
title = (soup.title.string if soup.title else "")
meta = ""
m = soup.find("meta", attrs={"name": "description"})
if m and m.get("content"):
meta = m["content"]
return {
"links": list(links),
"title": title,
"meta_description": meta
}
- Simple storage interface (pseudocode)
### storage.py
class Storage:
def __init__(self, db_url):
pass # initialize connection
def upsert_job(self, job):
pass # insert or update
def mark_fetched(self, job_id, status, http_status, content_hash, redirect_count, error_message=""):
pass
def store_content(self, job_id, html, links, title, meta_description):
pass
Notes:
- In production, you’d implement idempotent deduplication, content hashing (e.g., SHA-256) to detect re-crawls, and robust retry/backoff strategies.
- Use a robust queue system (e.g., RabbitMQ, Kafka, or a Redis-backed queue) for durability and scalability. ### 5) Politeness, robots.txt, and policy handling
Respecting sites is critical for ethical crawling.
- Fetch robots.txt at domain startup and respect Disallow rules.
- Implement crawl-delay per domain, with a maximum cap.
- Use a dynamic user-agent with contact info or a crawl-privacy header if required.
- Honor "Retry-After" from 429 or 503 responses.
Implementation tips:
- Maintain a robots.txt cache per domain with a TTL (e.g., 24 hours).
- Before fetching a URL, verify it’s allowed by robots.txt, and that you’re within the domain’s rate limits.
- If robots.txt disallows or a site imposes a long delay, skip or back off accordingly. ### 6) Observability and reliability
Observability turns a crawler from a script into a trusted system.
- Metrics to collect
- Requests per second per domain
- Success/error rate
- Average fetch latency
- 4xx/5xx breakdown
- Depth and breadth crawled
- Queue backlogs and retry counts
- Logging
- Structured logs with request_id, job_id, domain, status, and error context.
- Dashboards
- Real-time views for active domains, top error sources, and crawl progress.
- Alerting
- Thresholds for high error rates, prolonged idleness, or sustained backoffs.
Example: A simple Prometheus-based setup could expose counters and histograms, with Grafana dashboards to visualize trends over time.
7) Scaling strategies
As your crawl footprint grows, consider these approaches.
- Partition by domain
- Each domain or a set of domains gets a dedicated worker pool to enforce per-domain limits naturally.
- Sharding the queue
- Use multiple queue instances or a Kafka topic with consumer groups to scale horizontally.
- Backoff and retries
- Implement exponential backoff with jitter to reduce coordinated retries during global events.
- Content storage tiering
- Store raw HTML for a limited window (e.g., 7 days) and archive older content to cheaper storage if needed.
Practical tip: Start with a single, reliable crawl path and gradually shard as you observe bottlenecks in fetch throughput or storage I/O.
8) Testing and safety nets
- Unit tests for fetch resilience (timeouts, redirects, error handling).
- Integration tests against a mock server that simulates robots.txt and crawlable pages.
- Canary crawls
- Run in a dry run mode that logs decisions without performing network requests.
-
Safeguards
- Rate limit enforcement at the application layer even if the network shows short-term bursts.
- Blacklist misbehaving domains and implement a grace period for reintroduction. ### 9) Deployment and operations
Deploy in a containerized environment (Docker) or serverless workers for per-domain parallelism.
Use environment-specific configurations (staging vs. production) for domain policies and backoffs.
-
Automated health checks
- Verify that the fetcher can reach target hosts, robots.txt caches refresh, and the queue is progressing.
-
Rollbacks
- Maintain versioned migrations for schema changes and a feature flag for major crawl logic changes. ### 10) A hands-on minimal end-to-end example
Here’s how a minimal end-to-end run would look conceptually:
- Seed URLs are inserted into the crawl queue with priority.
- A worker pool fetches batches, respecting per-domain rate limits.
- Fetched pages are parsed to extract new links; new URLs are enqueued with updated priorities.
- The results are stored: status codes, content hashes, and extracted metadata.
- Observability pipelines publish metrics and logs to your dashboards.
Sample snippet to tie components together (pseudocode):
def run_crawl_cycle():
queue = CrawlQueue()
storage = Storage(db_url="postgres://...")
policy = DomainPolicyStore()
batch = queue.next_batch(32)
for job in batch:
dom_policy = policy.get(job.domain)
if not dom_policy.is_allowed():
job.status = "BLOCKED"
storage.upsert_job(job)
continue
result = fetch(job.url)
job.last_fetched_at = current_ts()
if result["status"] == 200:
parsed = parse_html(result["content"], result["final_url"])
storage.store_content(job.id, result["content"], parsed["links"], parsed["title"], parsed["meta_description"])
for link in parsed["links"]:
new_job = CrawlJob(url=link, domain=extract_domain(link), priority=job.priority+1)
storage.upsert_job(new_job)
storage.mark_fetched(job.id, "SUCCESS", result["status"], hash(result["content"]), result.get("redirect_count", 0))
else:
storage.mark_fetched(job.id, "RETRY" if job.retry_count < job.max_retries else "FAILED", result.get("status"), None, result.get("redirect_count", 0), error_message=result.get("error"))
This sketch emphasizes flow, not production readiness. Treat it as a blueprint to tailor to your tech stack and scale requirements.
Final thoughts
A resilient web crawl system is less about clever single-line tricks and more about thoughtful architecture, disciplined policy, and strong observability. Start with clear goals, implement a robust per-domain control plane, and invest in monitoring and safety nets. As you gain confidence, you can layer advanced features like JavaScript rendering with headless browsers, adaptive crawl budgeting using machine learning signals, or distributed scheduling with backpressure-aware queues.
If you’d like, tell me your preferred tech stack (language, storage, queue system) and target scale, and I’ll tailor a concrete starter kit with runnable code and deployment manifests. Would you prefer a Python-based stack with PostgreSQL and Redis, or something differently aligned with your existing infrastructure?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)