我们团队的核心服务是用Rust写的,一直运行得很稳定。上个月我们把Rust工具链从1.84升级到了1.85,结果出了一次严重的线上故障。
这次故障持续了四个小时,影响了几十万用户。事后我们做了一次完整的复盘,找到了根因,也做了很多改进。这篇文章我想把这次复盘记录下来,如果你也在用Rust,希望能帮你避免类似的坑。
故障的发生
那天是周二,我们做了一次常规的版本发布。这次发布包含了Rust工具链的升级,从1.84升级到1.85。
Rust的小版本升级一般都是向后兼容的,我们之前也升级过很多次,从来没出过问题。所以这次升级,我们没有太当回事,只是在测试环境跑了一遍自动化测试,就发布到生产环境了。
发布是在下午两点,发布过程很顺利,没有报错。发布之后,我们观察了一会儿监控,各项指标都正常,就放心地去做别的事情了。
但到了下午四点,监控突然告警,说服务的错误率飙升,从平时的0.1%涨到了5%,而且还在上升。同时,服务的内存使用率也在快速上涨,已经到了80%多。
我赶紧登录服务器看日志,发现大量的请求返回了500错误,错误信息是"out of memory"。还有一些请求出现了段错误,进程直接崩溃了。
这时候我意识到出大问题了。
紧急处理
第一反应是回滚。
我们赶紧把版本回滚到上一个版本,也就是用Rust 1.84编译的版本。回滚之后,错误率立刻降下来了,内存使用率也开始下降。服务慢慢恢复了正常。
从故障发生到回滚完成,大概用了二十分钟。虽然回滚很快,但这二十分钟里,已经有很多用户受到了影响。
回滚之后,我们开始排查问题。因为故障是在升级Rust版本之后出现的,所以我们第一怀疑就是Rust 1.85的问题。但Rust的小版本升级一向很稳定,怎么会出这么严重的问题呢?
我们开始做实验。在测试环境,用Rust 1.85重新编译代码,然后跑压力测试。果然,跑了一段时间之后,内存开始持续上涨,最后OOM了。而用1.84编译的版本,跑同样的压力测试,内存一直很稳定。
这就确认了,问题确实出在Rust 1.85的升级上。
排查过程
确认了是Rust版本的问题之后,我们开始深入排查,到底是什么原因导致的内存泄漏和崩溃。
第一步,我们用了内存分析工具,看看到底是哪里在泄漏内存。结果发现,泄漏的内存主要集中在我们用的一个第三方库里,这个库是用来做异步运行时的。
第二步,我们去查了这个库的更新日志,发现它在最近的版本里修复了一个和Rust 1.85相关的bug。bug的描述是:在Rust 1.85中,由于编译器的某个优化改变了代码的生成方式,导致这个库的异步任务在某些情况下不会被正确释放,造成内存泄漏。
第三步,我们去查了Rust 1.85的更新日志,发现确实有一个和异步相关的编译器优化。这个优化本意是提高异步代码的性能,但在某些边界情况下,会导致生成的代码有问题。
第四步,我们复现了这个bug。写了一个最小的复现程序,用Rust 1.85编译,果然能复现内存泄漏。然后我们把这个复现程序提交给了Rust官方和那个第三方库的维护者。
根因分析
经过深入分析,我们找到了这次故障的根本原因。
表面原因是,Rust 1.85的一个编译器优化,和我们用的异步运行时库不兼容,导致异步任务泄漏,最终造成OOM和进程崩溃。
但深层原因,是我们的升级流程有问题。
第一,我们对小版本升级太轻敌了。Rust的小版本升级虽然一般是向后兼容的,但"一般"不代表"一定"。编译器的任何改动,都可能在某些边界情况下引入bug。我们没有充分评估升级的风险,就直接发布到了生产环境。
第二,我们的测试不够充分。虽然跑了自动化测试,但自动化测试主要是功能测试,没有做长时间的压力测试和内存泄漏检测。内存泄漏这种问题,短时间的测试发现不了,需要长时间运行才能暴露出来。
第三,我们没有灰度发布。这次发布是全量发布的,一发布就影响了所有用户。如果有灰度发布,先让一小部分用户用新版本,观察一段时间,就能在影响扩大之前发现问题。
第四,我们的监控不够灵敏。内存泄漏是慢慢发生的,发布之后过了两个小时才告警。如果能更早地发现内存异常,就能在故障扩大之前处理。
第五,我们对第三方依赖的跟进不够。那个异步库其实在故障发生前一周就发布了修复版本,但我们没有及时更新依赖。如果能及时跟进依赖的更新,这次故障就能避免。
改进措施
针对这些根因,我们做了一系列的改进措施。
第一,建立了严格的升级流程。不管是大版本还是小版本升级,都要走完整的测试流程。包括功能测试、集成测试、压力测试、内存泄漏检测。测试通过之后,才能发布。
第二,引入了灰度发布机制。所有的发布,都先灰度到5%的用户,观察一个小时,没问题再扩大到20%,再观察,最后全量。灰度期间重点监控错误率、响应时间、内存使用率等指标。
第三,完善了监控告警。加了内存增长率的告警,如果内存在短时间内持续上涨,就立刻告警。这样能更早地发现内存泄漏之类的问题。
第四,建立了依赖更新机制。定期检查第三方依赖的更新,特别是安全更新和bug修复。重要的依赖更新,及时合并并发布。
第五,做了混沌工程。我们在测试环境定期做故障注入,比如杀进程、断网、高负载,测试系统的容错能力。通过混沌工程,发现了很多潜在的问题。
第六,完善了回滚机制。确保任何发布都能在五分钟内回滚。回滚脚本提前写好,定期演练,确保出了问题能快速回滚。
这次故障的教训
这次故障,给了我很多教训。
第一个教训是,不要轻敌。不管多么常规的操作,不管之前做过多少次,都不能掉以轻心。技术这东西,永远有你想不到的边界情况。对每一次发布、每一次升级,都要有敬畏之心。
第二个教训是,测试要全面。功能测试只能发现功能问题,性能问题、内存问题、并发问题,需要专门的测试才能发现。不要以为功能测试通过了就万事大吉。
第三个教训是,灰度发布很重要。不管测试多么充分,都不能保证100%没有问题。灰度发布是最后一道防线,能把问题的影响降到最低。
第四个教训是,监控要灵敏。好的监控,能在问题刚出现的时候就发现,而不是等问题扩大了才告警。监控的指标要全面,告警的阈值要合理。
第五个教训是,第三方依赖也是风险。我们用的很多第三方库,虽然很成熟,但也可能出问题。要关注依赖的更新,及时修复已知的bug。
第六个教训是,回滚能力是底线。不管出了什么问题,能快速回滚,就能把影响降到最低。回滚机制要简单可靠,定期演练,确保关键时刻能用。
Rust升级的特别建议
因为这次是Rust升级导致的故障,我特别给用Rust的朋友几个建议。
第一,Rust的小版本升级也要谨慎。虽然Rust的稳定性承诺很好,但编译器的bug还是存在的。特别是涉及到unsafe代码、异步运行时、泛型特化这些复杂特性的时候,更容易出问题。
第二,关注Rust的更新日志。每次升级之前,仔细看更新日志,特别是那些可能影响代码生成的优化。如果有不确定的地方,可以先在测试环境验证。
第三,用多个版本测试。重要的项目,可以同时用稳定版和beta版编译测试,提前发现未来版本可能引入的问题。
第四,关注依赖的兼容性。升级Rust版本之后,检查所有第三方依赖是否和新版本兼容。特别是异步运行时、序列化框架这些基础库,要重点关注。
第五,不要追新。Rust的新版本出来之后,不要急着升级,等一两个月,让社区先踩踩坑。等稳定了再升级,风险会小很多。
写在最后
这次故障,虽然惊心动魄,但也让我们的系统变得更健壮了。
技术工作就是这样,不断地出问题,不断地解决问题,然后系统变得越来越稳定。每一次故障,都是一次成长的机会。关键是要从故障中学习,不要白交学费。
Rust是一门很好的语言,我们会继续用下去。但经过这次故障,我们对Rust的升级和发布,有了更多的敬畏之心。
最后用一句话来结束这篇文章:"稳定不是没有故障,而是出了故障能快速恢复,并且不再犯同样的错误。"
愿每一个工程师,都能少踩坑,踩了坑也能快速爬出来,并且记住坑在哪里。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录