Python爬虫实战:容器化与定时调度实战 - Docker + Cron + 日志轮转 + 失败重试完整方案(附CSV导出 + SQLite持久化存储)!

㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~

㊙️本期爬虫难度指数:⭐⭐⭐

🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [一、项目背景与目标 🎯](#一、项目背景与目标 🎯)
      • [为什么要容器化? 🤔](#为什么要容器化? 🤔)
      • [本次实战目标 📋](#本次实战目标 📋)
    • [二、技术栈选型 🛠️](#二、技术栈选型 🛠️)
    • [三、环境准备 💻](#三、环境准备 💻)
      • [安装 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,结果遇到一堆问题:

  1. 环境依赖乱:开发环境能跑,服务器上各种报错(Python 版本不对、缺库)
  2. 日志爆炸:一个月日志文件 50GB,把硬盘撑爆了
  3. 失败不知道:脚本挂了没人知道,等发现时数据已经断了好几天
  4. 难以迁移:换台服务器要重新配置环境,费时费力

后来改用 Docker 容器化 + 完善的监控体系 ,体验好得多:打包一次到处运行,日志自动清理,失败自动告警

为什么要容器化? 🤔

传统部署的痛点

  • 环境不一致:"在我机器上能跑啊" → 服务器上各种奇怪错误
  • 依赖管理难:系统库、Python 包、配置文件散落各处
  • 资源隔离差:多个项目共用环境,互相干扰
  • 回滚困难:更新出问题,回到旧版本要手工操作

容器化的优势

  • 环境一致性:开发、测试、生产环境完全相同
  • 一键部署docker-compose up -d 搞定
  • 资源隔离:每个容器独立,互不影响
  • 版本管理:镜像打标签,随时回滚
  • 易于扩展:需要更多实例?启动更多容器即可

本次实战目标 📋

我会带你实现一个生产级的容器化定时任务系统,具体包括:

  1. Dockerfile 编写:多阶段构建、镜像优化、安全加固
  2. docker-compose 编排:多服务协同、网络配置、卷挂载
  3. 定时任务设计:cron 配置、时区处理、任务依赖
  4. 日志管理:日志轮转、压缩归档、远程传输
  5. 失败重试:指数退避、最大重试次数、状态持久化
  6. 健康检查:容器存活探测、服务可用性监控
  7. 告警通知:钉钉/邮件/企微通知
  8. 监控面板:Grafana + Prometheus 实时监控
  9. 备份恢复:数据备份策略、灾难恢复

最终效果:一个健壮的、可观测的、易维护的生产级定时任务系统

二、技术栈选型 🛠️

核心组件

组件 版本 作用 为什么选它
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"]

代码详解

  1. 多阶段构建

    • 构建阶段:安装编译工具、构建依赖
    • 运行阶段:只复制必要的运行时文件
    • 优势:最终镜像不包含编译工具,减小 200-500MB
  2. 虚拟环境

    • 即使在容器里也用虚拟环境
    • 好处:依赖隔离、方便调试
    • 复制到运行阶段时只需复制 /opt/venv 目录
  3. 非 root 用户

    • 创建专用用户 crawler
    • 以普通用户身份运行,增强安全性
    • 防止容器逃逸攻击
  4. 时区设置

    • TZ=Asia/Shanghai 设置上海时区
    • 否则容器默认 UTC,定时任务时间会错
  5. 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 "$@"

代码详解

  1. set -e

    • 任何命令返回非零退出码,脚本立即终止
    • 防止错误继续传播
  2. 环境变量检查

    • 容器启动前验证必需的环境变量
    • 避免运行时才发现配置缺失
  3. Redis 连接测试

    • 使用 Python 内联脚本测试连接
    • <<EOF ... EOF 是 Bash 的 Here Document 语法
    • 启动时就发现问题,而不是等到任务执行时
  4. 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

代码详解

  1. nodaemon=true

    • Supervisor 默认后台运行
    • Docker 要求 PID 1 进程前台运行,否则容器立即退程崩溃自动重启
    • 相当于内置的进程守护功能
  2. startsecs=10

    • 进程启动后持续运行 10 秒,才认为启动成功
    • 防止进程启动后立即崩溃被误判为成功
  3. 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 要求)

代码详解

  1. 环境变量

    • Cron 执行时的 PATH 非常简单(通常只有 /usr/bin:/bin
    • 必须显式设置,否则找不到 Python、自定义脚本等
  2. 日志重定向

    • >> /logs/cron/task.log:标准输出追加到文件
    • 2>&1:标准错误也重定向到标准输出
    • 否则输出会丢失(Cron 默认发邮件,容器里没邮件服务)
  3. 时间表达式示例

    • 0 2 * * *:每天 2:00
    • */5 * * * *:每 5 分钟
    • 0 9-17 * * 1-5:工作日的 9:00-17:00
    • 0 0 1 * *:每月 1 号的 0:00
  4. 空行结尾

    • 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

代码详解

  1. set -euo pipefail

    • -e:任何命令失败立即退出
    • -u:使用未定义变量时报错
    • -o pipefail:管道中任一命令失败,整个管道失败
  2. 指数退避算法

    • 第 1 次失败:等待 5 秒
    • 第 2 次失败:等待 10 秒(5 × 2)
    • 第 3 次失败:等待 20 秒(10 × 2)
    • 避免频繁重试加重服务器负担
  3. 并发控制

    • 用 Redis 的 task:$TASK_NAME:running 标记任务状态
    • 如果已有同名任务在跑,跳过本次执行
    • 防止任务堆积
  4. 日志记录

    • 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

代码详解

  1. daily vs size 100M

    • daily:每天轮转,适合日志量稳定的场景
    • size 100M:按大小轮转,适合日志量不可预测的场景
    • 可以同时使用:daily size 100M(满足任一条件即轮转)
  2. rotate 7

    • 保留 7 份归档文件
    • 文件命名:app.log.1app.log.2.gz...
    • 超过 7 份的会被删除
  3. delaycompress

    • 最新的归档文件不压缩
    • 好处:可以直接用 tail -f app.log.1 查看
    • 第二次轮转时才压缩成 app.log.2.gz
  4. 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}")

代码详解

  1. @wraps(func)

    • 保留原函数的元数据(名称、文档字符串等)
    • 否则装饰后 func.__name__ 会变成 wrapper
  2. 状态持久化

    • 重试次数、失败原因都存到 Redis
    • 好处:重启容器后状态不丢失
    • 可以在监控面板查看历史记录
  3. 失败回调

    • 允许自定义失败处理逻辑
    • 如发送告警、记录日志、触发补偿逻辑
  4. 指数退避的优势

    • 线性退避(每次等待 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']
    )

代码详解

  1. Markdown 格式

    • 钉钉和企业微信都支持 Markdown
    • 可以使用加粗、颜色、换行等格式
    • 比纯文本更美观
  2. 邮件 HTML

    • 使用 HTML 格式的邮件正文
    • 可以添加样式、颜色
    • 比纯文本更专业
  3. SMTP TLS

    • server.starttls() 启用加密传输
    • 保护邮箱密码不被窃听
  4. 可扩展设计

    • 添加新渠道只需新增一个方法
    • 如 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

代码详解

  1. restart: unless-stopped

    • 容器退出自动重启
    • 除非用 docker stop 手动停止
    • always 更灵活(重启宿主机后不会自动启动)
  2. depends_on

    • 定义启动顺序
    • crawler 依赖 redis,所以先启动 redis
    • 注意:只保证启动顺序,不保证服务就绪
  3. 卷挂载模式

    • ./config:/config:ro:只读挂载
    • ./data:/data:读写挂载
    • 好处:防止容器误修改配置文件
  4. 资源限制

    • limits:硬限制,超过会被 Kill
    • reservations:软限制,保证最低资源
    • 防止单个容器占满资源
  5. 命名卷 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

常见原因

  1. 时区问题:容器时区是 UTC,定时任务时间对不上
  2. 环境变量缺失:Cron 的 PATH 不包含 Python
  3. 权限问题: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  # 只能被内部服务访问

十一、总结与最佳实践 ✅

核心优势

  1. 环境一致性:开发、测试、生产完全相同
  2. 易于部署:一键启动,无需手动配置
  3. 资源隔离:容器间互不影响
  4. 可观测性:日志、指标、告警一应俱全
  5. 故障恢复:自动重启、备份恢复

最佳实践

容器化

  • ✅ 使用多阶段构建减小镜像
  • ✅ 以非 root 用户运行
  • ✅ 添加健康检查
  • ✅ 设置资源限制

定时任务

  • ✅ 时区配置正确
  • ✅ 环境变量完整
  • ✅ 日志重定向
  • ✅ 并发控制

日志管理

  • ✅ 使用 logrotate 自动清理
  • ✅ 按大小和时间轮转
  • ✅ 压缩归档节省空间
  • ✅ 保留适当天数

失败重试

  • ✅ 指数退避避免雪崩
  • ✅ 最大重试次数限制
  • ✅ 状态持久化
  • ✅ 失败告警

监控告警

  • ✅ 关键指标采集
  • ✅ 多渠道通知
  • ✅ 可视化面板
  • ✅ 异常自动告警

生产环境清单

  • 所有密钥使用环境变量或密钥管理服务
  • 容器以非 root 用户运行
  • 设置资源限制(CPU、内存)
  • 配置健康检查
  • 启用日志轮转
  • 设置自动备份
  • 配置告警通知
  • 定期扫描镜像漏洞
  • 文档完善(部署、运维、故障排查)
  • 灾难恢复演练

十二、扩展阅读 📚

官方文档

推荐工具

  • Portainer:Docker 可视化管理
  • Watchtower:自动更新容器镜像
  • Dozzle:实时查看容器日志
  • ctop:容器资源监控

进阶主题

  • Kubernetes 编排(适合大规模部署)
  • Service Mesh(Istio、Linkerd)
  • CI/CD 集成(Jenkins、GitLab CI)

🌟 文末

好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

✅ 专栏持续更新中|建议收藏 + 订阅

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
2601_949146532 小时前
Python语音通知接口接入教程:开发者快速集成AI语音API的脚本实现
人工智能·python·语音识别
寻梦csdn2 小时前
pycharm+miniconda兼容问题
ide·python·pycharm·conda
Java面试题总结3 小时前
基于 Java 的 PDF 文本水印实现方案(iText7 示例)
java·python·pdf
不懒不懒3 小时前
【决策树算法实战指南:从原理到Python实现】
python·决策树·id3·c4.5·catr
马猴烧酒.3 小时前
【面试八股|Java集合】Java集合常考面试题详解
java·开发语言·python·面试·八股
天空属于哈夫克33 小时前
Java 版:利用外部群 API 实现自动“技术开课”倒计时提醒
数据库·python·mysql
喵手4 小时前
Python爬虫实战:全站 Sitemap 自动发现 - 解析 sitemap.xml → 自动生成抓取队列的工业级实现!
爬虫·python·爬虫实战·零基础python爬虫教学·sitemap·解析sitemap.xml·自动生成抓取队列实现
luoluoal4 小时前
基于深度学习的web端多格式纠错系统(源码+文档)
python·mysql·django·毕业设计·源码
深蓝海拓4 小时前
PySide6从0开始学习的笔记(二十七) 日志管理
笔记·python·学习·pyqt