Caddy 监控与数据采集方案部署笔记

1. 架构设计与系统概览

为在资源受限的环境下实现多域名 HTTP 请求指标的监控与可视化,采用轻量化的 Pull 架构。数据收集与存储解耦,确保云端服务无负担运行。

1.1 总体架构

[ 云服务器 (1核1G) ]                                   [ 内网开发板 (pbsbc01h3) ]
  Caddy (/metrics)  ──(跨网 HTTP 响应, 仅几 KB)──>  Python 脚本 (Crontab 定时运行)
                                                                │
                                                                ▼ (本地读写)
                                                     SQLite 文件 (caddy_metrics.db)
                                                                ▲
                                                                │ (内网局域网读取)
                                                      Datasette (按需/常驻)

1.2 架构特点

  • 云服务器零存储负担:云服务器仅保留 Caddy 内存中的 Metrics 数据接口,关闭日志磁盘写入(driver: none),不保存任何本地数据库。
  • 数据存储本地化:内网 ARM 开发板负责定时拉取文本数据并存入本地 SQLite,避免内网与云端之间挂载网络文件系统的延迟与死锁风险。
  • 按需低损耗:数据采集通过轻量 Python 脚本定时触发,可视化由 Datasette 提供服务,大幅降低 CPU 和内存开销。

2. 云服务器 Caddy 配置

在云服务器的 Caddyfile 中开启 Admin API 与全局 Metrics 指标收集,并暴露安全的监控域名。

2.1 生成 Basic Auth 密码哈希

在云服务器终端执行 Caddy 密码哈希命令:

caddy hash-password --plaintext 'YourStrongPassword'

记录返回的密文哈希值(如 $2a$14$Z34...)。

2.2 Caddyfile 完整配置示例

修改 /etc/caddy/Caddyfile

{
    # 开启管理 API 与 Metrics 监控指标
    admin localhost:2019
    metrics {
        per_host
    }
    
    # 透传真实客户端 IP
    servers {
        metrics
        trusted_proxies static 0.0.0.0/0 ::/0
        client_ip_headers X-Forwarded-For
    }
}

# 业务 Snippet 复用模版
(zsecurity) {
    header {
        Server "Apache"
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        X-XSS-Protection "1; mode=block"
        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
    encode gzip zstd
}

(zbb) {
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        X-XSS-Protection "1; mode=block"
    }
    encode gzip zstd
}

# 监控专用域名
zcaddyjishu2.zhaopeng.site {
    # 配置 Basic Auth 身份校验
    basic_auth /metrics {
        ops_user $2a$14$Z34...YourGeneratedHashHere...
    }

    # 反向代理至本地 Admin API,必须强制重写 Host 报头包含端口号
    reverse_proxy /metrics localhost:2019 {
        header_up Host localhost:2019
    }
}

2.3 配置生效与校验

# 验证配置文件正确性
caddy validate --config /etc/caddy/Caddyfile

# 重载 Caddy 配置
systemctl reload caddy

# 测试接口响应
curl -u ops_user:YourStrongPassword https://zcaddyjishu2.zhaopeng.site/metrics

3. 阿里云 ESA(Edge Security Acceleration)边缘配置

若域名使用了阿里云 ESA 进行加速与安全防护,须进行以下配置以防数据抓取被拦截或响应被缓存:

  1. 缓存模式:创建页面规则(Page Rule),针对路径 zcaddyjishu2.zhaopeng.site/metrics* 设置缓存模式为:绕过缓存 / 不缓存 (Bypass Cache / No Cache)
  2. WAF 与 Bot 规则放行:在安全防护中将路径 /metrics 设置为白名单放行,避免开发板的 Python 脚本被误判拦截。
  3. SSL/TLS 模式:设置回源加密模式为 Full 或 Strict,默认使用 443 端口回源。

4. 内网开发板数据采集配置

在 ARM 开发板(pbsbc01h3,Python 3.9.2)上部署数据拉取与落盘脚本。

4.1 数据采集脚本 (/root/caddy_collector.py)

import sqlite3
import time
import re
import urllib.request
import ssl
import base64

METRICS_URL = "https://zcaddyjishu2.zhaopeng.site/metrics"
AUTH_USER = "zadmin"
AUTH_PASS = "P4mG"
DB_PATH = "/data/jishu/vqq2.db"
RETENTION_DAYS = 30

