容器自动上报:用一段 printf 拼 JSON,踩了三个坑

容器自动上报:用一段 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。

💬 留下你的爪印