共计 6683 个字符,预计需要花费 17 分钟才能阅读完成。
背景
当前手头上有许多服务器,部分服务器是固定流量的套餐,例如每月200GB流量。
在现有基建中,使用 node_exporter 采集服务器流量指标,VictoriaMetrics 存储指标,于 Grafana 中绘图,但是发现想要绘制指定自然周期内的流量数据很困难,只能绘制一个滚动的时间窗口内的流量数据。
原因如下:
1. Prometheus 的时间序列数据模型
Prometheus 采用的是时间序列数据库(TSDB)模型,其核心设计理念是存储和查询连续的时间序列数据点,而非聚合的时间段统计。它主要针对以下场景优化:
- 实时监控:关注系统当前状态和近期趋势
- 告警触发:基于最近数据点的阈值判断
- 趋势分析:观察指标随时间的变化模式
2. 查询模型的时间窗口机制
Prometheus 的 PromQL 查询语言设计为滑动时间窗口模型(Sliding Window Model) ,而非固定时间边界模型(Fixed Time Boundary Model) 。这意味着:
- 查询总是相对于查询执行时间点向后看一个时间窗口
- 没有内置的”按日历日期聚合”功能
- 函数如
rate()
,increase()
等都基于滑动窗口计算
3. Grafana 与 Prometheus 的交互机制
当 Grafana 查询 Prometheus 时,它使用的是 Prometheus HTTP API 中的两个主要端点:
-
/api/v1/query
:在单一时间点执行查询 -
/api/v1/query_range
:在时间范围内执行查询,返回一系列数据点
Grafana 传递的是相对时间范围参数,而非精确的日历边界。即使设置了精确的开始和结束时间,底层查询机制仍然是:
- 将时间范围分割成多个步长(step)
- 在每个步长上执行查询
- 返回时间序列数据点集合
4. 计数器指标的特性
网络流量指标如 node_network_receive_bytes_total
是单调递增计数器(Monotonically Increasing Counter) ,而非直接的速率或聚合值:
- 需要通过差值计算才能获得特定时间段的流量
- 计数器可能因服务重启而重置(Counter Reset)
- 没有内置机制在每个自然日开始时自动”归零”
综上,用 node_exporter 直接采集的指标不能满足需要。这种情况下需要依赖外部脚本统计自然周期内流量,再将其输出到 Prometheus 中。
实践
数据采集有很多种方式,首先应该厘清我们所需要的数据模型。
由于服务器是按月统计流量,那么指标应当返回当前时刻在计费周期(月)内消耗的流量。
metrics存储则有两种方式:
- 使用 node_exporter 的 textfile 收集自定义指标,可参考站内文章:node_exporter 添加自定义指标
-
自建 exporter,端口由 Prometheus 直接抓取。
vnStat 采集指标
vnStat 是一个轻量级的网络流量监控工具,它可以记录网络接口的流量并生成报告。
# Debian/Ubuntu
sudo apt install vnstat
# CentOS/RHEL
sudo yum install vnstat
命令示例:
# 启动服务
sudo systemctl start vnstat
sudo systemctl enable vnstat
# 查看所有接口的流量统计
vnstat
# 查看每日流量统计
vnstat -d
# 查看每月流量统计
vnstat -m
# 查看特定接口的流量
vnstat -i eth0
# 以json格式查看特定接口的流量
vnstat -i $INTERFACE --json
node_exporter 采集指标
使用计划任务定时运行 Shell 脚本即可,脚本如下:
#!/bin/bash
# 设置网卡名称
INTERFACE="ens3"
# 设置流量限制(单位:GB )
LIMIT=150
# 检查 vnstat 和 jq 是否已安装
if ! command -v vnstat &> /dev/null; then
echo "vnstat 未安装,请安装后重试。"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "jq 未安装,请安装后重试。"
exit 1
fi
# 检查 bc 是否已安装
if ! command -v bc &> /dev/null; then
echo "bc 未安装,请安装后重试。"
exit 1
fi
cat /dev/null > /var/lib/node_exporter/ecs_network_month_total_gb.prom.
cat /dev/null > /var/lib/node_exporter/ecs_network_month_total_gb.prom
# 获取当前流量(单位:bytes )
VNSTAT_JSON=$(vnstat -i $INTERFACE --json)
#echo "vnstat JSON 输出: $VNSTAT_JSON"
# 使用 jq 解析 JSON 数据获取接收和发送的流量(单位:bytes )
RX=$(echo $VNSTAT_JSON | jq -r '.interfaces[0].traffic.total.rx')
TX=$(echo $VNSTAT_JSON | jq -r '.interfaces[0].traffic.total.tx')
# 输出解析结果
#echo "接收流量 (RX): $RX bytes"
#echo "发送流量 (TX): $TX bytes"
# 检查 RX 和 TX 是否为有效的数字
if ! [[ $RX =~ ^[0-9]+$ ]] || ! [[ $TX =~ ^[0-9]+$ ]]; then
echo "RX 或 TX 不是有效的数字。"
exit 1
fi
# 计算总流量(单位:GB )
# 判断 RX 和 TX 中较大的值,交由node_exporter采集
cat > /var/lib/node_exporter/ecs_network_month_total_gb.prom <<EOF
# HELP ecs_network_month_total_gb
# TYPE ecs_network_month_total_gb gauge
EOF
if [ "$RX" -gt "$TX" ]; then
TOTAL=$(awk 'BEGIN{printf "%.3f\n", '"$RX"' / 1024 / 1024 / 1024}')
echo ecs_network_month_total_gb $TOTAL >> /var/lib/node_exporter/ecs_network_month_total_gb.prom
else
TOTAL=$(awk 'BEGIN{printf "%.3f\n", '"$TX"' / 1024 / 1024 / 1024}')
echo ecs_network_month_total_gb $TOTAL >> /var/lib/node_exporter/ecs_network_month_total_gb.prom
fi
自建 exporter 采集指标
可以使用 Systemd 管理脚本持续运行,Python 脚本如下:
#!/usr/bin/env python3
import json
import subprocess
from http.server import HTTPServer, BaseHTTPRequestHandler
import logging
# 配置参数
INTERFACE = "ens3"
LIMIT = 150 # GB
PORT = 9088 # Prometheus抓取端口
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('vnstat-exporter')
class VnstatCollector:
def __init__(self, interface):
self.interface = interface
def check_dependencies(self):
"""检查依赖项是否安装"""
dependencies = ['vnstat']
for dep in dependencies:
try:
subprocess.run(['which', dep], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError:
logger.error(f"{dep} 未安装,请安装后重试")
return False
return True
def collect_data(self):
"""收集vnstat数据"""
try:
# 获取vnstat JSON数据
result = subprocess.run(
['vnstat', '-i', self.interface, '--json'],
capture_output=True, text=True, check=True
)
vnstat_json = json.loads(result.stdout)
# 解析数据
rx = vnstat_json['interfaces'][0]['traffic']['total']['rx']
tx = vnstat_json['interfaces'][0]['traffic']['total']['tx']
# 转换为GB
rx_gb = rx / (1024 * 1024 * 1024)
tx_gb = tx / (1024 * 1024 * 1024)
# 取较大值
total_gb = max(rx_gb, tx_gb)
data = {
'rx_bytes': rx,
'tx_bytes': tx,
'rx_gb': rx_gb,
'tx_gb': tx_gb,
'total_gb': total_gb
}
logger.debug(f"数据已收集: RX={rx_gb:.3f}GB, TX={tx_gb:.3f}GB, Total={total_gb:.3f}GB")
return data
except Exception as e:
logger.error(f"收集数据时出错: {str(e)}")
return None
def get_metrics(self):
"""生成Prometheus格式的指标"""
data = self.collect_data()
if not data:
return "# 无法获取数据\n"
metrics = []
# 添加帮助和类型信息
metrics.append("# HELP ecs_network_month_total_gb 本月网络流量总计(GB)")
metrics.append("# TYPE ecs_network_month_total_gb gauge")
metrics.append(f"ecs_network_month_total_gb {data['total_gb']:.3f}")
# 添加更详细的指标
metrics.append("# HELP ecs_network_rx_bytes 接收的总字节数")
metrics.append("# TYPE ecs_network_rx_bytes gauge")
metrics.append(f"ecs_network_rx_bytes {data['rx_bytes']}")
metrics.append("# HELP ecs_network_tx_bytes 发送的总字节数")
metrics.append("# TYPE ecs_network_tx_bytes gauge")
metrics.append(f"ecs_network_tx_bytes {data['tx_bytes']}")
metrics.append("# HELP ecs_network_rx_gb 接收的总GB数")
metrics.append("# TYPE ecs_network_rx_gb gauge")
metrics.append(f"ecs_network_rx_gb {data['rx_gb']:.3f}")
metrics.append("# HELP ecs_network_tx_gb 发送的总GB数")
metrics.append("# TYPE ecs_network_tx_gb gauge")
metrics.append(f"ecs_network_tx_gb {data['tx_gb']:.3f}")
# 添加限制相关指标
metrics.append("# HELP ecs_network_limit_gb 流量限制(GB)")
metrics.append("# TYPE ecs_network_limit_gb gauge")
metrics.append(f"ecs_network_limit_gb {LIMIT}")
metrics.append("# HELP ecs_network_usage_percent 流量使用百分比")
metrics.append("# TYPE ecs_network_usage_percent gauge")
usage_percent = (data['total_gb'] / LIMIT) * 100
metrics.append(f"ecs_network_usage_percent {usage_percent:.2f}")
return "\n".join(metrics) + "\n"
class PrometheusHandler(BaseHTTPRequestHandler):
def __init__(self, *args, collector=None, **kwargs):
self.collector = collector
super().__init__(*args, **kwargs)
def do_GET(self):
if self.path == '/metrics':
# 每次请求时收集最新数据
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(self.collector.get_metrics().encode())
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b'Not Found')
def log_message(self, format, *args):
# 可以启用以下行来记录HTTP请求
# logger.debug(f"HTTP {format%args}")
pass
def main():
collector = VnstatCollector(INTERFACE)
# 检查依赖
if not collector.check_dependencies():
return
# 启动HTTP服务器
server = HTTPServer(('0.0.0.0', PORT), lambda *args: PrometheusHandler(*args, collector=collector))
logger.info(f"启动Prometheus指标服务器在端口 {PORT}")
logger.info(f"指标可通过 http://localhost:{PORT}/metrics 访问")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("服务器已停止")
if __name__ == "__main__":
main()
定时清空历史数据
云服务商往往不会按照自然月统计,而是按照账单日统计月流量,需要在计划任务中配置指定日期,清理 vnStat 数据。
Shell 脚本:
# cat /data/scripts/reset_network.sh
#!/bin/bash
# 停止 vnStat 服务
systemctl stop vnstat # 如果使用 systemd 管理服务
# 删除 vnStat 数据库文件(根据需要修改网络接口名称)
rm -f /var/lib/vnstat/* # 删除所有 vnstat 数据库文件
# 重新启动 vnStat 服务
systemctl start vnstat # 如果使用 systemd 管理服务
echo "vnStat 流量统计数据已重置。"
计划任务:
# 每月1日0时0分清空数据
0 0 1 * * /bin/bash /data/scripts/reset_network.sh
Grafana 绘图效果