在 Termux 里跑华为云 KooCLI:一次跨越 glibc、DNS、CA 和 seccomp 的适配
在 Termux 里跑华为云 KooCLI:一次跨越 glibc、DNS、CA 和 seccomp 的适配
摘要(三句话版)
华为云 KooCLI(hcloud)官方只提供 glibc 动态链接的 Linux 二进制,Termux 用的是 Android Bionic,直接执行报 cannot execute: required file not found。我在红米 K40(Android 13,内核 4.19.157)上把它跑通了,代价是踩了四个坑:glibc loader、$USER、/etc/resolv.conf、CA 证书。本文把每一步的命令、报错原文和根因都摊开讲,并额外给出一个更硬的结论——Android seccomp 白名单里没有 faccessat2,连 hcloud 自己在 obsutil 子命令路径上都会崩溃,而这个缺陷 Go 官方只在 GOOS=android 编译时规避。
本文适合谁:想在 Android 手机上做云资源运维、或者需要理解"为什么同一个 Go 二进制,一半命令能跑一半崩溃"的开发者。
数据截止:2026 年 10 月 23 日。所有命令均在本机实测,报错为原文复制;数字对应文末来源。
关键数字一览
| 数字 | 含义 | 来源 |
|---|---|---|
| 4.19.157 | 测试机内核版本(faccessat2 需要 5.8+) |
本机 uname -a |
| 40.2 MB | hcloud-real 二进制体积 |
本机 ls -l |
| 7.3.12 | 实测 KooCLI 版本 | hcloud version |
| 1.26.1 | hcloud-real 的 Go 编译器版本 |
strings 提取 |
| 68 / 0 | hcloud version 的 faccessat / faccessat2 调用次数 |
strace |
| 30 / 2 | hcloud OBS 的 faccessat / faccessat2 调用次数 |
strace |
| 4 | 需要修掉的适配坑数量 | 本文第二至五节 |
| 0.25 元 | 一次完整的 ECS 建-验-删闭环实际花费(账单分两笔:0.22 + 0.03) | 费用中心 |
四个坑一览
| # | 坑 | 报错原文 | 解法 |
|---|---|---|---|
| 1 | glibc 动态加载器缺失 | cannot execute: required file not found |
grun(glibc-runner) |
| 2 | $USER 未设置 |
user: Current requires cgo or $USER set in environment |
export USER=root |
| 3 | DNS 解析失败 | lookup ... on [::1]:53: connection refused |
proot bind mount resolv.conf |
| 4 | CA 证书缺失 | x509: certificate signed by unknown authority |
SSL_CERT_FILE |
一、为什么会有这个问题:Bionic 与 glibc 的根本冲突
Termux 不是"Android 上的 Linux 发行版",它是跑在 Android 内核上的一个普通应用进程。它的 libc 是 Android 的 Bionic,不是主流 Linux 的 glibc。两者在动态加载器上就不兼容:
| 标准 Linux(glibc) | Termux(Bionic) | |
|---|---|---|
| 动态加载器路径 | /lib/ld-linux-aarch64.so.1 |
/system/bin/linker64 |
| C 库 | libc.so.6 |
libc.so(Bionic) |
LD_PRELOAD |
常规机制 | 被 libtermux-exec.so 占用做可执行文件路径重写 |
hcloud 官方二进制的 readelf 输出是明确的:
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
这个路径在 Termux 里不存在,所以内核在 execve 阶段就直接拒绝,报的错是 cannot execute: required file not found——注意这个措辞,它容易让人误以为二进制文件本身丢了,其实是加载器找不到。
一句话结论:这不是"缺少依赖库",是内核层面找不到 ELF interpreter,装多少个
.so都没用。
二、坑 1 与坑 2:先补加载器,再补 $USER
glibc-runner 是 termux-pacman 项目提供的脚本层[9],核心动作是用 patchelf 改写二进制的 interpreter 指向和 rpath。官方 wiki 的原话是[1]:
Glibc-runner allows you to run such binaries with
$PREFIX/glibc/bin/ld.soor set the dynamic linker path and Glibc-based libraries directory path withpatchelf.
它提供两种用法:grun ./binary(临时包装)和 grun --set ./binary(永久改写)。我用的是前者,不污染原文件。
前置检查(本机已装,直接可用):
pkg list-installed | grep -iE "glibc|patchelf"
# glibc-repo/stable,now 1.0 all [installed]
# glibc-runner/glibc,now 2.0-3 all [installed]
# glibc/glibc,now 2.44 aarch64 [installed]
# patchelf/now 0.19.1 aarch64 [installed]
下载官方二进制(ARM64 版本,来源为华为云官方 OBS[2]):
curl -LO "https://cn-north-4-hdn-koocli.obs.cn-north-4.myhuaweicloud.com/cli/latest/huaweicloud-cli-linux-arm64.tar.gz"
tar -zxvf huaweicloud-cli-linux-arm64.tar.gz
# 解出 hcloud / README.md / OpenSourceSoftwareNotice.md
第一次运行,撞上坑 2:
grun ./hcloud version
# [CLI_ERROR]Failed to obtain home directory of current user.
# Cause: user: Current requires cgo or $USER set in environment
坑 2 是 Go 运行时的特性:os.UserHomeDir() 在无 cgo 构建下优先读 $USER 环境变量,然后去 /etc/passwd 里按用户名查家目录。而 Termux 的默认 shell 里 USER 确实是空的(本机实测 env | grep ^USER= 无输出),HOME 倒是有值——但这条路径先看 USER,空就直接报错说 “requires cgo or $USER set”。
即便 USER 设了,还得能在 /etc/passwd 里查到。Android 的 /etc/passwd 是个 0 字节空文件(实测 wc -l /system/etc/passwd = 0),所以 HOME 最好显式给出来:
env | grep -E "^USER=|^HOME="
# HOME=/data/data/com.termux/files/home ← 只有 HOME,没有 USER
# 两个都不设:直接报错
grun ./hcloud version
# [CLI_ERROR]Failed to obtain home directory of current user.
# Cause: user: Current requires cgo or $USER set in environment
# 只设 USER:能跑起来(实测通过)
USER=root grun ./hcloud version
# Current KooCLI version: 7.3.12
# 两个都给:本文采用的方式
USER=root HOME=$HOME grun ./hcloud version
# KooCLI will connect to the internet through HTTPS to manage your cloud resources...
# Agree and continue (y)/Disagree and exit (N):
到这一步二进制已经跑起来了,只差首次运行的隐私声明确认:
echo "y" | USER=root HOME=$HOME grun ./hcloud version
# Current KooCLI version: 7.3.12
一句话结论:坑 1 和坑 2 都是"能跑起来"级别的,一个
grun加一个export就够。
三、坑 3:DNS 为什么在 glibc 环境里能用、在 hcloud 里不能
这是整篇文章里最反直觉的一处。现象是:
# glibc 环境里的 getent 能正常解析
USER=root HOME=$HOME grun /data/data/com.termux/files/usr/glibc/bin/getent hosts huaweicloud.com
# 119.3.120.194 huaweicloud.com
# 但 hcloud 报 DNS 失败(首次运行、无元数据缓存时)
hcloud ECS --help
# [APIE_ERROR]Failed to obtain service list.
# Cause: dial tcp: lookup apiexplorer.cn-north-4.myhuaweicloud.com on [::1]:53:
# read udp [::1]:49867->[::1]:53: read: connection refused
同一个 glibc 环境,为什么一个能解析一个不能?
我一开始的猜测是"LD_PRELOAD 重写路径,只对 libc 程序有效,对 Go 静态二进制无效"。这个猜测是错的,用 strace 一查就露馅了。
先看 getent 实际打开的是哪个文件:
strace -f -e trace=openat -o g.log \
grun $PREFIX/glibc/bin/getent hosts huaweicloud.com
grep resolv g.log
# openat(AT_FDCWD, "/data/data/com.termux/files/usr/glibc/etc/resolv.conf", O_RDONLY) = 3
它读的是 $PREFIX/glibc/etc/resolv.conf,不是 /etc/resolv.conf。 这不是运行时重写的结果——glibc 在编译时就把 sysconfdir 定死成了 $PREFIX/glibc/etc,字符串直接烧在 libc.so.6 里:
strings $PREFIX/glibc/lib/libc.so.6 | grep "glibc/etc/"
# /data/data/com.termux/files/usr/glibc/etc/resolv.conf
# /data/data/com.termux/files/usr/glibc/etc/hosts
# /data/data/com.termux/files/usr/glibc/etc/nsswitch.conf
而 $PREFIX/glibc/etc/resolv.conf 本身是个软链,指向 Termux 的配置:
ls -l $PREFIX/glibc/etc/resolv.conf
# ... -> /data/data/com.termux/files/usr/etc/resolv.conf
LD_PRELOAD 在这里完全没参与。 顺带也排除了这个假设:libtermux-exec 的 strings 里只有 /bin/、/usr/bin 这类可执行文件路径的重写规则,压根没有 /etc/ 的映射表。
再看 hcloud 打开的是哪个:
strace -f -e trace=openat -o h.log \
grun $HOME/.local/bin/hcloud-real ECS ListServersDetails --cli-region=cn-north-4
grep resolv h.log
# openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
硬编码 /etc/resolv.conf,直接 ENOENT。 而 Termux 里 /etc 是个指向 /system/etc 的只读软链,那里面没有 resolv.conf:
ls -ld /etc
# lrw-r--r--. 1 root root 11 Jan 1 2009 /etc -> /system/etc
ls /system/etc/resolv.conf
# ls: cannot access '/system/etc/resolv.conf': No such file or directory
touch /system/etc/.probe
# touch: cannot touch '/system/etc/.probe': Read-only file system
所以真正的根因不是"LD_PRELOAD 救不了 Go",而是两条路径压根不同:
| 读哪个文件 | 怎么决定的 | |
|---|---|---|
glibc 的 getent |
$PREFIX/glibc/etc/resolv.conf |
编译期 sysconfdir 常量 |
Go 的 hcloud |
/etc/resolv.conf |
Go resolver 硬编码的绝对路径 |
Go 的 net 包在 Linux 上把 /etc/resolv.conf 写成了绝对路径,不看 sysconfdir,也不读任何环境变量。读到 ENOENT 之后它不报错,而是静默使用一份内置默认配置——实测报错里的 [::1]:53 就是这份默认配置的产物,本机 IPv6 回环上没有 DNS 服务在听:
dial tcp: lookup ... on [::1]:53: read udp [::1]:49867->[::1]:53: read: connection refused
(关于 Go 具体用哪几个默认 nameserver、代码在哪一行,本文不引源码——这里只陈述 strace 和报错原文能证明的部分:它确实尝试了 [::1]:53。)
注意一个无效尝试:GODEBUG=netdns=cgo 不管用,因为二进制里根本没编译进 cgo resolver:
GODEBUG=netdns=cgo hcloud ECS --help
# 同样的 [::1]:53 报错
有效解法是 proot 的 bind mount:
proot \
-b /data/data/com.termux/files/usr/etc/resolv.conf:/etc/resolv.conf \
-b /data/data/com.termux/files/usr/etc/hosts:/etc/hosts \
grun /path/to/hcloud-real ECS --help
这一幕是真实历史:--help 报 DNS 错只发生在首次运行(服务元数据还没缓存进 ~/.hcloud/metaRepo/)。之后 --help 会命中缓存不再走网络——所以判断 DNS 是否修好,必须用真实 API 调用,别用 --help。这一步之所以能证明"DNS 通了",是因为报错从 DNS 变成了证书,说明它确实联网了:
# tls: failed to verify certificate: x509: certificate signed by unknown authority
一句话结论:不是
LD_PRELOAD失效,是两条路径从一开始就不同——glibc 的sysconfdir被编译成$PREFIX/glibc/etc,而 Go 的 resolver 把/etc/resolv.conf写死在代码里。proot的 bind mount 是我实测有效的解法(理论上也可以改/etc软链或重编译走 cgo,但那两条我没验证,且/system/etc是只读的,改不了)。
四、坑 4:CA 证书与 SSL_CERT_FILE
Go 的 crypto/x509 会在一组固定的系统路径下找 CA 证书(例如 /etc/ssl/certs/ca-certificates.crt 这类发行版标准位置),而 Termux 把证书放在 $PREFIX/etc/tls/cert.pem——不在那份清单里。所以即使 DNS 通了,TLS 握手仍然失败:
# 加上 proot bind mount 后,报错从 DNS 变成了证书
tls: failed to verify certificate: x509: certificate signed by unknown authority
Go 支持用 SSL_CERT_FILE 环境变量覆盖这个查找路径:
export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem"
(本机实测该文件存在;glibc 包另有一份在 $PREFIX/glibc/etc/ssl/certs/ca-certificates.crt,两者都可作来源。)
四个坑全通之后,真实 API 调用成功:
hcloud ECS ListServersDetails --cli-region=cn-north-4
# {
# "count": 0,
# "servers": []
# }
一句话结论:四个坑里三个是环境变量/路径问题,只有坑 3 是真正的架构性冲突,必须靠
proot解决。
五、最终形态:一个包装器脚本
把四个修复固化进 ~/.local/bin/hcloud,原二进制改名 hcloud-real:
#!/data/data/com.termux/files/usr/bin/bash
PREFIX_TERMUX="${PREFIX:-/data/data/com.termux/files/usr}"
HCLOUD_REAL="$HOME/.local/bin/hcloud-real"
export USER="${USER:-root}"
export HOME="${HOME:-/data/data/com.termux/files/home}"
export SSL_CERT_FILE="${SSL_CERT_FILE:-$PREFIX_TERMUX/etc/tls/cert.pem}"
exec proot \
-b "$PREFIX_TERMUX/etc/resolv.conf:/etc/resolv.conf" \
-b "$PREFIX_TERMUX/etc/hosts:/etc/hosts" \
grun "$HCLOUD_REAL" "$@"
然后正常用:
hcloud configure set \
--cli-access-key="$AK" --cli-secret-key="$SK" \
--cli-region="cn-north-4" --cli-mode=AKSK
hcloud configure show
hcloud ECS ListServersDetails --cli-region=cn-north-4
六、seccomp 盲区:hcloud 的 OBS 子命令自己就会崩
先说一个我原先没料到的事实:hcloud 自己也不是全绿的。 包装器搭好之后我以为万事大吉,直到拿一个没缓存过的服务名去试:
grun ~/.local/bin/hcloud-real OBS
# SIGSYS: bad system call
# PC=0x1b6c0 m=6 sigcode=1
hcloud OBS 走的是内置 obsutil 子命令,它在 spawn 子进程前后会对自己的可执行文件做一次可执行性检查,参数是 X_OK | AT_EACCESS——这个检查路径正好落在 faccessat2 上。strace 抓到的原文:
faccessat2(AT_FDCWD, "/data/data/com.termux/files/home/.local/bin/hcloud-real", X_OK, AT_EACCESS) = -1 ENETDOWN
--- SIGSYS {si_signo=SIGSYS, si_code=SYS_SECCOMP, si_syscall=__NR_faccessat2} ---
包装器里那个 proot 顺手把它兜住了——hcloud OBS 经包装器调用就正常输出 obsutil 的帮助。这一点很关键:proot 在本方案里不只是"修 DNS",它同时是 seccomp 的绕行层。
(这不是 hcloud 特有的毛病。同样的现象在别的 glibc Go 二进制上也存在,比如华为云的 devbridge 在 auth status 路径同样踩 faccessat2 崩溃——这是这类二进制的通病,hcloud 只是我们手里的这一个。)
SIGSYS 不是段错误,是 seccomp 主动杀进程。 根因链条如下。
第一步:确认故障面。 用 strace 对比 hcloud 两条命令:
| 命令 | faccessat |
faccessat2 |
结果 |
|---|---|---|---|
hcloud version |
68 次 | 0 次 | ✅ 正常 |
hcloud OBS |
30 次 | 2 次 | ❌ 第 2 次即 SIGSYS |
结论:不是二进制整体不可用,而是"只要代码路径走到 faccessat2 就死"。 hcloud 常用的 ECS/VPC/EIP 路径恰好不碰它,但 OBS 子命令要拉起 obsutil,恰好踩中。
第二步:为什么 Android 不允许 faccessat2。 AOSP 的 seccomp 策略是白名单制,算法写在 SECCOMP_BLOCKLIST_APP.TXT 的注释里[3]:
The final seccomp allowlist is
SYSCALLS.TXT-SECCOMP_BLOCKLIST.TXT+SECCOMP_ALLOWLIST.TXT
而 SYSCALLS.TXT 里关于 faccessat 只有一行[3]:
__faccessat:faccessat(int, const char*, int) all
只有 faccessat,没有 faccessat2。 所以 faccessat2 不在白名单内,触发 seccomp 动作。Go 官方 issue #57393 里,Go 团队成员 prattmic 的判断与此一致[4]:
In this case I believe
faccess2is not allowed simply because it isn’t defined in the baseSYSCALLS.TXTyet (it is a fairly new syscall).
第三步:faccessat2 是什么时候的 syscall。 它是 Linux 5.8 引入的(2020 年)。测试机内核是 4.19.157,内核本身就没有这个 syscall。正常情况下应该返回 ENOSYS,Go 就会自动回退到旧的 faccessat。但 Android 的 seccomp 策略不是返回错误码,而是直接发信号终止进程——strace 里看到的 si_code=SYS_SECCOMP 就是这个含义。Go 的回退逻辑因此没机会执行。
Go 团队在 issue 里明确说过这一点[4]:
If
faccessat2is not supported then we expect that system call to returnENOSYSorEPERM. We certainly don’t expect the program to be killed by a signal.
第四步,也是最关键的一步:Go 1.20 修了,但没修到我们头上。
Go 确实在 1.20 修了这个问题(CL 458495,syscall: don't use faccessat2 on android[10])。但看修复后的源码[5]:
// Don't attempt on Android, which does not allow faccessat2 through
// its seccomp policy [1] on any version of Android as of 2022-12-20.
if runtime.GOOS != "android" {
if err := faccessat2(dirfd, path, mode, flags); err != ENOSYS && err != EPERM {
return err
}
}
这是编译期条件。 只有 GOOS=android 编译出来的二进制才跳过 faccessat2。
而 hcloud-real 的 strings 输出明确显示它是 linux/arm64 的 go1.26.1 二进制:
strings hcloud-real | grep -oE "go1\.[0-9]+(\.[0-9]+)?" | sort -u
# go1.26
# go1.26.1
Go 版本远超 1.20,但因为编译目标是 linux 而不是 android,那段规避代码根本不存在于二进制里。
一句话结论:Go 官方的修复对"跑在 Android 上的 linux/arm64 二进制"完全无效。这是
GOOS与"实际运行环境"错配导致的系统性盲区。
第五步:绕过它只需要 proot,不需要完整的 proot-distro。 proot 用 ptrace 在用户态接管子进程的 syscall,自己模拟执行,syscall 不再原样落到内核的 seccomp 过滤器上。这一点有实测支撑——同一个 hcloud OBS,直连裸二进制崩,套上包装器就正常:
# 直连(无 proot):崩
USER=root HOME=$HOME SSL_CERT_FILE=$PREFIX/etc/tls/cert.pem \
grun ~/.local/bin/hcloud-real OBS
# SIGSYS: bad system call
# 经包装器(带 proot):正常输出 obsutil 帮助
hcloud OBS
# Usage: obsutil [command] [args...] [options...]
结论:hcloud 包装器里为了修 DNS 而加的 proot,同时就是 seccomp 的绕行层。 一个 proot 同时解决 DNS 与 seccomp,不必额外装 proot-distro 或任何完整发行版。
一句话结论:
grun解决 glibc 加载,proot解决 seccomp(也顺带解决 DNS);两者各管一段,不能互相替代。同一个hcloud包装器脚本把两件事一次做完了。
七、一个完整的实战闭环:建 ECS、验站、提交、删除
跑通工具链之后,我用它完整走了一遍华为云的"2048 闯关"活动:从创建资源到公网验证通过约 6 分钟。命令序列如下(凭据从文件读取,不出现在命令行历史里)。
账单实测(费用中心,2026-10-23):一次完整的建-验-删闭环,现金支出累计 ¥0.25(按量付费整点结算,跨结算点被拆成 0.22 + 0.03 两笔)。判断"到底花了多少"要看「现金支出」累计值,不是「本月应付」——后者只反映已结算部分。
补子网 DNS(云主机 cloud-init 装软件的前提):
hcloud VPC UpdateSubnet --cli-region=cn-north-4 \
--subnet_id=2ecc42f7-1513-44a2-91c8-a09c3bb58ab5 \
--vpc_id=aaa40c74-864a-4381-9bec-0ca31d5caeeb \
--subnet.name=subnet-default-smb \
--subnet.dnsList.1=100.125.1.250 \
--subnet.dnsList.2=100.125.129.250
建安全组——这里踩到一个官方教程的坑:文档写的是 CreateSecurityGroup --security_group.vpc_id=...,但 KooCLI 默认走 v3 版本,v3 的 CreateSecurityGroup 不接受 vpc_id:
# 官方教程的写法,报错
hcloud VPC CreateSecurityGroup --security_group.vpc_id=... --security_group.name=game2048-sg
# [USE_ERROR]Invalid parameter: security_group.vpc_id
# 正确写法:显式指定 v2
hcloud VPC CreateSecurityGroup/v2 --cli-region=cn-north-4 \
--security_group.vpc_id=aaa40c74-864a-4381-9bec-0ca31d5caeeb \
--security_group.name=game2048-sg
# → security_group.id = 712403da-...
同理,加规则时 v3 用的是 multiport 而不是 port_range_min/max:
hcloud VPC CreateSecurityGroupRule --cli-region=cn-north-4 \
--security_group_rule.security_group_id=712403da-... \
--security_group_rule.direction=ingress \
--security_group_rule.protocol=tcp \
--security_group_rule.ethertype=IPv4 \
--security_group_rule.multiport=80 \
--security_group_rule.remote_ip_prefix=0.0.0.0/0
建 EIP(按流量计费,而不是按带宽——本例页面只有 4 KB,出云流量费实测趋近于零[6]:按量流量费 0.64 元/GB、IP 保有费 0.02 元/小时):
hcloud EIP CreatePublicip --cli-region=cn-north-4 \
--publicip.type=5_bgp --publicip.ip_version=4 \
--bandwidth.share_type=PER --bandwidth.name=game2048-bw \
--bandwidth.size=1 --bandwidth.charge_mode=traffic
# → public_ip_address = 120.46.80.79
ECS dry-run 先验参数(零成本):
hcloud ECS CreateServers --cli-region=cn-north-4 --dry_run=true \
--server.name=game2048-web \
--server.imageRef=dd7fba37-0ce2-465b-bae3-73319fcb64f0 \
--server.flavorRef=s6.medium.2 \
--server.vpcid=aaa40c74-864a-4381-9bec-0ca31d5caeeb \
--server.nics.1.subnet_id=2ecc42f7-1513-44a2-91c8-a09c3bb58ab5 \
--server.security_groups.1.id=712403da-5511-4160-97a7-47e664dbdc7c \
--server.availability_zone=cn-north-4a \
--server.root_volume.volumetype=SSD \
--server.root_volume.size=40
# 返回 202 = 参数校验通过,未创建
绑 EIP 时又踩一个坑:UpdatePublicip 要的是网卡 port_id,不是 server_id。传 server_id 会报 PortNotFound:
# 从 ECS 反查 port_id(v2 版本才有 device_id 参数)
hcloud VPC ListPorts/v2 --cli-region=cn-north-4 --device_id=<SERVER_ID>
# → ports[0].id = b314da8a-e561-4064-9b32-b85b882e55c0
hcloud EIP UpdatePublicip --cli-region=cn-north-4 \
--publicip_id=bffff683-17de-4047-8de5-14823415d797 \
--publicip.port_id=b314da8a-e561-4064-9b32-b85b882e55c0
验证与删除:
curl -sI http://120.46.80.79/hc-activity/index.html
# HTTP/1.1 200 OK
# Server: nginx/1.20.1
# Content-Length: 3988
hcloud ECS DeleteServers --cli-region=cn-north-4 \
--servers.1.id=<SERVER_ID> \
--delete_publicip=true --delete_volume=true
两个删除开关必须显式给。API 文档明确:--delete_volume 默认 false(只卸载不删盘,盘继续计费),--delete_publicip 不传时取决于 EIP 的 delete_on_termination 属性。实测即使传了 --delete_publicip=true,EIP 仍残留为 DOWN 状态,需要单独再删一次:
hcloud EIP DeletePublicip --cli-region=cn-north-4 --publicip_id=<EIP_ID>
清理后必须逐项核验,不能只看删除命令返回成功。实测的核验结果:
hcloud ECS ListServersDetails --cli-region=cn-north-4 | grep '"count"'
# "count": 0,
hcloud EVS ListVolumes --cli-region=cn-north-4 | grep '"count"'
# "count": 0,
hcloud EIP ListPublicips --cli-region=cn-north-4 | grep -E '"total_count"'
# "total_count": 0,
hcloud VPC ListSecurityGroups/v2 --cli-region=cn-north-4 | grep '"name"'
# "name": "sg-default-smb", ← 系统默认,非本次创建
# "name": "Sys-default", ← 系统默认,非本次创建
注意一个易混淆点:费用中心总览页的「近 7 天扣费资源」会显示"云硬盘 1、弹性云服务器 1",但这只是过去 7 天的账单统计,不代表资源还在。判断资源是否存活必须查 API 的实时列表,不能看这个统计块。
计费粒度值得记住:ECS 与 EIP 的按量付费都是秒级计费、整点结算[6][11]。关机不等于停止计费——vCPU 和内存不计费了,但云硬盘和带宽照扣[7]。想真正止损只有删除。
一句话结论:这个闭环的实操成本 0.25 元,但如果不删 EIP 和磁盘,会持续产生费用,且账单要等下一个整点才出现。
八、这套方案的能力边界
诚实地说清楚哪些行、哪些不行。
| 能力 | 状态 | 原因 |
|---|---|---|
hcloud 常用服务(ECS/VPC/EIP/EVS/IMS/CES…) |
✅ 可用 | 实测这些路径不触发 faccessat2 |
hcloud OBS(直连,不经包装器) |
❌ 崩溃 | 实测 spawn 子进程前后 faccessat2(X_OK, AT_EACCESS) |
hcloud 经包装器(带 proot) |
✅ 实测可用 | proot 让 syscall 不原样落到 seccomp(含会崩的 OBS 路径) |
| 手机端跑 Docker | ❌ 不推荐 | Termux 无内核容器支持,需 proot 套娃 |
这不是"潜伏风险",而是已经复现的故障。 hcloud 直连跑 OBS 服务名实测就崩。strace 能直接证明的只有两点:调用确实发生在 spawn 子进程前后、参数确实是 X_OK | AT_EACCESS、作用对象正是 hcloud-real 自己。至于 Go 在 os/exec 的哪一行发起这个检查,本文不引源码(本机当前网络下源码站不可达)。对读者的实际含义:只要某个功能要去找/拉起外部程序,就可能中招——obsutil 是最典型的例子。
实操建议:所有 hcloud 调用都走包装器,别直连 hcloud-real。包装器里的 proot 同时解决 DNS 和 seccomp 两件事,成本是每次调用多一点启动开销。
九、这件事真正的启示
一、GOOS 与运行环境的错配是系统性盲区。 Go 官方在 1.20 修了 faccessat2,但只对 GOOS=android 生效。所有"为 Linux 编译、在 Android 上跑"的二进制——包括 Termux 里装的绝大多数预编译工具——都继承这个缺陷。这不是华为云的问题,是整个生态的盲区。
二、不要把"能跑"归因于一个没验证的机制。 坑 3 我一开始认定是 LD_PRELOAD 的问题,写了一段很顺的因果解释,结果 strace 一查完全不成立:getent 读的是 $PREFIX/glibc/etc/resolv.conf(编译期常量),hcloud 读的是 /etc/resolv.conf(硬编码),两条路径本来就不同,跟 LD_PRELOAD 一点关系都没有。一个自洽的解释不等于正确的解释,strace 一条命令就能证伪。
三、官方文档的 CLI 示例可能已经过期。 这次撞了两次:CreateSecurityGroup 的 v3/v2 差异、CreateSecurityGroupRule 的 multiport vs port_range_*。动手前先 hcloud <Service> <Operation> --help 看当前参数,比照抄文档可靠。
四、闭源工具没有"重编译绕过"这条退路。 我确认过 huaweicloud/huaweicloud-cli 仓库[8]——只有 README.md(322 字节)、LICENSE、CONTRIBUTING.md 和 .github/ 模板,没有一行 Go 源码。所以"用 CGO 重新编译"这个思路(理论上 CGO 版会走 libc 的 access() 而非直接 syscall)虽然方向对,实际上无从下手。
十、如果你也要在 Termux 上跑云 CLI:五步清单
- 先判类型:
readelf -l <binary> | grep INTERP。看到/lib/ld-linux-*就是 glibc 动态链接,需要grun;看到/system/bin/linker64才是 Bionic 原生。 - 备好 glibc-runner:
pkg install glibc-repo glibc-runner,然后grun ./binary先试最简单的--version。 - 环境变量三件套:
USER、HOME、SSL_CERT_FILE。少任何一个都会在某个点报错。 - DNS 单独处理:如果程序是 Go 静态编译(
strings | grep "go1\."有输出且ldd报 “not a dynamic executable”),直接上proot -b resolv.conf:/etc/resolv.conf。 - 遇
SIGSYS就上strace:strace -f -e trace=faccessat,faccessat2能一眼看出是哪个 syscall。确认是faccessat2后,把命令套进proot -b resolv.conf:/etc/resolv.conf -b hosts:/etc/hosts grun <binary>即可——不用装proot-distro。另外,凡是功能内部要拉起子进程的(如hcloud OBS的obsutil),直连裸二进制当场就崩,所以统一走包装器最省心。
附录 A:本文实测环境的完整命令记录
按执行顺序,全部为原文复制(凭据已脱敏)。
A-1 环境确认
uname -a
# Linux localhost 4.19.157-perf-g92c089fc2d37 #1 SMP PREEMPT ... aarch64 Android
grep -i seccomp /proc/self/status
# Seccomp: 2
readelf -l hcloud | grep -A1 INTERP
# [Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
strings hcloud | grep -oE "go1\.[0-9]+(\.[0-9]+)?" | sort -u
# go1.26
# go1.26.1
A-2 坑 1 与坑 2
./hcloud version
# bash: ./hcloud: cannot execute: required file not found
grun ./hcloud version
# [CLI_ERROR]Failed to obtain home directory of current user.
# Cause: user: Current requires cgo or $USER set in environment
echo "y" | USER=root HOME=$HOME grun ./hcloud version
# Current KooCLI version: 7.3.12
A-3 坑 3 与坑 4(注意:下面前两条报错发生在首次运行时;KooCLI 会把服务元数据缓存在 ~/.hcloud/metaRepo/,之后 --help 走缓存就不再需要 DNS。判断 DNS 是否真的修好,要看真实 API 调用,不要用 --help。)
# glibc 环境 DNS 正常
USER=root HOME=$HOME grun $PREFIX/glibc/bin/getent hosts huaweicloud.com
# 119.3.120.194 huaweicloud.com
# 但 hcloud 首次运行(无缓存)失败
hcloud ECS --help
# [APIE_ERROR]Failed to obtain service list. Cause: dial tcp:
# lookup apiexplorer.cn-north-4.myhuaweicloud.com on [::1]:53:
# read udp [::1]:49867->[::1]:53: read: connection refused
# GODEBUG 无效
GODEBUG=netdns=cgo hcloud ECS --help
# 同样报错
# 真实 API 调用(不走缓存的试金石):无 proot 时仍失败
USER=root HOME=$HOME grun hcloud-real ECS ListServersDetails --cli-region=cn-north-4
# Failed to obtain project ID. Cause: dial tcp: lookup iam.cn-north-4... on [::1]:53: connection refused
# proot bind mount 之后 DNS 通,报错变成证书问题
proot -b $PREFIX/etc/resolv.conf:/etc/resolv.conf \
-b $PREFIX/etc/hosts:/etc/hosts \
grun hcloud-real ECS ListServersDetails --cli-region=cn-north-4
# tls: failed to verify certificate: x509: certificate signed by unknown authority
# 加 SSL_CERT_FILE 后完全正常
SSL_CERT_FILE=$PREFIX/etc/tls/cert.pem proot ... grun hcloud-real ECS ListServersDetails --cli-region=cn-north-4
# { "count": 0, "servers": [] }
A-4 seccomp 对比实验(只针对 hcloud)
# hcloud version:68 次 faccessat,0 次 faccessat2
strace -f -e trace=faccessat,faccessat2 -o hcloud.log grun hcloud-real version
grep -c "faccessat2" hcloud.log # 0
grep -c "faccessat(" hcloud.log # 68
# hcloud OBS:30 次 faccessat、2 次 faccessat2,第 2 次 SIGSYS
strace -f -e trace=faccessat,faccessat2 -o obs.log grun hcloud-real OBS
# SIGSYS: bad system call
grep -c "faccessat2" obs.log # 2
grep "faccessat2" obs.log # faccessat2(AT_FDCWD, ".../hcloud-real", X_OK, AT_EACCESS) = -1 ENETDOWN
A-5 proot 绕过验证(与包装器完全相同的结构,无需 proot-distro)
# hcloud OBS 直连(无 proot):崩
USER=root HOME=$HOME SSL_CERT_FILE=$PREFIX/etc/tls/cert.pem \
grun ~/.local/bin/hcloud-real OBS
# SIGSYS: bad system call
# 经包装器(带 proot):正常
hcloud OBS
# Usage: obsutil [command] [args...] [options...]
附录 B:尾注与来源
[1] termux-pacman/glibc-packages wiki, "About glibc runner (grun)". https://github-wiki-see.page/m/termux-pacman/glibc-packages/wiki/About-glibc-runner-(grun)
[2] 华为云 KooCLI 官方安装文档(Linux ARM64 下载地址), "在Linux系统上安装KooCLI". https://support.huaweicloud.com/qs-hcli/hcli_02_003_02.html
[3] AOSP Bionic 源码(`SYSCALLS.TXT` / `SECCOMP_BLOCKLIST_APP.TXT`). https://github.com/aosp-mirror/platform_bionic/tree/main/libc
[4] Go issue #57393, "syscall: Faccessat crashes because faccessat2 is blocked on Android", 2022-12-19. https://github.com/golang/go/issues/57393
[5] Go 源码 `src/syscall/syscall_linux.go`(`Faccessat` 实现,含 `runtime.GOOS != "android"` 条件). https://github.com/golang/go/blob/master/src/syscall/syscall_linux.go
[6] 华为云 EIP 计费文档, "按需计费"(秒级计费、整点结算;按量流量费 0.64 元/GB、IP 保有费 0.02 元/小时). https://support.huaweicloud.com/price-eip/eip_billing_0007.html
[7] 华为云 ECS FAQ, "弹性云服务器关机后还会计费吗?"(关机后云硬盘与带宽仍计费). https://support.huaweicloud.com/ecs_faq/zh-cn_topic_0018124776.html
[8] GitHub `huaweicloud/huaweicloud-cli` 仓库(仅模板文件,无 Go 源码). https://github.com/huaweicloud/huaweicloud-cli
[9] termux-pacman/glibc-packages 仓库(`glibc-runner` 脚本与 glibc 包构建定义). https://github.com/termux/glibc-packages
[10] Go CL 458495, "syscall: don't use faccessat2 on android". https://go-review.googlesource.com/c/go/+/458495
[11] 华为云 ECS 计费文档, "按需计费"(秒级计费,每小时出账单). https://support.huaweicloud.com/price-ecs/ecs_billing_2003.html
附录 C:版本与授权说明
- 本文所有命令均在本机实测,报错文本为终端原文复制,未做改写。
hcloud(KooCLI)为闭源软件,官方只分发二进制。经核实,huaweicloud/huaweicloud-cliGitHub 仓库不含源码[8],因此本文不涉及任何源码修改,也不提供重新编译方案。- 文中引用的 Go 源码片段(附录 B [5])为 Go 项目 BSD 风格许可,引用用于说明技术机制。
- AOSP
SYSCALLS.TXT引用([3])为 Apache-2.0 许可。 - 本文不含任何第三方图片。
本文所有数字均可在附录 B 的来源页中核对。文中的"坑"按实际踩坑顺序编号,不是按严重程度排序——真正卡住我的是坑 3(DNS),因为它牵涉 glibc 的编译期 sysconfdir 与 Go resolver 的硬编码路径这两套完全不同的机制。
- 点赞
- 收藏
- 关注作者
评论(0)