Engineering Case Study · RCA · Postmortem · Android · ECS · EIP · ACK · Spot Instance
**脱敏说明:**真实 RAM 用户、云账号、集群 ID、节点池 ID、实例 ID、AccessKey、仓库地址和本地路径均已移除或示例化。保留真实技术机制、版本演进和故障逻辑。
一、升级背景:工具
已经"能创建",但还不能覆盖真实恢复场景
工具
https://blog.csdn.net/u010042669/article/details/164044425?spm=1001.2014.3001.5502
最初版本的目标很聚焦:查看两个地域的 EIP,优先给已有抢占式实例绑定空闲 EIP;没有合适实例时,再通过启动模板创建新的抢占式 ECS。为了降低误操作风险,工具不提供解绑、释放 EIP、删除实例等破坏性功能,并把 RAM 凭证保存在 Android Keystore 中。
真正进入实操后,需求逐步暴露:模板 ID 不适合人工录入、节点恢复不仅是创建 ECS,还包括 EIP、ACK 节点池、密码、RBAC;更重要的是,抢占实例并不总是"被释放",还可能进入 Stopped 的节省停机状态。于是工具从"快捷创建器"升级成了资源状态识别 + 恢复编排器。

图 1:从 0.2.0 到 0.8.0 的版本演进
二、最终架构

图 2:0.8.0 最终架构
Android 端直接调用阿里云官方 OpenAPI:ECS 负责实例查询、模板读取、创建与启动;VPC/EIP 负责公网地址查询和绑定;ACK 负责节点状态查询与已有 ECS 入池。App 自己维护恢复状态和用户交互,不依赖浏览器控制台 DOM。
| 层 | 职责 |
|---|---|
| UI / ViewModel | 地域资源总览、候选列表、二次确认、密码输入、错误展示 |
| Recovery Orchestrator | 决定优先恢复旧实例、绑定 EIP、加入 ACK,还是创建新实例 |
| ECS Client | 实例、启动模板、DryRun、RunInstances、StartInstance |
| EIP Client | 查询空闲 EIP、绑定 EIP、等待 InUse |
| ACK Client | 查询节点/节点池、AttachInstancesToNodePool、等待 Ready |
| Secure Config | Android Keystore 保存专用 RAM 凭证;敏感入池密码不持久化 |
三、核心设计变化:恢复不是一条直线,而是状态机

