实战案例4:监控系统数据采集插件(CPU、内存、网络)
好了,终于到了第四个实战案例。前面我们聊了日志、配置、协议解析,这次来点更贴近系统底层的——监控数据采集。
说实话,监控系统是我个人工作中接触最多的领域之一。我在一家云计算公司待过几年,那时候每天跟CPU、内存、网络打交道。你想想看,一个集群几千台机器,每台都要采集几十个指标,如果采集器写得不够高效,光是采集本身就能吃掉不少资源。这就很尴尬了——监控系统反而成了被监控的对象。
所以,用插件化架构来做数据采集,是一个很自然的选择。不同的指标由不同的插件负责,互不干扰,还能按需加载。
整体架构设计
我们先看看这个采集系统的整体结构。说白了,就是一个主框架加上一堆插件。主框架负责加载、调度、卸载,插件负责干活。
核心设计思路:每个插件是一个独立的动态库(.so),主程序通过dlopen/dlsym来加载和调用。插件只暴露三个接口:init、collect、cleanup。
插件接口定义
接口设计是插件化的灵魂。我见过不少项目,接口定义得太复杂,结果插件开发者叫苦连天。我的原则是:接口越简单越好。
// plugin.h - 插件公共接口
#ifndef PLUGIN_H
#define PLUGIN_H
#include <stdint.h>
// 采集结果结构体
typedef struct {
char name[64]; // 指标名称
double value; // 指标值
uint64_t timestamp; // 采集时间戳
} metric_t;
// 插件描述信息
typedef struct {
char name[32]; // 插件名称
char version[16]; // 版本号
int interval; // 采集间隔(秒)
} plugin_info_t;
// 插件必须实现的三个接口
int plugin_init(plugin_info_t *info);
int plugin_collect(metric_t *metrics, int *count);
void plugin_cleanup(void);
#endif
我的经验:接口参数尽量用指针传递,避免大量数据拷贝。metric_t里的name字段固定64字节,够用又不浪费。我曾经见过有人用动态字符串,结果内存泄漏查了一整天。
CPU采集插件实现
CPU采集,说白了就是读/proc/stat文件。Linux下CPU信息都在这里。嗯,这里要注意:不同内核版本的文件格式略有差异,得做兼容处理。
// cpu_collector.c
#include "plugin.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static long prev_idle = 0, prev_total = 0;
int plugin_init(plugin_info_t *info) {
strcpy(info->name, "cpu_collector");
strcpy(info->version, "1.0.0");
info->interval = 2; // 每2秒采集一次
return 0;
}
int plugin_collect(metric_t *metrics, int *count) {
FILE *fp = fopen("/proc/stat", "r");
if (!fp) return -1;
char buf[256];
fgets(buf, sizeof(buf), fp); // 读取第一行
fclose(fp);
long user, nice, system, idle, iowait, irq, softirq, steal;
sscanf(buf, "cpu %ld %ld %ld %ld %ld %ld %ld %ld",
&user, &nice, &system, &idle,
&iowait, &irq, &softirq, &steal);
long total = user + nice + system + idle + iowait + irq + softirq + steal;
long delta_idle = idle - prev_idle;
long delta_total = total - prev_total;
double cpu_usage = 100.0 * (1.0 - (double)delta_idle / delta_total);
metrics[0].value = cpu_usage;
strcpy(metrics[0].name, "cpu_usage_percent");
metrics[0].timestamp = (uint64_t)time(NULL);
*count = 1;
prev_idle = idle;
prev_total = total;
return 0;
}
void plugin_cleanup(void) {
// 没啥好清理的
}
踩坑提醒:我第一次写CPU采集时,没处理第一次采集的数据。第一次调用时prev_idle和prev_total都是0,算出来的CPU使用率直接爆炸。后来加了个标志位,第一次只记录不计算。
内存采集插件实现
内存信息在/proc/meminfo里。这个文件内容很丰富,我们只取几个关键指标:总内存、可用内存、已用内存。
// mem_collector.c
#include "plugin.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int plugin_init(plugin_info_t *info) {
strcpy(info->name, "mem_collector");
strcpy(info->version, "1.0.0");
info->interval = 3; // 每3秒采集一次
return 0;
}
static long get_mem_value(const char *key) {
FILE *fp = fopen("/proc/meminfo", "r");
if (!fp) return -1;
char line[128];
long value = -1;
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, key, strlen(key)) == 0) {
sscanf(line, "%*s %ld", &value);
break;
}
}
fclose(fp);
return value;
}
int plugin_collect(metric_t *metrics, int *count) {
long total = get_mem_value("MemTotal:");
long free = get_mem_value("MemFree:");
long buffers = get_mem_value("Buffers:");
long cached = get_mem_value("Cached:");
if (total <= 0 || free < 0) return -1;
long used = total - free - buffers - cached;
double usage_percent = 100.0 * used / total;
metrics[0].value = (double)total;
strcpy(metrics[0].name, "mem_total_kb");
metrics[1].value = (double)used;
strcpy(metrics[1].name, "mem_used_kb");
metrics[2].value = usage_percent;
strcpy(metrics[2].name, "mem_usage_percent");
metrics[0].timestamp = metrics[1].timestamp = metrics[2].timestamp = (uint64_t)time(NULL);
*count = 3;
return 0;
}
void plugin_cleanup(void) {
// 无操作
}
你可能会问:为什么要把buffers和cached从已用内存里减掉?其实这是Linux内存管理的特性。buffers和cached这部分内存虽然被占用了,但应用程序需要时可以随时回收。所以算"真正已用"时要排除它们。
网络采集插件实现
网络采集稍微复杂一点。我们要从/proc/net/dev读取各个网卡的流量数据。而且需要计算速率,不能只给个累计值。
// net_collector.c
#include "plugin.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define MAX_INTERFACES 16
#define IF_NAME_LEN 16
typedef struct {
char name[IF_NAME_LEN];
long rx_bytes, tx_bytes;
long rx_packets, tx_packets;
} net_stat_t;
static net_stat_t prev_stats[MAX_INTERFACES];
static int if_count = 0;
int plugin_init(plugin_info_t *info) {
strcpy(info->name, "net_collector");
strcpy(info->version, "1.0.0");
info->interval = 5; // 网络变化慢,5秒采集一次
memset(prev_stats, 0, sizeof(prev_stats));
return 0;
}
int plugin_collect(metric_t *metrics, int *count) {
FILE *fp = fopen("/proc/net/dev", "r");
if (!fp) return -1;
char line[256];
int idx = 0;
*count = 0;
// 跳过前两行标题
fgets(line, sizeof(line), fp);
fgets(line, sizeof(line), fp);
while (fgets(line, sizeof(line), fp) && idx < MAX_INTERFACES) {
char if_name[IF_NAME_LEN];
long rx_bytes, rx_packets, tx_bytes, tx_packets;
sscanf(line, " %[^:]: %ld %ld %*ld %*ld %*ld %*ld %*ld %ld %ld",
if_name, &rx_bytes, &rx_packets, &tx_bytes, &tx_packets);
// 跳过回环接口
if (strcmp(if_name, "lo") == 0) continue;
// 计算速率(字节/秒)
long rx_delta = rx_bytes - prev_stats[idx].rx_bytes;
long tx_delta = tx_bytes - prev_stats[idx].tx_bytes;
if (prev_stats[idx].rx_bytes != 0) {
metrics[*count].value = (double)rx_delta / info->interval;
snprintf(metrics[*count].name, 64, "net_%s_rx_bytes", if_name);
metrics[*count].timestamp = (uint64_t)time(NULL);
(*count)++;
metrics[*count].value = (double)tx_delta / info->interval;
snprintf(metrics[*count].name, 64, "net_%s_tx_bytes", if_name);
metrics[*count].timestamp = (uint64_t)time(NULL);
(*count)++;
}
// 保存当前值供下次计算
strcpy(prev_stats[idx].name, if_name);
prev_stats[idx].rx_bytes = rx_bytes;
prev_stats[idx].tx_bytes = tx_bytes;
idx++;
}
if_count = idx;
fclose(fp);
return 0;
}
void plugin_cleanup(void) {
// 无操作
}
关键点:网络采集第一次调用时只记录不计算,第二次开始才有速率数据。这个逻辑和CPU采集是一样的。另外,我跳过了lo(回环)接口,因为监控它没啥意义。
主框架加载插件
有了插件,还得有个主框架来加载它们。这部分代码其实很通用,前面几章已经讲过类似的了。
// main_collector.c - 主框架核心
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>
typedef int (*init_func_t)(plugin_info_t *);
typedef int (*collect_func_t)(metric_t *, int *);
typedef void (*cleanup_func_t)(void);
typedef struct {
void *handle;
init_func_t init;
collect_func_t collect;
cleanup_func_t cleanup;
plugin_info_t info;
int active;
} plugin_instance_t;
#define MAX_PLUGINS 16
static plugin_instance_t plugins[MAX_PLUGINS];
static int plugin_count = 0;
int load_plugin(const char *so_path) {
void *handle = dlopen(so_path, RTLD_LAZY);
if (!handle) {
fprintf(stderr, "加载插件失败: %s\n", dlerror());
return -1;
}
plugin_instance_t *p = &plugins[plugin_count];
p->handle = handle;
p->init = (init_func_t)dlsym(handle, "plugin_init");
p->collect = (collect_func_t)dlsym(handle, "plugin_collect");
p->cleanup = (cleanup_func_t)dlsym(handle, "plugin_cleanup");
if (!p->init || !p->collect || !p->cleanup) {
fprintf(stderr, "插件接口不完整\n");
dlclose(handle);
return -1;
}
// 调用初始化
if (p->init(&p->info) != 0) {
fprintf(stderr, "插件初始化失败\n");
dlclose(handle);
return -1;
}
p->active = 1;
printf("已加载插件: %s v%s (间隔%d秒)\n",
p->info.name, p->info.version, p->info.interval);
return plugin_count++;
}
void run_collection_cycle(void) {
metric_t metrics[32];
int count;
for (int i = 0; i < plugin_count; i++) {
if (!plugins[i].active) continue;
count = 0;
if (plugins[i].collect(metrics, &count) == 0) {
// 上报数据
for (int j = 0; j < count; j++) {
printf("[%s] %s = %.2f\n",
plugins[i].info.name,
metrics[j].name, metrics[j].value);
}
}
}
}
我个人的习惯:每个插件采集完数据后,不要直接在主框架里做聚合或存储。主框架只负责调度和转发,具体怎么处理数据交给下游。这样插件和主框架的耦合度最低。
编译与运行
编译插件时,记得加-fPIC和-shared选项。主框架编译时要链接dl库。
# 编译插件
gcc -fPIC -shared -o cpu_collector.so cpu_collector.c
gcc -fPIC -shared -o mem_collector.so mem_collector.c
gcc -fPIC -shared -o net_collector.so net_collector.c
# 编译主框架
gcc -o collector main_collector.c -ldl
# 运行
./collector
实际项目中的坑
最后分享几个我在实际项目中踩过的坑:
- 文件描述符泄漏:采集插件频繁打开/proc下的文件,如果忘记fclose,几天后进程就崩了。我后来加了个RAII风格的封装,确保每个fopen都有对应的fclose。
- 采集间隔不同步:CPU每2秒、内存每3秒、网络每5秒。如果所有插件同时采集,瞬间CPU飙升。我后来加了时间片轮转,让不同插件的采集时间错开。
- 插件崩溃影响主框架:有个第三方插件写了个野指针,直接段错误。整个采集进程挂了。后来我考虑用子进程隔离,但代价太大。折中方案是加信号处理,捕获SIGSEGV后禁用该插件。
嗯,差不多就这些。监控采集插件这个案例,说白了就是"读文件、算指标、上报数据"三个步骤。但要把这三个步骤做好、做稳、做高效,里面门道不少。希望这个案例能给你一些启发。
公众号:蓝海资料掘金营,微信deep3321