Caddy 指标监控系统优化方案 (1C1G / SD卡环境)

一、 架构概述

针对资源受限的嵌入式开发板(1核1G、SD卡存储环境),通过将 SQLite 数据库整体迁移至内存盘 (/dev/shm) 运行,结合长周期持久化同步策略,彻底消除高频随机写入对 SD 卡的物理磨损,同时大幅提升数据采集与 Web 查询性能。

二、 核心优化策略

  1. 内存盘全内存运行:将数据库实时读写路径指向 /dev/shm/vqq2.db(Linux 内存文件系统),实现零 SD 卡 I/O 损耗。
  2. 长周期持久化同步:保持计划任务每 5 分钟高频采集,但通过时间戳控制每 2 小时(7200秒)将内存数据库整库覆盖备份回 SD 卡一次,兼顾硬件保护与数据安全。
  3. 单事务批量提交:采用全局单事务 executemany 写入,减少磁盘碎片与写放大。
  4. 正则预编译:在全局预编译正则表达式,降低单核 CPU 负载。
  5. Datasette 内存直读:Web 查询服务直接挂载内存数据库,实现零延迟实时响应。

三、 完整代码实现 (/data/jishu/2jishu.py)

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

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 = "/dev/shm/vqq2.db"
SD_BACKUP_PATH = "/data/jishu/vqq2.db"

RETENTION_DAYS = 30
LAST_CLEANUP_FILE = "/data/jishu/.last_cleanup"
LAST_SYNC_FILE = "/data/jishu/.last_sync"
SYNC_INTERVAL = 7200  # 同步间隔:7200秒 = 2小时

HOST_PATTERN = re.compile(
    r'^caddy_http_requests_total\{[^}]*host="([^"]+)"[^}]*\}\s+(\d+(?:\.\d+)?)',
    re.MULTILINE
)
CODE_PATTERN = re.compile(r'code="([^"]+)"')

