Spot 实例便宜是真便宜,但别把生产环境当成赌桌
笔名:Echo_Wish
说实话,做运维这么多年,我越来越觉得一件事:云资源省钱并不难,难的是省了钱之后,还能保证业务不出问题。
Spot 实例就是一个很典型的例子。
相比按需实例,Spot 实例通常能够以更低的价格获取云计算资源。对于预算有限、计算量又比较大的团队来说,这种诱惑确实很难拒绝。比如,日志分析、离线数据处理、机器学习训练、批量任务执行,这些工作往往需要大量 CPU 和内存,如果全部使用按需实例,账单可能相当可观。
但问题也恰恰出在这里。
Spot 实例便宜,不是因为它的性能一定差,而是因为它的资源使用权并不稳定。 云厂商可能因为资源供需变化等原因回收实例,你不能指望它像普通按需实例那样一直运行。
如果你的任务恰好跑了三个小时,最后十分钟实例被回收,而程序又没有保存进度,那么前面两个小时五十分钟的工作可能就白干了。
更麻烦的是,如果你把这种实例直接放进生产环境,承载着关键业务服务,却没有做好调度、重试和故障隔离,那么省下来的云资源费用,很可能还不够一次线上事故的损失。
所以,我对 Spot 实例的态度一直很明确:
Spot 可以大胆用,但必须建立在工作负载可中断、任务可以恢复、调度能够兜底的基础上。省钱应该是架构设计的结果,而不是拿业务稳定性去换。
今天我们就聊聊,怎么把 Spot 实例真正用起来,而不是只盯着它的低价。
一、先搞清楚:哪些任务适合放到 Spot 上?
很多团队在做云成本优化时,第一反应就是把现有实例换成 Spot。
这个思路其实有点危险。
因为我们真正应该考虑的,不是某个服务用了多少 CPU、占了多少内存,而是它被中断以后,会发生什么。
举个例子。
假设现在有三种工作负载:
- 日志离线分析: 一批日志处理到一半,实例被回收,重新启动后可以从上一个检查点继续处理。
- 图片批量转换: 一共有十万张图片,单张图片处理失败不会影响其他图片,重新调度即可。
- 在线订单服务: 用户正在提交订单,服务实例突然消失,如果没有其他健康实例接管请求,就可能影响交易。
前两种工作负载天然更适合 Spot,第三种则需要谨慎。
注意,我这里说的是“更适合”,而不是“绝对适合”。
比如图片转换任务,如果每处理一张图片就覆盖同一个输出文件,没有临时文件和原子提交机制,那么实例在写入过程中被回收,也可能产生损坏文件。
再比如日志分析,虽然可以重新执行,但如果任务没有幂等设计,重复处理就可能导致统计数据翻倍。
因此,判断一个任务能不能运行在 Spot 上,我通常会先问三个问题:
- 任务中断后,能不能重新执行?
- 重新执行时,会不会产生重复写入、重复扣款等副作用?
- 实例消失后,能不能在合理的时间内重新找到计算资源?
这三个问题如果都没有答案,那么即使 Spot 再便宜,也不建议直接上生产。
二、别把所有任务都压在 Spot 上,要分层调度
假设我们有一个 Kubernetes 集群,里面既运行在线业务,也运行批量数据处理任务。
如果所有节点都使用 Spot,那么一旦云厂商集中回收资源,整个集群就可能出现大面积资源不足。
反过来,如果所有节点都使用按需实例,又失去了 Spot 最重要的成本优势。
比较合理的做法,是把工作负载分层。
第一层:稳定资源池
按需实例承载核心在线业务、关键控制组件,以及必须保持可用的服务。优先保证基本容量。
第二层:弹性资源池
Spot 实例承载离线计算、批处理、可重试任务。资源充足时多跑任务,资源回收时允许缩容。
第三层:恢复与补偿机制
通过任务队列、检查点、重试、限流和资源补位,让任务在 Spot 中断后能够继续完成。
这种架构的关键,不是让按需实例和 Spot 实例完全隔离,而是明确每一类资源应该承担什么责任。
例如,一个批量计算平台平时有 100 个计算任务,其中 20 个是高优先级任务,另外 80 个可以延迟完成。
我们可以让稳定资源池始终保留足够的基础算力,高优先级任务优先获得保障,其余任务尽可能分配给 Spot。
当 Spot 资源充足时,80 个低优先级任务可以快速完成;当 Spot 资源减少时,系统允许这些任务排队,而不是挤占在线服务的资源。
当然,具体保留多少按需容量,不能只凭经验拍脑袋。它应该结合业务峰值、任务完成时限、实例回收历史以及可接受的故障恢复时间来确定。
真正成熟的调度,不是要求每个任务永远有机器可跑,而是知道哪些任务必须现在跑,哪些任务可以稍后跑。
三、Kubernetes 怎么调度?先用标签把边界划清楚
以 Kubernetes 为例,我们可以通过节点标签和 Pod 调度规则,把 Spot 工作负载尽量限制在对应的节点池中。
假设云平台已经将 Spot 节点标记为:
kubectl label node spot-worker-01 \
cloud.example.com/capacity-type=spot
对于可以中断的批处理任务,可以使用 nodeSelector:
apiVersion: batch/v1
kind: Job
metadata:
name: log-analysis
spec:
backoffLimit: 5
template:
spec:
restartPolicy: OnFailure
nodeSelector:
cloud.example.com/capacity-type: spot
containers:
- name: worker
image: example/log-worker:v1
resources:
requests:
cpu: "2"
memory: "4Gi"
这段配置表达的意思很简单:这个任务只调度到带有指定 Spot 标签的节点上。
不过,这里有几个容易被忽略的细节。
第一,nodeSelector 只是调度约束,并不会自动帮你创建节点,也不会保证 Spot 资源一定存在。如果没有符合条件的节点,Pod 就可能一直处于 Pending 状态。
第二,backoffLimit: 5 控制的是 Job 的失败重试次数,不代表系统能够保证任务最终成功,更不代表每次重试都能从上次中断的位置继续执行。
第三,restartPolicy: OnFailure 主要处理容器失败后的重启行为。如果整个节点消失了,容器重启策略并不能单独解决任务迁移和重新调度的问题。
Job 控制器、节点生命周期管理、任务持久化以及集群扩缩容机制,需要一起工作才能构成完整的恢复方案。
还有一点值得特别提醒:不要把 nodeSelector 当作唯一的安全边界。如果一个任务不能运行在某类节点上,除了设置调度规则,还应结合节点污点、容忍规则和集群准入策略,防止其他工作负载意外进入 Spot 节点池。
另外,如果 Spot 节点池完全没有容量,是否允许任务临时转移到按需节点,要由业务策略决定,而不是让调度器自行猜测。
四、真正的风险控制,不是重启,而是中断之前就做好准备
很多人提到 Spot 实例的风险控制,首先想到的就是自动重启。
但我认为,重启只是最后一道补救措施。
因为实例被回收以后再重启,意味着原来的计算环境已经消失。如果程序没有保存进度,那么重启多少次都只是重复劳动。
更合理的思路是:既要能接受中断,也要尽可能减少中断造成的损失。
1. 使用检查点,别让任务每次都从头开始
比如一个 Python 程序需要处理 100 万条日志。
如果每次都从第一条开始,那么运行到第 90 万条时实例被回收,下一次就得重新处理前面的数据。
我们可以定期保存处理进度。
下面是一个简化的示例:
import jsonfrom pathlib import PathCHECKPOINT = Path("/data/checkpoint.json")def load_checkpoint(): if not CHECKPOINT.exists(): return 0 data = json.loads(CHECKPOINT.read_text(encoding="utf-8")) return int(data.get("last_processed_id", 0))def save_checkpoint(last_processed_id): temp_file = CHECKPOINT.with_suffix(".tmp") temp_file.write_text( json.dumps({"last_processed_id": last_processed_id}), encoding="utf-8" ) temp_file.replace(CHECKPOINT)def process_batch(records): for record in records: process_record(record) # 定期保存进度 if record["id"] % 1000 == 0: save_checkpoint(record["id"])
这里仅展示检查点的基本思路,实际生产代码还需要完善。
首先,/data/checkpoint.json 必须位于实例被回收后仍然可访问的持久化存储中。如果它只是实例本地磁盘,那么实例没了,检查点也可能一起消失。
其次,代码假设记录 ID 能够可靠地标识处理进度,并且数据处理顺序满足要求。对于乱序数据、并行任务或者分区处理,通常需要为每个分区单独保存进度。
最后,检查点保存和业务结果写入之间还存在一致性问题。
比如,某条数据已经写入数据库,但检查点还没来得及更新,实例就被回收了。那么任务恢复后可能再次处理这条数据。
因此,真正可靠的实现还需要结合幂等写入、事务、去重键或可靠的提交机制。
如果任务每处理 1000 条数据就保存一次进度,最坏情况下可能需要重做最近一个批次的工作。这个粒度可以根据单批处理耗时、存储开销以及任务恢复成本调整。
检查点不是越频繁越好,而是要在保存成本和重复计算成本之间找到平衡。
2. 处理实例回收通知,给任务留出收尾时间
部分云平台会在回收 Spot 实例前提供中断通知或回收信号,但通知机制、提前量和保障程度因平台而异。
不能假设每次都能提前获得足够时间。
如果运行在 Kubernetes 中,可以为容器配置优雅终止处理逻辑。例如:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: worker
image: example/log-worker:v1
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- "kill -TERM 1; sleep 5"
这里的 terminationGracePeriodSeconds 表示 Kubernetes 在强制终止容器前允许其进行清理的宽限时间。
但要注意,preStop 钩子和宽限期并不能保证云平台一定会给你 60 秒。节点突然失联、底层基础设施故障,或者平台回收时间不足时,进程仍然可能来不及完成收尾。
更重要的是,示例假设 PID 1 就是能够正确处理 SIGTERM 的工作进程。如果容器使用了其他入口脚本或进程管理器,需要确保信号能够传递给真正的任务进程。
实际生产中,工作进程收到终止信号后,应该停止领取新任务,完成或安全中止当前批次,保存检查点,并确保未完成的任务能够重新进入队列。
如果使用消息队列,还应特别关注消息确认机制:任务没处理完就确认消息,可能造成任务丢失;处理完却没有确认,则可能造成重复执行。
这两种情况都需要通过明确的任务状态机和幂等机制来控制。
五、自动扩缩容很重要,但别让扩容机制互相打架
Spot 资源最大的特点之一,就是可用容量存在不确定性。
今天有 50 台机器,明天可能只能拿到 20 台;某个实例规格暂时没有容量,不代表换一个可用区或规格也拿不到。
所以,生产环境不能只配置一个 Spot 节点池,然后寄希望于云平台永远提供足够的资源。
更稳妥的方案,是让调度系统具备一定的弹性。
例如:
- 优先使用符合条件的 Spot 实例。
- Spot 资源不足时,允许低优先级任务排队。
- 对有严格完成时限的任务,必要时启用按需资源补位。
- 对持续失败的任务设置重试上限,避免无限重试。
- 通过多个可用区和多个兼容实例规格降低单一资源池耗尽的风险。
如果采用 Kubernetes,可以结合 Cluster Autoscaler 或 Karpenter 等节点供给组件实现弹性扩容,具体取决于云平台和集群架构。
但这里有个问题很容易被忽略:Pod 调度、节点扩容和任务重试,是三套不同的机制。它们配合不好,反而会制造新的故障。
例如,一个任务因 Spot 节点被回收而进入 Pending 状态,节点扩容组件尝试申请新实例,但连续申请失败。此时 Job 控制器又不断创建新的 Pod,系统就可能陷入反复尝试、资源浪费和日志刷屏的状态。
因此,建议明确几个策略:
- 资源申请失败时采用退避机制,不要无间隔地反复请求。
- 对不同优先级任务设置不同的资源等待时间。
- 只有关键任务才允许触发按需资源补位,并设置成本上限。
- 对低优先级任务使用队列吸收短期资源波动,而不是强迫集群立即扩容。
- 监控 Pending 时长、节点创建失败率、任务重试次数和实际完成时间。
举个例子,假设离线报表要求两小时内完成,平时使用 Spot 就能满足要求。
当 Spot 容量不足时,可以先等待五分钟;如果任务仍然没有获得资源,再根据剩余时间和预算决定是否切换到按需实例。
这样做的价值,不只是省钱,而是让资源切换具备明确的业务规则。
当然,等待五分钟只是一个示例。实际阈值应该根据任务的 SLO、历史资源获取延迟和成本预算制定。
六、别只看单价,要看一项任务最终花了多少钱
这是我觉得很多成本优化项目最容易踩的坑。
Spot 实例的小时单价低,并不意味着任务的最终成本一定低。
假设某个批处理任务在按需实例上运行 10 小时就能完成,平均每小时成本为 1 元。
如果使用 Spot,单价降到 0.4 元,但因为资源回收、任务重试和检查点恢复,累计消耗了 15 个实例小时,那么成本就是 6 元。
而按需实例的成本是 10 元。
Spot 仍然便宜,但节省幅度已经从预期的 60% 变成了 40%。
如果资源回收更加频繁,导致任务多次从头开始,最终累计消耗 30 个实例小时,那么成本就达到 12 元,反而超过按需实例。
这里还没有计算额外的存储、网络、调度系统维护和故障处理成本。
因此,我建议建立一个更贴近业务的成本指标:
def task_cost( instance_hours, hourly_price, storage_cost=0, retry_cost=0, operations_cost=0,): return ( instance_hours * hourly_price + storage_cost + retry_cost + operations_cost )spot_total = task_cost( instance_hours=15, hourly_price=0.4, storage_cost=0.5, retry_cost=0.8, operations_cost=0.3,)ondemand_total = task_cost( instance_hours=10, hourly_price=1.0, storage_cost=0.5, retry_cost=0, operations_cost=0.2,)print(f"Spot 总成本:{spot_total:.2f} 元")print(f"按需实例总成本:{ondemand_total:.2f} 元")
这段代码只是一个简化的成本模型,具体数字不代表任何云厂商的实际价格。
它想说明的是:我们不应该只比较实例单价,而应该比较完成同样一份有效工作所付出的总成本。
比如,处理一百万条日志到底花了多少钱?完成一万张图片转换到底消耗了多少资源?一份报表最终交付花了多长时间?
这些指标比单纯统计 Spot 的使用比例更有意义。
如果一个团队 Spot 使用率达到 90%,但任务经常超时、重试频繁、运维人员天天处理异常,那么这个成本优化项目未必成功。
七、真正应该监控的,是任务能不能按时完成
过去我们做运维,习惯盯着 CPU、内存、磁盘、网络。
这些指标当然重要,但对于可中断工作负载来说,它们并不足够。
一台 Spot 实例被回收之前,CPU 可能一直正常,内存也没有任何异常。真正的问题是:它什么时候会被回收,以及回收之后任务能不能继续完成。
因此,我更关注以下几类指标。
| 指标 | 为什么要看 |
|---|---|
| Spot 中断率 | 了解资源回收对工作负载的影响 |
| 任务重试率 | 判断中断和其他故障是否导致重复工作 |
| 检查点恢复耗时 | 衡量任务恢复能力是否足够 |
| Pending 持续时间 | 识别集群是否存在资源供给不足 |
| 任务按时完成率 | 判断业务目标是否真正得到保障 |
| 单位有效工作成本 | 衡量 Spot 是否真正带来了成本收益 |
这里尤其值得强调的是任务按时完成率。
如果一项离线任务每天早上 6 点必须完成,那么即使 Spot 实例回收了十次,只要任务最终能在 6 点前稳定完成,业务可能仍然可以接受。
反过来,如果任务只中断一次,却导致报表延迟三个小时,那么即使实例回收率很低,也可能已经违反了业务要求。
所以,不要把“实例运行得稳不稳”当作最终目标。
我们真正要保证的是业务结果,而不是某一台机器的生命周期。
对于有明确完成时限的任务,可以进一步建立 SLO,例如任务按时完成率达到 99.5%,并统计因 Spot 容量不足造成的超时次数。
当然,SLO 也应该考虑任务优先级、数据规模和外部依赖,不能把所有任务都放在同一个标准下衡量。
八、最后说点实际的:先让任务可恢复,再考虑大规模上 Spot
如果让我负责一个团队的 Spot 改造,我不会第一天就把所有批处理任务全部迁过去。
我会按下面的顺序推进。
第一步,挑选一个失败成本较低的任务,比如日志归档、图片转换或历史数据分析,验证任务中断后的恢复能力。
第二步,补齐检查点、幂等处理、重试和优雅终止机制。没有这些能力,就先别急着追求更高的 Spot 使用比例。
第三步,建立独立的 Spot 资源池,明确哪些任务可以使用,哪些任务必须使用稳定资源。
第四步,模拟资源中断、节点突然失联、Spot 容量不足以及检查点损坏等场景,观察任务能否恢复、是否出现重复写入,以及恢复时间是否满足要求。
第五步,再逐步扩大使用范围,同时持续跟踪总成本、任务完成率和故障恢复情况。
这里我特别想提醒一点:演练不要只做“杀掉一个 Pod”这么简单。
Pod 被删除后,Kubernetes 可能很快就创建新的 Pod;但真实的 Spot 中断还涉及节点消失、存储挂载、资源重新申请以及调度等待。
如果只测试容器重启,就认为系统已经具备应对 Spot 中断的能力,很可能会高估实际恢复能力。
更好的做法,是分别测试容器异常退出、节点不可用、资源池容量不足等不同故障,并验证每一种故障下的恢复路径。
当这些基础能力稳定以后,再去追求更高的 Spot 使用率,才有意义。
写在最后
Spot 实例给了我们一个很好的机会:用更低的资源价格,完成大量不要求连续运行的计算任务。
但它也提醒了我们一个很现实的问题:基础设施可以随时变化,业务却往往不能随便停下来。
面对这种矛盾,真正有效的办法不是祈祷实例不要被回收,而是从一开始就接受中断可能发生,并围绕这种不确定性设计系统。
任务要能恢复,调度要有边界,扩容要有策略,成本要算总账,监控要关注最终交付。
这些事情做好了,Spot 才能成为成本优化的利器,而不是藏在云账单背后的风险。
我一直认为,运维真正的价值,不是把每台机器维护得永远不出问题,而是让系统在机器出问题时,依然能够把该做的事情做好。
机器可以被回收,任务不能丢;资源可以不稳定,业务交付必须有底线。
这才是 Spot 实例与可中断工作负载调度最值得投入精力的地方。