创建一个分布式网络爬虫的故事

更新时间:2017-10-09 09:16:22点击次数:134次

编者按:作者通过创建和扩展自己的分布式爬虫,介绍了一系列工具和架构, 包括分布式体系结构、扩展、爬虫礼仪、安全、调试工具、Python 中的多任务处理等。以下为译文:

大概600万条记录,每个记录有15个左右的字段。

这是我的数据分析项目要处理的数据集,但它的记录有一个很大的问题:许多字段缺失,很多字段要么格式不一致或者过时了。换句话说,我的数据集非常脏。

但对于我这个业余数据科学家来说还是有点希望的-至少对于缺失和过时的字段来说。大多数记录包含至少一个到外部网站的超链接,在那里我可能找到我需要的信息。因此,这看起来像一个完美的网络爬虫的用例。

在这篇文章中,你将了解我是如何构建和扩展分布式网络爬虫的,特别是我如何处理随之而来的技术挑战。

初始需求

创建网络爬虫的想法令人兴奋。因为,你知道,爬虫很酷,对吧?

但我很快意识到,我的要求比我想象的要复杂得多:

  1. 给定指定 URL,爬虫程序需要自动发现特定记录中缺失字段的值。因此,如果一个网页不包含我正在寻找的信息,爬虫程序需要跟踪出站链接,直到找到该信息。
  2. 它需要是某种爬虫和抓取的混合功能,因为它必须同时跟踪出站链接并从网页中提取特定信息。
  3. 整个程序需要分布式处理,因为有可能有数亿个URL需要访问。
  4. 抓取的数据需要存储在某处,很可能是在数据库中。
  5. 爬虫程序需要7*24小时不间断工作,所以不能在我的笔记本电脑上运行它。
  6. 我不希望在云服务上花费太多 1
  7. 需要用Python编码,这是我选择的语言。

好吧,我曾经在以前的工作中写过很多爬虫,但从没有这么大的规模。所以对我来说这是个全新的领域。

初始设计

我最开始的设计是这样的: 

主要组件包括:

  1. 一个爬虫调度器,负责把URL分派给 m 个爬虫主控制器,并从它们收集结果(字段)。
  2. m个爬虫主控制器,负责管理 n 个子进程。这些子过程执行实际的爬取操作。为方便起见,我把他们称为爬虫。
  3. 一个数据库服务器,负责存储初始URL和提取的字段。

这样我最终会有 
m*n个爬虫,从而将负载分布在许多节点上。例如,4个主控制器,每个包含8个子进程的话,就相当于32个爬虫。

另外,所有进程间通信都将使用队列。 所以在理论上,它将很容易扩展。 我可以添加更多的主控制器,爬网率 - 一个性能指标- 会相应增加。

初始实现

现在我有一个看起来不错的设计,我需要选择使用哪些技术。

但别误会我的意思:我的目标不是提出一个完美的技术栈。 相反,我主要把它看作是一个学习的机会,也是一个挑战 - 所以如果需要,我更愿意提出自制的解决方案。

1. 云托管

我可以选择AWS,但是我对DigitalOcean更熟悉,恰好它是更便宜的。 所以我用了几个5美元每月的虚拟机(很省钱啦)。

2. HTTP 库

requests库是Python里处理HTTP请求的不二选择。

3. ETL 管道

当然,我需要从每个访问过的网页中提取所有的超链接。但我也需要在一些页面抓取具体数据。

因此,我构建了自己的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 用户个人资料页面的映射。它告诉爬虫程序应该从这些页面中抓取什么数据以及如何抓取:

  1. url_patterns 定义了与当前页URL 进行试探性匹配的模式。如果有一个匹配,那么当前页面确实是Gravatar的用户配置文件。

  2. url_parsers 定义了能够在页面中抓取特定URL的解析器,比如那些指向用户的个人网站或社交媒体资料的URL。

  3. fields 字段定义了要从页面抓取的数据。在Gravatar的用户配置文件里,我想抓取用户的全名和位置信息。

url_parsers 和 fields 都包含了一系列针对 web 页面 HTML 数据的处理器。它们执行转换(XPathJSONPath,查找和替换,等等)以获取所需的确切数据,并转成我想要的格式。因此,数据在存储在其它地方之前被规范化,这是特别有用的,因为所有网站都是不同的,并且它们表示数据的方式各不相同。