def init_db(conn):
    """初始化数据库表(新增 delta_count 记录每周期增量)"""
    with conn:
        conn.execute("PRAGMA journal_mode=WAL;")  # 开启 WAL 预写日志模式
        conn.execute("""
            CREATE TABLE IF NOT EXISTS http_requests (
                timestamp INTEGER,
                host TEXT,
                code TEXT,
                count INTEGER,
                delta_count INTEGER DEFAULT 0,
                PRIMARY KEY (timestamp, host, code)
            );
        """)

def fetch_metrics():
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE

    req = urllib.request.Request(METRICS_URL)
    auth_str = f"{AUTH_USER}:{AUTH_PASS}"
    b64_auth = base64.b64encode(auth_str.encode('utf-8')).decode('utf-8')
    req.add_header("Authorization", f"Basic {b64_auth}")

    with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
        return response.read().decode('utf-8')

def parse_and_save(metrics_text, conn):
    """解析指标并计算增量后存入数据库"""
    now = int(time.time())
    rows = []

    # 获取上一周期的最新累计值记录,用于计算增量
    cursor = conn.cursor()
    cursor.execute("""
        SELECT host, code, count 
        FROM http_requests 
        WHERE timestamp = (SELECT MAX(timestamp) FROM http_requests)
    """)
    last_records = {(row[0], row[1]): row[2] for row in cursor.fetchall()}

    # 匹配带 host 的指标行
    pattern = re.compile(
        r'^caddy_http_requests_total\{[^}]*host="([^"]+)"[^}]*\}\s+(\d+(?:\.\d+)?)',
        re.MULTILINE
    )

    for match in pattern.finditer(metrics_text):
        labels_str = match.group(0)
        host = match.group(1)
        current_count = int(float(match.group(2)))

        code_m = re.search(r'code="([^"]+)"', labels_str)
        code = code_m.group(1) if code_m else "200"

        # 计算增量 ΔCount
        last_count = last_records.get((host, code))
        if last_count is None:
            delta = 0  # 首次记录该域名/状态码时增量记为 0
        elif current_count >= last_count:
            delta = current_count - last_count
        else:
            delta = current_count  # 若 Caddy 服务重启过 Counter 重置,增量直接等于当前值

        rows.append((now, host, code, current_count, delta))

    if rows:
        with conn:
            conn.executemany("""
                INSERT OR REPLACE INTO http_requests (timestamp, host, code, count, delta_count)
                VALUES (?, ?, ?, ?, ?);
            """, rows)

            expire_time = now - (RETENTION_DAYS * 86400)
            conn.execute("DELETE FROM http_requests WHERE timestamp < ?;", (expire_time,))

    return len(rows)

def query_db(conn):
    """打印最近记录及每分钟增量"""
    cursor = conn.cursor()
    print("-" * 65)
    print("数据库增量查询 (最近 10 条)")
    print("-" * 65)

    cursor.execute("""
        SELECT datetime(timestamp, 'unixepoch', 'localtime'), host, code, delta_count, count
        FROM http_requests
        ORDER BY timestamp DESC
        LIMIT 10;
    """)
    for row in cursor.fetchall():
        print(f"时间: {row[0]} | 域名: {row[1]} | 状态: {row[2]} | 每分钟增量: +{row[3]} | 累计总数: {row[4]}")
    print("-" * 65)

def main():
    try:
        conn = sqlite3.connect(DB_PATH)
        init_db(conn)

        metrics_text = fetch_metrics()
        saved_count = parse_and_save(metrics_text, conn)

        print(f"成功更新 {saved_count} 条指标记录\n")
        query_db(conn)

    except Exception as e:
        print(f"处理失败: {e}")
    finally:
        if 'conn' in locals():
            conn.close()

if __name__ == "__main__":
    main()

俩云服务器数据采集脚本

import sqlite3
import time
import re
import urllib.request
import ssl
import base64

# 1. 配置两台云服务器的地址与认证信息
NODES = [
    {
        "name": "zcaddyjishu1",
        "url": "https://zcaddyjishu1.zhaopeng.site/metrics",
        "user": "zadmin",
        "pass": "P4mG"
    },
    {
        "name": "zcaddyjishu2",
        "url": "https://zcaddyjishu2.zhaopeng.site/metrics",
        "user": "zadmin",
        "pass": "P4mG"
    }
]

DB_PATH = "/data/jishu/vqq2.db"
RETENTION_DAYS = 30

