Web已死?前一千万个网站中有27.6%已经死亡

文摘   2024-11-08 15:25   美国  

互联网在很多方面都有记忆。从旧网站的存档版本到搜索引擎缓存,通常都有办法挖掘过去并找到信息 - 即使是那些已不再活跃的网站。你可能听说过 Internet Archive(互联网档案馆),这是一个用于探索网络历史的流行工具,最近由于黑客攻击和其他挑战而出现故障。但如果没有 Internet Archive 呢?互联网是否仍然"记得"这些网站?


在本文中,我们将深入研究前1000万个域名,并揭示一个令人惊讶的发现:其中超过四分之一 - 27.6% - 实际上已经失效。下面,我将带您了解分析这些域名所涉及的步骤和基础设施,以及此研究的系统要求、代码片段和统计结果。


挑战:分析1000万个域名


感谢像  DomCop[1]  这样的资源,我们可以访问前1000万个域名的列表,这是我们的起点。处理如此大量的URL需要大量计算资源、并行处理和优化的HTTP请求处理。


为了快速获得准确的结果,我们需要一个设计良好的爬虫,能够在几分钟内处理数百万个请求。以下是我们的方法和系统设计的细节。


大规模域名爬取的系统设计


为了在合理的时间内分析1000万个域名,我们设定了10分钟内完成任务的目标。这需要一个系统能够每秒处理约16,667个请求。通过将负载分配给100个工作进程,每个进程需要处理每秒约167个请求。


1. 使用Redis进行高效队列管理


Redis凭借其轻松处理每秒超过10,000个请求的能力,在管理作业队列方面发挥了关键作用。然而,即使使用Redis,跟踪数百万个域名的状态码也可能使系统超载。为防止这种情况,我们利用了Redis管道,允许同时处理多个作业并减少Redis集群的负载。

// SPopN retrieves multiple items from a Redis set efficiently.func SPopN(key string, n int) []string {    pipe := Redis.Pipeline()    for i := 0; i < n; i++ {        pipe.SPop(ctx, key)    }    cmders, err := pipe.Exec(ctx)    if err != nil { return nil }
results := make([]string, 0, n) for _, cmder := range cmders { if spopCmd, ok := cmder.(*redis.StringCmd); ok { val, err := spopCmd.Result() if err == nil && val != "" { results = append(results, val) } } } return results}


使用这种方法,我们可以以最小的性能影响从Redis中提取大批量数据,一次最多获取100个作业。

func (w *Worker) fetchJobs() {    for {        if len(w.Jobs) > 100 {            time.Sleep(time.Second)            continue        }        jobs := SPopN(w.Name+jobQueue, 100)        for _, job := range jobs {            w.AddJob(job)        }    }}

2. 优化DNS请求


为了高效解析域名,我们使用了多个公共DNS服务器(如Google DNS、Cloudflare)并处理每秒高达16,667个请求。公共DNS服务器通常会限制大量请求,因此我们实现了DNS超时和限制错误的错误处理和重试机制。

