
简介基于Docker的分布式爬虫服务项目定位为面向爬虫工程师及计算机相关专业学生的完整工程实践资料。项目以Go语言实现覆盖从proto接口定义、业务代码到Docker镜像构建的完整链路可直接用于毕业设计、课程设计或作为分布式爬虫架构的入门参考。压缩包共11个文件以Go源码为主5个另含proto接口定义、Dockerfile及构建脚本、Markdown说明文档和架构示意图大小约311KB结构清晰便于对照学习和快速定位关键模块。已有54人浏览/学习适合具备Python或Go基础、希望理解容器化爬虫调度与RPC通信机制的读者。资料包含可运行的爬虫客户端与服务端源码、单机与分布式爬虫示例、镜像构建脚本及详细文档并附项目授权码可帮助读者快速复现环境、理清模块关系并在此基础上扩展自己的功能。1. Docker 分布式爬虫服务先解决任务分发再谈容器数量很多人以为分布式爬虫就是把 Docker 容器多开几个再挂个队列就完事。真正跑起来才会发现任务失败、连接泄漏、节点间协调数据的序列化开销比抓取本身更消耗时间。这个项目提供了一套用 gRPC 定义爬虫任务的分布式服务 zergcrawl.proto 声明任务模型service_container 里运行 gRPC 服务端zerg_client 作为客户端提交 URL 和接收结果Docker 负责镜像封装与快速部署。它把单机爬虫升级为可水平扩展的服务适合从脚本型爬虫切换到服务化架构的团队也适合想一次性理解 Docker、gRPC、任务调度三者的开发者。2. 协议设计crawl.proto 如何把 URL 列表变成可传输对象分布式爬虫里调度端和 Worker 端最常交换的数据是任务描述这一层如果设计得不好后面所有服务化改造都会受阻。用 REST 下发任务时请求体是 JSON字段名冗长序列化消耗 CPU而且服务端改了字段名客户端往往要到运行时才发现不兼容。这个项目在 protos/crawl.proto 里用 protobuf 定义任务模型编译生成 crawl.pb.go 后服务端和客户端共用同一份代码字段结构在编译期就固定下来跨版本对接时不会出现“服务端发了新字段、客户端解析直接报错”的情况。2.1 为什么用 gRPC 而不是 REST 下发爬虫任务爬虫任务的调用特点是高频、短消息、需要双向确认。gRPC 基于 HTTP/2一条长连接上可以复用多个 stream客户端抓完一批 URL 再取下一批不需要每次都重新建 TCP 连接。REST 每发一次请求就要走一遍 HTTP 头消息体里还有 JSON 的引号和花括号量大之后带宽和延迟都会吃亏。crawl.proto 里还可以同时声明普通 RPC 和流式 RPC比如Fetch(JobQuery) returns (stream CrawlItem)调度端可以持续收到 Worker 回传的解析结果在做增量展示和断点续抓时比轮询 REST 接口方便得多。再加上强类型约束proto 文件本身就是一份可读的接口文档后端改字段后客户端编译阶段就会暴露问题。2.2 crawl.proto 的核心消息体以项目里最常见的写法为例protos/crawl.proto 的核心定义大致如下syntax proto3; package zerg; service CrawlService { rpc Submit(CrawlRequest) returns (CrawlReply); rpc Fetch(JobQuery) returns (stream CrawlItem); } message CrawlRequest { string job_id 1; repeated string urls 2; int32 depth 3; int32 max_concurrency 4; bool follow_robots 5; mapstring, string headers 6; } message CrawlReply { string job_id 1; int32 accepted 2; string message 3; } message JobQuery { string job_id 1; } message CrawlItem { string url 1; int32 status_code 2; string body_snippet 3; int64 fetch_time_ms 4; }CrawlRequest是任务下发的主消息体。job_id标记一次完整抓取任务后续重试、暂停、查日志都靠它urls是repeated字符串数组每次可以提交一批入口地址depth表示递归抓取层数设为 0 表示只抓入口页设为 2 表示再往下抓两层。max_concurrency是抓取并发上限分布式模式下 Worker 会把这个值当成参考follow_robots控制是否遵循目标站点的 robots 协议合法抓取时我建议始终置为 trueheaders用mapstring, string携带自定义请求头比如 User-Agent 和 Referer。CrawlItem是回传结果的结构status_code判断 HTTP 状态body_snippet只保留页面正文的前一段避免把整份 HTML 塞进消息fetch_time_ms记录单次抓取耗时方便统计调度效率。depth越大请求量会呈指数级增长我一般会把 depth 和 max_concurrency 做联动抓取层数加深时并发相应调小避免瞬时流量过大把目标站点压垮。字段名类型作用job_idstring任务标识串起整条调用链urlsrepeated string待抓取 URL 列表depthint32递归抓取层数0 表示只抓入口max_concurrencyint32并发上限传给 Worker 做节流follow_robotsbool是否尊重目标站点 robotsheadersmapstring,string自定义请求头模拟浏览器场景2.3 编译 pb 文件时的依赖与命令拿到 crawl.proto 后需要把它编译成 Go 代码项目里的 crawl.pb.go 就是这条命令的产物protoc --go_outpathssource_relative:. --go-grpc_outpathssource_relative:. protos/crawl.proto--go_out生成消息结构的序列化代码--go-grpc_out生成 gRPC 服务端和客户端的接口桩代码。pathssource_relative让输出文件落在 proto 所在目录下避免生成嵌套路径。编译前要确认本地装了 protoc 以及 protoc-gen-go、protoc-gen-go-grpc 两个插件否则会提示找不到可执行文件。提示proto 文件是服务端和客户端共同遵守的契约。改动字段名时保留原字段编号只废弃不删除否则旧版本客户端解析时会错位。crawl.pb.go 是自动生成的代码不要手工修改每次重新生成后用 git diff 查看变更即可。3. Dockerfile 与 build_docker_image.sh把 gRPC 服务打包成可分发镜像gRPC 服务本身跨平台可编译但部署环境里缺依赖库、glibc 版本不一致、证书文件路径不同都会让本地跑得好好的服务在服务器上启动失败。用 Docker 可以把 Go 二进制和它需要的运行环境一起固化下来。service_container 目录下的 Dockerfile 和 build_docker_image.sh 就是干这件事的。3.1 多阶段构建builder 阶段与运行阶段分离镜像体积直接决定节点扩容速度。多阶段构建是 Go 服务镜像的标准做法第一阶段用完整 Go 镜像编译静态二进制第二阶段把二进制复制进精简的 alpine 镜像最终镜像里不保留 Go 工具链和源码。FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o zerg-service ./service_container FROM alpine:3.19 RUN apk add --no-cache ca-certificates WORKDIR /root/ COPY --frombuilder /app/zerg-service . COPY protos/crawl.proto ./protos/ EXPOSE 50051 ENTRYPOINT [./zerg-service]go mod download被单独放在COPY . .之前是为了让 Docker 利用层缓存go.mod 和 go.sum 没变时后续构建不会重新下载依赖。CGO_ENABLED0关闭 CGO编译出纯静态二进制运行阶段不需要 gcc 环境。GOOSlinux明确目标操作系统避免在 macOS 上编译出 Mach-O 格式导致容器启动失败。如果不确定线上 Go 版本保持与 go.mod 声明的版本一致即可。运行阶段选择 alpine 而不是 scratch是因为需要apk add --no-cache ca-certificates来补系统根证书爬虫服务访问 HTTPS 页面时会用到它。COPY --frombuilder只拿编译产物ENTRYPOINT指定容器启动命令。如果服务端代码依赖时区数据还需要在运行阶段追加tzdata包否则日志时间会偏移 8 小时。3.2 build_docker_image.sh 里的镜像标签与推送项目里的脚本通常在构建完成后对镜像打双标签便于本地开发和线上回滚#!/bin/bash set -euo pipefail IMAGE_NAMEzerg-service TAG$(git rev-parse --short HEAD) docker build -t ${IMAGE_NAME}:${TAG} . docker tag ${IMAGE_NAME}:${TAG} ${IMAGE_NAME}:latest docker push ${IMAGE_NAME}:${TAG}用 git 短哈希作为镜像标签能明确知道服务器上跑的是哪一次构建。latest标签只适合开发环境生产部署时应固定到具体版本。set -euo pipefail让脚本在任何命令失败时立即退出变量未定义时直接报错避免把空字符串当成镜像标签。如果公司内部有 Docker 仓库docker push前先docker login认证否则推送会遇 401 错误。3.3 启动容器时的网络与服务发现参数镜像构建完成后单节点验证用 docker run 足够docker run -d --name zerg-service \ -p 50051:50051 \ --restart unless-stopped \ --cpus 2 \ --memory 1g \ zerg-service:latest-p 50051:50051把容器内 gRPC 端口映射到宿主机--restart unless-stopped让服务在崩溃或机器重启后自动拉起--cpus 2和--memory 1g限定资源上限避免爬虫并发时把宿主机 CPU 打满。Windows 上用 Docker Desktop 跑这个命令时要确认 WSL2 内核已开启否则启动阶段会因虚拟化支持未打开而失败。参数作用注意点-p 50051:50051端口映射宿主机端口冲突时改左侧端口--restart unless-stopped崩溃自动重启手动 stop 后不会自动拉起--cpus 2限制容器 CPU超限是节流而不是杀掉进程--memory 1g限制内存超过上限会被 OOM Killer 回收3.4 用 docker compose 拉起一主一从的验证环境docker run 适合单容器验证但模拟分布式爬虫的最简形态至少要一个调度端加一个 Worker 节点。docker compose 把容器、网络、重启策略写在一个 yml 里一条命令全部启动services: zerg-scheduler: build: ./service_container ports: - 50051:50051 restart: unless-stopped zerg-worker: build: ./service_container depends_on: - zerg-scheduler command: [./zerg-service, -worker, -schedulerzerg-scheduler:50051] restart: unless-stopped假设 service.go 支持-worker子命令这套配置会让 worker 容器和调度容器共享同一镜像但启动参数不同。depends_on只保证容器启动顺序不保证服务已就绪如果 worker 启动过快导致连接失败可以添加轻量健康检查或者让客户端用 gRPC 的 wait-for-ready 语义重试。4. zerg_client 调用链与单机/分布式爬虫入口的选择服务端就绪后客户端代码怎么写决定整个爬虫系统能不能稳定跑。zerg_client/client.go 是项目里的 gRPC 客户端封装example 目录下同时提供 single_machine_crawl.go 和 zerg_crawl.go分别应对本地调试和分布式运行。4.1 客户端拨号与连接复用gRPC 客户端的连接管理直接影响分布式爬虫稳定性。每抓几百个页面就新建连接的做法会让服务端积压大量 TIME_WAIT 状态最后直接拒绝新连接。正确做法是启动时建好连接整个进程生命周期内复用import ( google.golang.org/grpc google.golang.org/grpc/credentials/insecure ) conn, err : grpc.NewClient(zerg-scheduler:50051, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions( grpc.MaxCallSendMsgSize(420), grpc.WaitForReady(true), ), ) if err ! nil { log.Fatalf(dial failed: %v, err) } defer conn.Close()grpc.NewClient是较新版本推荐的入口不再要求拨号时立即建立连接而是在第一次 RPC 调用时实际连接后续自动处理重连。WaitForReady(true)表示服务端暂时不可用时客户端保持等待适合容器迁移或调度端滚动升级场景。MaxCallSendMsgSize(420)把发送消息上限设为 4 MB防止提交超大 URL 列表时触发资源耗尽错误。提示旧项目里常见的是grpc.Dial。两者参数基本兼容但grpc.NewClient没有阻塞等待连接成功的语义不要在拨号后立刻假设连接可用应依赖 RPC 调用本身的错误处理。4.2 example 两个入口的定位差异single_machine_crawl.go不经过 gRPC 服务端直接在进程内用 HTTP 抓取并解析页面作用是给出性能基线和最简单的实现方便在没有 Docker 环境的机器上快速验证页面解析规则。zerg_crawl.go才是分布式入口它连接调度端通过流式 RPC 持续领取 URL并把抓取结果回传stream, err : client.Fetch(ctx, zerg.JobQuery{JobId: jobID}) if err ! nil { return err } for { item, err : stream.Recv() if err io.EOF { break } if err ! nil { if status.Code(err) codes.ResourceExhausted { time.Sleep(2 * time.Second) continue } return err } parseAndStore(item.GetUrl(), item.GetStatusCode(), item.GetBodySnippet()) }Fetch返回一个流对象stream.Recv()每收到一条CrawlItem就处理一个页面。ResourceExhausted表示调度端在限流等待 2 秒继续读流这是分布式爬虫最常见的背压处理方式。io.EOF是服务端关闭流的正常信号代表本轮任务分发完毕。维度single_machine_crawl.gozerg_crawl.go与 gRPC 服务端关系不依赖直接 HTTP 抓取依赖连接调度端领任务队列维护进程内切片调度端统一分配去重机制本地 map需要调度端配合全局去重适用场景页面解析调试、性能基线分布式部署、批量抓取4.3 常见误用把 gRPC 连接放在遍历循环里我看过不少爬虫客户端在 for 循环里调用grpc.NewClient/grpc.Dial跑一会儿容器网络连接数就暴涨。gRPC 连接内部有 HTTP/2 连接池和 keepalive 机制频繁创建新客户端不会带来并发能力提升反而会触发服务端最大连接数限制。连接应该初始化一次作为依赖传入业务方法。还有一种常见误用是把 URL 列表一次性塞满整个数组proto 虽然支持 repeated但 gRPC 有 4 MB 默认消息大小限制一次提交三四千条 URL 很容易触发 ResourceExhausted。分批提交时建议每批 100 到 500 条配合流式接收结果吞吐不会下降排错却简单得多。5. 容器部署后的验证方法与 keepalive 调优镜像和客户端都准备好之后部署验证要按链路从下往上查容器起来没有、端口在不在、RPC 协议通不通。5.1 用 grpcurl 快速确认服务是否存活curl 无法直接验证 gRPC 接口因为 gRPC 走 HTTP/2 且消息体是二进制编码curl 没有能力构造正确的请求帧。用 grpcurl 模拟一个 Submit 请求就简单很多它会自动把 JSON 转换成 protobuf 二进制格式grpcurl -plaintext \ -d {job_id:test-001,urls:[https://example.com],depth:0,max_concurrency:4} \ localhost:50051 zerg.CrawlService/Submit如果返回accepted: 1说明容器、端口映射和 proto 反序列化都已经正常。如果报Failed to dial target先用docker ps -a看容器是否退出再检查docker logs zerg-service里有没有监听端口错误。5.2 gRPC keepalive 参数解决长连接空转断开分布式爬虫中Worker 抓完一批 URL 后可能要等调度端分配下一批中间有一段空闲时间。默认 keepalive 间隔较长跨网络环境时中间节点会把空闲连接回收表现就是下一次请求报transport is closing。在 service.go 里调整服务端 keepalive 参数是常见解法import ( google.golang.org/grpc/keepalive time ) var kp keepalive.ServerParameters{ MaxConnectionIdle: 5 * time.Minute, MaxConnectionAge: 10 * time.Minute, Time: 30 * time.Second, Timeout: 5 * time.Second, }Time: 30 * time.Second让服务端每 30 秒主动 ping 一次客户端Timeout: 5 * time.Second是 ping 超时超时就断开连接。两个参数配合后空闲长连接会被持续保活不会到真正发数据时才发现连不上。5.3 用 docker stats 匹配并发参数调优时优先看资源水位而不是盲目调大并发数docker stats --format table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}如果容器 CPU 长期低于 30% 但吞吐上不去瓶颈多在目标站点响应速度或调度串行化继续调高max_concurrency意义不大如果 CPU 一直贴着上限就把--cpus调大或者增加 Worker 容器副本。多个 Worker 并发领取任务时调度端必须做全局 URL 去重否则同一页面会被不同副本重复抓取抓取量和目标站点压力都会翻倍。本文还有配套的精品资源点击获取