三周前我为了省点预算,把一台原本 4 核 8G 的机器换成了同配置的竞价实例。价格确实香,便宜了快七成,跑数据脚本、做压测、编译构建这些活儿放上去毫无压力。然后第三天晚上十二点,我正在写周报,手机弹了一条告警邮件:实例已被回收。我登上控制台一看,那台机器已经不在了,磁盘上攒了三天的中间数据、跑了一半的批处理、还没传回对象存储的日志,全部没了。那一刻我算是真真切切体会到一句话竞价实例便宜,但它的便宜是有代价的,代价就是它随时可能不在。
如果你也在找一台跑批处理、跑测试、跑临时任务用的便宜机器,竞价实例之外的常规低价云服务器其实更省心:tudcloud(低价云服务器,root 权限按需配置)、budgetvm(面向小团队的低成本云主机方案),把跑批和跑测试这类可中断的活儿丢上去,既不用自己扛回收,也不用为 7×24 的稳定付全价。
这件事之后我花了两周把整套流程重新设计了一遍,现在批处理跑在竞价实例上,被回收我也不慌,甚至回收本身变成了流程的一部分。今天这篇文章就把这套踩坑和方案完整讲一遍:竞价实例到底是怎么被回收的、中断通知到底有多少时间可用、怎么让任务优雅退出而不是被硬杀、以及数据到底应该放在哪里。如果你也在用便宜机器跑活儿,这篇大概率能帮你省下几个晚上的睡眠。
竞价实例(也叫抢占式实例、Spot Instance)的逻辑其实很简单:平台把手头闲置的物理容量卖出去,价格随供需浮动。当这部分容量被更高优先级的负载需要时,平台会先回收你的实例。回收前一般会给一段提前通知,主流云厂商给的是 2 分钟的告警窗口,不同厂商、不同资源池差别不小,有的给得更短。
这里有个关键点很多人搞混:回收不是关机,是直接销毁。实例一旦被回收,本地磁盘上的所有数据、没上传的镜像、正在跑的进程,全部消失,不可恢复。所以「回收前我把机器关机保命」这种想法是行不通的关机期间不占资源可以避免被回收,但你得真的知道什么时候会回收、并且能在那之前把活干完。
回收触发的原因也不只是「有更高优先级的订单来了」这么简单。同一资源池里,你的竞价实例和别人的竞价实例是竞争关系,谁出价高、谁先被调度完全看当时的供需曲线。另外一个容易被忽略的原因是你自己扩容占的资源你在同一个可用区里临时加了一台按量付费实例,平台没那么多现货,就把竞价的那台挤走了。这种事我遇到过一次,当时完全没往这个方向想,以为是玄学。
出事那天我第一反应是赶紧登上控制台关机,幻想能保住现场。等我登上控制台,实例状态已经是「已回收」,数据盘跟着一起释放了。那一刻我才算明白:竞价实例的生命周期里没有「关机保数据」这个选项,关机只是让它暂时不被回收而已,磁盘内容随时会没。
更隐蔽的坑是本地盘和云盘的生命周期不一样。有的厂商里,实例被回收时数据盘会保留一段时间可以重新挂载,有的厂商是连着一起删。我这台属于后者,亏大了。后来我做了一轮盘点:把跑批任务用到的一切「本地状态」全部列出来,包括下载的依赖包、写的临时文件、编译产物、脚本的进度文件,逐个问一句「这东西丢了会不会要重跑三个小时」。答案是会,于是全部外置。
中断通知是整套方案里最关键的一环。我见过两种截然不同的接法:
第一种是「收到通知才处理」。云监控在收到回收事件后调用一个回调或者发消息到你的消息队列,你的服务收到后再去停进程、保存状态。这条路看着正常,但在实际环境里经常来不及通知本身要走公网、你的回调服务可能正在忙、机器可能已经被回收了。实测下来,云厂商给的通知窗口加上处理链路,通常要留出至少 30 秒的安全余量,超过这个数就危险。
第二种是我后来采用的:不指望通知,而是让任务随时可被打断。脚本每处理完一个批次就往持久化存储里写一次进度文件,用「已完成批次号」标记;下一台机器接手的时候,读进度文件、跳过已完成的批次、接着跑。这样一来,被回收的那一刻不需要做任何事,损失最多就是当前批次的时间,机器重建后接着跑就行。
这个思路的转变很关键:从「在回收前抢救」变成「让状态随时可以丢掉」。前者你永远赢不了,后者你根本不需要赢。
进程被强杀的时候,最容易出问题的是「写到一半」的场景。数据库写到一半的记录、正在上传的文件、已经扣了款但没记账的任务这些半成品状态比直接失败还难收拾。
我在跑批脚本里加了三层保护。第一层是信号捕获:收到终止信号后不立刻退出,先把当前这批次的中间结果刷盘、把临时文件改名成完整的临时文件、记录进度,然后主动退出。第二层是幂等:所有写操作都设计成重复执行不会产生副作用,进度文件用「写临时文件再原子改名」的方式更新,避免读到半行 JSON。第三层是分布式锁:多台机器可能同时被调度起来跑同一个任务,用锁保证同一时刻只有一台在真正干活。
这里面最值得强调的是幂等。抢占式场景下「重复执行」不是异常情况,而是常态机器被回收、任务被重新调度、重试逻辑触发,同一段逻辑被执行三次是再正常不过的事。如果你的任务不具备幂等性,那不管你做多少监控和告警,最后都会因为重复扣款、重复下单、重复发消息出问题。
现在我的方案里,竞价实例是彻底的「无状态执行者」。所有需要跨任务存活的东西都放在外面:
代码和配置放在镜像里或者对象存储,容器化之后新机器起来直接拉镜像拉配置,几分钟就能进入工作状态;进度和中间结果放在对象存储或一个带持久卷的小实例上,按批次分文件存,命名带上批次号,重启直接扫目录就知道做到哪儿了;密钥这类敏感配置走密钥管理服务注入,不落盘;日志直接吐到日志服务,本地那份只是缓存。
这么做有个额外的好处:整套流程从「维护一台机器」变成了「启动一个任务」。前者你得盯着机器,后者你只需要一个能创建实例、能拉镜像、能读进度的东西。这是云原生那套东西之所以被发明出来的原因,只不过在竞价实例这个场景里体现得特别明显。
光有幂等还不够,你得让机器能被自动拉起来。我现在的做法是给批处理任务配一条重建规则:监控发现实例状态变成「回收中」,或者干脆用「实例不存在」这个事实作为触发条件,直接创建一台新的竞价实例,用同一套镜像和配置启动,任务自己读进度接着跑。整个过程不需要人工介入,人只在第二天早上看一份汇总报告。
这里有几个细节值得注意。新实例要尽量选在同一个可用区,这样访问对象存储和数据库延迟稳定;创建时要能接受「这一次分配失败」的情况,抢占式资源的库存本来就不稳定,失败就退避重试,别写死只试一次;如果你对某台机器上的任务有强依赖(比如要保留现场排查问题),那就给这台机器单独配一个「快照」,回收前先存一份代价是快照要花钱,但比丢现场便宜。
另外我给自己定了一条规矩:竞价实例只跑可中断的活儿。跑批、压测、编译、爬虫、备份这类「重跑一遍成本可接受」的任务放上去;线上主库、对外接口、常驻服务这些不能断的,老老实实用按量付费或者包年包月。省下来的钱拿去买稳定,比省下来再想办法补救划算得多。
算这笔账的时候我一开始算错了。我拿竞价实例的单价比包月实例便宜 70% 去看,觉得这钱省得真香。但实际上,中间还漏了三项成本:失败重跑的时间成本、为了做幂等和进度管理多写的代码成本、还有数据外置带来的存储和流量成本。把这三项加回去之后,省下来的比例从 70% 掉到了大概 40% 出头。
但这 40% 对某些场景依然非常值得跑批任务的机器一天能干十几个小时的活儿,闲着的时段就该省;压测环境随时要起环境重建,成本敏感;构建集群弹性大,节点数量波动明显。相对地,如果你的任务一天只跑两三个小时、或者机器得常年在线,竞价实例带来的折腾就不划算了。
我的判断标准很简单:这个任务重跑一遍要多久?重跑时间在半小时以内、且能接受偶尔重跑的,放竞价实例;重跑要一整天的,或者丢一次就出事的,别碰。存量的任务、重要的数据、对外的服务,永远不要放在会随时消失的机器上。
回头看,被回收那晚其实不是最惨的,最惨的是我发现三天的活儿白干了,根因是我把「便宜」当成了「可以省事」的理由。竞价实例的正确用法,从来不是把它当便宜版的标准服务器用,而是把它当成一种可以随时丢弃的计算资源来用状态外置、任务幂等、进程能优雅退出、机器能自动重建,这四件事做到了,被回收就只是流程里的一步日志而已。
如果你也想上手,建议别一上来就扔生产任务。先拿一个跑批脚本试一周,故意手动模拟回收,看看你的任务能不能自动续上。等你发现「机器没了」这件事已经不会让你心跳加速了,再往上加东西。




把跑批这类可中断的活儿放在能自动重建的机器上,剩下的精力才配得上业务本身。