图 3:抢占式节点恢复状态机
早期流程默认"抢占节点出问题 = 重新创建"。这在实例被直接释放时成立,但不适用于节省停机。0.8.0 把实例状态真正纳入恢复模型:如果实例仍存在且处于 Stopped,优先调用 StartInstance;只有实例已经不存在,才进入模板创建流程。
**这次升级最重要的设计变化:**恢复决策从"执行动作"驱动,改成"云资源当前状态"驱动。先识别状态,再决定动作。
四、版本演进详解
| 版本 | 关键升级 | 解决的问题 |
|---|---|---|
| 0.2.0 | 启动模板浏览、自动填充 ID/版本、错误码与 RequestId | 模板 ID 难人工获取,配置门槛高 |
| 0.3.0 | 张家口绑定 EIP 后加入 ACK,并等待 Ready | "ECS 已创建"并不等于"K8s 节点已恢复" |
| 0.4.0 | 读取模板完整配置、模板值→最终值对比、强制 PostPaid/公网带宽 0、DryRun | 控制台模板回填不透明,无法知道 API 最终创建参数 |
| 0.4.1 | 创建前弹框选择跟随市场价/最高限价 | 限价参数错误导致创建前本地校验失败 |
| 0.5.0 | 拆分"已有实例绑 EIP""已绑 EIP 入池""新建+绑定+入池" | 一次性大流程不利于处理已经部分恢复的资源 |
| 0.6.0~0.6.2 | ECS 全地域查询、ACK 状态交叉判断、多候选人工选择、修复待入池识别 | 错误依赖 ACK 标签,导致 Web 创建实例无法发现 |
| 0.7.0 | ACK 入池增加一次性新密码输入 | 补齐节点池添加已有 ECS 所需参数 |
| 0.8.0 | Stop/Terminate 中断模式、Stopped 展示、StartInstance 恢复、等待 ACK Ready | 真正覆盖"节省停机"这一抢占恢复场景 |
五、模板能力为什么要从"选 ID"升级成"配置解释器"
启动模板最初只是一个 ID 输入框,但真实使用时控制台会提示"部分配置无法自动填充"。这里必须区分:控制台 UI 回填失败,不代表 RunInstances 无法使用模板;但模板里引用的镜像、交换机、安全组、磁盘或固定 IP 仍可能失效。
因此 0.4.0 开始读取模板完整版本配置,展示"模板原值 → 最终创建值",并明确哪些参数被 App 覆盖。比如模板可能保存了预付费,但 App 会显式覆盖为 PostPaid;普通公网带宽强制为 0,以避免与后续 EIP 绑定冲突。
模板:InstanceChargeType = PrePaid
App: InstanceChargeType = PostPaid
模板:InternetMaxBandwidthOut = 5
App: InternetMaxBandwidthOut = 0
模板/用户配置:InstanceType / VSwitch / SpotStrategy
创建前:重新读取模板 + 风险检查 + RunInstances DryRun
DryRun 能提前发现很多参数、权限、库存和业务限制,但它不是"创建成功保证书",更不能证明后续 EIP 绑定、UserData、ACK 入池一定成功。
六、Troubleshooting 1:为什么节点池规格检查反而制造了错误
**Symptoms:**App 判断新实例规格与节点池不一致,并在实例已经创建后阻止入池;但实际集群中的现有节点恰好就是该规格。
Investigation: 发现 App 读取的是节点池 scaling_group.instance_types,并把它当成了"集群现有节点的实际规格"。
Root Cause: 把"未来扩容候选规格配置"误当成"当前已纳管节点事实"。这是典型的配置数据与事实数据混淆。
Resolution: 删除错误的规格硬拦截。节点事实改由 DescribeClusterNodes 判断;最终能否加入由 ACK 接口自身校验。
**Lessons Learned:**云 API 中同一个概念往往同时存在"期望配置"和"实际状态"。恢复工具必须优先信实际状态 API,而不是根据配置字段推断现实。
七、Troubleshooting 2:为什么两个新按钮一直是灰色
**Symptoms:**Web 控制台已经创建出抢占式 ECS,但 App 的"已有实例绑 EIP"无法点击。
**Root Cause:**App 用 ACK 集群标签作为 ECS 查询前置条件。但一个尚未被 ACK 纳管的 Web 创建实例,本来就可能没有 ACK 标签,因此被错误过滤。
Resolution: 直接调用 DescribeInstances 查询地域内抢占式实例,再通过 EIP 状态和 DescribeClusterNodes 与 ACK 节点列表交叉判断。多个候选实例不再自动选,而是弹出列表人工确认。
**Verification:**0.6.0 后,未入集群实例和已在集群但无 EIP 的实例都可以被正确标注;入池候选则只保留尚未进入 ACK 的实例。
八、Troubleshooting 3:ForbiddenAttachInstance 为什么补了 password 还是失败

