干爬虫这件事,大多数人会经历一个分水岭:前期追的问题是“能不能抓到”,后期追的是“出问题了能不能立刻知道为什么”。后者几乎全靠日志。Scrapy日志系统表面上只是几个settings参数,可真到生产环境跑起来,命名空间、日志级别、落盘策略、切割轮转、监控告警,每一环都有各自的坑。我维护的采集任务从小规模脚本长到几十个线上服务,日志方案前后改了三次,这篇文章就围绕Scrapy日志系统做一次完整拆解,把机制、配置、运维和排障经验一次讲透,并给出我目前生产环境直接能用的配置模板,适合那些正把爬虫从本地脚本搬上服务器、或者天天被日志刷屏却找不到关键报错的开发者。
1. 为什么先要把日志当基础设施看待
1.1 一次凌晨三点的事故复盘
先讲一个真实教训。某天凌晨,一个全量采集任务在运行到第3个小时后悄悄退出,退出码是0,库里没有任何新数据。第二天早上我发现进程已经不在了,日志文件的最后一行停在了3点02分。当时的第一反应是代码写错了,但代码一点没动过。后来打开LOG_FILE一行一行看,才发现前30分钟日志里就已经出现了大量请求超时的WARNING,随后引擎进入重试循环,重试也一直失败,最后进程带着一条“Spider closed (finished)”被清理掉。整个过程没有报错崩溃,没有异常堆栈,看起来一切正常,但业务结果是空的。
那次以后我给自己定了个规矩:任何上生产的任务,必须在5分钟内能从日志回答三个问题——当前抓取进度是多少、最近的ERROR是什么、最后一条日志是什么时间。回答不上来,这个任务就不具备可运维性。这就是把日志当基础设施看待的第一个理由:它是事后溯源的唯一证据。
1.2 日志在Scrapy架构里的位置
Scrapy是一个由Twisted驱动的异步框架,请求从Scheduler进入Downloader,下载完成后由Scraper解析,Item流向Pipeline,中间还有多层Middlewares穿插。这套流程每个环节都埋着自己的logger,日志输出与业务代码是横向关系,不会改变抓取流程,也不会阻塞主逻辑。
这正是日志系统区别于普通print的地方。print写在哪个函数里就只能看到哪个函数的事,而Scrapy的日志天然覆盖整个请求生命周期。你可以完全不改爬虫代码,只靠调整日志级别和格式,就能观察引擎调度、下载器连接、Spider回调、Pipeline写入的全过程。
| Logger名称空间 | 主要职责 | 常见日志内容 |
|---|---|---|
| scrapy | 进程级入口、配置加载 | Spider opened / Spider closed |
| scrapy.core.engine | 请求与响应的流转调度 | 请求队列、重试、引擎关闭 |
| scrapy.core.scraper | 回调执行、Item提取 | Scraped from... 等 |
| scrapy.core.downloader | 下载过程、连接与超时 | 下载完成、连接失败、超时 |
| scrapy.middleware | 中间件初始化与调用 | 处理器链、抛出的异常 |
| scrapy.extensions | 扩展模块运行状态 | LogStats每60秒的输出 |
这套命名空间体系看起来简单,却是生产环境里最值钱的东西。因为logging是分层传播的,你可以只把scrapy.core.downloader调到DEBUG,而不必把整个任务都打成DEBUG,后面排查连接问题会频繁用到这个手段。
1.3 为什么不建议继续用print
很多人维护老爬虫的时候,代码里到处都是print。print的问题不是不能用,而是不可分级、不可重定向、不可切割、不可结构化。多线程并发时print输出还会互相交错,几乎没法还原时序。Scrapy底层使用的是Python标准logging框架,所有组件都通过模块级logger打点,这意味着你能获得统一的时间戳、级别、logger名称,能按文件输出,能配置格式,能接第三方handler。这些能力在本地脚本时代看起来都无所谓,一旦任务变成7x24小时运行,每一条不可检索的输出都是隐患。
2. Scrapy日志的底层机制:logging框架和命名空间
2.1 Scrapy吃透了Python的logging
Scrapy并没有自己发明一套日志体系,它是对Python标准logging的封装和默认配置。真正干活的是logging.getLogger('scrapy')这一整套logger树。各个Core组件通过不同名称的logger输出,最终汇聚到root logger,再交给handler处理。
所以你在settings.py里看到的LOG_*其实就是在配置logging的基本行为。理解了这一点,很多奇怪现象就解释得通:为什么只设置了LOG_LEVEL = 'INFO',某些第三方库的DEBUG日志还在刷屏?因为那些日志来自其他logger,比如urllib3或selenium,它们的级别由自己的logger控制,Scrapy的LOG_LEVEL管不到。
生产环境里我遇到过比较头疼的情况是Scrapy接了Playwright做动态页面采集。Playwright和它底层的websockets库日志量极其大,一跑起来终端里全是浏览器协议的DEBUG消息。解决办法就是把这两个第三方logger单独拎出来调高等级:
import logging logging.getLogger('playwright').setLevel(logging.WARNING) logging.getLogger('websockets').setLevel(logging.WARNING)这段代码放在settings.py顶层或者自定义扩展的__init__里都行。注意不要在爬虫运行过程中反复调用basicConfig,否则handler会叠加,一条日志被打印两遍甚至三遍。
2.2 核心logger名称和级别语义
前文表格里那些logger并不是随便命名的。排查问题时,我一般用两条线索定位:一是[scrapy.core.engine]和[scrapy.core.downloader]之间的时间差,二是[scrapy.core.scraper]输出的解析结果。如果请求一直没进入Scraper,问题大概率出在Downloader或Middlewares;如果Scraper有日志但Pipeline没有后续,问题就出在Item处理链路。
Scrapy日志级别从低到高是DEBUG、INFO、WARNING、ERROR、CRITICAL。每个级别的含义要说清楚:
- DEBUG:输出每个请求的下载详情、每个回调的处理结果,开发调试时用。量极大,生产环境慎开。
- INFO:任务开启、关闭、周期统计、关键状态变化。这是生产环境的主力级别。
- WARNING:不致命但需要注意的情况,比如重试、丢弃请求、某个第三方接口异常。
- ERROR:处理spider或pipeline时出现异常,通常伴随traceback。
- CRITICAL:导致进程无法继续的严重错误。
很多从文档新手期过来的人会误以为INFO看不到关键排错信息,其实默认INFO下重试、超时、Spider异常都会以WARNING或ERROR级别打出来。真正看漏问题往往是因为日志量太大被刷掉了。
2.3 关键配置项的语义与默认值
这些配置项是Scrapy日志系统的对外接口,放在settings.py里即可:
| 配置项 | 默认值 | 作用说明 |
|---|---|---|
| LOG_ENABLED | True | 日志总开关 |
| LOG_LEVEL | DEBUG | 日志级别,注意默认是DEBUG不是INFO |
| LOG_FORMAT | '%(asctime)s [%(name)s] %(levelname)s: %(message)s' | 单行日志格式 |
| LOG_DATEFORMAT | '%Y-%m-%d %H:%M:%S' | 时间戳格式 |
| LOG_ENCODING | 'utf-8' | 日志文件编码 |
| LOG_FILE | 空 | 设置后日志写入文件而非终端 |
| LOG_STDOUT | False | 是否把sys.stdout重定向到日志系统 |
| LOG_SHORT_NAMES | False | 是否简化logger名称 |
有一个细节特别容易踩:如果你自定义了LOG_FORMAT但没有同时设置LOG_DATEFORMAT,时间格式会退化成一个很难看的样子。因为Scrapy只是把自定义格式交给basicConfig,不一定保留默认时间格式。所以我每次配置格式都会把日期格式一起写上。
2.4 配置的优先级:命令行、settings与代码
日志设置不是只能在settings.py里写。运行时可以用-s参数覆盖,比如:
scrapy crawl my_spider -s LOG_LEVEL=ERROR也可以直接使用crawl子命令自带的--loglevel ERROR。这条命令在排查线上问题时很救命,不需要改代码,不用重启任务,就能临时把日志级别调高或调低。要注意的优先级顺序是:命令行参数大于settings.py,settings.py大于Scrapy内置默认值。
如果你的配置来自环境变量,也可以约定一套统一的注入方式。我的习惯是项目里放一个logging_config.py,所有任务都从这里读取级别和落盘路径,避免每个爬虫文件各自定义日志。
3. 一份能直接抄的日志配置模板
3.1 开发环境配置
本地开发的目标是信息足够多、一眼能看到请求细节。所以我会开DEBUG,把格式保持成Scrapy默认风格,并将日志直接输出到终端:
# settings_dev.py LOG_ENABLED = True LOG_LEVEL = 'DEBUG' LOG_FORMAT = '%(asctime)s [%(name)s] %(levelname)s: %(message)s' LOG_DATEFORMAT = '%Y-%m-%d %H:%M:%S'此时运行任务,每个请求的抓取结果都会以DEBUG级别输出,包括响应状态、内容类型、item内容。开发时看到的东西足够多,能快速判断页面解析逻辑是否正确。
3.2 生产环境配置
生产环境的目标是信息可控、能落盘、能追踪、能切割。下面是我目前线上任务通用的模板:
# settings_prod.py import os from datetime import datetime LOG_ENABLED = True LOG_LEVEL = os.getenv('SCRAPY_LOG_LEVEL', 'INFO') LOG_ENCODING = 'utf-8' # 按日期生成日志文件,避免单文件无限膨胀 LOG_DIR = os.getenv('SCRAPY_LOG_DIR', '/var/log/scrapy') os.makedirs(LOG_DIR, exist_ok=True) LOG_FILE = os.path.join( LOG_DIR, f"{os.getenv('SCRAPY_JOB_NAME', 'crawl')}_{datetime.now():%Y%m%d}.log" ) LOG_FORMAT = '%(asctime)s [%(name)s] %(levelname)s: %(message)s' LOG_DATEFORMAT = '%Y-%m-%d %H:%M:%S' LOG_STDOUT = False这里有几个有意为之的选择。第一,LOG_LEVEL用环境变量控制,不写死在代码里,方便线上临时切换。第二,日志文件名带日期,后续按天切割时不需要依赖外部工具重命名。第三,LOG_STDOUT=False意味着日志只进文件,stdout留给进程管理器或调度平台去捕获,两边职责分开,互不干扰。
3.3 在Spider和Pipeline里正确打日志
很多人的爬虫里是这样打日志的:
print(f"抓取失败: {url}")我建议全部改成Spider自带的logger:
class MySpider(scrapy.Spider): name = 'my_spider' def parse(self, response): self.logger.info('parsed %s, status=%s', response.url, response.status) ...self.logger是Scrapy为每个Spider准备的logger,已经带上了爬虫名称,输出的日志天然可区分是哪个任务。Pipeline里同样可以使用spider.logger:
class ItemPipeline: def process_item(self, item, spider): if item.get('title'): spider.logger.debug('item ok: %s', item.get('url')) else: spider.logger.warning('item missing title: %s', item.get('url')) return item注意使用self.logger.info('message %s', value)这种延迟格式化写法,而不是self.logger.info(f'message {value}')。前者在日志级别被过滤掉时不会执行字符串拼接,能省一点开销。
3.4 单独调整某个组件的日志级别
生产环境有个很实用的小技巧:只把某个logger调到DEBUG,而不影响其他模块。比如怀疑下载器连接有问题,可以在spider初始化或扩展里写:
import logging logging.getLogger('scrapy.core.downloader').setLevel(logging.DEBUG)其它logger保持INFO不动,这样既能拿到下载器细节,又不会被海量DEBUG日志淹没。排查完记得恢复。
4. 生产环境日志的运维化:切割、结构化、集中收集
4.1 日志文件不断膨胀怎么办
Scrapy默认没有按天切割的机制,LOG_FILE会一直往同一个文件里写。任务跑一周,单文件轻松超过几百MB。虽然文件名带日期可以缓解,但历史文件长期堆积同样占磁盘。更稳妥的做法是用logrotate切割:
/var/log/scrapy/*.log { daily rotate 14 compress missingok copytruncate }这里面有一个关键点:必须用copytruncate。因为爬虫进程会一直持有日志文件句柄,传统的rename+重开方案会导致进程继续往旧的已改名文件里写,日志就丢了。copytruncate会复制一份当前内容并清空原文件,虽然会丢失两次复制之间的极少日志,但对采集任务来说这个损失可以接受。
如果跑在容器里,更省事的方案是把日志直接打到stdout,由容器运行时或K8s的日志采集组件负责轮转。这种情况下Scrapy侧的LOG_FILE就不需要了,把LOG_STDOUT保持默认False,日志自然会落到容器标准输出。
4.2 结构化日志:为后续分析铺路
很多人grep日志靠肉眼盯时间戳和关键词,任务少还行,任务一多就乱。我的做法是生产环境输出JSON结构化日志,每一行都是完整的字段对:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record): payload = { 'time': self.formatTime(record, '%Y-%m-%dT%H:%M:%S'), 'level': record.levelname, 'logger': record.name, 'message': record.getMessage(), } if record.exc_info: payload['exc'] = self.formatException(record.exc_info) return json.dumps(payload, ensure_ascii=False) logging.getLogger('scrapy').handlers[0].setFormatter( JsonFormatter() )日志一旦变成标准JSON,无论接ELK、Loki还是ClickHouse,都能直接解析。这也回应了很多人问“有什么工具能精准分析日志”——工具再多,前提是日志格式规整,否则任何平台都只能做文本全文检索,没法做字段级聚合。
4.3 多任务和分布式场景的日志收敛
当集群里有几十个任务时,单台机器上的本地日志文件定位问题效率太低。我更推荐每个任务按“项目名/任务名/日期.log”的目录结构落盘,然后由统一的采集agent上传。每一行日志保持相同的schema,只是附加上机器节点和任务名两个字段。这样后续查问题时,可以按任务名过滤,也能按节点分布做对比,比如看某个节点上403比例是否异常偏高。
4.4 用日志做最简单的监控告警
生产环境不一定要上复杂监控系统。用crontab加一个脚本轮询日志文件就够了:
#!/bin/bash # /usr/local/bin/scrapy_log_alert.sh LOG_FILE="/var/log/scrapy/my_task_$(date +%Y%m%d).log" ERROR_COUNT=$(grep -c "ERROR" "$LOG_FILE" || true) if [ "$ERROR_COUNT" -gt 50 ]; then echo "my_task 当前 ERROR 数量: $ERROR_COUNT" | mail -s "Scrapy任务异常" ops@example.com fi这个脚本粗糙但有效。更精细的告警可以统计最近5分钟的ERROR条数、单条ERROR内容是否匹配已知白名单、LogStats输出是否停滞等。核心思路是:日志不只是给人看的,也可以作为监控系统的数据源。
5. 日志驱动的三个排障案例
5.1 抓不到数据但任务没报错:从时间戳还原卡点
有段时间一个采集任务每天能跑完,但产出越来越少。从任务维度看没有报错,Stats页面也正常。我打开日志看时间线,发现LogStats每60秒输出一次进度,但某些时段两行LogStats之间的时间间隔长达三分钟,说明循环被卡住了。
随后把scrapy.core.downloader的级别临时调到DEBUG,日志里出现了大量等待连接的记录,一个请求发出后迟迟收不到响应。最终定位到出口IP被目标站点限流,请求全部排队。修复方案是降低并发并切换出口节点池。整个过程基本靠日志时间戳和DEBUG细节完成,没有加任何调试代码。这类问题最迷惑的地方在于任务不会崩溃,看似一切正常,只有从日志的时间线才能发现吞吐在某个时段骤降。
5.2 大量403响应:从日志分布判断触发风控
反爬导致的403是另一个常见场景。默认INFO级别不会输出每个请求的状态码,需要把下载中间件的日志调到DEBUG才能看到status=403。但更高效的方式是打开scrapy.core.scraper或自定义一个响应日志中间件,把所有请求的状态码统一打出来:
class ResponseLogMiddleware: def process_response(self, request, response, spider): spider.logger.info( 'download %s -> %s [%s]', request.url, response.status, response.headers.get('Content-Type', b'') ) return response开了这个中间件以后,我可以用grep直接统计每个小时403的条数。如果403只在某个时间段飙升,大概率是请求频率触发了风控。通过日志溯源到具体路径和参数,再针对性调整限速,比盲目换UA更有效。
5.3 异常被中间件吞掉:靠WARNING兜底
还有一次诡异问题:Spider没有报错,但数据就是少了一部分。翻日志发现一条WARNING持续出现,内容是某个DownloaderMiddleware捕获异常后自动重试。中间件本身没有把异常抛出去,所以引擎认为任务正常。当时我把scrapy.middleware的级别调到DEBUG才看到完整traceback,原来是一个第三方库解析响应头时抛了非致命异常。
这个案例说明生产环境不要只盯ERROR。WARNING级别往往藏着中间件吞掉的异常,是判断链路是否真的健康的重要信号。我后来规定线上日志至少保留到WARNING级别,禁止任何人为了“日志干净”把级别调成ERROR。
5.4 把日志和请求关联起来
Scrapy官方日志没有内置request_id这类关联字段,并发请求一多,不同请求的日志交错在一起很难串起来。我的做法是在DownloaderMiddleware里给每个请求生成一个短id,放进request.meta,然后在响应日志里带上:
class RequestIdMiddleware: def process_request(self, request, spider): request.meta['request_id'] = request.url.split('/')[-1][:32] return None def process_response(self, request, response, spider): spider.logger.info( 'req=%s status=%s time=%.2fs', request.meta['request_id'], response.status, response.meta.get('download_latency', 0) ) return responsedownload_latency是Scrapy Downloader中间件默认写入的字段,单位是秒。通过req=字段,可以轻松把一次请求的调度记录、下载记录和解析记录串起来,排查分布式任务时尤其有用。
6. 日志配置里的坑和性能经验
6.1 生产环境默认是DEBUG,不改等于开闸放水
这是最普遍的问题:很多人从文档复制settings.py,没有显式设置LOG_LEVEL,实际默认是DEBUG。每抓一个页面就打印一行DEBUG,一天几十万请求,日志文件直接以GB为单位增长。我见过一个任务上线一周后磁盘被日志写满,进程直接崩溃。生产环境第一件事就是把LOG_LEVEL显式设为INFO或ERROR,这一点比任何花哨配置都重要。
6.2 日志落盘是否影响抓取性能
日志写入确实有成本,尤其是高并发任务。DEBUG级别时每个请求都输出,格式化和磁盘IO都会抢CPU。实测下来,同一个任务在INFO级别下比DEBUG级别下吞吐高出不少,数据量越大差距越明显。建议是:生产常态INFO,需要排障时再临时开DEBUG,排查完立刻恢复。
如果确实需要长期保留DEBUG日志,我会把日志写到一个独立磁盘或tmpfs上,避免和数据库、缓存抢IO。
6.3 Windows上的中文乱码和容器里的双重时间戳
Windows上经常遇到日志中文乱码,因为系统控制台默认编码可能是gbk,而Scrapy默认用utf-8写文件。解决方式是在settings里显式设置LOG_ENCODING = 'utf-8',然后用支持utf-8的终端打开,或者直接看日志文件而不是控制台。
容器场景的坑不一样。很多容器运行时会自动给stdout加时间戳,Scrapy自己的日志格式里也有时间戳,结果每行日志出现两个时间。看起来不碍事,但采集平台解析时经常错乱。处理办法是二选一:要么Scrapy日志只进文件不输出stdout,要么容器运行时关闭自动加时间戳的选项,不要两边重复。
6.4 警惕敏感信息写进日志
日志里最容易出现敏感信息的地方是请求头和Item数据。Cookie、Token、密码、手机号一旦写进日志,就可能随着日志文件被采集、归档、同步到各种平台,等于明文泄露。我在日志格式里刻意不打印请求头列表,Pipeline打Item时也只打url和id,绝不打敏感字段。这是生产环境必须守住的底线。
6.5 从日志到可观测性的下一步
如果把日志只当作排查工具,天花板很低。现在我会把每个任务的关键指标做成结构化日志,让采集平台直接聚合:每小时的请求数、成功响应数、解析成功数、平均耗时。这样日志本身不只是文本,还是一套运行指标数据源。再配合LogStats扩展的周期输出,基本能做到任务异常时不用登录服务器就能定位方向和影响范围。
最后的个人习惯
我现在部署任何Scrapy任务,第一步永远是tail -f看三到五分钟日志,确认“Spider opened”、第一条请求、第一条响应、第一条Item都能正常出现,然后才去配监控告警。日志是爬虫系统里唯一不会说谎的组件,它不关心你写了多少中间件,不关心你用的是不是最先进的调度策略,它只真实记录任务每一步发生了什么。上线前把日志方案想清楚,远比事后对着空数据猜原因要省时间。如果你现在还在用print维护爬虫,这篇文章里的任何一段落到你项目里,都能立刻改善排查效率,不妨从一个任务开始试。