背景是买了阿里云5年的HK机器。监控流量用
这个月没注意居然用超了,一月5.7的实例费,这个月居然流量费都15了,太坑了
所以简单做了个流量监控
长这样,想调整可以让AI做调整,都是静态网页。

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 时,按以下顺序溯源:
- 获取 PID:在面板的“关联进程”列找到对应的
pid=XXXXXX。 - 确认是否为交互会话:
如果对应用户的wTTY列为空,说明是 SSH 隧道(如-R端口转发或代理跳板)。 - 查看进程树:
ps -ef | grep <PID> - 终止异常进程:
kill -9 <PID>
7.4 重置流量计数器(可选)
如果更换了网卡或重置了计费周期,需要清空 vnstat 数据库:
sudo systemctl stop vnstat
sudo rm -f /var/lib/vnstat/eth0
sudo systemctl start vnstat
512M内存算是超高配置了
阿里云流量费是真刺客 我也被扣过一次 vnstat设个阈值告警就老实了
@hqsocks55 #2 雀食如此