图 4:RAM 与 ACK RBAC 双层权限模型
第一次看到 ForbiddenAttachInstance 时,排查方向先落在 Web 控制台要求输入"新密码 + 确认密码"。App 当时确实没有传 password,所以 0.7.0 增加了入池密码弹窗,并确保密码只用于本次请求、不保存、不写日志。
但是补了 password 后错误仍然存在,最终定位到ACK 集群 RBAC :RAM 策略里的 cs:AttachInstancesToNodePool 只代表"允许调用这个云 API",并不自动获得目标 Kubernetes 集群的运维权限。给对应 RAM 用户增加目标集群运维角色后,实例成功入池。
**这是这次升级最有价值的权限认知:**云账号 RAM 权限、资源授权范围、ACK 集群 RBAC 是不同层。只看 Action 列表无法判断最终是否有权限。
九、Troubleshooting 4:RAM 策略明明 Resource=*,为什么仍然查不到资源
早期凭证报错还暴露出另一个容易混淆的概念:策略定义中的 Resource: "*",不等于这条策略一定在整个账号范围生效。如果用户绑定策略时选择的是某个资源组,最终权限仍然受资源组边界限制。
最终有效权限
= 策略允许的 Action / Resource
∩ 用户绑定时的授权范围
∩(涉及 ACK 时)目标集群 RBAC
这促使工具的权限排查不再只看"策略 JSON 有没有某个 Action",而是把授权范围与集群 RBAC 一并纳入诊断。
十、0.8.0:从"重建"补齐到"恢复停机实例"
现场工单确认,抢占式实例配置为"节省停机"时,中断后实例并不会消失,而是进入 Stopped:计算资源释放,但磁盘、EIP、快照等资源仍可保留。早期工具看不出这种实例,也没有恢复入口,所以只能把问题误认为"需要新建节点"。
0.8.0 新增两种中断行为选择:Stop(节省停机)和 Terminate(直接释放)。刷新时单独展示 Stopped 实例,并新增 StartInstance 恢复流程;目标地域如果原来就是 ACK 节点,则实例 Running 后继续等待节点恢复为 Ready。
同时把 Stopped 实例从"可绑 EIP""待入池"候选中排除,因为一个尚未恢复计算资源的实例不应该进入后续恢复动作。

图 5:0.8.0 最终操作流程
十一、一个主动放弃的功能:为什么最后删掉 Pod 重调度入口
升级过程中曾考虑增加"一键触发 Pod 重新调度",因为节点恢复后可能存在 Pod 分布不均。但进一步分析发现,ACK 官方的重调度机制依赖集群内的 Koordinator/Descheduler,而手机 App 当前只有 RAM AccessKey;真正驱逐 Pod 需要 Kubernetes KubeConfig 或 ServiceAccount/RBAC,并且必须处理 PDB、DaemonSet、单副本和本地临时数据等风险。
因此,最初增加的"重调度配置说明"入口最终被删除。原因不是做不到,而是它超出了本工具当前的安全权限边界,也容易让用户误解为可以安全一键执行。
一个成熟的升级过程不仅包含"加功能",也包含及时删除不该属于当前产品边界的功能。
十二、完整踩坑矩阵

