Caddy监控与数据采集方案
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 进行加速与安全防护,须进行以下配置以防数据抓取被拦截或响应被缓存:
- 缓存模式:创建页面规则(Page Rule),针对路径
zcaddyjishu2.zhaopeng.site/metrics*设置缓存模式为:绕过缓存 / 不缓存 (Bypass Cache / No Cache)。 - WAF 与 Bot 规则放行:在安全防护中将路径
/metrics设置为白名单放行,避免开发板的 Python 脚本被误判拦截。 - 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 卡/闪存的写入损耗。 - 开发板通过本地
timestamp与host联合索引,确保多天数据量累计后的查询耗时保持在毫秒级。