㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [一、项目背景与目标 🎯](#一、项目背景与目标 🎯)
-
- [为什么要容器化? 🤔](#为什么要容器化? 🤔)
- [本次实战目标 📋](#本次实战目标 📋)
- [二、技术栈选型 🛠️](#二、技术栈选型 🛠️)
-
- 核心组件
- 架构设计图(文字版)
- [为什么用 Supervisor 而不是直接运行?](#为什么用 Supervisor 而不是直接运行?)
- [三、环境准备 💻](#三、环境准备 💻)
-
- [安装 Docker 和 Docker Compose](#安装 Docker 和 Docker Compose)
- 项目结构
- [四、核心代码实现 🧑💻](#四、核心代码实现 🧑💻)
-
- [4.1 Dockerfile(docker/Dockerfile)](#4.1 Dockerfile(docker/Dockerfile))
- [4.2 容器启动脚本(docker/entrypoint.sh)](#4.2 容器启动脚本(docker/entrypoint.sh))
- [4.3 Supervisor 配置(config/supervisord.conf)](#4.3 Supervisor 配置(config/supervisord.conf))
- [4.4 Cron 任务配置(config/crontab)](#4.4 Cron 任务配置(config/crontab))
- [4.5 任务执行脚本(scripts/run_task.sh)](#4.5 任务执行脚本(scripts/run_task.sh))
- [4.6 日志轮转配置(config/logrotate.conf)](#4.6 日志轮转配置(config/logrotate.conf))
- [4.7 失败重试处理器(src/retry/handler.py)](#4.7 失败重试处理器(src/retry/handler.py))
- [4.8 告警通知器(src/notification/notifier.py)](#4.8 告警通知器(src/notification/notifier.py))
- [4.9 Docker Compose 编排(docker-compose.yml)](#4.9 Docker Compose 编排(docker-compose.yml))
- [4.10 环境变量配置(.env)](#4.10 环境变量配置(.env))
- [五、部署与运行 🚀](#五、部署与运行 🚀)
-
- [5.1 本地开发环境](#5.1 本地开发环境)
- [5.2 生产环境部署](#5.2 生产环境部署)
- [六、监控与告警 📊](#六、监控与告警 📊)
-
- [6.1 Prometheus 配置(config/prometheus.yml)](#6.1 Prometheus 配置(config/prometheus.yml))
- [6.2 指标导出器(src/monitor/metrics.py)](#6.2 指标导出器(src/monitor/metrics.py))
- [七、故障排查与调试 🔍](#七、故障排查与调试 🔍)
-
- [7.1 常见问题诊断](#7.1 常见问题诊断)
- [7.2 健康检查脚本(scripts/health_check.sh)](#7.2 健康检查脚本(scripts/health_check.sh))
- [八、备份与恢复 💾](#八、备份与恢复 💾)
-
- [8.1 自动备份脚本(scripts/backup.sh)](#8.1 自动备份脚本(scripts/backup.sh))
- [8.2 恢复脚本(scripts/restore.sh)](#8.2 恢复脚本(scripts/restore.sh))
- [九、性能优化 ⚡](#九、性能优化 ⚡)
-
- [9.1 镜像优化](#9.1 镜像优化)
- [9.2 构建缓存优化](#9.2 构建缓存优化)
- [9.3 并发控制](#9.3 并发控制)
- [十、安全加固 🔒](#十、安全加固 🔒)
-
- [10.1 镜像安全扫描](#10.1 镜像安全扫描)
- [10.2 密钥管理](#10.2 密钥管理)
- [10.3 网络隔离](#10.3 网络隔离)
- [十一、总结与最佳实践 ✅](#十一、总结与最佳实践 ✅)
- [十二、扩展阅读 📚](#十二、扩展阅读 📚)
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
一、项目背景与目标 🎯
最近在做一个数据采集项目,需求很明确:每天凌晨 2 点自动抓取数据,失败了自动重试,日志不能占满硬盘,还要能随时查看运行状态。
一开始我直接在服务器上跑 Python 脚本 + crontab,结果遇到一堆问题:
- 环境依赖乱:开发环境能跑,服务器上各种报错(Python 版本不对、缺库)
- 日志爆炸:一个月日志文件 50GB,把硬盘撑爆了
- 失败不知道:脚本挂了没人知道,等发现时数据已经断了好几天
- 难以迁移:换台服务器要重新配置环境,费时费力
后来改用 Docker 容器化 + 完善的监控体系 ,体验好得多:打包一次到处运行,日志自动清理,失败自动告警。
为什么要容器化? 🤔
传统部署的痛点:
- 环境不一致:"在我机器上能跑啊" → 服务器上各种奇怪错误
- 依赖管理难:系统库、Python 包、配置文件散落各处
- 资源隔离差:多个项目共用环境,互相干扰
- 回滚困难:更新出问题,回到旧版本要手工操作
容器化的优势:
- 环境一致性:开发、测试、生产环境完全相同
- 一键部署 :
docker-compose up -d搞定 - 资源隔离:每个容器独立,互不影响
- 版本管理:镜像打标签,随时回滚
- 易于扩展:需要更多实例?启动更多容器即可
本次实战目标 📋
我会带你实现一个生产级的容器化定时任务系统,具体包括:
- Dockerfile 编写:多阶段构建、镜像优化、安全加固
- docker-compose 编排:多服务协同、网络配置、卷挂载
- 定时任务设计:cron 配置、时区处理、任务依赖
- 日志管理:日志轮转、压缩归档、远程传输
- 失败重试:指数退避、最大重试次数、状态持久化
- 健康检查:容器存活探测、服务可用性监控
- 告警通知:钉钉/邮件/企微通知
- 监控面板:Grafana + Prometheus 实时监控
- 备份恢复:数据备份策略、灾难恢复
最终效果:一个健壮的、可观测的、易维护的生产级定时任务系统。
二、技术栈选型 🛠️
核心组件
| 组件 | 版本 | 作用 | 为什么选它 |
|---|---|---|---|
| Docker | 24.0+ | 容器引擎 | 业界标准,生态成熟 |
| Docker Compose | 2.20+ | 多容器编排 | 本地开发友好,配置简洁 |
| Alpine Linux | 3.18+ | 基础镜像 | 体积小(5MB),安全性高 |
| Python | 3.11+ | 运行环境 | 官方镜像,稳定可靠 |
| Cron | - | 定时调度 | Linux 原生,无额外依赖 |
| Logrotate | - | 日志轮转 | 自动压缩清理,节省空间 |
| Supervisor | 4.2+ | 进程管理 | 管理多个后台进程(cron + 应用) |
| Redis | 7.0+ | 状态存储 | 记录任务执行状态、重试次数 |
| Prometheus | 2.45+ | 指标监控 | 时序数据库,强大的查询语言 |
| Grafana | 10.0+ | 可视化 | 美观的监控面板 |
架构设计图(文字版)
json
┌──────────────────────────────────────┐
│ Docker Host(宿主机) │
│ │
│ ┌────────────────────────────────┐ │
│ │ 爬虫容器 (crawler) │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Supervisor │ │ │
│ │ │ ├─ Cron (定时触发) │ │ │
│ │ │ └─ Python 爬虫程序 │ │ │
│ │ └──────────────────────────┘ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Logrotate (日志轮转) │ │ │
│ │ └──────────────────────────┘ │ │
│ └────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────┐ │
│ │ Redis 容器 (状态存储) │ │
│ │ - 重试计数 │ │
│ │ - 任务状态 │ │
│ └────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────┐ │
│ │ 监控容器 │ │
│ │ ├─ Prometheus (指标采集) │ │
│ │ └─ Grafana (可视化) │ │
│ └────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ 卷挂载 (Volumes) │ │
│ │ ├─ /data (数据持久化) │ │
│ │ ├─ /logs (日志存储) │ │
│ │ └─ /config (配置文件) │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
为什么用 Supervisor 而不是直接运行?
问题:Docker 容器的 PID 1 进程退出,整个容器就停止了
传统方案:
bash
CMD ["python", "main.py"]
缺点:
- 只能运行一个进程
- 无法同时运行 cron + 应用
- 进程崩溃容器就退出
Supervisor 方案:
bash
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
优点:
- 可以管理多个进程(cron、应用、日志轮转)
- 进程崩溃自动重启
- 提供 Web 管理界面
三、环境准备 💻
安装 Docker 和 Docker Compose
Ubuntu/Debian:
bash
# 卸载旧版本
sudo apt-get remove docker docker-engine docker.io containerd runc
# 安装依赖
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg lsb-release
# 添加 Docker 官方 GPG 密钥
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# 添加仓库
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 安装 Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 验证安装
docker --version
docker compose version
CentOS/RHEL:
bash
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl start docker
sudo systemctl enable docker
macOS/Windows:
下载 Docker Desktop:https://www.docker.com/products/docker-desktop
项目结构
json
docker_cron_project/
├── docker/
│ ├── Dockerfile # 爬虫镜像
│ ├── Dockerfile.monitor # 监控镜像
│ └── entrypoint.sh # 容器启动脚本
├── config/
│ ├── supervisord.conf # Supervisor 配置
│ ├── crontab
│ ├── logrotate.conf # 日志轮转配置
│ └── prometheus.yml # Prometheus 配置
├── src/
│ ├── __init__.py
│ ├── crawler/
│ │ └── main.py # 爬虫主程序
│ ├── retry/
│ │ └── handler.py # 重试处理
│ ├── notification/
│ │ └── notifier.py # 告警通知
│ └── monitor/
│ └── metrics.py # 指标采集
├── scripts/
│ ├── run_task.sh # 任务执行脚本
│ └── health_check.sh # 健康检查脚本
├── logs/ # 日志目录
├── data/ # 数据目录
├── docker-compose.yml # 编排配置
├── .env # 环境变量
└── README.md
四、核心代码实现 🧑💻
4.1 Dockerfile(docker/Dockerfile)
多阶段构建,优化镜像大小:
dockerfile
# ============================================================
# 第一阶段:构建阶段
# ============================================================
FROM python:3.11-slim AS builder
# 设置工作目录
WORKDIR /build
# 安装构建依赖
RUN apt-get update && apt-get install -y \
gcc \
g++ \
make \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖到虚拟环境
# 使用虚拟环境可以精确控制依赖,减少镜像大小
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 安装依赖(使用国内镜像加速)
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ \
-r requirements.txt
# ============================================================
# 第二阶段:运行阶段
# ============================================================
FROM python:3.11-slim
# 元数据标签
LABEL maintainer="your-email@example.com" \
version="1.0" \
description="Containerized crawler with cron and retry"
# 设置环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TZ=Asia/Shanghai \
LANG=C.UTF-8
# 创建非 root 用户(安全最佳实践)
# Docker 容器默认以 root 运行,存在安全风险
RUN groupadd -r crawler && useradd -r -g crawler crawler
# 安装运行时依赖
RUN apt-get update && apt-get install -y \
cron \
supervisor \
logrotate \
tzdata \
curl \
&& rm -rf /var/lib/apt/lists/*
# 设置时区
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 复制虚拟环境(从构建阶段)
COPY --from=builder /opt/venv /opt/venv
# 设置 PATH
ENV PATH="/opt/venv/bin:$PATH"
# 创建必要的目录
RUN mkdir -p /app /data /logs /config \
&& chown -R crawler:crawler /app /data /logs
# 设置工作目录
WORKDIR /app
# 复制应用代码
COPY --chown=crawler:crawler src/ ./src/
COPY --chown=crawler:crawler scripts/ ./scripts/
# 复制配置文件
COPY config/supervisord.conf /etc/supervisor/supervisord.conf
COPY config/crontab /etc/cron.d/crawler-cron
COPY config/logrotate.conf /etc/logrotate.d/crawler
# 设置 cron 权限(必须是 644,否则不执行)
RUN chmod 0644 /etc/cron.d/crawler-cron \
&& crontab /etc/cron.d/crawler-cron
# 复制启动脚本
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& chmod +x /app/scripts/*.sh
# 暴露端口(Supervisor Web UI)
EXPOSE 9001
# 健康检查
# 每 秒检查一次,连续失败 3 次则标记为 unhealthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD /app/scripts/health_check.sh || exit 1
# 切换到非 root 用户
USER crawler
# 容器启动命令
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
代码详解:
-
多阶段构建:
- 构建阶段:安装编译工具、构建依赖
- 运行阶段:只复制必要的运行时文件
- 优势:最终镜像不包含编译工具,减小 200-500MB
-
虚拟环境:
- 即使在容器里也用虚拟环境
- 好处:依赖隔离、方便调试
- 复制到运行阶段时只需复制
/opt/venv目录
-
非 root 用户:
- 创建专用用户
crawler - 以普通用户身份运行,增强安全性
- 防止容器逃逸攻击
- 创建专用用户
-
时区设置:
TZ=Asia/Shanghai设置上海时区- 否则容器默认 UTC,定时任务时间会错
-
HEALTHCHECK 指令:
- Docker 原生的健康检查
--interval=30s:每 30 秒检查一次--retries=3:连续失败 3 次才标记为不健康
4.2 容器启动脚本(docker/entrypoint.sh)
处理容器启动时的初始化逻辑:
bash
#!/bin/bash
set -e
# ============================================================
# 容器启动脚本
#
# 职责:
# 1. 初始化环境
# 2. 创建必要的目录
# 3. 设置权限
# 4. 启动 cron 服务
# 5. 执行主进程(Supervisor)
# ============================================================
echo "========================================="
echo "容器启动中..."
echo "========================================="
# ========== 环境变量检查 ==========
required_vars=(
"REDIS_URL"
"API_KEY"
)
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "错误: 环境变量 $var 未设置"
exit 1
fi
done
echo "✓ 环境变量检查通过"
# ========== 创建目录 ==========
mkdir -p /logs/crawler /logs/cron /logs/supervisor /data/cache /data/output
echo "✓ 目录创建完成"
# ========== 初始化 Redis 连接测试 ==========
echo "正在测试 Redis 连接..."
python3 <<EOF
import sys
import redis
import os
try:
r = redis.from_url(os.getenv('REDIS_URL'), socket_connect_timeout=5)
r.ping()
print("✓ Redis 连接成功")
except Exception as e:
print(f"✗ Redis 连接失败: {e}", file=sys.stderr)
sys.exit(1)
EOF
# ========== 启动 Cron 服务 ==========
echo "启动 Cron 服务..."
# Cron 在后台运行,需要手动启动
service cron start
if [ $? -eq 0 ]; then
echo "✓ Cron 服务启动成功"
else
echo "✗ Cron 服务启动失败"
exit 1
fi
# ========== 打印 Cron 任务列表 ==========
echo "当前 Cron 任务:"
crontab -l
#入的命令 ==========
echo "========================================="
echo "执行主进程: $@"
echo "========================================="
exec "$@"
代码详解:
-
set -e:- 任何命令返回非零退出码,脚本立即终止
- 防止错误继续传播
-
环境变量检查:
- 容器启动前验证必需的环境变量
- 避免运行时才发现配置缺失
-
Redis 连接测试:
- 使用 Python 内联脚本测试连接
<<EOF ... EOF是 Bash 的 Here Document 语法- 启动时就发现问题,而不是等到任务执行时
-
exec "$@":exec替换当前进程,而不是创建子进程- 保证 PID 1 是 supervisord,信号处理正确
4.3 Supervisor 配置(config/supervisord.conf)
管理容器内的多个进程:
ini
; ============================================================
; Supervisor 配置文件
;
; 管理的进程:
; 1. Cron 守护进程
; 2. Python 爬虫程序(可选,如需常驻)
; 3. 指标导出器(Prometheus Exporter)
; ============================================================
[supervisord]
nodaemon=true ; 前台运行(Docker 要求)
user=crawler ; 以 crawler 用户运行
logfile=/logs/supervisor/supervisord.log
logfile_maxbytes=50MB
logfile_backups=10
loglevel=info
pidfile=/tmp/supervisord.pid
; ========== Cron 进程 ==========
[program:cron]
command=/usr/sbin/cron -f ; -f 表示前台运行
autostart=true ; 自动启动
autorestart=true ; 崩溃自动重启
startretries=3 ; 启动失败重试 3 次
stdout_logfile=/logs/cron/stdout.log
stderr_logfile=/logs/cron/stderr.log
stdout_logfile_maxbytes=10MB
stderr_logfile_maxbytes=10MB
; ========== 指标导出器(可选)==========
; 暴露 Prometheus 指标,供监控系统抓取
[program:metrics_exporter]
command=python /app/src/monitor/metrics.py
autostart=true
autorestart=true
startretries=3
stdout_logfile=/logs/metrics/stdout.log
stderr_logfile=/logs/metrics/stderr.log
; ========== 常驻爬虫(可选)==========
; 如果爬虫需要持续运行(如监听消息队列)
; [program:crawler_daemon]
; command=python /app/src/crawler/daemon.py
; autostart=true
; autorestart=true
; startsecs=10 ; 启动 10 秒后才认为启动成功
; stopwaitsecs=30 ; 停止信号后等待 30 秒
; stdout_logfile=/logs/crawler/daemon_stdout.log
; stderr_logfile=/logs/crawler/daemon_stderr.log
; ========== Web UI 配置 ==========
[inet_http_server]
port=*:9001 ; 监听所有接口的 9001 端口
username=admin ; 用户名
password=changeme ; 密码(生产环境请修改!)
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
代码详解:
-
nodaemon=true:- Supervisor 默认后台运行
- Docker 要求 PID 1 进程前台运行,否则容器立即退程崩溃自动重启
- 相当于内置的进程守护功能
-
startsecs=10:- 进程启动后持续运行 10 秒,才认为启动成功
- 防止进程启动后立即崩溃被误判为成功
-
Web UI:
- 访问
http://localhost:9001查看进程状态 - 可以手动启动/停止/重启进程
- 访问
4.4 Cron 任务配置(config/crontab)
定义定时任务的执行时间和命令:
bash
# ============================================================
# Cron 任务配置
#
# 格式说明:
# 分 时 日 月 周 命令
# * * * * * command
# ┬ ┬ ┬ ┬ ┬
# │ │ │ │ └─ 星期几 (0-7, 0 和 7 都表示周日)
# │ │ │ └──── 月份 (1-12)
# │ │ └─────── 日期 (1-31)
# │ └────────── 小时 (0-23)
# └───────────── 分钟 (0-59)
#
# 特殊符号:
# * : 任意值
# , : 多个值(如 1,3,5)
# - : 范围(如 1-5)
# / : 间隔(如 */5 表示每 5 分钟)
# ============================================================
# 环境变量(Cron 默认的 PATH 很有限,需要手动设置)
PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
# 每天凌晨 2 点执行主任务
0 2 * * * /app/scripts/run_task.sh >> /logs/cron/task.log 2>&1
# 每小时执行一次增量更新
0 * * * * /app/scripts/run_task.sh --incremental >> /logs/cron/task_hourly.log 2>&1
# 每天凌晨 3 点清理缓存
0 3 * * * find /data/cache -type f -mtime +7 -delete >> /logs/cron/cleanup.log 2>&1
# 每周日凌晨 4 点执行完整备份
0 4 * * 0 /app/scripts/backup.sh >> /logs/cron/backup.log 2>&1
# 每 5 分钟检查健康状态
*/5 * * * * /app/scripts/health_check.sh >> /logs/cron/health.log 2>&1
# 空行结尾(Cron 要求)
代码详解:
-
环境变量:
- Cron 执行时的
PATH非常简单(通常只有/usr/bin:/bin) - 必须显式设置,否则找不到 Python、自定义脚本等
- Cron 执行时的
-
日志重定向:
>> /logs/cron/task.log:标准输出追加到文件2>&1:标准错误也重定向到标准输出- 否则输出会丢失(Cron 默认发邮件,容器里没邮件服务)
-
时间表达式示例:
0 2 * * *:每天 2:00*/5 * * * *:每 5 分钟0 9-17 * * 1-5:工作日的 9:00-17:000 0 1 * *:每月 1 号的 0:00
-
空行结尾:
- Cron 文件必须以空行结尾
- 否则最后一行任务可能不执行
4.5 任务执行脚本(scripts/run_task.sh)
实际执行爬虫任务的脚本,包含重试逻辑:
bash
#!/bin/bash
# ============================================================
# 任务执行脚本
#
# 功能:
# 1. 执行 Python 爬虫
# 2. 失败自动重试(指数退避)
# 3. 记录执行状态到 Redis
# 4. 失败时发送告警
# ============================================================
set -euo pipefail
# ========== 配置 ==========
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="/logs/crawler"
REDIS_URL="${REDIS_URL:-redis://redis:6379/0}"
# 重试配置
MAX_RETRIES=3
INITIAL_BACKOFF=5 # 初始等待时间(秒)
MAX_BACKOFF=300 # 最大等待时间(秒)
# 任务标识
TASK_NAME="${1:-daily_crawl}"
TASK_ID="task_$(date +%Y%m%d_%H%M%S)"
# ========== 日志函数 ==========
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_DIR/$TASK_NAME.log"
}
log_error() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_DIR/$TASK_NAME.log" >&2
}
# ========== 发送告警 ==========
send_alert() {
local message="$1"
python3 <<EOF
from src.notification.notifier import Notifier
notifier = Notifier()
notifier.send_alert("任务失败", "$message")
EOF
}
# ========== 执行任务 ==========
run_task() {
log "开始执行任务: $TASK_NAME (ID: $TASK_ID)"
cd "$APP_DIR"
# 执行 Python 爬虫
python3 src/crawler/main.py \
--task-name "$TASK_NAME" \
--task-id "$TASK_ID" \
--log-file "$LOG_DIR/$TASK_NAME.log"
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log "任务执行成功"
# 记录成功状态到 Redis
redis-cli -u "$REDIS_URL" SET "task:$TASK_ID:status" "success" EX 86400
return 0
else
log_error "任务执行失败,退出码: $exit_code"
return $exit_code
fi
}
# ========== 重试逻辑 ==========
retry_with_backoff() {
local attempt=1
local backoff=$INITIAL_BACKOFF
while [ $attempt -le $MAX_RETRIES ]; do
log "尝试 $attempt/$MAX_RETRIES..."
if run_task; then
log "任务成功完成"
return 0
fi
if [ $attempt -lt $MAX_RETRIES ]; then
log "等待 $backoff 秒后重试..."
sleep $backoff
# 指数退避:每次等待时间翻倍
backoff=$((backoff * 2))
# 不超过最大等待时间
if [ $backoff -gt $MAX_BACKOFF ]; then
backoff=$MAX_BACKOFF
fi
fi
attempt=$((attempt + 1))
done
# 所有重试都失败
log_error "任务失败,已重试 $MAX_RETRIES 次"
# 记录失败状态
redis-cli -u "$REDIS_URL" SET "task:$TASK_ID:status" "failed" EX 86400
redis-cli -u "$REDIS_URL" INC行失败(ID: $TASK_ID),已重试 $MAX_RETRIES 次"
return 1
}
# ========== 主流程 ==========
main() {
log "========================================"
log "任务启动: $TASK_NAME"
log "========================================"
# 检查是否有同名任务正在运行
if redis-cli -u "$REDIS_URL" GET "task:$TASK_NAME:running" | grep -q "1"; then
log_error "任务已在运行中,跳过本次执行"
exit 1
fi
# 标记任务为运行中
redis-cli -u "$REDIS_URL" SET "task:$TASK_NAME:running" "1" EX 7200
# 执行任务(带重试)
if retry_with_backoff; then
exit_code=0
else
exit_code=1
fi
# 清除运行标记
redis-cli -u "$REDIS_URL" DEL "task:$TASK_NAME:running"
log "========================================"
log "任务结束,退出码: $exit_code"
log "========================================"
exit $exit_code
}
# 执行主流程
main
代码详解:
-
set -euo pipefail:-e:任何命令失败立即退出-u:使用未定义变量时报错-o pipefail:管道中任一命令失败,整个管道失败
-
指数退避算法:
- 第 1 次失败:等待 5 秒
- 第 2 次失败:等待 10 秒(5 × 2)
- 第 3 次失败:等待 20 秒(10 × 2)
- 避免频繁重试加重服务器负担
-
并发控制:
- 用 Redis 的
task:$TASK_NAME:running标记任务状态 - 如果已有同名任务在跑,跳过本次执行
- 防止任务堆积
- 用 Redis 的
-
日志记录:
tee -a同时输出到终端和文件-a表示追加模式
4.6 日志轮转配置(config/logrotate.conf)
自动清理和压缩旧日志:
bash
# ============================================================
# Logrotate 配置文件
#
# 功能:
# 1. 按大小或时间轮转日志
# 2. 压缩旧日志节省空间
# 3. 删除过期日志
# 4. 支持通配符批量处理
# ============================================================
# ========== 爬虫日志 ==========
/logs/crawler/*.log {
daily # 每天轮转一次
rotate 7 # 保留 7 份归档(即保留 7 天)
compress # 压缩旧日志(gzip)
delaycompress # 延迟压缩(最新的归档不压缩,方便查看)
missingok # 日志文件不存在不报错
notifempty # 日志为空不轮转
create 0640 crawler crawler # 创建新日志文件的权限和所有者
sharedscripts # 多个日志文件共用一个脚本
# 轮转后执行的脚本
postrotate
# 发送日志已轮转的通知(可选)
# echo "Crawler logs rotated at $(date)" | mail -s "Log Rotation" admin@example.com
endscript
}
# ========== Cron 日志 ==========
/logs/cron/*.log {
size 100M # 按大小轮转(超过 100MB)
rotate 5 # 保留 5 份
compress
delaycompress
missingok
notifempty
create 0640 crawler crawler
}
# ========== Supervisor 日志 ==========
/logs/supervisor/*.log {
weekly # 每周轮转一次
rotate 4 # 保留 4 周
compress
delaycompress
missingok
notifempty
create 0640 crawler crawler
}
# ========== 指标日志 ==========
/logs/metrics/*.log {
daily
rotate 3 # 只保留 3 天(指标数据已存入 Prometheus)
compress
delaycompress
missingok
notifempty
create 0640 crawler crawler
}
执行 logrotate 的方式:
bash
# 手动执行(测试配置是否正确)
logrotate -d /etc/logrotate.d/crawler # -d 表示 debug,只打印不实际执行
logrotate -f /etc/logrotate.d/crawler # -f 表示强制执行
# 添加到 cron(每小时检查一次)
# 在 crontab 文件中添加:
0 * * * * /usr/sbin/logrotate /etc/logrotate.d/crawler
代码详解:
-
dailyvssize 100M:daily:每天轮转,适合日志量稳定的场景size 100M:按大小轮转,适合日志量不可预测的场景- 可以同时使用:
daily size 100M(满足任一条件即轮转)
-
rotate 7:- 保留 7 份归档文件
- 文件命名:
app.log.1、app.log.2.gz... - 超过 7 份的会被删除
-
delaycompress:- 最新的归档文件不压缩
- 好处:可以直接用
tail -f app.log.1查看 - 第二次轮转时才压缩成
app.log.2.gz
-
sharedscripts:- 多个日志文件只执行一次
postrotate脚本 - 避免重复通知
- 多个日志文件只执行一次
4.7 失败重试处理器(src/retry/handler.py)
Python 级别的重试逻辑,比 Shell 脚本更灵活:
python
import time
import logging
import redis
from typing import Callable, Any, Optional
from functools import wraps
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class RetryHandler:
"""
重试处理器
特性:
1. 指数退避策略
2. 状态持久化到 Redis
3. 最大重试次数限制
4. 支持装饰器模式
5. 失败回调(如发送告警)
"""
def __init__(self,
redis_url: str,
max_retries: int = 3,
initial_backoff: int = 5,
max_backoff: int = 300,
backoff_factor: float = 2.0):
"""
初始化重试处理器
Args:
redis_url: Redis 连接 URL
max_retries: 最大重试次数
initial_backoff: 初始退避时间(秒)
max_backoff: 最大退避时间(秒)
backoff_factor: 退避因子(每次乘以此值)
"""
self.redis = redis.from_url(redis_url)
self.max_retries = max_retries
self.initial_backoff = initial_backoff
self.max_backoff = max_backoff
self.backoff_factor = backoff_factor
logger.info(f"重试处理器初始化: max_retries={max_retries}, backoff_factor={backoff_factor}")
def retry(self,
task_name: str,
on_failure: Optional[Callable] = None):
"""
重试装饰器
用法:
@retry_handler.retry(task_name='crawl_data')
def my_task():
# 任务逻辑
pass
Args:
task_name: 任务名称(用于 Redis 键)
on_failure: 失败时的回调函数
Returns:
装饰器函数
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
attempt = 0
backoff = self.initial_backoff
last_exception = None
while attempt < self.max_retries:
attempt += 1
try:
logger.info(f"[{task_name}] 尝试 {attempt}/{self.max_retries}")
# 记录尝试次数到 Redis
self._record_attempt(task_name, attempt)
# 执行任务
result = func(*args, **kwargs)
# 成功后清除重试记录
self._clear_retry_state(task_name)
logger.info(f"[{task_name}] 任务成功")
return result
except Exception as e:
last_exception = e
logger.error(f"[{task_name}] 尝试 {attempt} 失败: {e}")
# 记录失败到 Redis
self._record_failure(task_name, str(e))
# 如果还有重试机会,等待后重试
if attempt < self.max_retries:
logger.warning(f"[{task_name}] 等待 {backoff} 秒后重试...")
time.sleep(backoff)
# 计算下次退避时间(指数增长)
backoff = min(backoff * self.backoff_factor, self.max_backoff)
# 所有重试都失败
logger.error(f"[{task_name}] 所有重试失败,放弃任务")
# 记录最终失败状态
self._record_final_failure(task_name)
# 调用失败回调
if on_failure:
try:
on_failure(task_name, last_exception)
except Exception as callback_error:
logger.error(f"失败回调执行出错: {callback_error}")
# 抛出最后一次的异常
raise last_exception
return wrapper
return decorator
def _record_attempt(self, task_name: str, attempt: int):
"""
记录尝试次数
Args:
task_name: 任务名称
attempt: 尝试次数
"""
key = f"retry:{task_name}:attempts"
self.redis.set(key, attempt, ex=86400) # 24 小时过期
# 记录尝试时间
timestamp_key = f"retry:{task_name}:attempt_{attempt}_time"
self.redis.set(timestamp_key, datetime.now().isoformat(), ex=86400)
def _record_failure(self, task_name: str, error: str):
"""
记录失败信息
Args:
task_name: 任务名称
error: 错误信息
"""
key = f"retry:{task_name}:last_error"
self.redis.set(key, error, ex=86400)
# 增加失败计数
fail_count_key = f"retry:{task_name}:fail_count"
self.redis.incr(fail_count_key)
self.redis.expire(fail_count_key, 86400)
def _record_final_failure(self, task_name: str):
"""
记录最终失败状态
Args:
task_name: 任务名称
"""
key = f"retry:{task_name}:status"
self.redis.set(key, 'failed', ex=86400)
# 记录失败时间
fail_time_key = f"retry:{task_name}:fail_time"
self.redis.set(fail_time_key, datetime.now().isoformat(), ex=86400)
def _clear_retry_state(self, task_name: str):
"""
清除重试状态
Args:
task_name: 任务名称
"""
keys_to_delete = [
f"retry:{task_name}:attempts",
f"retry:{task_name}:last_error",
f"retry:{task_name}:status",
]
for key in keys_to_delete:
self.redis.delete(key)
# 记录成功时间
success_key = f"retry:{task_name}:success_time"
self.redis.set(success_key, datetime.now().isoformat(), ex=86400)
def get_retry_stats(self, task_name: str) -> dict:
"""
获取重试统计信息
Args:
task_name: 任务名称
Returns:
dict: 统计信息
"""
return {
'attempts': int(self.redis.get(f"retry:{task_name}:attempts") or 0),
'fail_count': int(self.redis.get(f"retry:{task_name}:fail_count") or 0),
'last_error': (self.redis.get(f"retry:{task_name}:last_error") or b'').decode('utf-8'),
'status': (self.redis.get(f"retry:{task_name}:status") or b'').decode('utf-8'),
}
# ========== 使用示例 ==========
if __name__ == '__main__':
from src.notification.notifier import Notifier
# 初始化
retry_handler = RetryHandler(
redis_url='redis://localhost:6379/0',
max_retries=3,
initial_backoff=5,
backoff_factor=2.0
)
notifier = Notifier()
# 定义失败回调
def on_task_failure(task_name: str, exception: Exception):
notifier.send_alert(
title=f"任务失败: {task_name}",
message=f"错误: {str(exception)}"
)
# 使用装饰器
@retry_handler.retry(task_name='test_task', on_failure=on_task_failure)
def risky_task():
import random
if random.random() < 0.7: # 70% 概率失败
raise Exception("模拟的随机失败")
print("任务成功!")
# 执行任务
try:
risky_task()
except Exception as e:
print(f"最终失败: {e}")
# 查看统计
stats = retry_handler.get_retry_stats('test_task')
print(f"统计: {stats}")
代码详解:
-
@wraps(func):- 保留原函数的元数据(名称、文档字符串等)
- 否则装饰后
func.__name__会变成wrapper
-
状态持久化:
- 重试次数、失败原因都存到 Redis
- 好处:重启容器后状态不丢失
- 可以在监控面板查看历史记录
-
失败回调:
- 允许自定义失败处理逻辑
- 如发送告警、记录日志、触发补偿逻辑
-
指数退避的优势:
- 线性退避(每次等待 5 秒):5, 5, 5, 5...
- 指数退避(因子 2):5, 10, 20, 40...
- 后者能更快"冷却",避免对下游服务造成持续压力
4.8 告警通知器(src/notification/notifier.py)
支持多种通知渠道:
python
import requests
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, List
import os
logger = logging.getLogger(__name__)
class Notifier:
"""
告警通知器
支持的渠道:
1. 钉钉机器人
2. 企业微信机器人
3. 邮件
4. Slack (可扩展)
"""
def __init__(self):
"""初始化通知器"""
# 从环境变量读取配置
self.dingtalk_webhook = os.getenv('DINGTALK_WEBHOOK')
self.wecom_webhook = os.getenv('WECOM_WEBHOOK')
# 邮件配置
self.smtp_server = os.getenv('SMTP_SERVER', 'smtp.gmail.com')
self.smtp_port = int(os.getenv('SMTP_PORT', '587'))
self.email_sender = os.getenv('EMAIL_SENDER')
self.email_password = os.getenv('EMAIL_PASSWORD')
self.email_receivers = os.getenv('EMAIL_RECEIVERS', '').split(',')
logger.info("告警通知器初始化完成")
def send_alert(self,
title: str,
message: str,
level: str = 'warning',
channels: Optional[List[str]] = None):
"""
发送告警
Args:
title: 标题
message: 消息内容
level: 级别 (info/warning/error)
channels: 通知渠道列表 (默认全部)
"""
if channels is None:
channels = ['dingtalk', 'wecom', 'email']
logger.info(f"发送告警: {title} - {message}")
# 钉钉
if 'dingtalk' in channels and self.dingtalk_webhook:
self._send_dingtalk(title, message, level)
# 企业微信
if 'wecom' in channels and self.wecom_webhook:
self._send_wecom(title, message, level)
# 邮件
if 'email' in channels and self.email_sender:
self._send_email(title, message, level)
def _send_dingtalk(self, title: str, message: str, level: str):
"""
发送钉钉通知
文档: https://open.dingtalk.com/document/robots/custom-robot-access
"""
# 根据级别选择颜色
colors = {
'info': '#0084FF',
'warning': '#FFA500',
'error': '#FF0000'
}
payload = {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": f"### {title}\n\n"
f"**级别**: {level.upper()}\n\n"
f"**消息**: {message}\n\n"
f"**时间**: {self._get_timestamp()}"
}
}
try:
response = requests.post(
self.dingtalk_webhook,
json=payload,
timeout=10
)
response.raise_for_status()
result = response.json()
if result.get('errcode') == 0:
logger.info("钉钉通知发送成功")
else:
logger.error(f"钉钉通知发送失败: {result.get('errmsg')}")
except Exception as e:
logger.error(f"钉钉通知发送异常: {e}")
def _send_wecom(self, title: str, message: str, level: str):
"""
发送企业微信通知
文档: https://developer.work.weixin.qq.com/document/path/91770
"""
payload = {
"msgtype": "markdown",
"markdown": {
"content": f"**{title}**\n"
f">级别: <font color=\"warning\">{level.upper()}</font>\n"
f">消息: {message}\n"
f">时间: {self._get_timestamp()}"
}
}
try:
response = requests.post(
self.wecom_webhook,
json=payload,
timeout=10
)
response.raise_for_status()
result = response.json()
if result.get('errcode') == 0:
logger.info("企业微信通知发送成功")
else:
logger.error(f"企业微信通知发送失败: {result.get('errmsg')}")
except Exception as e:
logger.error(f"企业微信通知发送异常: {e}")
def _send_email(self, title: str, message: str, level: str):
"""
发送邮件通知
"""
# 构造邮件
msg = MIMEMultipart('alternative')
msg['Subject'] = f"[{level.upper()}] {title}"
msg['From'] = self.email_sender
msg['To'] = ', '.join(self.email_receivers)
# HTML 内容
html = f"""
<html>
<body>
<h2 style="color: {'red' if level == 'error' else 'orange'};">{title}</h2>
<p><strong>级别:</strong> {level.upper()}</p>
<p><strong>消息:</strong></p>
<pre>{message}</pre>
<p><strong>时间:</strong> {self._get_timestamp()}</p>
</body>
</html>
"""
msg.attach(MIMEText(html, 'html'))
try:
# 连接 SMTP 服务器
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls() # 启用 TLS
server.login(self.email_sender, self.email_password)
server.send_message(msg)
logger.info(f"邮件通知发送成功: {', '.join(self.email_receivers)}")
except Exception as e:
logger.error(f"邮件通知发送异常: {e}")
@staticmethod
def _get_timestamp() -> str:
"""获取当前时间戳"""
from datetime import datetime
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# ========== 使用示例 ==========
if __name__ == '__main__':
notifier = Notifier()
notifier.send_alert(
title='任务执行失败',
message='数据库连接超时,已重试 3 次仍失败',
level='error',
channels=['dingtalk', 'email']
)
代码详解:
-
Markdown 格式:
- 钉钉和企业微信都支持 Markdown
- 可以使用加粗、颜色、换行等格式
- 比纯文本更美观
-
邮件 HTML:
- 使用 HTML 格式的邮件正文
- 可以添加样式、颜色
- 比纯文本更专业
-
SMTP TLS:
server.starttls()启用加密传输- 保护邮箱密码不被窃听
-
可扩展设计:
- 添加新渠道只需新增一个方法
- 如 Slack:
_send_slack()
4.9 Docker Compose 编排(docker-compose.yml)
定义多容器应用:
yaml
version: '3.8'
# ============================================================
# Docker Compose 配置
#
# 服务:
# 1. crawler: 爬虫容器
# 2. redis: 状态存储
# 3. prometheus: 指标采集
# 4. grafana: 监控面板
# ============================================================
services:
# ========== 爬虫服务 ==========
crawler:
build:
context: .
dockerfile: docker/Dockerfile
container_name: crawler
restart: unless-stopped # 容器退出自动重启(除非手动停止)
environment:
- TZ=Asia/Shanghai
- REDIS_URL=redis://redis:6379/0
- API_KEY=${API_KEY}
- DINGTALK_WEBHOOK=${DINGTALK_WEBHOOK}
- WECOM_WEBHOOK=${WECOM_WEBHOOK}
- EMAIL_SENDER=${EMAIL_SENDER}
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
- EMAIL_RECEIVERS=${EMAIL_RECEIVERS}
volumes:
# 数据持久化
- ./data:/data
# 日志持久化
- ./logs:/logs
# 配置文件(只读)
- ./config:/config:ro
# 代码热更新(开发环境)
# - ./src:/app/src:ro
depends_on:
- redis
networks:
- app-network
# 健康检查
healthcheck:
test: ["CMD", "/app/scripts/health_check.sh"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# 资源限制
deploy:
resources:
limits:
cpus: '2.0' # 最多使用 2 核
memory: 2G # 最多使用 2GB 内存
reservations:
cpus: '0.5' # 至少保证 0.5 核
memory: 512M # 至少保证 512MB 内存
# ========== Redis 服务 ==========
redis:
image: redis:7.0-alpine
container_name: redis
restart: unless-stopped
command: redis-server --appendonly yes # 启用 AOF 持久化
volumes:
- redis-data:/data
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
# ========== Prometheus 服务 ==========
prometheus:
image: prom/prometheus:v2.45.0
container_name: prometheus
restart: unless-stopped
volumes:
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d' # 保留 30 天数据
ports:
- "9090:9090"
networks:
- app-network
depends_on:
- crawler
# ========== Grafana 服务 ==========
grafana:
image: grafana/grafana:10.0.0
container_name: grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=changeme # 生产环境请修改!
- GF_INSTALL_PLUGINS=redis-datasource
volumes:
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- app-network
depends_on:
- prometheus
# ========== 网络配置 ==========
networks:
app-network:
driver: bridge
# ========== 卷配置 ==========
volumes:
redis-data:
driver: local
prometheus-data:
driver: local
grafana-data:
driver: local
代码详解:
-
restart: unless-stopped:- 容器退出自动重启
- 除非用
docker stop手动停止 - 比
always更灵活(重启宿主机后不会自动启动)
-
depends_on:- 定义启动顺序
crawler依赖redis,所以先启动redis- 注意:只保证启动顺序,不保证服务就绪
-
卷挂载模式:
./config:/config:ro:只读挂载./data:/data:读写挂载- 好处:防止容器误修改配置文件
-
资源限制:
limits:硬限制,超过会被 Killreservations:软限制,保证最低资源- 防止单个容器占满资源
-
命名卷 vs 绑定挂载:
- 命名卷(
redis-data):由 Docker 管理,存在/var/lib/docker/volumes/ - 绑定挂载(
./data):直接映射宿主机目录 - 命名卷更安全,绑定挂载更方便查看
- 命名卷(
4.10 环境变量配置(.env)
敏感信息不应硬编码,使用环境变量:
bash
# ============================================================
# 环境变量配置
#
# 注意事项:
# 1. 不要提交到 Git(添加到 .gitignore)
# 2. 生产环境使用密钥管理服务(如 AWS Secrets Manager)
# 3. 本地开发可以用 .env.example 作为模板
# ============================================================
# ========== API 密钥 ==========
API_KEY=your_api_key_here
# ========== Redis 配置 ==========
REDIS_URL=redis://redis:6379/0
# ========== 钉钉机器人 ==========
DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN
# ========== 企业微信机器人 ==========
WECOM_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
# ========== 邮件配置 ==========
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
EMAIL_SENDER=your-email@gmail.com
EMAIL_PASSWORD=your_app_password
EMAIL_RECEIVERS=admin1@example.com,admin2@example.com
# ========== Grafana 配置 ==========
GF_SECURITY_ADMIN_PASSWORD=secure_password_here
.env.example(提交到 Git 的模板):
bash
# API 密钥
API_KEY=
# Redis
REDIS_URL=redis://redis:6379/0
# 钉钉
DINGTALK_WEBHOOK=
# 企业微信
WECOM_WEBHOOK=
# 邮件
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
EMAIL_SENDER=
EMAIL_PASSWORD=
EMAIL_RECEIVERS=
五、部署与运行 🚀
5.1 本地开发环境
bash
# 1. 克隆代码
git clone https://github.com/your-repo/docker-cron-project.git
cd docker-cron-project
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env 文件,填入实际的密钥
# 3. 构建镜像
docker compose build
# 4. 启动服务
docker compose up -d
# 5. 查看日志
docker compose logs -f crawler
# 6. 进入容器调试
docker compose exec crawler bash
# 7. 停止服务
docker compose down
# 8. 重启单个服务
docker compose restart crawler
5.2 生产环境部署
bash
# 1. 拉取代码
git pull origin main
# 2. 构建生产镜像(带版本标签)
docker compose build
docker tag docker-cron-project_crawler:latest my-registry.com/crawler:v1.0.0
# 3. 推送到私有镜像仓库
docker push my-registry.com/crawler:v1.0.0
# 4. 在生产服务器上拉取并启动
docker pull my-registry.com/crawler:v1.0.0
docker compose -f docker-compose.prod.yml up -d
# 5. 设置开机自启
sudo systemctl enable docker
docker-compose.prod.yml(生产环境配置):
yaml
version: '3.8'
services:
crawler:
image: my-registry.com/crawler:v1.0.0 # 使用具体版本
restart: always # 总是重启
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 其他配置同 docker-compose.yml
六、监控与告警 📊
6.1 Prometheus 配置(config/prometheus.yml)
yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'crawler'
static_configs:
- targets: ['crawler:8000'] # 假设爬虫暴露 8000 端口的指标
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
6.2 指标导出器(src/monitor/metrics.py)
python
from prometheus_client import start_http_server, Counter, Gauge, Histogram
import time
# 定义指标
task_total = Counter('task_total', '任务总数', ['status'])
task_duration = Histogram('task_duration_seconds', '任务执行耗时')
retry_count = Counter('retry_total', '重试次数', ['task_name'])
queue_length = Gauge('queue_length', '队列长度')
# 启动 HTTP 服务器
start_http_server(8000)
# 模拟指标更新
while True:
task_total.labels(status='success').inc()
time.sleep(60)
七、故障排查与调试 🔍
7.1 常见问题诊断
问题 1:容器启动后立即退出
bash
# 查看容器退出原因
docker compose ps
docker compose logs crawler
# 常见原因:
# 1. Dockerfile 中 CMD 命令执行失败
# 2. entrypoint.sh 脚本有语法错误
# 3. 环境变量缺失
# 解决方案:以交互模式启动调试
docker compose run --rm crawler bash
问题 2:Cron 任务不执行
bash
# 进入容器检查
docker compose exec crawler bash
# 检查 cron 服务状态
service cron status
# 查看 crontab 是否正确加载
crontab -l
# 手动执行任务测试
/app/scripts/run_task.sh
# 检查 cron 日志
tail -f /var/log/cron.log
常见原因:
- 时区问题:容器时区是 UTC,定时任务时间对不上
- 环境变量缺失:Cron 的 PATH 不包含 Python
- 权限问题:crontab 文件权限必须是 644
解决方案:
bash
# 1. 设置时区
ENV TZ=Asia/Shanghai
# 2. 在 crontab 文件中设置完整 PATH
PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin
# 3. 检查权限
chmod 0644 /etc/cron.d/crawler-cron
问题 3:日志文件权限错误
bash
# 错误信息:Permission denied
# 原因:容器内用 crawler 用户,但日志目录属于 root
# 解决:在 Dockerfile 中设置正确的所有者
RUN chown -R crawler:crawler /logs
# 或在 docker-compose.yml 中映射用户 ID
user: "1000:1000"
问题 4:Redis 连接失败
bash
# 错误信息:redis.exceptions.ConnectionError
# 检查 Redis 是否启动
docker compose ps redis
# 测试连接
docker compose exec crawler redis-cli -u $REDIS_URL ping
# 检查网络
docker network ls
docker network inspect docker-cron-project_app-network
问题 5:容器内存溢出被 Kill
bash
# 查看容器资源使用
docker stats
# 查看系统日志
dmesg | grep -i "killed process"
# 解决:增加内存限制
deploy:
resources:
limits:
memory: 4G # 从 2G 增加到 4G
7.2 健康检查脚本(scripts/health_check.sh)
bash
#!/bin/bash
# ============================================================
# 健康检查脚本
#
# 检查项:
# 1. Redis 连接
# 2. 磁盘空间
# 3. 进程存活
# 4. 最近任务执行状态
# ============================================================
set -e
EXIT_CODE=0
# ========== 检查 Redis 连接 ==========
echo "检查 Redis 连接..."
if redis-cli -u "$REDIS_URL" ping > /dev/null 2>&1; then
echo "✓ Redis 连接正常"
else
echo "✗ Redis 连接失败"
EXIT_CODE=1
fi
# ========== 检查磁盘空间 ==========
echo "检查磁盘空间..."
DISK_USAGE=$(df -h /data | tail -1 | awk '{print $5}' | sed 's/%//')
if [ "$DISK_USAGE" -lt 90 ]; then
echo "✓ 磁盘空间充足 (${DISK_USAGE}%)"
else
echo "✗ 磁盘空间不足 (${DISK_USAGE}%)"
EXIT_CODE=1
fi
# ========== 检查关键进程 ==========
echo "检查关键进程..."
if pgrep -f supervisord > /dev/null; then
echo "✓ Supervisor 运行中"
else
echo "✗ Supervisor 未运行"
EXIT_CODE=1
fi
if pgrep -f cron > /dev/null; then
echo "✓ Cron 运行中"
else
echo "✗ Cron 未运行"
EXIT_CODE=1
fi
# ========== 检查最近任务状态 ==========
echo "检查最近任务状态..."
LAST_SUCCESS=$(redis-cli -u "$REDIS_URL" GET "task:daily_crawl:success_time" 2>/dev/null || echo "")
if [ -n "$LAST_SUCCESS" ]; then
# 计算距离上次成功的时间(小时)
LAST_SUCCESS_TS=$(date -d "$LAST_SUCCESS" +%s 2>/dev/null || echo 0)
NOW_TS=$(date +%s)
HOURS_DIFF=$(( (NOW_TS - LAST_SUCCESS_TS) / 3600 ))
if [ "$HOURS_DIFF" -lt 48 ]; then
echo "✓ 最近任务正常 (${HOURS_DIFF}小时前)"
else
echo "⚠ 最近任务异常 (${HOURS_DIFF}小时前)"
EXIT_CODE=1
fi
else
echo "⚠ 无任务执行记录"
fi
# ========== 输出结果 ==========
echo "========================================"
if [ $EXIT_CODE -eq 0 ]; then
echo "健康检查通过"
else
echo "健康检查失败"
fi
echo "========================================"
exit $EXIT_CODE
八、备份与恢复 💾
8.1 自动备份脚本(scripts/backup.sh)
bash
#!/bin/bash
# ============================================================
# 自动备份脚本
#
# 备份内容:
# 1. Redis 数据
# 2. 爬取的数据文件
# 3. 日志文件(最近 7 天)
# 4. 配置文件
# ============================================================
set -euo pipefail
BACKUP_DIR="/data/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${TIMESTAMP}"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_NAME}"
mkdir -p "$BACKUP_PATH"
echo "========================================="
echo "开始备份: $BACKUP_NAME"
echo "========================================="
# ========== 备份 Redis ==========
echo "备份 Redis 数据..."
redis-cli -u "$REDIS_URL" SAVE
cp /data/redis/dump.rdb "$BACKUP_PATH/redis.rdb"
echo "✓ Redis 备份完成"
# ========== 备份数据文件 ==========
echo "备份数据文件..."
tar -czf "$BACKUP_PATH/data.tar.gz" -C /data/output . 2>/dev/null || true
echo "✓ 数据文件备份完成"
# ========== 备份最近日志 ==========
echo "备份最近 7 天日志..."
find /logs -type f -mtime -7 -print0 | tar -czf "$BACKUP_PATH/logs.tar.gz" --null -T -
echo "✓ 日志备份完成"
# ========== 备份配置 ==========
echo "备份配置文件..."
tar -czf "$BACKUP_PATH/config.tar.gz" -C /config .
echo "✓ 配置备份完成"
# ========== 生成备份清单 ==========
cat > "$BACKUP_PATH/manifest.txt" <<EOF
备份时间: $(date)
Redis 数据: $(du -h "$BACKUP_PATH/redis.rdb" | cut -f1)
数据文件: $(du -h "$BACKUP_PATH/data.tar.gz" | cut -f1)
日志文件: $(du -h "$BACKUP_PATH/logs.tar.gz" | cut -f1)
配置文件: $(du -h "$BACKUP_PATH/config.tar.gz" | cut -f1)
总大小: $(du -sh "$BACKUP_PATH" | cut -f1)
EOF
# ========== 清理旧备份(保留最近 7 个) ==========
echo "清理旧备份..."
cd "$BACKUP_DIR"
ls -t | tail -n +8 | xargs -I {} rm -rf {}
echo "✓ 旧备份清理完成"
# ========== 上传到远程(可选) ==========
# 如果配置了 S3 或其他云存储
if [ -n "${S3_BUCKET:-}" ]; then
echo "上传到 S3..."
aws s3 cp "$BACKUP_PATH" "s3://$S3_BUCKET/backups/$BACKUP_NAME" --recursive
echo "✓ 上传完成"
fi
echo "========================================="
echo "备份完成: $BACKUP_PATH"
echo "========================================="
8.2 恢复脚本(scripts/restore.sh)
bash
#!/bin/bash
# ============================================================
# 数据恢复脚本
# ============================================================
set -euo pipefail
if [ $# -lt 1 ]; then
echo "用法: $0 <备份目录名>"
echo "示例: $0 backup_20240131_020000"
exit 1
fi
BACKUP_NAME=$1
BACKUP_PATH="/data/backups/$BACKUP_NAME"
if [ ! -d "$BACKUP_PATH" ]; then
echo "错误: 备份目录不存在: $BACKUP_PATH"
exit 1
fi
echo "========================================="
echo "从备份恢复: $BACKUP_NAME"
echo "========================================="
# 确认操作
read -p "此操作将覆盖现有数据,是否继续?(yes/no): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "操作已取消"
exit 0
fi
# ========== 停止相关服务 ==========
echo "停止服务..."
docker compose stop crawler
echo "✓ 服务已停止"
# ========== 恢复 Redis ==========
echo "恢复 Redis 数据..."
docker compose stop redis
cp "$BACKUP_PATH/redis.rdb" /data/redis/dump.rdb
docker compose start redis
echo "✓ Redis 数据已恢复"
# ========== 恢复数据文件 ==========
echo "恢复数据文件..."
rm -rf /data/output/*
tar -xzf "$BACKUP_PATH/data.tar.gz" -C /data/output
echo "✓ 数据文件已恢复"
# ========== 恢复配置 ==========
echo "恢复配置文件..."
tar -xzf "$BACKUP_PATH/config.tar.gz" -C /config
echo "✓ 配置文件已恢复"
# ========== 重启服务 ==========
echo "重启服务..."
docker compose start crawler
echo "✓ 服务已重启"
echo "========================================="
echo "恢复完成"
echo "========================================="
九、性能优化 ⚡
9.1 镜像优化
优化前(镜像 1.2GB):
dockerfile
FROM python:3.11
RUN pip install -r requirements.txt
COPY . /app
优化后(镜像 300MB):
dockerfile
# 使用 slim 基础镜像
FROM python:3.11-slim
# 多阶段构建
FROM builder AS final
# 只安装运行时依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
cron \
&& rm -rf /var/lib/apt/lists/*
# 使用 .dockerignore 排除无关文件
# .dockerignore 内容:
# .git
# __pycache__
# *.pyc
# tests/
9.2 构建缓存优化
dockerfile
# ❌ 错误:每次代码变化都重新安装依赖
COPY . /app
RUN pip install -r requirements.txt
# ✅ 正确:先复制依赖文件,利用缓存
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt
COPY . /app
9.3 并发控制
python
# 使用信号量限制并发
from threading import Semaphore
max_concurrent = 4
semaphore = Semaphore(max_concurrent)
def fetch_with_limit():
with semaphore:
# 执行爬取
pass
十、安全加固 🔒
10.1 镜像安全扫描
bash
# 使用 Trivy 扫描镜像漏洞
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image crawler:latest
# 使用 Snyk 扫描
snyk container test crawler:latest
10.2 密钥管理
不要硬编码密钥:
python
# ❌ 错误
API_KEY = "sk-1234567890abcdef"
# ✅ 正确
import os
API_KEY = os.getenv('API_KEY')
if not API_KEY:
raise ValueError("API_KEY 环境变量未设置")
使用 Docker Secrets(Swarm 模式):
yaml
services:
crawler:
secrets:
- api_key
secrets:
api_key:
file: ./secrets/api_key.txt
10.3 网络隔离
yaml
# 创建独立网络
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # 内部网络,无法访问外网
services:
crawler:
networks:
- frontend
- backend
redis:
networks:
- backend # 只能被内部服务访问
十一、总结与最佳实践 ✅
核心优势
- 环境一致性:开发、测试、生产完全相同
- 易于部署:一键启动,无需手动配置
- 资源隔离:容器间互不影响
- 可观测性:日志、指标、告警一应俱全
- 故障恢复:自动重启、备份恢复
最佳实践
容器化:
- ✅ 使用多阶段构建减小镜像
- ✅ 以非 root 用户运行
- ✅ 添加健康检查
- ✅ 设置资源限制
定时任务:
- ✅ 时区配置正确
- ✅ 环境变量完整
- ✅ 日志重定向
- ✅ 并发控制
日志管理:
- ✅ 使用 logrotate 自动清理
- ✅ 按大小和时间轮转
- ✅ 压缩归档节省空间
- ✅ 保留适当天数
失败重试:
- ✅ 指数退避避免雪崩
- ✅ 最大重试次数限制
- ✅ 状态持久化
- ✅ 失败告警
监控告警:
- ✅ 关键指标采集
- ✅ 多渠道通知
- ✅ 可视化面板
- ✅ 异常自动告警
生产环境清单
- 所有密钥使用环境变量或密钥管理服务
- 容器以非 root 用户运行
- 设置资源限制(CPU、内存)
- 配置健康检查
- 启用日志轮转
- 设置自动备份
- 配置告警通知
- 定期扫描镜像漏洞
- 文档完善(部署、运维、故障排查)
- 灾难恢复演练
十二、扩展阅读 📚
官方文档:
- Docker 官方文档:https://docs.docker.com/
- Docker Compose 文档:https://docs.docker.com/compose/
- Prometheus 文档:https://prometheus.io/docs/
推荐工具:
- Portainer:Docker 可视化管理
- Watchtower:自动更新容器镜像
- Dozzle:实时查看容器日志
- ctop:容器资源监控
进阶主题:
- Kubernetes 编排(适合大规模部署)
- Service Mesh(Istio、Linkerd)
- CI/CD 集成(Jenkins、GitLab CI)
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