图 6:关键踩坑与纠偏
| 问题 | 错误假设 | 真正原因 | 最终修复 |
|---|---|---|---|
| 模板 ID 难获取 | 用户可以手工从控制台复制 | 配置体验不适合移动恢复场景 | 模板列表浏览 + 自动版本填充 |
| 模板网页回填告警 | API 也会按网页逻辑丢参数 | UI 回填与 OpenAPI 模板执行是两回事 | 读取模板详情 + 最终参数预览 + DryRun |
| 节点池规格不匹配 | scaling_group.instance_types 就是实际节点规格 | 配置字段 ≠ 节点事实 | DescribeClusterNodes + ACK 最终校验 |
| Web 创建实例发现不了 | 未加入 ACK 的 ECS 也应该有 ACK 标签 | ACK 标签往往在纳管后才出现 | ECS 全量查询 + ACK 交叉判断 |
| 入池 Forbidden | 只缺 password | 最终还缺 ACK 集群 RBAC | 保留 password + 集群运维 RBAC |
| 抢占中断后只能新建 | 中断等价于实例消失 | Stop 模式保留实例并进入 Stopped | 识别 Stopped + StartInstance |
十三、安全设计与风险边界
| 设计 | 目的 |
|---|---|
| 专用 RAM 用户 + 最小权限 | 降低手机端长期凭证泄露后的影响面 |
| Android Keystore | AccessKey 不明文写入 APK/普通偏好存储 |
| 操作前重新查询 | 避免用户基于过期列表操作云资源 |
| DryRun | 尽可能在计费资源真正创建前发现参数/权限问题 |
| ClientToken / 幂等思路 | 避免网络重试导致重复创建 |
| 不提供 DeleteInstance / ReleaseEipAddress | 把破坏性能力排除在 App 范围之外 |
| ACK 入池密码不持久化 | 密码仅本次请求使用,关闭弹窗即清空 |
| Stopped 不进入后续候选 | 防止错误状态的实例被绑定/入池编排继续推进 |
十四、验证过程
版本迭代不是只改 UI。自动测试数量从最初 4 项,随着 ACK、模板、候选选择、密码和停机恢复能力逐步增加,最终 0.8.0 已达到 21 项单元测试,同时持续执行 Debug APK 构建和 Android Lint。
更重要的是,真实链路最终完成了:RAM 授权 → ECS 查询/创建 → EIP 绑定 → ACK 入池 → Ready;随后又根据真实抢占停机事故补齐 StartInstance 恢复。这个过程说明测试需要同时包含本地单元测试和真实云资源的受控验证。
十五、Lessons Learned
1. 恢复工具首先是"状态识别器",其次才是"动作执行器"。 不理解 Running / Stopped / Released / EIP / ACK 的组合状态,就会把所有故障都错误地归结为"重新创建"。
2. 云平台的"配置"与"事实"必须分开。 节点池配置、ECS 标签、启动模板都只是配置或元数据;真实实例状态和 ACK 节点状态需要专门的查询 API 验证。
3. Permission Denied 不是一个维度。 RAM Action、账号/资源组授权范围、ACK 集群 RBAC、请求参数缺失都可能最终表现为 Forbidden。
4. 自动化必须允许"部分恢复"。 ECS 可能已经创建、EIP 可能已经绑定、ACK 可能尚未入池。把动作拆开,比只有一个"大按钮"更适合真实故障现场。
5. 移动端工具必须保留人工选择。 当地域里存在多个未绑 EIP 的抢占式实例时,不能为了自动化而猜测目标实例。
6. 删除错误功能也是升级。 Pod 重调度需要完全不同的 Kubernetes 权限、安全检查和运行模型,不应硬塞进云资源恢复工具。
十六、后续优化建议
| 优先级 | 优化项 | 原因 |
|---|---|---|
| P0 | 把永久 AccessKey 逐步替换为 STS / 更短期凭证方案 | 即便 Keystore 加密,移动端仍属于较弱信任环境 |
| P0 | 完善恢复任务日志:每一步 API、耗时、RequestId、最终状态 | 真实故障时便于追溯"卡在哪一步" |
| P1 | 增加恢复计划 dry-run 页面 | 执行前一次性展示:会启动哪台、绑定哪个 EIP、是否入池 |
| P1 | 增加抢占事件历史 / 最近一次中断原因 | 帮助区分库存不足、价格超限和人工停机 |
| P1 | 对模板引用资源做更全面的存在性验证 | 减少 RunInstances 前后才发现失效镜像/VSwitch/安全组 |
| P2 | 将 Recovery Orchestrator 明确建模为状态机/工作流 | 随着恢复分支增加,降低 if/else 继续膨胀的维护风险 |
十七、总结
这次升级过程的价值,不在于版本号从 0.2 一路升到 0.8,而在于工具对"抢占式 ACK 节点恢复"这件事的理解发生了质变。最初是:有空闲 EIP 就创建 ECS;后来变成:先识别真实云状态,再决定恢复旧实例、绑定 EIP、加入节点池,还是创建新实例。
真正成熟后的恢复链路是:刷新事实 → 识别状态 → 前置校验 → 用户选择 → 执行动作 → 等待异步资源收敛 → 再次刷新验证。这套模式不仅适用于阿里云 ACK,也适用于很多"云资源自动修复"工具。
**一句话总结:**不要把云运维自动化写成一串 API 调用;要把它设计成一个以真实资源状态为输入、以可验证目标状态为终点的恢复状态机。