手动创建所有这些映射花费了我很多时间,因为相关网站的列表非常长(数百个)。

4. 消息处理

最初,我想知道RabbitMQ是否适合。 但是我决定,我不想要单独的服务器来管理队列。 我想要的一切都要如闪电般快速而且要独立运行。

所以我用了ZeroMQ的push/pull队列,我把它们加到了queuelib的FifoDiskQueue上,以便将数据保存到磁盘,以防系统崩溃。 另外,使用push/pull队列可以确保使用轮转调度算法将URL分派给主控制器。

了解ZeroMQ如何工作和理解其几个极端案例花了我一段时间。 但是学习如何实现自己的消息传递真的很有趣,最终是值得的,尤其是性能方面。

5. 存储处理

一个好的关系数据库可以完成这项工作。 但是我需要存储类似对象的结果(字段),所以我选了MongoDB

加分项:MongoDB相当容易使用和管理。

6. 日志记录和监控

我使用了 Python 的日志模块,加上一个 RotatingFileHandler,每个进程生成一个日志文件。这对于管理由每个主控制器管理的各个爬虫进程的日志文件特别有用。这也有助于调试。

为了监视各种节点,我没有使用任何花哨的工具或框架。我只是每隔几个小时使用 MongoChef连接到 MongoDB 服务器,按照我的计算, 检查已经处理好的记录的平均数。如果数字变小了,很可能意味着某件事情 (坏的) 正在发生,比如一个进程崩溃了或其他别的什么事情。

当然,你知道的-所有的血,汗水和眼泪都在这里。

7. 管理已经爬过的URLs

Web爬虫很可能会不止一次碰到同一个URL。但是你通常不想重新抓取它,因为网页可能没有改变。

为了避免这个问题,我在爬虫程序调度器上使用了一个本地SQLite数据库来存储每个已爬过的URL,以及与其抓取日期相对应的时间戳。因此,每当新的URL出现时,调度程序会在SQLite数据库中搜索该URL,以查看是否已经被爬过。如果没有,则执行爬取。否则,就忽略掉。

我选择SQLite是因为它的快速和易于使用。每个爬取URL附带的时间戳对调试和事件回溯都非常有用,万一有人对我的爬虫提出投诉的话。

8. URL过滤

我的目标不是抓取整个网络。相反,我想自动发现我感兴趣的网址,并过滤掉那些没用的网址。

利用前面介绍的ETL配置,我感兴趣的URL被列入白名单。为了过滤掉我不想要的网址,我使用Alexa的100万顶级网站列表中的前20K个网站。

这个概念很简单:任何出现在前20K的网站有很大的可能性是无用的,如youtube.com或amazon.com。然而,根据我自己的分析,那些20K以外的网站更有可能有与我的分析相关,比如个人网站和博客等。

9. 安全

我不希望任何人篡改我的 DigitalOcean 虚拟机,所以:

  1. 我关闭了每个虚拟机上使用 iptables的所有端口。我选择性地打开了我绝对需要的端口(80、443、22、27017等)。
  2. 我在 MongoDB 上启用了 SSL 身份验证,因此只有拥有适当证书的用户才能登录。
  3. 我在所有虚拟机上都使用了加密的磁盘。
  4. 我在每个虚拟机上都启用了fail2ban,以阻止多次失败的登录请求。
  5. 我在所有虚拟机上都配置了基于SSH密钥的身份验证。
  6. 我在 ZeroMQ 中启用了 SSL身份验证

好吧,也许我对安全有点过分了:) 但我是故意的:这不仅是一个很好的学习机会,而且也是保护我数据的一种非常有效的方法。

10. 内存

一个每月5美元的DigitalOcean 虚拟机只有512MB的内存,所以它可做的相当有限。 经过多次测试运行,我确定我的所有节点都应该有1GB的内存。 所以我在每个虚拟机上创建了一个512MB的交换文件。

礼貌…是啥?

我对自己实现最初设计的工作速度感到惊讶。事情进展顺利,我的早期测试显示了我爬虫的令人印象深刻的性能数字(爬网率) 。所以我很兴奋,那是肯定的:)!

但后来,我看到Jim Mischel的一篇文章,完全改变了我的想法。事实是,我的爬虫根本不 “客气”。它不停地抓取网页,没有任何限制。当然,它抓取速度非常快,但由于同样的原因,网站管理员可能会封杀它。