def init_db(conn):
    """初始化数据库表(主键包含 node 以区分不同云服务器)"""
    with conn:
        conn.execute("PRAGMA journal_mode=WAL;")  # 开启 WAL 预写日志模式
        conn.execute("""
            CREATE TABLE IF NOT EXISTS http_requests (
                timestamp INTEGER,
                node TEXT,
                host TEXT,
                code TEXT,
                count INTEGER,
                delta_count INTEGER DEFAULT 0,
                PRIMARY KEY (timestamp, node, host, code)
            );
        """)

def fetch_metrics(node_cfg):
    """从指定节点拉取 Prometheus 指标数据"""
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE

    req = urllib.request.Request(node_cfg["url"])
    auth_str = f"{node_cfg['user']}:{node_cfg['pass']}"
    b64_auth = base64.b64encode(auth_str.encode('utf-8')).decode('utf-8')
    req.add_header("Authorization", f"Basic {b64_auth}")

    with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
        return response.read().decode('utf-8')

def parse_and_save(node_name, metrics_text, conn):
    """解析单台服务器的指标并计算增量存入 DB"""
    now = int(time.time())
    rows = []

    # 获取当前节点上一周期的最新累计值记录 (以 node + host + code 为联合依据)
    cursor = conn.cursor()
    cursor.execute("""
        SELECT host, code, count 
        FROM http_requests 
        WHERE node = ? AND timestamp = (
            SELECT MAX(timestamp) FROM http_requests WHERE node = ?
        )
    """, (node_name, node_name))
    last_records = {(row[0], row[1]): row[2] for row in cursor.fetchall()}

    # 正则匹配 host
    pattern = re.compile(
        r'^caddy_http_requests_total\{[^}]*host="([^"]+)"[^}]*\}\s+(\d+(?:\.\d+)?)',
        re.MULTILINE
    )

    for match in pattern.finditer(metrics_text):
        labels_str = match.group(0)
        host = match.group(1)
        current_count = int(float(match.group(2)))

        code_m = re.search(r'code="([^"]+)"', labels_str)
        code = code_m.group(1) if code_m else "200"

        # 计算增量
        last_count = last_records.get((host, code))
        if last_count is None:
            delta = 0
        elif current_count >= last_count:
            delta = current_count - last_count
        else:
            delta = current_count  # 重启保护

        rows.append((now, node_name, host, code, current_count, delta))

    if rows:
        with conn:
            conn.executemany("""
                INSERT OR REPLACE INTO http_requests (timestamp, node, host, code, count, delta_count)
                VALUES (?, ?, ?, ?, ?, ?);
            """, rows)

            expire_time = now - (RETENTION_DAYS * 86400)
            conn.execute("DELETE FROM http_requests WHERE timestamp < ?;", (expire_time,))

    return len(rows)

def query_db(conn):
    """打印数据库最近记录"""
    cursor = conn.cursor()
    print("-" * 80)
    print("多节点增量查询 (最近 10 条)")
    print("-" * 80)

    cursor.execute("""
        SELECT datetime(timestamp, 'unixepoch', 'localtime'), node, host, code, delta_count, count
        FROM http_requests
        ORDER BY timestamp DESC
        LIMIT 10;
    """)
    for row in cursor.fetchall():
        print(f"时间: {row[0]} | 节点: {row[1]} | 域名: {row[2]} | 状态: {row[3]} | 增量: +{row[4]} | 累计: {row[5]}")
    print("-" * 80)

def main():
    try:
        conn = sqlite3.connect(DB_PATH)
        init_db(conn)

        total_saved = 0
        for node in NODES:
            try:
                metrics_text = fetch_metrics(node)
                saved_count = parse_and_save(node["name"], metrics_text, conn)
                total_saved += saved_count
            except Exception as e:
                print(f"拉取节点 [{node['name']}] 失败: {e}")

        print(f"成功更新 {total_saved} 条数据项\n")
        query_db(conn)

    except Exception as e:
        print(f"数据库异常: {e}")
    finally:
        if 'conn' in locals():
            conn.close()

if __name__ == "__main__":
    main()

4.2 设置 Crontab 定时任务

在开发板终端执行 crontab -e,添加以下配置实现每 5 分钟自动执行:

*/5 * * * * /usr/bin/python3 /root/caddy_collector.py > /dev/null 2>&1

5. 基于 Datasette 的轻量化可视化部署

采用 Datasette 作为本地轻量数据库浏览与图表渲染工具,避开重型容器部署。

5.1 环境安装与初始化

# 更新环境与构建工具
apt-get update && apt-get install -y python3-pip python3-dev build-essential