var dnsServers = []string{    "8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "208.67.222.222", "208.67.220.220",}


通过在多个服务器之间平衡负载,我们可以避免单个DNS提供商施加的速率限制。


3. HTTP请求处理


为了检查域名状态,我们尝试对每个IP地址进行直接的HTTP/HTTPS请求。如果HTTP请求遇到协议错误,以下代码会使用HTTPS重试。

func (w *Worker) worker(job string) { var ips []net.IPAddr var err error var customDNSServer string for retry := 0; retry < 5; retry++ {  customDNSServer = dnsServers[rand.Intn(len(dnsServers))]  resolver := &net.Resolver{   PreferGo: true,   Dial: func(ctx context.Context, network, address string) (net.Conn, error) {    d := net.Dialer{}    return d.DialContext(ctx, "udp", customDNSServer+":53")   },  }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel()
ips, err = resolver.LookupIPAddr(ctx, job) if err == nil && len(ips) > 0 { break }
log.Printf("Retry %d: Failed to resolve %s on DNS server: %s, error: %v", retry+1, job, customDNSServer, err) }
if err != nil || len(ips) == 0 { log.Printf("Failed to resolve %s on DNS server: %s after retries, error: %v", job, customDNSServer, err) w.updateStats(1000) return }
customDialer := &net.Dialer{ Timeout: 10 * time.Second, } customTransport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { port := "80" if strings.HasPrefix(addr, "https://") { port = "443" } return customDialer.DialContext(ctx, network, ips[0].String()+":"+port) }, } client := &http.Client{ Timeout: 10 * time.Second, Transport: customTransport, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, }
req, err := http.NewRequestWithContext(context.Background(), "GET", "http://"+job, nil) if err != nil { log.Printf("Failed to create request: %v", err) w.updateStats(0) return } req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req) if err != nil { if urlErr, ok := err.(*url.Error); ok && strings.Contains(urlErr.Err.Error(), "http: server gave HTTP response to HTTPS client") { log.Printf("Request failed due to HTTP response to HTTPS client: %v", err) // Retry with HTTPS req.URL.Scheme = "https" customTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return customDialer.DialContext(ctx, network, ips[0].String()+":443") } resp, err = client.Do(req) if err != nil { log.Printf("HTTPS request failed: %v", err) w.updateStats(0) return } } else { log.Printf("Request failed: %v", err) w.updateStats(0) return } } defer resp.Body.Close()
log.Printf("Received response from %s: %s", job, resp.Status) w.updateStats(resp.StatusCode)}


部署策略


我们的爬虫部署包含400个工作副本,每个处理200个并发请求。这种配置需要20个实例、160个vCPU和450GB内存。CPU使用率仅约30%,该设置效率高且经济实惠,如下所示。



apiVersion: apps/v1kind: Deploymentmetadata:  name: workerspec:  replicas: 400  ...  containers:    - name: worker      image: ghcr.io/tonywangcn/ten-million-domains:20241028150232      resources:        limits:          memory: "2Gi"          cpu: "1000m"        requests:          memory: "300Mi"          cpu: "300m"

这种设置的大致成本约为每1000万个请求0.0116美元,整个分析的总成本不到1美元

数据分析:实际有多少网站可访问?


爬虫的状态码数据让我们可以将域名分类为"可访问"或"不可访问"。以下是使用的标准:


  • 可访问:状态码不是1000(DNS未找到)、0(超时)、404(未找到)或5xx(服务器错误)。

  • 不可访问:具有上述状态码的域名,表明它们要么无法访问,要么不再服务。

accessible_condition = (    (df["status_code"] != 1000) &    (df["status_code"] != 0) &    (df["status_code"] != 404) &    ~df["status_code"].between(500, 599))inaccessible_condition = ~accessible_condition


汇总结果后,我们发现27.6%的域名要么不活跃,要么无法访问。这意味着前1000万个域名中有超过275万个域名已经失效。


状态码数量比率
3014,989,49150%
10001,883,06319%
2001,087,51611%
302659,7917%
0522,2215%


结论


对于像1000万个域名这样大的数据集,必然存在影响准确性的格式不一致问题。例如,带有www前缀的域名理想情况下应该与不带前缀的域名相同对待,但URL构造方式的变化可能导致不匹配。此外,某些域名服务于特定功能,如内容分发网络(CDN)或API端点,这些可能没有传统的主页或可能按设计返回404状态。这在解释可访问性时增加了一层复杂性。


实现完全的数据清洁和统一格式需要大量额外的处理时间。然而,由于数据量庞大,微小的不一致可能仅占整个数据集的1%左右,这意味着它们不会显著影响最终结果:前1000万个域名中超过四分之一不再可访问。这表明随着时间推移,你在互联网上的历史和贡献可能会逐渐消失。


虽然爬虫本身在大约10分钟内完成任务,但达到这一点所需的研究、开发和测试花费了数天甚至数周的努力。


该项目的源代码可在 GitHub[3] 上获取。请负责任地使用它 - 这是为了道德和建设性使用,而不是为了压垮或滥用服务器。


感谢阅读,希望这项研究能让你更深入地理解互联网的无常性。


参考链接


  1. DomCop: https://www.domcop.com/files/top/top10milliondomains.csv.zip

  2. Patreon: https://www.patreon.com/tonywang_dev

  3. GitHub: https://github.com/tonywangcn/ten-million-domains


幻想发生器
图解技术本质
 最新文章