那么,礼貌对网络爬虫意味着什么呢?

  1. 它必须通过适当的用户代理字符串标识自己。
  2. 它必须尊重 robots.txt 的规则。
  3. 它不能太快地向网站发送连续请求。

相当容易实现,对不对?

。我很快意识到,我爬虫的分布式特性使事情复杂了许多。

更新的要求

除了我已经实现的需求之外,我还需要:

  1. 创建一个页面描述我的爬虫在做什么。
  2. 在我的爬虫所做的每一个HTTP请求中传递User-Agent头,并包含一个指向我创建的说明页面的链接。
  3. 为每个域定期下载robots.txt,并根据以下条件检查是否允许抓取URL: 
    1. 包含/排除规则。
    2. 抓取延迟指令。在不存在的情况下,对同一域的后续请求需要以保守的秒数(例如15秒)间隔开。这是为了确保爬虫不会在网站上造成额外的负载。

然而,第三点有些难度。实际上,分布式Web爬虫怎么能:

# 保持一个单一的,最新的robots.txt文件缓存,并与所有进程分享? 
# 避免过于频繁地下载同一个域的robots.txt文件? 
# 跟踪每个域上次爬网的时间,以尊重抓取延迟指令?

这意味着我的爬虫会有一些重大的变化。

更新的设计

这是我更新后的设计。 

与以前设计的主要区别是:

  1. 将为每个域下载Robots.txt文件。
  2. Robots.txt 文件将被缓存在数据库中。每隔一小时左右,每个文件将根据需要单独失效 并根据域重新下载。 这样做是为了确保爬虫能够遵守robots.txt文件里的任何更改。
  3. 最后一个抓取日期也将被缓存到每个域的数据库中。这将用作参考,以遵守 robots.txt 中包含的抓取延迟指令。

此时,我担心这些变化会减慢我爬虫的速度。实际上几乎肯定会。但我没有选择,否则我的爬虫会使其它网站超负载。

更新后的实现

到目前为止,我所选择的一切都保持不变,除了几个关键的区别。

1. 处理 robots.txt

我选择了 reppy 库而不是 urllib 的 robotparser 是因为:

  1. 它支持抓取延迟(crawl-delay)指令。
  2. 它会自动处理已过期的robots.txt文件的下载。
  3. 它支持目录包含规则 (即允许指令),基于Google 自己的 robots.txt 的实现。 这些规则在网络上的robots.txt文件中很常见。

所以这是一个显而易见的选择。

2. 缓存 robots.txt 和上次爬网日期

我添加了第二个专门用于缓存内容的MongoDB服务器。在服务器上,我创建了两个不同的数据库,以避免任何可能的数据库级锁争用2:

# 数据库(1): 保存了每个域的上次爬网日期。 
# 数据库(2): 保存了每个域的 robots.txt 文件副本。

此外,我不得不小小修改一下修改 reppy 库,使它缓存 robots.txt 文件在 MongoDB而不是在内存中。

处理 bug 和问题

在开发过程中,我花了大量的时间调试、分析和优化我的爬虫。 实际上比我预期的时间多了很多。

除了挂掉3,内存泄漏4,变慢5,崩溃6和各种其他错误,我遇到了一系列意想不到的问题。

1. 内存管理

内存不是无限的资源 - 特别是在每月5美元的 DigitalOcean 虚拟机上。

事实上,我不得不限制在内存中一次存放多少个Python对象。 例如,调度员非常快地将URL推送给主控制器,比后者爬取它们要快得多。 同时,主控制器通常有8个爬取进程可供使用,因此这些进程需要不断地提供新的URL来爬取。

因此,我设置了一个阈值,确定主控制器上可以在内存中一次处理多少个URL。 这使我能够在内存使用和性能之间取得平衡。

2. 瓶颈

我很快意识到,我不能让我的网络爬虫不受约束,否则它会抓取整个网络-这根本不是我的目标。

因此,我将爬取深度限制为 1,这意味着只会抓取指定网址及其直接的子网址。这样我的爬虫可以自动发现它要特别寻找的大部分网页。

3. 动态生成的内容

我发现很多网站都是用JavaScript动态生成的。这意味着当你使用爬虫下载任意网页时,你可能没有它的全部内容。也就是说,除非你能够解释和执行其脚本来生成页面的内容。要做到这一点,你需要一个JavaScript引擎。

