logo NodeSeekbeta

阿里云500MB超低内存 VPS 部署轻量流量监控与连接审计面板

背景是买了阿里云5年的HK机器。监控流量用
这个月没注意居然用超了,一月5.7的实例费,这个月居然流量费都15了,太坑了
所以简单做了个流量监控

长这样,想调整可以让AI做调整,都是静态网页。

image

1. 架构与设计优势

在低配置 VPS(如 512MB 内存)上,传统的 Prometheus + Grafana 或 Netdata 极易因为内存开销(150MB~300MB+)触发内核 OOM Killer 导致宕机。

本方案采用 极低资源开销架构:

  • 流量统计核心:vnStat 2.x(C 编写,直接采集内核网络接口计数器,内存占用仅 2MB~4MB)。
  • 连接与归属地探针:轻量级 Python 脚本定期提取 ss 活动套接字,并结合 MaxMind 离线 GeoLite2-City 数据库进行本地归属地解析(运行即退,不占常驻内存)。
  • Web 服务端:Python 3 内置单线程 HTTP 托管静态页面(内存占用 ~8MB)。
  • 前端可视化:纯前端静态单页架构(Bootstrap 5 + Chart.js),由客户端浏览器渲染,不消耗服务器 CPU。

整套系统常驻内存开销控制在 15MB 以内。


2. 准备工作与依赖安装

在 Debian / Ubuntu 系统上执行:

# 更新源并安装核心依赖
sudo apt update
sudo apt install -y vnstat python3 python3-pip curl

# 安装 GeoIP 解析模块(Debian 12/13 需指定 --break-system-packages)
pip3 install geoip2 --break-system-packages

# 启动并使能 vnStat 守护进程
sudo systemctl enable --now vnstat

3. 部署离线 GeoIP 数据库与采集探针

3.1 下载离线城市库

sudo mkdir -p /var/www/vnstat/api /var/www/vnstat/geoip
cd /var/www/vnstat/geoip
sudo curl -sSL -o GeoLite2-City.mmdb https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb

3.2 创建连接与归属地解析探针

创建 /usr/local/bin/dump_conns.py:

sudo cat << 'EOF' > /usr/local/bin/dump_conns.py
#!/usr/bin/env python3
import json
import subprocess
import geoip2.database
import ipaddress

DB_PATH = '/var/www/vnstat/geoip/GeoLite2-City.mmdb'
OUT_PATH = '/var/www/vnstat/api/connections.json'

try:
    reader = geoip2.database.Reader(DB_PATH)
except Exception:
    reader = None

def is_private_ip(ip):
    try:
        return ipaddress.ip_address(ip).is_private
    except:
        return True

def get_location(ip):
    if not reader or is_private_ip(ip):
        return "局域网/保留地址"
    try:
        resp = reader.city(ip)
        country = resp.country.names.get('zh-CN', resp.country.name or '未知')
        city = resp.city.names.get('zh-CN', resp.city.name or '')
        return f"{country} {city}".strip()
    except:
        return "未知地区"

def parse_connections():
    cmd = ["ss", "-ntp", "state", "established"]
    res = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
    lines = res.stdout.strip().split('\n')[1:]
    
    conns = []
    for line in lines:
        parts = line.split()
        if len(parts) < 4:
            continue
        local = parts[2]
        remote = parts[3]
        
        if ']:' in remote:
            r_ip = remote.split(']:')[0].strip('[')
        else:
            r_ip = remote.split(':')[0]
            
        proc = parts[4] if len(parts) >= 5 else "-"
        loc = get_location(r_ip)
        
        # 判断进出站(根据常规服务监听端口)
        direction = "进站" if any(local.endswith(p) for p in [":8080", ":22", ":80", ":443"]) else "出站"

        conns.append({
            "local": local,
            "remote": remote,
            "remote_ip": r_ip,
            "location": loc,
            "direction": direction,
            "process": proc
        })
    
    with open(OUT_PATH, 'w') as f:
        json.dump(conns, f, ensure_ascii=False)

if __name__ == '__main__':
    parse_connections()
