原文:The Tale of Creating a Distributed Web Crawler
作者:Benoit Bernard
译者:roy
编者按:作者通过创建和扩展自己的分布式爬虫,介绍了一系列工具和架构, 包括分布式体系结构、扩展、爬虫礼仪、安全、调试工具、Python 中的多任务处理等。以下为译文:
大概600万条记录,每个记录有15个左右的字段。
这是我的数据分析项目要处理的数据集,但它的记录有一个很大的问题:许多字段缺失,很多字段要么格式不一致或者过时了。换句话说,我的数据集非常脏。
但对于我这个业余数据科学家来说还是有点希望的-至少对于缺失和过时的字段来说。大多数记录包含至少一个到外部网站的超链接,在那里我可能找到我需要的信息。因此,这看起来像一个完美的网络爬虫的用例。
在这篇文章中,你将了解我是如何构建和扩展分布式网络爬虫的,特别是我如何处理随之而来的技术挑战。
创建网络爬虫的想法令人兴奋。因为,你知道,爬虫很酷,对吧?
但我很快意识到,我的要求比我想象的要复杂得多:
好吧,我曾经在以前的工作中写过很多爬虫,但从没有这么大的规模。所以对我来说这是个全新的领域。
主要组件包括:
这样我最终会有
m*n个爬虫,从而将负载分布在许多节点上。例如,4个主控制器,每个包含8个子进程的话,就相当于32个爬虫。
另外,所有进程间通信都将使用队列。 所以在理论上,它将很容易扩展。 我可以添加更多的主控制器,爬网率 - 一个性能指标- 会相应增加。
现在我有一个看起来不错的设计,我需要选择使用哪些技术。
但别误会我的意思:我的目标不是提出一个完美的技术栈。 相反,我主要把它看作是一个学习的机会,也是一个挑战 - 所以如果需要,我更愿意提出自制的解决方案。
我可以选择AWS,但是我对DigitalOcean更熟悉,恰好它是更便宜的。 所以我用了几个5美元每月的虚拟机(很省钱啦)。
requests库是Python里处理HTTP请求的不二选择。
当然,我需要从每个访问过的网页中提取所有的超链接。但我也需要在一些页面抓取具体数据。
因此,我构建了自己的ETL管道,以便能够以我所需的数据格式提取数据并进行转换。
它可以通过配置文件进行定制,如下所示:
{
"name": "gravatar",
"url_patterns": [
{
"type": "regex",
"pattern": "^https?:\\/\\/(?:(?:www|\\w{2})\\.)?gravatar\\.com\\/(?!avatar|support|site|connect)\\w+\\/?$"
}
],
"url_parsers": [
{
"description": "URLs in the 'Find Me Online' section.",
"processors": [
{
"type": "xpath",
"parameters": {
"expression": "//h3[contains(text(),'Find Me Online')]/following-sibling::ul[@class='list-details'][1]//a/@href"
}
}
]
},
{
"description": "URLs in the 'Websites' section.",
"processors": [
{
"type": "xpath",
"parameters": {
"expression": "//ul[@class='list-sites']//a/@href"
}
}
]
}
],
"fields": [
{
"name": "name",
"processors": [
{
"type": "xpath",
"parameters": {
"expression": "//div[@class='profile-description']/h2[@class='fn']/a/text()"
}
},
{
"type": "trim",
"parameters": {
}
}
]
},
{
"name": "location",
"processors": [
{
"type": "xpath",
"parameters": {
"expression": "//div[@class='profile-description']/p[@class='location']/text()"
}
},
{
"type": "trim",
"parameters": {
}
}
]
}
]
}
你在上面看到的是一个Gravatar 用户个人资料页面的映射。它告诉爬虫程序应该从这些页面中抓取什么数据以及如何抓取:
url_patterns 定义了与当前页URL 进行试探性匹配的模式。如果有一个匹配,那么当前页面确实是Gravatar的用户配置文件。
url_parsers 定义了能够在页面中抓取特定URL的解析器,比如那些指向用户的个人网站或社交媒体资料的URL。
fields 字段定义了要从页面抓取的数据。在Gravatar的用户配置文件里,我想抓取用户的全名和位置信息。
url_parsers 和 fields 都包含了一系列针对 web 页面 HTML 数据的处理器。它们执行转换(XPath,JSONPath,查找和替换,等等)以获取所需的确切数据,并转成我想要的格式。因此,数据在存储在其它地方之前被规范化,这是特别有用的,因为所有网站都是不同的,并且它们表示数据的方式各不相同。
手动创建所有这些映射花费了我很多时间,因为相关网站的列表非常长(数百个)。
最初,我想知道RabbitMQ是否适合。 但是我决定,我不想要单独的服务器来管理队列。 我想要的一切都要如闪电般快速而且要独立运行。
所以我用了ZeroMQ的push/pull队列,我把它们加到了queuelib的FifoDiskQueue上,以便将数据保存到磁盘,以防系统崩溃。 另外,使用push/pull队列可以确保使用轮转调度算法将URL分派给主控制器。
了解ZeroMQ如何工作和理解其几个极端案例花了我一段时间。 但是学习如何实现自己的消息传递真的很有趣,最终是值得的,尤其是性能方面。
一个好的关系数据库可以完成这项工作。 但是我需要存储类似对象的结果(字段),所以我选了MongoDB。
加分项:MongoDB相当容易使用和管理。
我使用了 Python 的日志模块,加上一个 RotatingFileHandler,每个进程生成一个日志文件。这对于管理由每个主控制器管理的各个爬虫进程的日志文件特别有用。这也有助于调试。
为了监视各种节点,我没有使用任何花哨的工具或框架。我只是每隔几个小时使用 MongoChef连接到 MongoDB 服务器,按照我的计算, 检查已经处理好的记录的平均数。如果数字变小了,很可能意味着某件事情 (坏的) 正在发生,比如一个进程崩溃了或其他别的什么事情。
当然,你知道的-所有的血,汗水和眼泪都在这里。
Web爬虫很可能会不止一次碰到同一个URL。但是你通常不想重新抓取它,因为网页可能没有改变。
为了避免这个问题,我在爬虫程序调度器上使用了一个本地SQLite数据库来存储每个已爬过的URL,以及与其抓取日期相对应的时间戳。因此,每当新的URL出现时,调度程序会在SQLite数据库中搜索该URL,以查看是否已经被爬过。如果没有,则执行爬取。否则,就忽略掉。
我选择SQLite是因为它的快速和易于使用。每个爬取URL附带的时间戳对调试和事件回溯都非常有用,万一有人对我的爬虫提出投诉的话。
我的目标不是抓取整个网络。相反,我想自动发现我感兴趣的网址,并过滤掉那些没用的网址。
利用前面介绍的ETL配置,我感兴趣的URL被列入白名单。为了过滤掉我不想要的网址,我使用Alexa的100万顶级网站列表中的前20K个网站。
这个概念很简单:任何出现在前20K的网站有很大的可能性是无用的,如youtube.com或amazon.com。然而,根据我自己的分析,那些20K以外的网站更有可能有与我的分析相关,比如个人网站和博客等。
我不希望任何人篡改我的 DigitalOcean 虚拟机,所以:
好吧,也许我对安全有点过分了:) 但我是故意的:这不仅是一个很好的学习机会,而且也是保护我数据的一种非常有效的方法。
一个每月5美元的DigitalOcean 虚拟机只有512MB的内存,所以它可做的相当有限。 经过多次测试运行,我确定我的所有节点都应该有1GB的内存。 所以我在每个虚拟机上创建了一个512MB的交换文件。
我对自己实现最初设计的工作速度感到惊讶。事情进展顺利,我的早期测试显示了我爬虫的令人印象深刻的性能数字(爬网率) 。所以我很兴奋,那是肯定的:)!
但后来,我看到Jim Mischel的一篇文章,完全改变了我的想法。事实是,我的爬虫根本不 “客气”。它不停地抓取网页,没有任何限制。当然,它抓取速度非常快,但由于同样的原因,网站管理员可能会封杀它。
那么,礼貌对网络爬虫意味着什么呢?
相当容易实现,对不对?
错。我很快意识到,我爬虫的分布式特性使事情复杂了许多。
除了我已经实现的需求之外,我还需要:
然而,第三点有些难度。实际上,分布式Web爬虫怎么能:
# 保持一个单一的,最新的robots.txt文件缓存,并与所有进程分享?
# 避免过于频繁地下载同一个域的robots.txt文件?
# 跟踪每个域上次爬网的时间,以尊重抓取延迟指令?
这意味着我的爬虫会有一些重大的变化。
与以前设计的主要区别是:
此时,我担心这些变化会减慢我爬虫的速度。实际上几乎肯定会。但我没有选择,否则我的爬虫会使其它网站超负载。
到目前为止,我所选择的一切都保持不变,除了几个关键的区别。
我选择了 reppy 库而不是 urllib 的 robotparser 是因为:
所以这是一个显而易见的选择。
我添加了第二个专门用于缓存内容的MongoDB服务器。在服务器上,我创建了两个不同的数据库,以避免任何可能的数据库级锁争用2:
# 数据库(1): 保存了每个域的上次爬网日期。
# 数据库(2): 保存了每个域的 robots.txt 文件副本。
此外,我不得不小小修改一下修改 reppy 库,使它缓存 robots.txt 文件在 MongoDB而不是在内存中。
在开发过程中,我花了大量的时间调试、分析和优化我的爬虫。 实际上比我预期的时间多了很多。
除了挂掉3,内存泄漏4,变慢5,崩溃6和各种其他错误,我遇到了一系列意想不到的问题。
内存不是无限的资源 - 特别是在每月5美元的 DigitalOcean 虚拟机上。
事实上,我不得不限制在内存中一次存放多少个Python对象。 例如,调度员非常快地将URL推送给主控制器,比后者爬取它们要快得多。 同时,主控制器通常有8个爬取进程可供使用,因此这些进程需要不断地提供新的URL来爬取。
因此,我设置了一个阈值,确定主控制器上可以在内存中一次处理多少个URL。 这使我能够在内存使用和性能之间取得平衡。
我很快意识到,我不能让我的网络爬虫不受约束,否则它会抓取整个网络-这根本不是我的目标。
因此,我将爬取深度限制为 1,这意味着只会抓取指定网址及其直接的子网址。这样我的爬虫可以自动发现它要特别寻找的大部分网页。
我发现很多网站都是用JavaScript动态生成的。这意味着当你使用爬虫下载任意网页时,你可能没有它的全部内容。也就是说,除非你能够解释和执行其脚本来生成页面的内容。要做到这一点,你需要一个JavaScript引擎。
现在有很多方法可以解决这个问题,但我还是选择了一个非常简单的解决方案。我指定了一些主控制器,让它们只抓取动态生成的网页。
在那些主控制器上:
因此,我有几个节点能够抓取动态生成的网页。
我已经知道,构建一个常规爬虫意味着要处理各种奇怪的API极端案例。但是网络爬虫呢?
好吧,如果你把网络看成是一个API,它肯定是巨大的,疯狂的,非常不一致的:
以上只是网络爬虫需要处理的许多问题的一部分。
使用网络爬虫,你通常会对爬取速度感兴趣,即每秒下载的网页数量。例如,每4个主控制器,每个使用8个子进程,我估计我的爬虫程序速率超过每秒40页。
但我更感兴趣的是,每小时我的原始数据集有多少记录得到正确的解析。因为,正如前面提到的,我爬虫的最初目的是通过抓取丢失的字段或刷新过时的字段来填充数据集中的空白。
因此,使用与上面相同的配置,每小时它能够解析大约2600条记录。当然,这是一个令人失望的数字,但仍然足够好了,因为大多数网页都是无用的,而且过滤掉了。
如果我不得不从头开始的话,有几件事情,我会采用不同的方式:
我可能会选择 RabbitMQ 或者 Redis, 而不是ZeroMQ, 主要是为了方便和易用性,即使他们比较慢。
我可能会使用 New Relic 和 Loggly 工具来监控我虚拟机上的资源并集中处理所有节点生成的日志。
我可能会把处理 robots.txt 文件和上次爬取日期的缓存去中心话来提高总体爬取速度。这意味着,对于每个爬虫过程,将 MongoDB 服务器 #2 替换为在每个主控制器上的缓存。
总结:
幸运的是,ZeroMQ 支持前缀匹配,因此我可以根据域名将 URL 路由到特定的主控制器节点。我已经写了一个主要基于 SQLite的持久化缓存。我肯定会重用它,以防止多个缓存占用太多的内存。
在这篇文章中,我们已经看到了如何构建一个分布式 web 爬虫来填补脏数据集中的缺失数据。
起初,我并不期待这个项目变得如此庞大和复杂-大多数软件项目可能都这样。
但最终我确实得到了回报,因为我学到了大量的东西: 分布式体系结构、扩展、礼仪、安全、调试工具、Python 中的多任务处理、robots.txt文件 等等。
现在,有一个问题,我没有在我的文章里回答。哪一个数据集可以证明所有的工作都是正确的?这一切背后的原因是什么?
这是你在我以后的文章中会看到的!
后记: 请在下面的评论栏中留下你的问题和意见!
更新(2017/09/19): 这篇文章发表在Reddit。它也发表在Python Weekly,Pycoders Weekly 和Programming Digest。如果你有机会订阅他们,你不会失望的!谢谢大家的支持和反馈!
1: 我只花了35美元每月 (5美元/月/VM * 7 VMs = 35美元/月)。我曾想给文章取标题为“一个穷人关于创建一个分布式网络爬虫的的建议”。
2: 现在回想起来,有2个不同的MongoDB数据库可能是不必要的。这是因为在MongoDB 3 以上版本写锁是针对每个文件的,而不是针对每个数据库。这似乎与3之前版本相反,据MongoDB的文档和这个Stackoverflow答案。
3: 关于更多挂机的细节,请看这里和这里
4: 关于更多内存泄露的细节,请看这里和这里
5: 关于更多运行缓慢的细节,请看这里
6: 关于更多崩溃的细节,请看这里
7: 这就是你为什么要按块下载网页
8: 有些网页就是这样设计的。其他的输出一条错误信息或者看起来无限长的堆栈跟踪信息。无论哪种方式,它们都很大!