用 KooCLI 批量管理云资源:三个脚本,和 14 条里 5 个假失败的教训
用 KooCLI 批量管理云资源:三个脚本,和 14 条里 5 个假失败的教训
清理测试环境那天,我写了个三十行的脚本,按台账把 14 个资源逐条删掉。跑完输出 5 行 FAIL,退出码 1。我拿着这 5 条去控制台核对,发现只有 1 条真的需要人处理:另外 3 条资源早就不存在了,1 条是网络抖了一下。
脚本没有做错事,但它给出的"失败"信号是错的。错误的信号比没有信号更贵——那天我多花了二十分钟在控制台上逐个点开确认。下面三个脚本是那次事故之后的版本,都跑过真实资源,也都在本地用固定夹具复现过。
一、先确认 KooCLI 的元数据是按需下载的
装完之后 hcloud 不在 PATH 里,DevKit 检查会直接返回 spawnSync hcloud ENOENT,错误码 HCLOUD_NOT_FOUND。Windows 下它认的安装目录是 ~/hcloud,不在 C:\Program Files,where hcloud 找不到属正常。
真正值得先弄清楚的是 ~/.hcloud/metaRepo。我这台机器上它只有 461 KB、46 个文件,覆盖 7 个服务:bss、ecs、eip、iam、ims、sts、vpc。服务总索引 services_cn.json 里列了 220 个服务,但只有你用过的服务才会被下载下来,每个服务再按 operation 缓存模板。各服务的元数据时间戳很说明问题:
| 服务 | 缓存 operation 数 | 元数据 updateTime |
|---|---|---|
| IAM | 240 | 2026-10-21 14:55 |
| ECS | 132 | 2026-10-23 00:09 |
| VPC | 202 | 2026-10-23 00:09 |
| EIP | 68 | 2026-10-23 00:12 |
EIP 的元数据是 00:12 落盘的,第一次调用在 00:13。意思是:一个新服务第一次被调用时,CLI 要先下载元数据再发请求,这一条会比后面几条慢一截。批量脚本里第一条慢、剩下的快,不是资源本身的问题。
七个服务合计 790 个 operation,其中 16 个同时挂着 v2 和 v3 两套版本(EIP 3 个,VPC 13 个)。这一点在第四节会变成坑。
二、脚本一:盘点,一次列全量
原则只有一条:不要在客户端解析整页 JSON,让 CLI 在服务端过滤。
LIST = {
"EIP": ("ListPublicips", "publicips[].id"),
"VPC": ("ListSecurityGroups", "security_groups[].id"),
}
def ids_of(svc):
op, path = LIST[svc]
args = CLI + [svc, op, "--cli-region=" + REGION,
"--cli-output=json", "--cli-query=%s" % path]
p = subprocess.run(args, capture_output=True, text=True, encoding="utf-8")
if p.returncode != 0 or not p.stdout.strip():
sys.stderr.write("[%s] 列举失败: %s\n" % (svc, (p.stderr or p.stdout)[:200]))
return []
return json.loads(p.stdout)
--cli-query 走 JMESPath,返回的是纯数组 ["4f06...", "6731..."];不加它,返回的是完整资源对象,其中 links、description、created_at 之类的字段你得自己剥。旧参数 --cli-json-filter 也能做同样的事,但官方已明确只维护不再升级,新脚本一律用 --cli-query。
跑 14 个资源的环境,盘点返回 11 行——因为列举只返回现存的资源。这条看起来理所当然,实际是个陷阱:盘点拿不到"已经被删掉"这个信息,而批量删除最需要的恰恰是它。所以台账必须来自快照,不能每次现拉。
三、脚本二:批量释放,把"已不存在"当成功
第一版(下面叫 v1)的判定逻辑就一行:
if p.returncode != 0:
fail += 1
print("FAIL %-6s %s %s" % (svc, name, (p.stdout or p.stderr).strip()[:60]))
拿同一份 14 条台账跑,结果:
FAIL EIP eip-db021ed7 {"request_id": "0f169f98...", "error_ms
FAIL EIP eip-26527cd4 ERROR [EIP] - dial tcp 121.37.0.1:443: i/o timeout
FAIL EIP eip-10181558 {"request_id": "2eeaff8e...", "error_ms
FAIL VPC vpc-61552e5e {"code": "VPC.0602", "message": "{\"NeutronError\": ...
FAIL VPC vpc-3c39cc97 {"code": "VPC.0602", "message": "{\"NeutronError\": ...
共 14 条,失败 5 条
5 个失败,退出码 1,耗时 5.66 秒。而这 5 条里,3 条的状态码是 404(资源已不存在,删除的目标早就达成了),1 条是 409(公网 IP 还绑着实例,重试一万次也没用),只有 1 条是真正的网络超时。
v2 改成先分类再决定怎么处理:
GONE = ("EIP.7904", "VPC.0602") # 资源已不存在:删除的目标已经达成
BLOCKED = ("EIP.7922",) # 还绑着实例:重试无用,得先解绑
def classify(p):
out = (p.stdout or "").strip()
if not out: # 没有 JSON:网络层就断了
return "transient", (p.stderr or "").strip()[:120]
body = json.loads(out)
code = body.get("error_code") or body.get("code") or ""
msg = body.get("error_msg") or body.get("message") or ""
if "error" not in body and p.returncode == 0:
return "ok", ""
if code in GONE or "could not be found" in msg or "does not exist" in msg:
return "gone", code
if code in BLOCKED:
return "blocked", code
return "failed", code or msg[:80]
四类结果对应四种处置:ok 记账;gone 也记账,不算失败;transient 退避重试,最多两次,间隔 1.5 秒、3 秒;blocked 立刻停手,报给人工解绑,不浪费重试预算。同一份台账再跑:
OK EIP eip-812bbb0c
GONE EIP eip-db021ed7 EIP.7904
BLOCKED EIP eip-10181558 EIP.7922
GONE VPC vpc-61552e5e VPC.0602
合计 14:成功 10,已不存在 3,被占用 1,失败 0
退出码 0,耗时 7.45 秒——比 v1 多了 1.5 秒,正是那次退避等待。多花 1.5 秒换掉 4 个假失败,这笔账怎么算都值。另外加 --apply 才真删,默认只打印 DRY 行,一次 API 都不发:演练模式下 14 条清单、0 次调用、退出码 0。
顺序上还有一处细节:EIP 排前面,安全组排最后。安全组被删掉之后,它的规则会跟着消失,脚本再回头删规则就会收到 404——日志里那两行 VPC.0602 ... Security group rule ... does not exist 就是这么来的,两个请求间隔 1.6 秒。
四、日志里还能读出三件事
错误体有两种形状。 EIP 返回的是 error_code + error_msg + request_id;VPC 走 v2 时返回的是 code + message,而且 message 是一层嵌套的 JSON 字符串:{"NeutronError":{"message":"Security group rule ... does not exist","type":"SecurityGroupRuleNotFound"}}。本地夹具实测,VPC 响应体只有 ['code', 'message'] 两个顶层字段——只读 error_code 的脚本,会把两条已经删掉的 VPC 资源报成失败。这就是 v2 里 body.get("error_code") or body.get("code") 那一行的由来,它不是防御性编程的装饰。
409 不是重试能解决的问题。 日志里同一条 AssociatePublicips 在 00:13:17.649 和 00:13:24.201 各报了一次 EIP.7922 ... has already bound instance,间隔 6.55 秒。同一台机器的第二次尝试,参数一个字都没变。这种情况该做的是"先解绑再删",或者干脆标记为人工处理。
失败是有固定成本的。 每次 API 失败,KooCLI 会额外请求错误中心拿解决方案,四次实测分别耗时 324 / 301 / 309 / 276 ms,均值约 302 ms。四次失败就是 1.2 秒纯等待,换回来的是四行 Failed to obtain solutions from Error Center. Cause: No solutions in response body.——这条路径连官方都没存到解法。所以批量脚本要做的是减少失败次数,而不是失败后去查解释。
五、脚本三:对账,用快照而不是实时列举
第三个脚本最短,也最省钱:把上周的快照和今天的清单做差集,输出"应该删但还活着"的清单。
snap = {r[1] for r in read_tsv("snapshot-1023.tsv")}
live = {r[1] for r in read_tsv("inventory_full.tsv")}
orphan = snap & live # 上周就有、这周还在
with open("orphan.tsv", "w", encoding="utf-8") as f:
f.write("service\tid\tname\n")
for svc, rid, name in read_tsv("snapshot-1023.tsv"):
if rid in orphan:
f.write("%s\t%s\t%s\n" % (svc, rid, name))
差集本身三行,价值在于它把"成本"变成了一个可比较的数字:上周 11 条现存,今天 11 条现存,差集 11 条——说明这一周没有任何资源被清理掉,账单会原样延续。反过来,如果差集在缩小,那就是真的在收敛。
这里刻意不用实时列举的结果做基线,因为实时列举永远看不到已删除的资源,你无法判断"少了"是因为被清理了,还是因为列举接口分页漏了。
结论
批量管理云资源的脚本,判定逻辑比删除逻辑重要一个数量级:删除只有一句 DeleteXxx,而"这条到底算成功还是失败"决定了你第二天要不要再花二十分钟去控制台核对。把 404 归到成功、把 409 归到人工、把网络抖动归到重试,三类分开之后,14 条资源里需要人看的从 5 条降到 1 条——这个比例才是脚本存在的理由。
- 点赞
- 收藏
- 关注作者
评论(0)