前情提要
上一篇我们解决了架构设计的实时性、安全性、可扩展性问题,定义了 ProtocolHeader 协议头和两条通信链路。这一篇我们把镜头对准 zmagent——整个系统中最贴近资源、最复杂的终端。
不同于 HTTP 的短连接,client 采用 TCP 长连接与 server 通信,优势在于方便命令下发。client 首先需要完成注册、认证,然后持续心跳保活链路,再就是任务执行与结果返回。
任务执行分两种:一种为实时命令,一种为定时任务,都需要放到单独的线程中执行,完成后统一通过通信链路返回 server。
从功能上看,client 分三种,底层协议一样,但分工不同:
-
agent:保持与 server 的长连接,定时采集 cpu/mem/disk/process 信息
-
bussiness:短连接,主要执行上传文件、提交入库命令,为扩展性服务
-
admin:主要用于 web 层对 agent 的管理命令下发
一、系统组成

| 命令字 | 方向 | 作用 |
|---|---|---|
0xA101 | Agent → Server | 主机注册 |
0xAf0f | Agent → Server | 心跳保活 |
0xA301 | Server → Agent | 执行脚本命令 |
0xA303 | Agent → Server | 执行结果上报 |
0xA304 | Agent → Server | 定时上报(JSON) |
0xA402 | Admin → Server | 管理命令下发 |
协议层不做业务解析,只负责拆包、校验同步头(非
0x1234直接断连)、按datalen读取载荷。
二、线程模型:各司其职
agent 是多线程协作的常驻进程,避免单线程阻塞导致命令响应延迟:
| 线程 | 职责 | 触发条件 |
|---|---|---|
| main | 初始化、注册、守护 | 启动时执行,断线后触发重连 |
| heartbeat | 保活 | 每 30 秒发送 0xAf0f |
| recvThread | 网络收包、协议解析 | 阻塞 recv,区分即时命令与配置更新 |
| execThread | 即时命令执行 | 由 cmdQueue + condition_variable 驱动 |
| scheduler | 定时任务轮询 | 每 60 秒扫描一次任务表 |
两条任务通道互不干扰:
-
即时命令:recvThread 收到
0xA301→ 入cmdQueue→ notify → execThread 消费 -
定时任务:scheduler 扫描本地
g_tasks→ 到期即执行 →0xA304JSON 上报
三、启动与注册
启动流程是严格顺序的:
-
读配置:
basecomm.conf中拿serverip和hostid -
建连:阻塞
connect到 server:6783,成功后切非阻塞 -
注册:若 conf 中无
hostid,发送0xA101注册包。此时 socket 临时切回阻塞模式,5 秒超时等待 Server 分配 ID,写入配置后后续复用 -
起线程:recv、heartbeat、exec 开始工作
-
守护:main 线程每秒
isConnected(),断线即进入重连
cpp
// 重连逻辑(简化)
void reconnect() {
disconnect();
sleep_for(5s);
if (init("basecomm.conf")) {
registerHost(); // 重新注册
}
}
四、即时命令执行:从收包到上报
Server 下发 0xA301 后,agent 侧完整处理链:
cpp
// 1. recvThread 解析入队
ServerCmd cmd;
cmd.cmd_id = packet.cmd_id;
cmd.script = packet.script_name; // 如 "cpu_check.sh"
cmd.params = packet.params; // 如 "-i 5"
{
lock_guard<mutex> lock(cmdMutex);
cmdQueue.push(cmd);
}
cmdCond.notify_one();
// 2. execThread 消费执行
void execThread() {
while (running) {
unique_lock<mutex> lock(cmdMutex);
cmdCond.wait(lock, []{ return !cmdQueue.empty(); });
auto cmd = cmdQueue.front(); cmdQueue.pop();
lock.unlock();
// 3. 安全校验
if (cmd.script.find("..") != string::npos ||
cmd.script.find('/') != string::npos) {
reportResult(cmd.cmd_id, -1, "非法脚本路径");
continue;
}
// 4. 执行(白名单目录)
string fullCmd = "/opt/zmagent/scripts/" + cmd.script
+ " " + sanitizeParams(cmd.params);
FILE* fp = popen(fullCmd.c_str(), "r");
char buf[1024];
string output;
while (fgets(buf, sizeof(buf), fp)) {
output += buf;
if (output.size() > 900) break; // 截断防撑爆
}
int code = pclose(fp);
// 5. 上报结果 0xA303
reportResult(cmd.cmd_id, code, output);
}
}
安全校验三条铁律:
-
脚本必须在
/opt/zmagent/scripts/下,禁止..和/ -
参数字符白名单:
[a-zA-Z0-9_.-],其余直接丢弃 -
输出截断 900 字节,防止单条结果包过大
五、定时任务与上报
定时任务不走队列,由 scheduler 线程独立驱动:
cpp
// scheduler 线程(60 秒周期)
while (running) {
sleep(60);
for (auto& task : g_tasks) {
if (task.isDue()) {
string json = executeAndFormatJson(task); // 采集 cpu/mem/...
sendTimedReport(json); // datatype=0xA304
}
}
}
0xA304 帧复用 STRUCT_REMOTE_TASK_PACKET 结构,但实际只发有效 JSON 长度,不填充固定 512 字节,节省带宽:
cpp
pkt.header.datatype = 0xA304;
pkt.header.datalen = json.size();
memcpy(pkt.taskParams, json.data(), json.size());
send(socket, &pkt.header, sizeof(header) + json.size() + 1, 0);
Server 收到后,直接通过 Unix Socket 转发给 zm_db_service.py 入库。
六、Server 侧如何配合
agent 不是孤立的,它的很多行为依赖 Server 的配合:
1. HostID 持久化 Server 维护 host_ids.txt,按 IP 分配 ID。agent 首次注册拿到 ID 后写回本地 conf,重连时直接携带,避免重复分配。
2. 命令暂存与转发 Admin 通过 0xA402 下发的命令,若目标 agent 当时未就绪,Server 将其压入该 hostid 对应的 pendingCmds。agent 连接的心跳间隙,Server 检查并下发 0xA301。
3. 结果入库 agent 上报的 0xA303/0xA304,Server 提取 JSON 后,通过 /tmp/zm_python_service.sock 转给 Python DB Service,异步写入 PostgreSQL,避免 C++ 层直接碰数据
七、附检查脚本样例
check_disk.sh*
check_docker_container.sh*
check_process.sh*
check_resource.sh*
check_disk.sh
#!/bin/bash
mode="${1:-/}"
# 排除的虚拟文件系统类型(不区分大小写)
EXCLUDE_TYPES="tmpfs|devtmpfs|proc|sysfs|cgroup|cgroup2|pstore|configfs|debugfs|tracefs|fusectl|fuse|overlay|squashfs|ramfs|hugetlbfs|mqueue|securityfs|bpf"
# ==================== 单路径模式(兼容旧版) ====================
if [ "$mode" != "all_disk" ]; then
target_dir="$mode"
if [ -z "$target_dir" ]; then
echo '{"error":"缺少目录参数"}'
exit 1
fi
if [ ! -d "$target_dir" ]; then
echo '{"error":"目录不存在"}'
exit 1
fi
df_output=$(df -P "$target_dir" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$df_output" ]; then
echo '{"error":"获取磁盘信息失败"}'
exit 1
fi
# 提取并转换为 JSON
echo "$df_output" | awk 'NR==2 {
total_mb = int($2 / 1024);
used_mb = int($3 / 1024);
avail_mb = int($4 / 1024);
gsub(/%/, "", $5);
printf "{\"filesystem\":\"%s\",\"type\":\"unknown\",\"size_mb\":%d,\"used_mb\":%d,\"avail_mb\":%d,\"use_percent\":%s,\"mounted_on\":\"%s\"}",
$1, total_mb, used_mb, avail_mb, $5, $6
}'
exit 0
fi
# ==================== all_disk 模式 ====================
# 使用 timeout 5秒防止 NFS 挂起阻塞
df_output=$(timeout 5 df -PT 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$df_output" ]; then
echo '[]'
exit 0
fi
# 用 awk 直接生成 JSON 数组,过滤虚拟文件系统
echo "$df_output" | awk -v exclude="$EXCLUDE_TYPES" '
BEGIN {
n = split(exclude, arr, "|");
for (i = 1; i <= n; i++) skip[tolower(arr[i])] = 1;
printf "[";
first = 1;
}
NR > 1 {
type = tolower($2);
if (type in skip) next;
total_mb = int($3 / 1024);
used_mb = int($4 / 1024);
avail_mb = int($5 / 1024);
gsub(/%/, "", $6);
if (!first) printf ",";
first = 0;
printf "{\"filesystem\":\"%s\",\"type\":\"%s\",\"size_mb\":%d,\"used_mb\":%d,\"avail_mb\":%d,\"use_percent\":%s,\"mounted_on\":\"%s\"}",
$1, $2, total_mb, used_mb, avail_mb, $6, $7;
}
END {
printf "]";
}
八、小结
zmagent 的核心就这几件事:
-
长连接保活:注册 → 心跳 → 断线重连
-
双任务通道:即时命令走队列 + 条件变量;定时任务走轮询
-
安全执行:路径白名单、参数过滤、输出截断
-
零业务耦合:采集逻辑交给 shell 脚本,agent 只负责"收-转-发"
代码量不大,但多线程的边界、阻塞调用的锁范围、重连时的状态清理,全是细节。下一篇我们把镜头转向 server 端——它要同时面对上千个 agent 的连接、Admin 的指令、Python DB Service 的转发,是怎么扛住的。
下篇预告:《全栈之路4---zmserver 开发实现》,看 C++ Server 如何处理并发连接与命令调度。

257

被折叠的 条评论
为什么被折叠?