EOF

sudo chmod +x /usr/local/bin/dump_conns.py

4. 部署前端可视化界面

创建静态单页 /var/www/vnstat/index.html:

sudo cat << 'EOF' > /var/www/vnstat/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>高精细网络流量监控 - vnStat</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css">
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        body { background-color: #f4f6f9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
        .card { border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: none; }
        .table-responsive { max-height: 350px; }
        .stat-value { font-size: 1.35rem; font-weight: 700; }
        .badge-in { background-color: #28a745; }
        .badge-out { background-color: #0d6efd; }
    </style>
</head>
<body class="py-4">
    <div class="container-fluid px-4">
        <div class="d-flex justify-content-between align-items-center mb-4">
            <div>
                <h3 class="m-0 fw-bold">网络精细化与连接审计仪表盘</h3>
                <small class="text-muted">颗粒度支持:5分钟 / 小时 / 天 / 进出站连接与归属地走向</small>
            </div>
            <div>
                <span class="badge bg-primary me-2" id="iface-tag">-</span>
                <span class="badge bg-secondary" id="last-update">数据加载中...</span>
            </div>
        </div>
        
        <div class="row g-3 mb-4">
            <div class="col-6 col-lg-3">
                <div class="card p-3 border-start border-4 border-info">
                    <div class="text-muted small">最近一个5分钟区间流量</div>
                    <div class="stat-value text-info" id="latest-5m">-</div>
                </div>
            </div>
            <div class="col-6 col-lg-3">
                <div class="card p-3 border-start border-4 border-success">
                    <div class="text-muted small">今日流量 (Rx / Tx)</div>
                    <div class="stat-value" style="font-size: 1.1rem;">
                        <span class="text-success" id="today-rx">-</span> / <span class="text-danger" id="today-tx">-</span>
                    </div>
                </div>
            </div>
            <div class="col-6 col-lg-3">
                <div class="card p-3 border-start border-4 border-warning">
                    <div class="text-muted small">本月累计 (Rx / Tx)</div>
                    <div class="stat-value" style="font-size: 1.1rem;">
                        <span class="text-success" id="month-rx">-</span> / <span class="text-danger" id="month-tx">-</span>
                    </div>
                </div>
            </div>
            <div class="col-6 col-lg-3">
                <div class="card p-3 border-start border-4 border-dark">
                    <div class="text-muted small">历史总吞吐</div>
                    <div class="stat-value text-dark" id="total-traffic">-</div>
                </div>
            </div>
        </div>

        <div class="card p-3 mb-4">
            <div class="d-flex justify-content-between align-items-center mb-2">
                <h5 class="card-title m-0 fw-bold">当前活动连接及地区走向审计 (实时 Established)</h5>
                <span class="badge bg-light text-dark border" id="conn-count">活跃连接数: 0</span>
            </div>
            <div class="table-responsive">
                <table class="table table-sm table-hover align-middle mb-0">
                    <thead class="table-light">
                        <tr>
                            <th>方向</th>
                            <th>远端 IP / 目标走向</th>
                            <th>归属地 (国家/城市)</th>
                            <th>本地端点</th>
                            <th>关联进程</th>
                        </tr>
                    </thead>
                    <tbody id="conn-table-body">
                        <tr><td colspan="5" class="text-center text-muted">正在查询进出站连接数据...</td></tr>
                    </tbody>
                </table>
            </div>
        </div>

        <div class="card p-3 mb-4">
            <div class="d-flex justify-content-between align-items-center mb-2">
                <h5 class="card-title m-0">极细粒度:最近 5 分钟级别流量趋势 (MB)</h5>
                <span class="badge bg-info-subtle text-info border">最高精度采样</span>
            </div>
            <canvas id="fiveMinuteChart" height="70"></canvas>
        </div>

        <div class="row g-4 mb-4">
            <div class="col-lg-6">
                <div class="card p-3 h-100">
                    <h5 class="card-title mb-3">按小时趋势:最近 24 小时 (MB)</h5>
                    <canvas id="hourlyChart" height="130"></canvas>
                </div>
            </div>
            <div class="col-lg-6">
                <div class="card p-3 h-100">
                    <h5 class="card-title mb-3">按天趋势:最近 30 天 (MB)</h5>
                    <canvas id="dailyChart" height="130"></canvas>
                </div>
            </div>
        </div>

        <div class="row g-4">
            <div class="col-lg-6">
                <div class="card p-3">
                    <h6 class="card-title mb-2 fw-bold">最近 5 分钟级明细记录</h6>
                    <div class="table-responsive">
                        <table class="table table-sm table-hover align-middle mb-0">
                            <thead class="table-light">
                                <tr><th>时间区间</th><th>下行 (Rx)</th><th>上行 (Tx)</th><th>合计</th></tr>
                            </thead>
                            <tbody id="fivemin-table-body">
                                <tr><td colspan="4" class="text-center text-muted">加载中...</td></tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
            <div class="col-lg-6">
                <div class="card p-3">
                    <h6 class="card-title mb-2 fw-bold">每日历史明细记录</h6>
                    <div class="table-responsive">
                        <table class="table table-sm table-hover align-middle mb-0">
                            <thead class="table-light">
                                <tr><th>日期</th><th>下行 (Rx)</th><th>上行 (Tx)</th><th>合计</th></tr>
                            </thead>
                            <tbody id="daily-table-body">
                                <tr><td colspan="4" class="text-center text-muted">加载中...</td></tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function formatBytes(bytes) {
            if (!bytes || bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }

        // 加载活跃连接
        fetch('/api/connections.json?t=' + Date.now())
            .then(res => res.json())
            .then(conns => {
                document.getElementById('conn-count').textContent = '活跃连接数: ' + conns.length;
                if (!conns || conns.length === 0) {
                    document.getElementById('conn-table-body').innerHTML = '<tr><td colspan="5" class="text-center text-muted">当前暂无活跃的外部 TCP 连接</td></tr>';
                    return;
                }
                const rows = conns.map(c => {
                    const badgeClass = c.direction === '进站' ? 'badge-in' : 'badge-out';
                    return `
                        <tr>
                            <td><span class="badge ${badgeClass} text-white">${c.direction}</span></td>
                            <td><code class="fw-bold text-dark">${c.remote_ip}</code> <small class="text-muted">(${c.remote.split(':').pop()})</small></td>
                            <td><span class="badge bg-secondary-subtle text-secondary-emphasis border">${c.location}</span></td>
                            <td><small class="text-muted">${c.local}</small></td>
                            <td><small class="text-primary">${c.process || '-'}</small></td>
                        </tr>
                    `;
                });
                document.getElementById('conn-table-body').innerHTML = rows.join('');
            })
            .catch(() => {
                document.getElementById('conn-table-body').innerHTML = '<tr><td colspan="5" class="text-center text-muted">暂未采集到连接数据</td></tr>';
            });

        // 加载 vnStat 流量数据
        fetch('/api/vnstat.json?t=' + Date.now())
            .then(res => res.json())
            .then(data => {
                const iface = data.interfaces[0];
                if (!iface) return;

                document.getElementById('iface-tag').textContent = '网卡: ' + iface.name;
                document.getElementById('total-traffic').textContent = formatBytes(iface.traffic.total.rx + iface.traffic.total.tx);
                if (iface.updated && iface.updated.date) {
                    const u = iface.updated;
                    document.getElementById('last-update').textContent = `更新: ${u.date.year}-${u.date.month}-${u.date.day} ${String(u.time.hour).padStart(2,'0')}:${String(u.time.minute).padStart(2,'0')}`;
                }

                // 5 分钟走势
                const fiveMins = iface.traffic.fiveminute || [];
                const fmLabels = [], fmRx = [], fmTx = [], fmRows = [];
                fiveMins.forEach(item => {
                    const timeLabel = `${String(item.time.hour).padStart(2, '0')}:${String(item.time.minute).padStart(2, '0')}`;
                    fmLabels.push(timeLabel);
                    fmRx.push((item.rx / (1024 * 1024)).toFixed(2));
                    fmTx.push((item.tx / (1024 * 1024)).toFixed(2));
                    fmRows.unshift(`
                        <tr>
                            <td><span class="badge bg-light text-dark">${timeLabel}</span></td>
                            <td class="text-success">${formatBytes(item.rx)}</td>
                            <td class="text-danger">${formatBytes(item.tx)}</td>
                            <td class="fw-bold">${formatBytes(item.rx + item.tx)}</td>
                        </tr>
                    `);
                });

                if (fiveMins.length > 0) {
                    const lastFm = fiveMins[fiveMins.length - 1];
                    document.getElementById('latest-5m').textContent = formatBytes(lastFm.rx + lastFm.tx);
                } else {
                    document.getElementById('latest-5m').textContent = '积累数据中...';
                }
                document.getElementById('fivemin-table-body').innerHTML = fmRows.slice(0, 30).join('') || '<tr><td colspan="4" class="text-center">运行几分钟后将生成 5 分钟片段数据</td></tr>';

                new Chart(document.getElementById('fiveMinuteChart'), {
                    type: 'line',
                    data: {
                        labels: fmLabels.slice(-48),
                        datasets: [
                            { label: '入站 Rx (MB)', data: fmRx.slice(-48), borderColor: '#28a745', backgroundColor: 'rgba(40, 167, 69, 0.1)', fill: true, tension: 0.3 },
                            { label: '出站 Tx (MB)', data: fmTx.slice(-48), borderColor: '#dc3545', backgroundColor: 'rgba(220, 53, 69, 0.1)', fill: true, tension: 0.3 }
                        ]
                    },
                    options: { responsive: true, scales: { y: { beginAtZero: true } } }
                });

                // 按天走势
                const days = iface.traffic.day || [];
                const dayLabels = [], dayRx = [], dayTx = [], dayRows = [];
                days.forEach(item => {
                    const d = `${item.date.year}-${String(item.date.month).padStart(2, '0')}-${String(item.date.day).padStart(2, '0')}`;
                    dayLabels.push(d);
                    dayRx.push((item.rx / (1024 * 1024)).toFixed(2));
                    dayTx.push((item.tx / (1024 * 1024)).toFixed(2));
                    dayRows.unshift(`
                        <tr>
                            <td>${d}</td>
                            <td class="text-success">${formatBytes(item.rx)}</td>
                            <td class="text-danger">${formatBytes(item.tx)}</td>
                            <td class="fw-bold">${formatBytes(item.rx + item.tx)}</td>
                        </tr>
                    `);
                });

                if (days.length > 0) {
                    const today = days[days.length - 1];
                    document.getElementById('today-rx').textContent = formatBytes(today.rx);
                    document.getElementById('today-tx').textContent = formatBytes(today.tx);
                }
                document.getElementById('daily-table-body').innerHTML = dayRows.join('') || '<tr><td colspan="4" class="text-center">暂无数据</td></tr>';

                new Chart(document.getElementById('dailyChart'), {
                    type: 'bar',
                    data: {
                        labels: dayLabels.slice(-30),
                        datasets: [
                            { label: '入站 (Rx)', data: dayRx.slice(-30), backgroundColor: 'rgba(40, 167, 69, 0.7)' },
                            { label: '出站 (Tx)', data: dayTx.slice(-30), backgroundColor: 'rgba(220, 53, 69, 0.7)' }
                        ]
                    },
                    options: { responsive: true, scales: { y: { beginAtZero: true } } }
                });

                // 本月与按小时
                const months = iface.traffic.month || [];
                if (months.length > 0) {
                    const m = months[months.length - 1];
                    document.getElementById('month-rx').textContent = formatBytes(m.rx);
                    document.getElementById('month-tx').textContent = formatBytes(m.tx);
                }

                const hours = iface.traffic.hour || [];
                const hLabels = [], hRx = [], hTx = [];
                hours.slice(-24).forEach(item => {
                    hLabels.push(String(item.time.hour).padStart(2, '0') + ':00');
                    hRx.push((item.rx / (1024 * 1024)).toFixed(2));
                    hTx.push((item.tx / (1024 * 1024)).toFixed(2));
                });

                new Chart(document.getElementById('hourlyChart'), {
                    type: 'bar',
                    data: {
                        labels: hLabels,
                        datasets: [
                            { label: '入站', data: hRx, backgroundColor: '#28a745' },
                            { label: '出站', data: hTx, backgroundColor: '#dc3545' }
                        ]
                    },
                    options: { responsive: true, scales: { y: { beginAtZero: true } } }
                });
            })
            .catch(err => console.error('加载 JSON 失败:', err));
    </script>
</body>
</html>
EOF

5. 配置 Systemd 托管与定时任务

5.1 配置 Web 服务守护进程

创建 /etc/systemd/system/vnstat-web.service:

[Unit]
Description=Lightweight vnStat Static Web Server
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/var/www/vnstat
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
Restart=always

[Install]
WantedBy=multi-user.target

应用并启动服务:

sudo systemctl daemon-reload
sudo systemctl enable --now vnstat-web

5.2 配置 Crontab 定时刷新

执行 crontab -e,添加以下两行(设为每 2 分钟更新一次数据源):

*/2 * * * * /usr/bin/vnstat --json > /var/www/vnstat/api/vnstat.json 2>/dev/null
*/2 * * * * /usr/local/bin/dump_conns.py >/dev/null 2>&1

手动先触发一次初始化导出:

/usr/bin/vnstat --json > /var/www/vnstat/api/vnstat.json
/usr/local/bin/dump_conns.py

6. 网络安全与访问控制

6.1 云厂商安全组设置(如阿里云/腾讯云)

进入云厂商控制台的 安全组 (Security Groups):

  • 方向:入方向
  • 协议类型:自定义 TCP
  • 端口范围:8080/8080
  • 授权对象:0.0.0.0/0(建议限制为你自己的常用 IP,防止监控面板暴露于公网)。

6.2 系统防火墙(如开启了 UFW)

sudo ufw allow 8080/tcp

访问 http://<服务器公网IP>:8080 即可浏览完整面板。


7. 后续日常运维与故障排查

7.1 服务状态与诊断

排查目标 命令 说明
Web 端口监听状态 ss -tulpn | grep 8080 确认端口是否处于 LISTEN 状态
Web 服务日志 journalctl -u vnstat-web -f 查看是否有 203/EXEC 或权限报错
vnStat 后台状态 systemctl status vnstat 确认流量采集核心正常存活
内存实际占用查看 ps aux | grep -E 'vnstat|python3' 查看常驻内存是否保持在极低水平

7.2 离线 GeoIP 数据库更新机制

MaxMind 的 GeoLite2 数据库每月均有变动。建议在 crontab 中添加每月 1 号自动更新的计划任务:

0 3 1 * * curl -sSL -o /var/www/vnstat/geoip/GeoLite2-City.mmdb https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb >/dev/null 2>&1

7.3 异常连接排查流程(如发现未知出站 IP)

当在仪表盘的“当前活动连接”中看到陌生的出站国外 IP 时,按以下顺序溯源:

  1. 获取 PID:在面板的“关联进程”列找到对应的 pid=XXXXXX。
  2. 确认是否为交互会话:
    w
    
    如果对应用户的 TTY 列为空,说明是 SSH 隧道(如 -R 端口转发或代理跳板)。
  3. 查看进程树:
    ps -ef | grep <PID>
    
  4. 终止异常进程:
    kill -9 <PID>
    

7.4 重置流量计数器(可选)

如果更换了网卡或重置了计费周期,需要清空 vnstat 数据库:

sudo systemctl stop vnstat
sudo rm -f /var/lib/vnstat/eth0
sudo systemctl start vnstat
  • 512M内存算是超高配置了 xhj008

  • 阿里云流量费是真刺客 我也被扣过一次 vnstat设个阈值告警就老实了

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有72091位seeker

🎉欢迎新用户🎉