基于Docker的分布式爬虫服务:从单机到容器化集群的架构实践
简介面向Python爬虫开发者的Docker分布式爬虫服务完整资料包以Go语言实现任务调度与通信解决传统单机爬虫扩展性差、部署环境不一致等问题。资源共11个文件压缩包大小311KB主要包含Go服务端/客户端源码、proto接口定义、Dockerfile、镜像构建脚本、shell部署脚本及Markdown文档覆盖从容器化打包、分布式服务注册到爬虫任务调度的完整链路。项目内提供单机与分布式爬虫运行示例、gRPC服务端与客户端调用代码并配有清晰的构建说明能够帮助读者快速理解基于Docker部署分布式爬虫的核心方法。整个目录按service_container、zerg_client、example等模块组织方便按需查阅和二次开发文档中还说明了Docker网络下爬虫任务互通与资源隔离的配置要点。适合计算机相关专业学生用于课程设计、毕业设计、项目初期演示也可作为企业爬虫服务容器化改造的参考。目前已有54人学习资料体积小但内容集中便于快速上手。1. 基于 docker 的分布式爬虫服务到底在解决什么问题单机爬虫跑得通不代表把它复制成 10 份还能跑得通这是我想先说清楚的结论。你本地调试好了一个基于 scrapy 或 requests 的爬虫换一台机器重新装依赖版本冲突、lxml 编译失败、Redis 版本对不上任何一个都是半小时起步的折腾真到业务量上来要扩容时这种环境不一致会被放大成“一到发布就出玄学问题”。基于 docker 的分布式爬虫服务思路是反过来把每个爬虫实例打包成标准容器把待抓 URL 和已抓指纹交给独立的 Redis 去管再用 docker-compose 统一编排。这样加节点、换代码、回滚版本都不再碰宿主机环境。适合已经写过单机爬虫想把任务摊到多台机器或同一台机器的多个容器上又不想维护一整套 Hadoop 集群的团队。2. 分布式爬虫的角色拆分控制节点、worker、队列与存储怎么各司其职2.1 网络爬虫从单机到分布式的本质变化队列和去重从内存搬到共享存储先说清楚分布式爬虫和单机爬虫的分界在哪里。单机爬虫再复杂循环也只有四步从队列取 URL、下载页面、解析提取新链接、去重后丢回队列。这个循环在 scrapy 里由调度器维护在你自己用 requests 加 BeautifulSoup 写的脚本里就是一个 while 循环。队列和已抓集合都放在进程内存里跑完一批任务就没了这也是网络爬虫原理里最基础的一块。当爬虫从单机变成分布式内存里的队列和去重集合就是第一个撑不住的东西。两个 worker 同时跑互相不知道对方已经抓过什么重复抓取马上就来了。所以分布式爬虫真正要解决的不是“怎么下载页面”——下载、解析、数据入库这些逻辑几乎不用改——而是三个共享问题任务队列共享、去重集合共享、结果存储共享。这三个问题落地了剩下的就是决定 worker 容器跑多少个实例。我一般不会一上来就引入 Kafka 或 Celery。爬虫的 URL 任务不像订单消息那样需要精确的事务边界队列丢了也能靠重新解析列表页回填所以 Redis 的 List 加 Set 已经能覆盖绝大多数业务量。真正要考虑换架构的信号是单日 URL 量到了千万级、Redis 内存吃紧的时候那时候再上布隆过滤器或者独立消息队列不迟在那之前组件越少越不容易在凌晨三点出故障。2.2 每个角色的选型理由为什么 Redis 做队列为什么不用自己写调度先把服务拆成角色后面写 Dockerfile 才不会被“这个脚本放哪”的问题卡住。一个典型的基于 docker 的分布式爬虫服务包含下面几张表里这些角色角色职责运行载体关键注意点控制节点把种子 URL 写入任务队列负责任务分发宿主机定时任务或轻量容器只做调度不要让它抓页面worker消费队列里的 URL下载、解析、提取新链接、入库docker 容器可横向扩多个必须无状态所有状态放 Redis任务队列临时保存待抓 URLRedis List开 AOF 持久化防止重启丢任务去重存储记录已抓过的 URL 指纹避免重复消费Redis Set / Bitmap普通场景 Set 够用超大规模再用 Bitmap结果存储落结构化数据MySQL / MongoDB 均可连接池上限要和 worker 数量匹配为什么这些角色都往容器里扔worker 容器化收益最明显一个镜像包含 Python 版本、系统库、依赖和爬虫代码在任何机器上跑行为一致。控制节点通常不需要做成容器因为它就是个往 Redis 写 URL 的脚本放宿主机 cron 里就行关键是别让它和 worker 抢资源。Redis 在这里同时承担队列和去重存储主要是省组件运维只需要管好这一个中间件。为什么不用自己写 socket 调度新手很容易想搞一套 master 分发。这个方向的问题在于断线重连、消息确认、异常重试、任务状态机全要自己实现调试成本远超想象。而用 Redis 的阻塞命令队列行为是现成的brpop 在没有消息时挂起连接消息一进来立刻返回。你写的代码只剩业务部分这是最划算的选型。2.3 一个可参考的目录骨架配置集中、代码分层、启动入口单一这类“详细文档加资料齐全”的包里子其实就是一套结构清晰的工程目录。我自己整理爬虫服务时会按下面这个方式分文件你也照这个思路去组织自己的项目路径作用docker-compose.yml编排入口定义 redis、mysql、worker 三个服务worker/Dockerfileworker 镜像构建文件worker/requirements.txt依赖锁定pip 安装用worker/src/spiders/product.py页面解析与入库逻辑scheduler/seed.py种子 URL 入队脚本控制节点调用config/settings.pyRedis、数据库连接配置统一出口scripts/health.sh健康检查脚本定时看队列积压这个结构的原则是配置集中在一个文件里代码按“调度”和“抓取”分层启动入口只有 docker-compose.yml 一条。后面所有命令都基于这套目录展开如果你拿到的资料包结构不同把路径替换成你自己的即可核心思路不会变。3. 从 Dockerfile 到 docker-compose把分布式爬虫服务跑起来的最小配置3.1 最简 Dockerfile从基础镜像到能跑 scrapy 的最小依赖先写 worker 的镜像。下面是能跑 scrapy 项目的最简 Dockerfile重点在基础镜像选择和构建缓存利用上。# 基于 python 3.10 slim 镜像工作目录 /app FROM python:3.10-slim # 先装编译工具否则 lxml 等 C 扩展会在构建时现场编译失败 RUN apt-get update apt-get install -y --no-install-recommends \ gcc \ libffi-dev \ libssl-dev \ rm -rf /var/lib/apt/lists/* WORKDIR /app # 先只拷贝依赖文件利用 docker 构建缓存避免每次改代码都重装依赖 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷贝业务代码 COPY src/ ./src/ # 容器内时区和编码中文页面抓下来乱码的坑在这里提前堵上 ENV TZAsia/Shanghai \ PYTHONUNBUFFERED1 \ LANGC.UTF-8 CMD [scrapy, crawl, product]选择 python:3.10-slim 而不是 alpine原因很简单alpine 体积确实小但默认用 musl libcscrapy 依赖的 lxml、cryptography 这类带 C 扩展的库在 alpine 上编译经常报奇奇怪怪的错误。slim 基于 Debian跟本地开发环境更接近踩坑概率低很多。requirements.txt 里通常至少包含 scrapy、redis、requests、lxml再加一个 MySQL 驱动比如 pymysql 或 mysql-connector-python。先用 COPY requirements.txt 再用 COPY src是因为 docker 构建缓存按层判断只要依赖文件没变pip install 那一层就不会重跑如果你把代码先拷进去改一行代码就要全量重装依赖构建速度会慢到让人想砸电脑。3.2 编排服务定义docker-compose 里的一组 Redis、MySQL 与 worker 配置镜像写好后用 docker-compose 把三个服务编排在一起。这里有一份可以直接试跑的配置services: redis: image: redis:7-alpine ports: - 6379:6379 volumes: - redis-data:/data command: redis-server --appendonly yes mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: spider ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql worker: build: ./worker depends_on: - redis - mysql environment: REDIS_URL: redis://redis:6379/0 MYSQL_HOST: mysql volumes: - ./worker/src:/app/src command: scrapy crawl product volumes: redis-data: mysql-data:注意两个细节。第一worker 连接 Redis 用的主机名写的是 redis不是 localhost因为容器间通信走的是 docker 内部网络主机名就是服务名。第二depends_on 只保证 Redis 和 MySQL 先启动不保证它们已经就绪。如果 worker 启动太早连不上库建议在服务定义里加 restart: on-failure让容器自己重启几次。mysql:8.0 的端口映射在本地调试时有用生产环境可以考虑把端口映射去掉只让服务在容器网络内部被 worker 访问减少暴露面。密码明文写在 compose 里适合本地开发放到服务器上时建议改成读取 .env 文件的方式。3.3 从 docker desktop 到服务器启动、看日志和扩容的常用命令本地开发时用 docker desktop 没问题服务器上建议直接装 docker engine两者的启动命令一致。第一次跑起来下面这几条命令最常用# 构建并后台启动全部服务 docker compose up -d --build # 跟踪 worker 日志看抓取是否正常 docker compose logs -f worker # 把 worker 扩到 4 个实例 docker compose up -d --scale worker4 # 查看服务状态 docker compose ps # 停止并移除容器数据卷保留 docker compose down--scale worker4 是分布式爬虫最直接的扩容方式但有前提worker 服务定义里不能写 container_name也不能给 worker 映射固定端口否则扩展时会报“容器名称已存在”或端口冲突。worker 之间本来就不需要互相通信不映射端口反而更干净。从 docker desktop 到服务器的迁移不需要在服务器上重新装 Python 环境。把整个工程目录打包传上去执行 docker compose up -d --build行为跟本地完全一致这就是容器化带来的最大好处。 提示离职交接或半夜救火时doker compose ps 的输出比任何文档都真实先养成看它的习惯。4. 用 Redis 做任务队列和去重多个 worker 不重复消费的关键实现4.1 任务入队种子 URL 怎么进 Redis 队列分布式爬虫的起点是控制节点把 URL 写进队列。入队脚本的核心不是 lpush而是先做一次 sadd 去重。下面是一个常见的 seed 脚本写法import os import redis import requests from bs4 import BeautifulSoup r redis.from_url(os.getenv(REDIS_URL, redis://localhost:6379/0)) def seed_product_urls(start_url: str) - int: added 0 resp requests.get(start_url, timeout10) resp.raise_for_status() for a in BeautifulSoup(resp.text, html.parser).select(a.product-link): url a[href] # sadd 返回 1 表示第一次见到这个 URL才允许入队 if r.sadd(spider:seen, url): r.lpush(spider:todo, url) added 1 return added if __name__ __main__: print(seed_product_urls(https://example.com/list))这段代码最关键的是 r.sadd(spider:seen, url) 的返回值。Redis 的 sadd 在成员不存在时返回 1存在时返回 0所以它天然就是分布式去重闸门。先判断返回值为 1 再入队能保证同一 URL 只进队列一次哪怕多个控制节点同时跑也不会重复。用 requests 抓列表页只是示例实际项目里这里也可以用 scrapy 的 spider 专门抓入口页。入队和生产 URL 的逻辑分开后worker 代码就只关心“从队列拿 URL抓完再回填新 URL”职责单一。4.2 worker 消费容器里的 scrapy 怎么从 Redis 队列取 URLworker 侧要解决的是“多个容器同时取任务不打架”。Redis 的 brpop 是阻塞式弹出多个 worker 同时挂在这个命令上时Redis 保证每个消息只会被一个 worker 取走这是队列消费不重复的基础import os import redis import scrapy from scrapy.crawler import CrawlerProcess r redis.from_url(os.getenv(REDIS_URL, redis://localhost:6379/0)) class ProductSpider(scrapy.Spider): name product def start_requests(self): while True: item r.brpop([spider:todo], timeout5) if not item: continue yield scrapy.Request(urlitem[1].decode(utf-8), callbackself.parse) def parse(self, response): # 解析字段并入库这里省略管道逻辑 yield {url: response.url, title: response.css(title::text).get()}brpop 返回的是一个元组第一个元素是队列名第二个是值所以 item[1].decode(utf-8) 才是 URL。timeout5 表示阻塞 5 秒没消息就返回空让循环有机会做其他事情而不是无限挂死。页面解析完成后从 response 里提取的新链接要继续回填队列否则爬虫跑完一层就停了。回填逻辑和入队脚本一致sadd 通过再 lpush把新 URL 交给下一个 worker 消费。这样整张任务网就自动滚动起来了不需要人为给每个 worker 分配抓取范围。4.3 去重规模变大怎么办Bitmap 与分布式锁的实际用途URL 量到百万级别以后Redis Set 的内存开销会开始让人心疼。一个折中方案是用 Bitmap 做指纹存储Python 里可以直接操作 Redis 的 setbit 命令import hashlib import redis r redis.from_url(redis://localhost:6379/0) def mark_seen(url: str) - bool: # 用 md5 前 8 位取模映射到 bitmap 的 offset offset int(hashlib.md5(url.encode()).hexdigest()[:8], 16) % 100_000_000 return r.setbit(spider:seen:bm, offset, 1)setbit 返回 0 表示这个 offset 之前是 0这次是第一次见返回 1 表示已存在。用 1 亿位也就是 12MB 左右可以承载上千万 URL 的指纹比 Set 省得多。代价是存在哈希碰撞会把不同的 URL 误判为已抓所以 Bitmap 适合“抓过的就不太需要重抓”的列表页关键页面还是建议用 Set。还有一个常见的并发问题某些操作要求同一时刻只有一个 worker 在执行比如刷新登录态、跑定时统计。这时候需要一把分布式锁用 Redis 的 setnx 参数实现def acquire_lock(r, name: str, ttl: int 30) - bool: # nxTrue 表示只有键不存在时才能设置成功ex 设置过期时间 return r.set(fspider:lock:{name}, 1, nxTrue, exttl)nxTrue 对应 Redis 的 setnx 语义只有键不存在时才能写入谁写成功谁就拿到锁。exttl 是必须的不然 worker 宕机后锁永远不释放整条链路都会堵死。爬虫场景里很少需要分布式事务因为 URL 重复抓一次最多产生重复数据靠数据库唯一索引做幂等就行真正要防的是并发下的重复写入和共享资源竞争。5. 基于 docker 的分布式爬虫避坑5 个高频故障的排查路径5.1 容器里连不上 Redislocalhost 不是你以为的那个 localhost现象worker 容器启动后疯狂报 ConnectionRefusedError日志里 Redis 连接失败但宿主机上 redis-cli 却能正常访问。原因在容器内部localhost 指向容器自己的网络命名空间而不是宿主机。docker-compose 默认创建了一个 bridge 网络每个服务在网内都有自己的主机名worker 里写 localhost 等于在找自己自然连不上宿主机上的 Redis。解决worker 的 Redis 地址改成服务名也就是 docker-compose.yml 里 Redis 服务的名字比如 redis://redis:6379/0。宿主机调试脚本时再用 localhost容器部署用服务名这个在配置里拆成环境变量区分即可。docker 安装 Redis 时如果遇到类似问题先检查端口映射有没有加上再确认连接地址写的是不是容器名。5.2 多个 worker 重复抓取同一 URLscrapy 自带去重被容器隔离了现象数据库里出现大量重复记录同一个商品 URL 被两个 worker 各抓了一次而且抓取时间几乎同时。原因scrapy 自带的去重是基于内存的每个容器实例都有自己的去重集合互相看不见。我见过不少项目直接把单机 scrapy 项目塞进多个容器以为多容器就等于分布式结果容器越多重复抓取越严重。解决关闭 scrapy 内置去重统一走 Redis 去重。做法是入队时先 sadd消费时用 brpop 原子弹出回填链接时也走同一个 sadd 判断。另外给结果表加 URL 唯一索引即使有漏网之鱼重复数据也进不来这是后悔药级别的兜底。5.3 共享卷里的文件锁冲突无状态设计才是容器化的前提现象多个 worker 同时在跑日志里出现 PermissionError 或“Resource temporarily unavailable”报错指向同一个本地文件目录。原因有的爬虫代码会把图片、临时文件写到本地磁盘目录再用 fcntl 做文件锁防并发写。单机跑没问题多容器共享同一个 volume 时两个 worker 同时写同一路径就打架了。解决让 worker 无状态。临时文件写容器内的 /tmp不要写持久化目录最终产物按 URL 的 hash 分目录存储避免不同 worker 落到同一路径。日志里如果有锁相关报错优先检查代码里有没有写本地文件的地方。凡是“每个 worker 各自状态”的设计都要在容器化时重新审视。5.4 抓下来的中文乱码基础镜像缺了 locale 和连接串编码现象页面抓取正常存进 MySQL 的中文变成问号或乱码在容器里翻日志输出也是乱码。原因python:3.10-slim 这类基础镜像默认 locale 不是 UTF-8Python 在处理编码时行为会变得很诡异另外 MySQL 连接串如果没指定 charset驱动会按默认 latin1 处理。解决Dockerfile 里加 ENV LANGC.UTF-8并且 MySQL 连接 URL 里显式加上 charsetutf8mb4。这两个问题同时处理乱码基本就能消除。如果数据库表建的时候就是 latin1那还得先改表字符集这种历史问题只能重建表或转数据没有捷径。5.5 扩容后速度不升反降瓶颈不在 worker 数量而在连接池和后端现象--scale worker16 扩到 16 个容器后抓取速度没变快反而 MySQL 报连接数超限Redis 也不时超时。原因worker 数量增加数据库连接数和 Redis 连接数同步放大。MySQL 默认 max_connections 是 15116 个 worker 每个建 20 个连接就把连接池打爆了Redis 的连接数上去后网络带宽和内存也会成为新的瓶颈。解决先看队列积压再决定扩多少。用 redis-cli 执行 llen spider:todo如果积压数持续下降说明现有 worker 够用不需要扩如果积压数稳定不减才是 worker 能力不足。同时把 MySQL 连接池上限调到 worker 数乘 5 以下并适当调大 max_connections但别盲目加到几千数据库自己也会扛不住。扩容是系统工程不是把容器数量翻倍就完事。6. 验证吞吐与留好回滚队列积压数才是扩容的唯一标准6.1 从队列积压看吞吐两条命令定位瓶颈验证分布式爬虫服务跑没跑对不看容器数量看队列积压。最直接的检查命令# 在宿主机执行查看待抓队列和已完成队列长度 docker compose exec -T redis redis-cli llen spider:todo docker compose exec -T redis redis-cli llen spider:done隔一分钟执行两次算差值就是消费速率。todo 队列持续下降说明消费速度大于生产速度todo 队列只增不减说明 worker 消费能力不够优先查目标站响应时间和解析耗时。别一上来就看 CPU爬虫的瓶颈通常在网络 I/O 和数据库写入上。6.2 监控脚本与镜像回滚习惯可以把检查写成脚本放到宿主机 cron 里定时跑积压超过阈值就自动扩容#!/usr/bin/env bash # health.sh每 30 秒检查一次队列积压超过阈值自动加 worker while true; do pending$(docker compose exec -T redis redis-cli llen spider:todo 2/dev/null) echo $(date %H:%M:%S) pending$pending if [ ${pending:-0} -gt 500 ]; then docker compose up -d --scale worker8 fi sleep 30 done加 -T 是为了在 cron 和脚本环境下不分配伪终端否则会报“the input device is not a TTY”。自动扩容要设上限否则队列一直堆积会把 worker 撑到几十个数据库先垮。镜像版本管理上我习惯每次构建后打一个带日期的 tag而不是永远用 latest。回滚时只要把 docker-compose.yml 里 worker 的 image 指回上一个 tag重新 up 一遍就恢复比重新改代码快得多。我自己最早只看容器数量不看队列积压扩容翻了车之后才把积压数写进监控页。先把队列长度看清再谈扩容希望帮到你。本文还有配套的精品资源点击获取