现在有很多方法可以解决这个问题,但我还是选择了一个非常简单的解决方案。我指定了一些主控制器,让它们只抓取动态生成的网页。

在那些主控制器上:

  1. 我安装了谷歌浏览器Chrome驱动程序
  2. 我安装了Selenium的Python绑定
  3. 我安装了xvfb来模拟监视器的存在,因为Chrome有一个GUI,而CentOS默认没有。

因此,我有几个节点能够抓取动态生成的网页。

4. 极端情况

我已经知道,构建一个常规爬虫意味着要处理各种奇怪的API极端案例。但是网络爬虫呢?

好吧,如果你把网络看成是一个API,它肯定是巨大的,疯狂的,非常不一致的:

  1. 页面并非都是以同样的方式构建的。
  2. 页面通常包含无效字符(即与页面编码不兼容)。
  3. 服务器经常返回各种HTTP错误(500,404,400等等),包括自定义的错误(999,有人能告诉我这是啥不?)。
  4. 服务器经常无法访问,导致超时。域名/网站可能不再存在,或者可能存在DNS问题,或者可能是负载过重,或者服务器可能配置不正确或者…你明白的:)
  5. 有些页面是巨大的,有几十兆字节或者更多7。这意味着,如果你一次下载完全,并将它们全部加载到内存中的话,你很可能会在某个时刻耗尽内存8
  6. 服务器有时返回不正确的HTML,或非HTML内容,如JSON、XML或其他内容。谁知道为什么?!
  7. 网页通常包含无效和不正确的URL。或你不想爬取的URL,比如像大的二进制文件(如PDF文件,视频,等等)。

以上只是网络爬虫需要处理的许多问题的一部分。

性能数据

使用网络爬虫,你通常会对爬取速度感兴趣,即每秒下载的网页数量。例如,每4个主控制器,每个使用8个子进程,我估计我的爬虫程序速率超过每秒40页。

但我更感兴趣的是,每小时我的原始数据集有多少记录得到正确的解析。因为,正如前面提到的,我爬虫的最初目的是通过抓取丢失的字段或刷新过时的字段来填充数据集中的空白。

因此,使用与上面相同的配置,每小时它能够解析大约2600条记录。当然,这是一个令人失望的数字,但仍然足够好了,因为大多数网页都是无用的,而且过滤掉了。

未来的改进

如果我不得不从头开始的话,有几件事情,我会采用不同的方式:

1. 消息传递

我可能会选择 RabbitMQ 或者 Redis, 而不是ZeroMQ, 主要是为了方便和易用性,即使他们比较慢。

2. 监控/日志

我可能会使用 New Relic 和 Loggly 工具来监控我虚拟机上的资源并集中处理所有节点生成的日志。

3. 设计

我可能会把处理 robots.txt 文件和上次爬取日期的缓存去中心话来提高总体爬取速度。这意味着,对于每个爬虫过程,将 MongoDB 服务器 #2 替换为在每个主控制器上的缓存。

下面是可能的体系结构: 

总结:

  1. 在每个主控制器节点上,每个爬虫程序进程都将有自己的 robots.txt 文件 和上次爬取的日期缓存;这将替换集中式缓存 (MongoDB 服务器 #2)。
  2. 由于这个原因,调度员需要将每个 URL 发送到一个非常特定的主控制器节点。
  3. 当接收到一个新的要爬取的URL,一个主控制器节点需要发送到一个非常特定的爬虫。否则,不同主控制器下面的的多个爬虫进程可能同时抓取完全相同的网站。我的爬虫可能会被禁止,因为它没有遵循 robots.txt 的规则。

幸运的是,ZeroMQ 支持前缀匹配,因此我可以根据域名将 URL 路由到特定的主控制器节点。我已经写了一个主要基于 SQLite的持久化缓存。我肯定会重用它,以防止多个缓存占用太多的内存。

最后的思考

在这篇文章中,我们已经看到了如何构建一个分布式 web 爬虫来填补脏数据集中的缺失数据。

起初,我并不期待这个项目变得如此庞大和复杂-大多数软件项目可能都这样。

但最终我确实得到了回报,因为我学到了大量的东西: 分布式体系结构、扩展、礼仪、安全、调试工具、Python 中的多任务处理、robots.txt文件 等等。

  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息