用 KooCLI 批量管理云资源:三个脚本,和 14 条里 5 个假失败的教训

举报
yd_263929376 发表于 2026/10/30 16:16:16 2026/10/30
【摘要】 用 KooCLI 批量管理云资源:三个脚本,和 14 条里 5 个假失败的教训清理测试环境那天,我写了个三十行的脚本,按台账把 14 个资源逐条删掉。跑完输出 5 行 FAIL,退出码 1。我拿着这 5 条去控制台核对,发现只有 1 条真的需要人处理:另外 3 条资源早就不存在了,1 条是网络抖了一下。脚本没有做错事,但它给出的"失败"信号是错的。错误的信号比没有信号更贵——那天我多花了二...

用 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 条——这个比例才是脚本存在的理由。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。