基于Docker和Redis的分布式爬虫服务架构与实践
简介基于Docker容器技术构建的分布式爬虫服务项目文档与源码齐备适合爬虫开发方向的学习者、计算机专业在校生以及需要完成毕业设计或课程设计的人群。项目代码曾获导师指导认可答辩评审达到九十五分。实现上采用Go语言编写利用容器化方式部署通过protobuf完成服务接口定义整体架构清晰可作为容器化微服务开发的参考范例。压缩包共包含十一个文件其中五个为Go源码文件涵盖服务端、客户端以及单机与分布式爬虫示例同时附带了协议文件、Dockerfile构建脚本、构建镜像的Shell脚本、说明文档和授权文件虽体积紧凑但结构完整。资料包大小约为三百一十一KB目前已有五十四人学习下载。通过阅读源码能够掌握分布式任务分发、容器化打包、接口定义等关键实现方法并可直接在现有代码基础上扩展功能满足课程设计、项目演示或初期立项等需求。1. 基于Docker的分布式爬虫服务先想清楚它解决什么问题单机爬虫有个让人头疼的临界点任务量到几千个 URL 时脚本还能靠多线程硬扛到了每天几十万级 URL、8 小时必须跑完一批采集任务的时候重启脚本已经解决不了问题。你需要的不是跑得更快的爬虫而是一套能把任务拆开、分给多个节点同时执行的架构。基于 Docker 的分布式爬虫服务做的就是这样一件事用 Redis 当任务队列和全局去重中心把爬虫代码打成镜像同时拉起多个 worker 容器争抢任务哪个节点空闲哪个节点就干活。它的价值在于三个词——环境一致、横向扩展、快速恢复。适合已经有明确 URL 列表、对数据时效和重复率有要求的团队不适合想“装个工具就自动爬遍全网”的场景。下面这套方案我会从架构选型讲到 compose 编排再把扩容参数和让我半夜起来看日志的几个坑一次说清。2. 核心架构拆解Redis 调度、指纹去重、结果收敛三件事都得想清楚2.1 分布式爬虫到底“分”在哪任务在队列里worker 要尽量无状态很多人以为分布式爬虫就是在三台机器上各跑一个 scrapy 脚本实际上那只是“并行”不是“分布式”。真正的分布式要满足一个条件任务来源和执行节点彻底分离。所有待抓 URL 先进队列worker 从同一个队列里抢任务谁空闲谁执行执行完把结果交给统一的数据出口。这就要求 worker 必须无状态化不写本地文件、不在本地维护待抓列表、不依赖本机内存里的任何会话数据。状态全部放到 Redis 和数据库里worker 本身就是一个随时可以被销毁重建的执行单元。这一点决定了我们能不能用 Docker 来承载它。如果爬虫代码里到处是本地路径、本地队列、本地去重集合那容器一重启业务就断Docker 带来的弹性就毫无意义。另一个关键点是数据收敛。分布式节点的抓取结果不能各自落盘要统一写入同一个 MySQL 或 MongoDB入库环节还要做幂等兜底。我的原则是凡是能算作“状态”的东西一律不进容器凡是容器内的数据删除容器时都不允许丢。2.2 任务队列用 Redis 还是 Kafka这个选择直接决定运维复杂度任务队列是分布式爬虫的心脏。最常用的方案有三个Redis List、Kafka、数据库表。数据库表做消息队列基本是拿关系型数据库的短处硬顶除非团队没有 Redis 也不愿意引入中间件否则不建议。Redis List 的 BRPOP 是阻塞式原子弹出多个 worker 同时等待也不会取到同一个任务这正是抢任务模式需要的语义。配合 LPUSH 入队一个最简单的 FIFO 队列就成立了。绝大多数爬虫项目的任务量在万级到百万级每天Redis 单机几万 QPS 完全扛得住没必要为了“分布式”三个字把 Kafka 也拉进来。Kafka 的价值在千万级消息、多消费组、消息回溯爬虫场景很少需要这些引进来反而多了 zookeeper、分区、offset 管理一堆事。不过 Redis List 有个短板它不支持按优先级取任务。如果你的任务里有“必须优先抓”的列表或者对时效有分层要求可以用 Redis ZSet 代替 Listscore 当优先级用 ZPOPMIN 或 Lua 脚本弹出。scrapy_redis 里也内置了 PriorityQueue 实现选它基本够用。2.3 去重与调度指纹放 Redis不放在任何 worker 本地分布式爬虫最容易翻车的地方是去重。单机爬虫用一个本地 set 就能去重分布式环境下每个 worker 的本地 set 互不相同同一个 URL 就可能被两个节点各抓一次结果就是数据重复、目标站点限流。正确做法是全局去重。scrapy_redis 的 RFPDupeFilter 会把请求指纹写进一个 Redis 集合指纹是请求的方法、URL、参数、body 组合后的 SHA1 值而不是简单的 URL 字符串。这里有个值得注意的细节直接用 URL 去重会漏掉同一页面通过不同参数顺序访问的情况指纹去重要可靠得多。调度器出场之前先查指纹集合已存在的请求直接丢弃没见过的才进队列。这样整个调度链路就是入队前查重、出队后执行、结果统一入库。如果你在任务入队时从外部批量 LPUSH而没有经过查重这一步那就等于绕过了去重机制后面一定会有重复抓取这一点到第 5 章我会展开讲。2.4 Docker 在这里解决的是环境一致和节点回收的隐性成本爬虫对运行环境极其敏感。同一套 scrapy 代码在 Python 3.8 和 3.11 下行为不一样lxml 版本不一致可能导致某些页面解析直接抛异常OpenSSL 版本不同连 HTTPS 握手都可能失败。这种依赖矩阵在裸机上手动维护几乎每个环境都是一次踩坑实验。Docker 把依赖全部固化进镜像里。新节点加入集群只需要拉镜像、启动容器不需要在新机器上重新安装 lxml、配置 Python 环境。节点挂了直接删掉容器再起一个新的不会留下一台需要人工排查的半损坏机器。这一层价值在分布式场景里会被指数级放大节点越多环境不一致带来的偶发故障越多而 Docker 把“环境漂移”直接归零了。3. 跑通最小分布式爬虫Dockerfile、compose 编排和三个验证命令3.1 目录结构与文件清单这套最小工程只有 5 个文件先看一个能跑通的最小工程。我不建议一上来就加管理后台、定时调度、监控看板那些都是后话。先把“一个队列、两个 worker、一个 Redis”跑通再逐步加东西。crawler/ Dockerfile requirements.txt scrapy.cfg project/ settings.py spiders/example.py docker-compose.ymlrequirements.txt 内容固定住版本避免构建镜像时拉到的版本和本地不一致scrapy2.11.2 scrapy_redis0.7.2scrapy.cfg 是 scrapy 标准配置不写 project 名的话默认也能跑。这里我强调两个文件Dockerfile 和 docker-compose.yml以及一个关键点——worker 容器不映射任何端口到宿主机它是纯消费者不需要对外服务。3.2 Dockerfile把 worker 的依赖环境固化下来FROM python:3.11-slim ENV PYTHONUNBUFFERED1 ENV PYTHONDONTWRITEBYTECODE1 RUN apt-get update apt-get install -y --no-install-recommends curl \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [scrapy, crawl, example]PYTHONUNBUFFERED1 是容器里最容易忽略的配置没有它scrapy 的日志会因缓冲积压docker compose logs 里什么都看不到排查问题时像在看黑匣子。安装 curl 是为了后面在容器里做连通性探测不装也能跑但排障时少一个工具会很难受。构建顺序上先把 requirements.txt 复制进去装依赖再 COPY 源码。这样只要依赖没变每次构建都能命中 Docker 的层缓存几秒钟就完成不用每次重装一遍 scrapy。CMD 直接调 scrapy crawl配合 scrapy_redis 的长驻写法worker 会一直监听 Redis 队列不会跑完一批任务就退出。3.3 爬虫代码接入 Redis 队列与全局去重settings.py 里最关键的四行配置import os REDIS_URL os.getenv(REDIS_URL, redis://redis:6379/0) SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter SCHEDULER_PERSIST True SCHEDULER_QUEUE_CLASS scrapy_redis.queue.PriorityQueue CONCURRENT_REQUESTS int(os.getenv(CONCURRENT_REQUESTS, 8)) DOWNLOAD_DELAY float(os.getenv(DOWNLOAD_DELAY, 0.5)) DOWNLOAD_TIMEOUT int(os.getenv(DOWNLOAD_TIMEOUT, 30))SCHEDULER_PERSIST 必须为 True。它的含义是爬虫退出时不清空 Redis 里的调度队列和指纹集合。如果设成 False每次 worker 重启都会把队列丢光你的爬虫就变成了“重启即失忆”。REDIS_URL 里写的是服务名 redis不是 localhost这个我在第 5 章会专门讲为什么。爬虫类直接用 scrapy_redis 提供的 RedisSpiderfrom scrapy_redis.spider import RedisSpider class ExampleSpider(RedisSpider): name example redis_key example:start_urls close_after_idle False def parse(self, response): title response.css(title::text).get() if title: yield {url: response.url, title: title}close_after_idle False 是让 worker 常驻的关键。默认情况下队列空转一段时间后 scrapy 会认为任务结束整个进程退出设成 False 后 worker 会一直阻塞在 BRPOP 上等新任务入队这才是分布式 worker 该有的状态。你用 docker compose ps 看它它应该永远在“running”而不是“exited”。3.4 docker-compose 编排Redis 与 worker 的启动关系services: redis: image: redis:7-alpine ports: - 127.0.0.1:6379:6379 healthcheck: test: [CMD, redis-cli, ping] interval: 5s timeout: 3s retries: 5 worker: build: ./crawler environment: REDIS_URL: redis://redis:6379/0 CONCURRENT_REQUESTS: 8 DOWNLOAD_DELAY: 0.5 depends_on: redis: condition: service_healthyredis 端口只绑定到宿主机 127.0.0.1外部访问不到只有宿主机和 compose 网络里的 worker 能连。加 healthcheck 是为了让 worker 不要在 Redis 还没就绪时就开始启动depends_on 只保证容器启动顺序不保证 Redis 内部已 ready。这里的全部编排就是两个服务。Redis 负责队列、去重、调度三件事worker 是同一个镜像的多个副本。想跑三个 worker 只需要加--scale worker3不需要改任何代码。3.5 启动与验证三个命令确认分布式在生效docker compose up -d --scale worker3这一步拉起 1 个 Redis 和 3 个 worker 容器。确认状态docker compose ps看到 3 个 worker 都处于 running 后往队列里推几条 URLredis-cli -h 127.0.0.1 -p 6379 LPUSH example:start_urls http://example.com/page/1 redis-cli -h 127.0.0.1 -p 6379 LPUSH example:start_urls http://example.com/page/2然后看日志docker compose logs -f --tail200 worker你会看到不同容器 ID 的日志在交替输出抓取记录。这说明三个 worker 真的在从同一个队列里抢任务而不是各跑各的。如果日志里始终只有一个容器在处理说明另外两个 worker 没有连上队列或者 Redis 地址配错了。4. 参数与扩容并发、延时、内存三个 worker 扩到十五个的可调项4.1 用环境变量把调参变成部署动作一组常用的初始参数分布式爬虫的扩容不是把--scale worker15敲下去就完事。扩完之后并发、延时、内存如果不跟着调目标站点很快会返回 403 或 429。我常用的环境变量参数表参数典型值说明CONCURRENT_REQUESTS8~16每个 worker 同时发出的请求数DOWNLOAD_DELAY0.5~2.0同一 worker 两个请求的间隔秒数DOWNLOAD_TIMEOUT30单请求超时超过即重试或丢弃RETRY_TIMES2失败重试次数不建议超过 3RETRY_HTTP_CODES500,502,503,504,408哪些状态码需要重试CONCURRENT_REQUESTS_PER_DOMAIN4单域名并发上限保守值初始值怎么定面向公开站点且不清楚对方服务器水位时DOWNLOAD_DELAY 从 0.5 起步单 worker 并发 8。如果目标站点响应在 200ms 内且没有明显风控再逐步提到并发 16。一旦看到 403 或 429 增多先把 DOWNLOAD_DELAY 调回 1.0而不是继续加 worker——这个问题我会在第 6 章用探针页验证的方法展开。4.2 扩容的正确姿势compose scale、Redis 外置、数据卷docker compose up -d --scale worker15执行这个命令有个前提worker 服务不能声明 ports。如果 worker 里写了端口映射扩容到第二个容器就会报端口冲突。所以 worker 服务连 ports 都不要写。节点扩到 15 个之后Redis 无论如何不能再用 compose 里的临时容器。常见做法是把 Redis 单独部署到一台宿主机或者直接用云数据库worker 通过内网地址访问。因为 worker 挂了 Docker 可以秒级拉起Redis 挂了整套分布式爬虫就整体瘫痪这是一个明确的单点必须排除掉。Redis 数据卷也要提前挂上。redis:7-alpine容器被删除后队列、指纹集合、调度状态全部丢失相当于爬虫失忆。生产环境至少给 Redis 挂一个持久化卷redis: image: redis:7-alpine volumes: - redis-data:/data command: redis-server --appendonly yes4.3 扩容瓶颈会转移先看 Redis 队列再看 MySQL 连接池加到一定规模后你会发现瓶颈不在爬虫本身而在数据出口。15 个 worker 同时往 MySQL 写结果每个 worker 10 个并发连接就是 150 个连接MySQL 默认 max_connections 常常只有 151瞬间被打满。我的做法是在入库 Pipeline 里做批量写入缓冲攒满一批再提交。单条 item 入库的写法在分布式场景下是灾难import pymysql class BatchInsertPipeline: def __init__(self, batch_size100): self.items [] self.batch_size batch_size def process_item(self, item, spider): self.items.append(item) if len(self.items) self.batch_size: self._flush() return item def _flush(self): conn pymysql.connect( hostself.host, userself.user, passwordself.password, databaseself.db, ) with conn.cursor() as cursor: cursor.executemany( INSERT IGNORE INTO pages (url, title) VALUES (%s, %s), [(i[url], i[title]) for i in self.items], ) conn.commit() conn.close() self.items.clear()这里用了 INSERT IGNORE 而不是普通 INSERT配合表上 url 字段的唯一索引做幂等兜底。这样即便去重环节有漏网之鱼数据库层面也能挡住。executemany 一次提交 100 条比一条条 execute 加 commit 快一个数量级。4.4 用 docker stats 与队列长度判断该不该再加机器扩容前先看三个数据容器水位、队列趋势、目标站成功率。docker stats --no-stream redis-cli -h 127.0.0.1 -p 6379 LLEN example:start_urls判断逻辑如果队列长度持续增长而 docker stats 里 worker CPU 利用率不高说明瓶颈在下载环节可能是 DOWNLOAD_DELAY 太大也可能是目标站响应慢加 worker 帮不上忙。如果队列很快清零、worker CPU 都跑满说明任务量超过了当前节点能力这时候加 worker 是有效的。如果加了 worker 之后成功率掉下来了那不是机器不够是目标站点承受不了要回头降并发。5. 基于 Docker 的分布式爬虫避坑五个让我半夜起来看日志的问题5.1 报错 permission denied while trying to connect to the docker api现象执行 docker compose up 直接报 permission denied看起来像是没权限访问 Docker 服务。很多新手在这步就开始重装 Docker其实和安装关系不大。原因当前 Linux 用户不在 docker 用户组里无法访问 /var/run/docker.sock 这个 Unix socket。解决sudo usermod -aG docker $USER newgrp docker docker info执行完重新登录一次终端最稳妥。还有另一种情况是 Docker daemon 没在跑systemctl status docker先看一眼服务没启动也会报同一个错别急着重装。5.2 容器里的爬虫连不上 RedisConnection refused现象worker 启动后日志里持续刷Connection refused但宿主机上 redis-cli 明明能连。原因设置里 REDIS_URL 写成了redis://localhost:6379/0。在容器里localhost 指向容器自己不是宿主机也不是 compose 里的 redis 服务。解决compose 网络里一律用服务名互相访问把地址改成redis://redis:6379/0。同时确认 depends_on 里加了 healthcheck否则 Redis 还没起来 worker 就开始连也会看到同样的 refused。排查网络问题先 ping 再连docker compose exec worker ping redis通不通比看日志猜半天快得多。5.3 同一个 URL 被两个 worker 都抓了不是队列重复是入队侧的竞态现象日志里能看到两个不同容器 ID 在处理同一个 URL结果表里出现重复数据目标站点开始限流。原因BRPOP 从队列里取任务是原子的不会重复弹出。重复出在入队侧如果外部脚本直接向 Redis List 里 LPUSH而没有经过去重检查同一条 URL 可能被推入两次两个 worker 各取一次各自调度器又互不知道最终重复抓取。解决入队前先查指纹集合对“先查去重再入队”这组操作用 Redis 的 SETNX 加一把轻量分布式锁或者直接写一个 Lua 脚本保证原子性。同时数据库层建 URL 唯一索引做兜底。我的经验是别为这层去重写一套复杂的分布式锁框架Redis 单机上 SETNX 足够锁的场景只有保护入队这一小段不是全局互斥。5.4 worker 半夜被 OOM 杀掉日志里什么错误都没有现象docker compose ps 看到 worker 处于 exited 状态docker compose logs 里最后一条日志还停在正常抓取没有任何异常栈。原因容器内存达到 mem_limit 上限被内核直接 OOM 杀掉应用层根本来不及打日志。解决compose 里明确设置内存上限和重启策略worker: mem_limit: 512m restart: unless-stopped同时限制单个响应体大小scrapy 里设置 DOWNLOAD_MAXSIZE防止抓到大页面时内存暴涨。如果业务确实需要抓大文件把 mem_limit 放到 1g也好过在默认无限内存下裸奔——至少进程不会被莫名其妙杀掉。5.5 镜像下载慢、build 到一半失败现象docker build 拉基础镜像时卡住或者 pip install 阶段长时间没输出最终构建失败。原因镜像仓库的网络路径不稳定直接用默认 registry 拉基础镜像经常超时。解决在 Docker daemon 配置里指定可用的镜像加速地址配置后重启 Docker 服务再 build。另一个实用技巧是依赖层的缓存先 COPY requirements.txt 再 pip install最后复制源码这样日常修改代码不会重新下载依赖。看到 pip install 卡住时先确认是不是网络问题而不是构建逻辑问题不要反反复复改 Dockerfile。6. 验证分布式爬虫服务扛不扛得住三个我常做的自检方法6.1 先测重复率再谈吞吐分布式爬虫最容易隐藏的问题是重复。我每次上线前都会用带唯一编号的 URL 做一轮压测for i in $(seq 1 100); do redis-cli -h 127.0.0.1 -p 6379 LPUSH example:start_urls http://test.local/item/${i} done sleep 60 mysql -h 127.0.0.1 -u crawler -p crawler \ -e select count(*) as total, count(distinct url) as uniq from pages;如果 total 大于 uniq先别扩容回头查入队侧去重和数据库幂等设置。重复率归零之后再加 worker这个顺序不要反。6.2 用探针页测目标站点容忍度再回填并发我对每个目标站点都会做一个“探针”预先抓它几十个页面记录成功率和响应时间分布。然后单 worker 从低并发试起逐步提高 CONCURRENT_REQUESTS观察成功率拐点。拐点出现在哪里DOWNLOAD_DELAY 就留在那附近。这套方法能让我在扩容前就知道目标站的水位而不是等 403 批量涌进日志才后知后觉。6.3 滚动扩容与重启的日常习惯改参数我从来不进容器里手工改一律通过 compose 的 environment 下发改完docker compose up -d重建 worker。容器本来就是设计成可丢弃的手工改完容器一删就白改了。扩容时先加 2~3 个 worker 观察 10 分钟确认成功率不掉、重复率不升再继续加。这个习惯帮我避开了大多数半夜起来看日志的场面。基于 Docker 的分布式爬虫服务真正难的不是把容器跑起来而是让重复率、目标站承受力、并发三者之间找到一个稳定的平衡点。希望帮到你。本文还有配套的精品资源点击获取