def init_db(conn):
    try:
        conn.execute("PRAGMA journal_mode=WAL;")
    except sqlite3.OperationalError:
        pass

    conn.execute("PRAGMA synchronous=NORMAL;")
    conn.execute("PRAGMA busy_timeout=5000;")
    conn.execute("PRAGMA wal_autocheckpoint=5000;")
    conn.execute("PRAGMA temp_store=MEMORY;")

    with conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS http_requests (
                timestamp   INTEGER,
                node        TEXT,
                host        TEXT,
                code        INTEGER,
                count       INTEGER,
                delta_count INTEGER DEFAULT 0,
                PRIMARY KEY (timestamp, node, host, code)
            ) WITHOUT ROWID;
        """)

def should_cleanup(now):
    today = now // 86400
    try:
        with open(LAST_CLEANUP_FILE) as f:
            last_day = int(f.read().strip())
    except (FileNotFoundError, ValueError):
        last_day = 0
    return today > last_day, today

def mark_cleaned(today):
    try:
        with open(LAST_CLEANUP_FILE, "w") as f:
            f.write(str(today))
    except OSError as e:
        print(f"写清理标记失败: {e}")

def should_sync_to_sd(now):
    try:
        with open(LAST_SYNC_FILE, "r") as f:
            last_sync_time = int(f.read().strip())
    except (FileNotFoundError, ValueError):
        last_sync_time = 0
    return (now - last_sync_time) >= SYNC_INTERVAL

def mark_synced(now):
    try:
        with open(LAST_SYNC_FILE, "w") as f:
            f.write(str(now))
    except OSError as e:
        print(f"写同步标记失败: {e}")

def fetch_metrics(node_cfg):
    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_metrics(node_name, metrics_text, conn, now):
    rows = []
    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()}

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

        code_m = CODE_PATTERN.search(labels_str)
        try:
            code = int(code_m.group(1)) if code_m else 200
        except (ValueError, AttributeError):
            code = 0

        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))

    return 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():
    now = int(time.time())

    if not os.path.exists(DB_PATH) and os.path.exists(SD_BACKUP_PATH):
        try:
            shutil.copy2(SD_BACKUP_PATH, DB_PATH)
            print("【初始化】已从 SD 卡恢复历史数据库到内存盘 (/dev/shm)")
        except Exception as e:
            print(f"恢复数据库失败: {e}")

    all_rows = []
    try:
        conn = sqlite3.connect(DB_PATH, timeout=30)
        init_db(conn)

        for node in NODES:
            try:
                metrics_text = fetch_metrics(node)
                rows = parse_metrics(node["name"], metrics_text, conn, now)
                all_rows.extend(rows)
            except Exception as e:
                print(f"拉取节点 [{node['name']}] 失败: {e}")

        if all_rows:
            need_clean, today = should_cleanup(now)
            with conn:
                conn.executemany("""
                    INSERT OR IGNORE INTO http_requests
                    (timestamp, node, host, code, count, delta_count)
                    VALUES (?, ?, ?, ?, ?, ?);
                """, all_rows)

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

            if need_clean:
                mark_cleaned(today)

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

        conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
        conn.close()

        if should_sync_to_sd(now):
            shutil.copy2(DB_PATH, SD_BACKUP_PATH)
            mark_synced(now)
            print("【持久化】内存数据库已按周期备份回 SD 卡")
        else:
            print("【跳过同步】未到 2 小时同步周期,当前仅在内存运行")

    except Exception as e:
        print(f"数据库异常: {e}")

if __name__ == "__main__":
    main()

四、 部署与维护命令

  1. 更新 Web 服务指向内存数据库:

    pkill -f datasette
    nohup datasette /dev/shm/vqq2.db -h 0.0.0.0 -p 8001 --cors > /dev/null 2>&1 &
    
  2. 配置计划任务 (Crontab):

    */5 * * * * /usr/bin/python3 /data/jishu/2jishu.py >> /data/jishu/cron.log 2>&1
    

AdGuard Home 运行在内存盘与定时持久化方案 (1C1G / SD卡环境)

一、 架构概述

针对资源受限的嵌入式开发板(1核1G、SD卡存储环境),通过将 AdGuard Home 的运行时工作目录 (workdir) 迁移至内存盘 (/dev/shm),彻底消除 stats.db 等高频写入对 SD 卡的物理磨损。同时采用定时同步脚本,实现每 2 小时将内存数据持久化备份回 SD 卡。

二、 核心改造步骤

1. 迁移现有数据到内存并修改挂载

在重建容器前,需将 SD 卡现有的工作目录复制到内存盘,并调整 podman-compose.yml 的卷挂载路径。

执行初始化迁移与停止容器:

podman-compose down
cp -r /data/adguard/workdir /dev/shm/adguard_work

修改 podman-compose.yml:

podman run -d --name adguardhome \
-p 192.168.1.9:53:53/tcp \
-p 192.168.1.9:53:53/udp \
-p 3000:3000/tcp \
-m 300m --memory-swap 300m \
-p 8081:80/tcp \
-v /dev/shm/adguard_work:/opt/adguardhome/work:Z \
-v /data/adguard/confdir:/opt/adguardhome/conf:Z \
--log-driver none \
-e TZ=Asia/Shanghai \
docker.1ms.run/adguard/adguardhome

2. 编写定时持久化同步脚本 (/data/adguard/sync_adguard.sh)

创建同步脚本:

cat << 'EOF' > /data/adguard/sync_adguard.sh
#!/bin/bash
SRC="/dev/shm/adguard_work/"
DST="/data/adguard/workdir/"

if [ -d "$SRC" ]; then
    cp -rp "$SRC"/* "$DST"
    echo "$(date '+%Y-%m-%d %H:%M:%S') - AdGuard workdir synced to SD card"
else
    echo "Error: /dev/shm/adguard_work does not exist!"
fi
EOF

赋予执行权限:

chmod +x /data/adguard/sync_adguard.sh

3. 配置计划任务 (Crontab)

通过 Cron 实现每 2 小时自动将内存中的运行数据备份回 SD 卡:

crontab -e

添加以下任务:

0 */2 * * * /bin/bash /data/adguard/sync_adguard.sh >> /data/adguard/sync.log 2>&1

root@pbsbc01h3:/dev/shm# du -sh *
21M    adguard_work
52K    libpod_lock
6.6M    vqq2.db
32K    vqq2.db-shm
0    vqq2.db-wal

运行状态非常完美。

从 du 输出可以确认:

  1. AdGuard Home 工作目录 (adguard_work, 21M) 与 Caddy 监控数据库 (vqq2.db, 6.6M) 均已成功托管至内存盘 /dev/shm。
  2. SQLite 的 vqq2.db-wal 大小为 0,说明脚本中的 PRAGMA wal_checkpoint(TRUNCATE); 已成功执行,日志已完全归档并清空,内存占用处于极佳状态。

当前硬件的 SD 卡写入瓶颈已被彻底消除。

标签: none

添加新评论