实战案例4:监控系统数据采集插件(CPU、内存、网络)

好了,终于到了第四个实战案例。前面我们聊了日志、配置、协议解析,这次来点更贴近系统底层的——监控数据采集。

说实话,监控系统是我个人工作中接触最多的领域之一。我在一家云计算公司待过几年,那时候每天跟CPU、内存、网络打交道。你想想看,一个集群几千台机器,每台都要采集几十个指标,如果采集器写得不够高效,光是采集本身就能吃掉不少资源。这就很尴尬了——监控系统反而成了被监控的对象。

所以,用插件化架构来做数据采集,是一个很自然的选择。不同的指标由不同的插件负责,互不干扰,还能按需加载。

整体架构设计

我们先看看这个采集系统的整体结构。说白了,就是一个主框架加上一堆插件。主框架负责加载、调度、卸载,插件负责干活。

核心设计思路:每个插件是一个独立的动态库(.so),主程序通过dlopen/dlsym来加载和调用。插件只暴露三个接口:init、collect、cleanup。

采集主框架 插件管理器 CPU采集插件 cpu_collector.so 内存采集插件 mem_collector.so 网络采集插件 net_collector.so 数据聚合与上报 每个插件独立编译为.so,通过统一接口与主框架交互 支持运行时动态加载/卸载,不影响其他插件

插件接口定义

接口设计是插件化的灵魂。我见过不少项目,接口定义得太复杂,结果插件开发者叫苦连天。我的原则是:接口越简单越好

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