# 升级 pip 并安装 Datasette 及图表插件
pip3 install --upgrade pip
pip3 install datasette datasette-vega -i https://pypi.tuna.tsinghua.edu.cn/simple

5.2 运行服务

  • 即用即开模式

    datasette /data/jishu/vqq2.db -h 0.0.0.0 -p 8001
    
  • 常驻后台模式

    nohup datasette /data/jishu/vqq2.db -h 0.0.0.0 -p 8001 --cors > /dev/null 2>&1 &
    
    退出后台
    pkill -f "datasette /data/jishu/vqq2.db"

5.3. 带有节点区分的 3 个核心查询

5.3.1 24 小时内各节点/域名请求总数

SELECT 
    node AS "节点",
    host AS "域名", 
    SUM(delta_count) AS "24小时访问量"
FROM http_requests
WHERE timestamp >= strftime('%s', 'now', '-1 day')
GROUP BY node, host
ORDER BY node ASC, "24小时访问量" DESC;

5.3.2 特定域名近 7 天各节点访问趋势

SELECT 
    date(timestamp, 'unixepoch', 'localtime') AS "日期",
    node AS "节点",
    host AS "域名",
    SUM(delta_count) AS "当日访问量"
FROM http_requests
WHERE host = 'zcaddyjishu2.zhaopeng.site' 
  AND timestamp >= strftime('%s', 'now', '-7 days')
GROUP BY "日期", node, host
ORDER BY "日期" ASC, node ASC;

5.3.3 24 小时内各节点/状态码分布

SELECT 
    node AS "节点",
    host AS "域名",
    code AS "状态码",
    SUM(delta_count) AS "次数"
FROM http_requests
WHERE timestamp >= strftime('%s', 'now', '-1 day')
GROUP BY node, host, code
ORDER BY node, host, code;

5.3.4 常用运维与异常排查 SQL

最近 1 小时异常响应监控 (状态码 4xx / 5xx)
用来排查网站遭遇扫描或后端服务崩溃(如 502/504 Bad Gateway):

SELECT 
    node AS "节点",
    host AS "域名",
    code AS "状态码",
    SUM(delta_count) AS "报错次数"
FROM http_requests
WHERE timestamp >= strftime('%s', 'now', '-1 hour')
  AND code >= 400
GROUP BY node, host, code
HAVING SUM(delta_count) > 0
ORDER BY "报错次数" DESC;

两台节点流量分担比例 (近 24 小时负载均衡情况)
查看流量是否均匀分布在两台服务器上:

SELECT 
    node AS "节点",
    SUM(delta_count) AS "请求总数",
    ROUND(SUM(delta_count) * 100.0 / (SELECT SUM(delta_count) FROM http_requests WHERE timestamp >= strftime('%s', 'now', '-1 day')), 2) || '%' AS "流量占比"
FROM http_requests
WHERE timestamp >= strftime('%s', 'now', '-1 day')
GROUP BY node;

实时高频域名排行榜 (最近 15 分钟最活跃服务)
实时查看当前哪个站点被访问最多:

SELECT 
    host AS "域名",
    SUM(delta_count) AS "15分钟请求数"
FROM http_requests
WHERE timestamp >= strftime('%s', 'now', '-15 minutes')
GROUP BY host
ORDER BY "15分钟请求数" DESC;

6. 核心技术问题与排查备忘

6.1 basic_auth 指令区分

  • 全局配置中的 admin:属于 Caddy 保留关键字,用于指定管理 API 的监听地址,不可更改
  • 站点块中的 basic_auth 用户名:用户自选的认证账号名称,可自由修改(对应 curl -u 参数及 Python 代码中的 URL 账号)。

6.2 Host Header 拦截排查 (host not allowed)

  • 问题现象:访问 /metrics 接口时返回 {"error":"host not allowed: ..."}
  • 根本原因:Caddy Admin API 对外部 Host 标头存在安全限制,必须精确匹配其监听的地址及端口。
  • 解决方案:在反向代理配置中显式重写请求标头为 header_up Host localhost:2019

6.3 性能与写盘优化

  • SQLite 打开 WAL 模式PRAGMA journal_mode=WAL;)及 同步优化PRAGMA synchronous=NORMAL;),大幅降低开发板 SD 卡/闪存的写入损耗。
  • 开发板通过本地 timestamphost 联合索引,确保多天数据量累计后的查询耗时保持在毫秒级。

标签: none

添加新评论