容器自动上报:用一段 printf 拼 JSON,踩了三个坑
狸花云控制台上,每台托管容器都有一张「运行状态」卡片:IPv4、IPv6、系统、内核、磁盘、内存、已装了哪些工具。这些数据不是从 PVE 来的——PVE 只知道容器开着,不知道里面跑的什么系统、还剩多少内存。
容器里也没有我们的 agent。装 agent 意味着要维护一个常驻进程、要考虑它自己崩了怎么办、要考虑用户卸载它怎么办。所以选了另一条路:平台主动 SSH 进去,跑一段临时脚本,让脚本自己把环境信息打成 JSON 回传。
拉模式(pull)而不是推模式(push)。代价是平台必须持有容器的登录凭据,收益是被采集端零常驻。
脚本长什么样
不是独立文件,是后端字符串拼出来的,跟着请求走:
VMID="212"
IP4="$(ip -4 addr show scope global | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1)"
IP6="$(ip -6 addr show scope global | grep -oE 'inet6 [0-9a-f:]+' | awk '{print $2}' | grep -v '^fe80' | head -1)"
KR="$(uname -r)"
DT="$(df -BG / | awk 'NR==2{gsub("G","",$2); print $2}')"
MT="$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)"
MA="$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo)"
MU=$((MT - MA))
TOOLS=""
for t in curl wget git htop vim unzip tree netstat dig tcpdump mtr iotop ss; do
if command -v "$t" >/dev/null 2>&1; then
TOOLS="$TOOLS\"$t\","
fi
done
TOOLS="[${TOOLS%,}]"
JSON=$(printf '{"vmid":"%s","hostname":"%s","ipv4":"%s","ipv6":"%s","kernel":"%s","os":"%s","tools":%s,"diskTotalGB":%s,"memTotalMB":%s,"uptimeSec":%s}' \
"$VMID" "$HN" "$IP4" "$IP6" "$KR" "$OS" "$TOOLS" "${DT:-0}" "${MT:-0}" "${UP:-0}")
curl -s -m 8 -X POST 'http://192.168.2.214:37100/api/agent/report' \
-H 'Content-Type: application/json' -d "$JSON" >/dev/null
字段来源全在系统自带文件里,不依赖任何额外安装:
| 字段 | 采集方式 | 缺值兜底 |
|---|---|---|
| ipv4 / ipv6 | ip addr show scope global 过滤 fe80 |
空字符串 |
| hostname | hostname |
空 |
| os | /etc/os-release 的 PRETTY_NAME |
Linux |
| kernel | uname -r |
空 |
| diskTotalGB / diskUsedGB | df -BG / 去单位 |
${DT:-0} |
| memTotalMB | /proc/meminfo MemTotal ÷1024 |
0 |
| memUsedMB | MemTotal − MemAvailable | 计算得出 |
| uptimeSec | /proc/uptime 取整 |
0 |
| tools | command -v 逐个探测 17 个命令 |
[] |
坑一:双引号被吃了两层
第一版 tools 长这样:
TOOLS="$TOOLS$t," # 结果:[curl,wget,]
拼出来的 JSON 是 "tools":[curl,wget]——没有引号的裸标识符。服务端 express.json() 直接抛解析错误,接口返回 400,前端只看到「上报失败」,日志里连个字段名都没有。
原因是转义层级算错了。这条脚本要过三层:后端 JS 字符串 → SSH 传输 → 远端 shell 解析。JS 里一个 \" 到远端只剩一个 ",而远端还需要一个 " 才能真正写进变量。所以必须写成 \\":
TOOLS="$TOOLS\\"$t\\"," # JS 里看到的是 \\",远端收到 \",变量里是 "
还有一个边界:一个命令都没装时 TOOLS 是空串,[${TOOLS%,}] 会退化成 [],这个倒是意外地正确。空数组能过,裸标识符过不了。
坑二:值里本身带引号
PRETTY_NAME 在 Debian 系上带引号:
PRETTY_NAME="Ubuntu 22.04.3 LTS"
早期版本用字符串拼接 JSON,"os":" + 这个值 + ",直接拼出 "os":""Ubuntu 22.04.3 LTS""——JSON 断了。改成 printf 加 %s 占位符后,引号变成普通的正文字符,前端显示 "Ubuntu 22.04.3 LTS"(就是带引号的字符串),虽然多了两个引号,但 JSON 合法。
现在做了两次 sed 剥引号,也没做 JSON 转义——因为剩下可能出现的特殊字符(hostname 里不会有引号,内核版本不会有反斜杠)概率极低。这是明确的技术债:要用字符串拼 JSON,就得假设值里没有引号和反斜杠。真要做干净,应该在远端用 jq -n --arg 生成,但容器里不一定有 jq,那又回到「依赖额外安装」的老问题。
坑三:10 分钟的窗口装不下一轮全量
自动上报的调度是:后端启动 3 分钟后先跑一次,之后每 10 分钟一轮。实现是 setInterval,遍历所有 running 容器,逐个 SSH 执行脚本,单台超时 20 秒。
问题在于串行。20 台容器里如果 5 台 IP 刚分配还没就绪、SSH 一路等到超时,这一轮就要花 100 秒;容器数再翻一倍、网络再差一点,一轮就可能超过 10 分钟——setInterval 不管上一轮有没有跑完,到点就再起一轮,两轮叠在一起同时 SSH 同一台机器,连接数翻倍,还可能互相把对方的超时拖长。
目前靠三个约束压住:单台 catch 掉不中断整轮、terminal.guestIp() 拿不到 IP 直接 continue(跳过而不是等超时)、单台超时压到 20 秒。这是「够用」而不是「正确」——正确做法是加一个进行中标志,上一轮没结束就跳过本轮,或者改成队列加并发上限。
顺带说一句「一键补报」
自动上报之外还留了个手动入口:管理员点一下,立刻对全部 running 容器跑一次。这条路径单台超时给到 30 秒,因为人就在屏幕前等结果,宁可慢一点也要拿全。
两条路径共用同一个脚本生成函数,唯一的差别就是超时值。共用是有意的——上报字段一旦两边各写一份,早晚会出现「手动补报有 tools、自动上报没有」这种对不上的情况。
小结
没有 agent 的采集,本质是拿「每次重新探测」换「零常驻」。这笔交易在三层 shell 转义、值里带引号、调度窗口这三个地方各收了一次利息。都不致命,但每一个都能让前端显示成一片空白,而日志里只有一句 400。