五一放假期间,本打算好好休息,却被一个频繁报警的问题困扰。我们是电商平台,一个部署在我们 Serverless
环境中的 KA
商户,由于商户在配置压力阈值时不当,导致商户核心负载在节日期间频繁扩缩容。
尽管我们设置了 5 分钟的缩容抑制,仍无法阻止请求像海浪一样不断涌向核心负载的 Pod
。这个 Pod
的扩缩容频率变化太快了,有时是 10 个,有时是 50 个,有时是 20 个,有时是 70 个。商户在五一期间经常出现订单提交失败的情况。
因此,我在五一假期最后一天被迫加班。节后第一天,我被大领导召见(兄弟们,我渡劫去了),说我在假期期间未能保障商户服务的稳定性,导致商户在节日期间出现订单提交失败。
最终,在 5 月 6 日下午的公司复盘会议上,我与业务负责人一起分析了日志和监控数据。我们发现商户的核心负载扩缩容频率变化太快,导致商户在休假期间经常出现订单提交失败的情况。最终,我们发现商户的底层业务代码存在问题。当 Pod
关闭时,没有优先关闭 Http
服务器,导致数据库连接和其他外部调用接口关闭时,仍有外部请求进入服务内部,导致商户在休假期间经常出现订单提交失败的情况。
即使你的代码和业务系统平时表现出色,但一个细节问题可能导致故障,让人认为你没有考虑到细节。尽管我们已经在关闭 Pod
时添加了优雅停机操作,但服务内部模块的关闭顺序可能会引发问题。如果不妥善处理,最终还是会出现故障。这也引出了今天的故事:优雅关闭。
举个优雅关闭的例子
下面是一个简单的 Go
语言程序,它监听系统信号,当接收到 SIGINT
或 SIGTERM
信号时,输出 Shutting down...
并退出程序。
go
复制代码
package main import ( "fmt" "os" "os/signal" "syscall" ) func main() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) go func() { <-c fmt.Println("Shutting down...") // 执行关闭流程 // 步骤1: ... // 步骤2: ... // 步骤3: ... os.Exit(0) }() fmt.Println("Server is running...") select {} }
我想问问朋友们,有多少人曾经写过类似的代码?也许只是一个无限循环或者一个服务绑定在某个端口上,等待着被强制终止。这样的做法非常危险。当生产环境出现问题时,你会发现自己面临的挑战和被追责的风险。
事件背景
也许对于许多研发小伙伴来说,刚才的问题是日常工作中的一部分。但对我来说,这是一个巨大的挑战。作为 Serverless
平台的负责人,我负责确保平台的稳定性和性能。同时,我也是一个研发人员,负责维护多个底层框架和库。通过这次事件,我发现大多数研发小伙伴在关闭服务时都存在偷懒或完全忽视的情况,这是非常危险的。
经过认真总结,我发现如果研发小伙伴在关闭服务时考虑到业务服务模块的关闭顺序,这个问题就不会发生。说是这样说,实际情况是研发小伙伴并不会考虑到这个问题。即便有人有心要做这个事情,但由于业务模块的复杂性,很难保证每次都能正确地关闭服务内部的模块。
回顾过去,我有一个开源项目叫做 GS
,它是一个提供简单易用的优雅关闭 Go
语言库。它可以帮助开发者在关闭服务时优雅地关闭服务内部的模块。在向我们的研发小伙伴介绍这个库时,小伙伴反馈 GS
这个项目虽然有特色,可以帮助我们解决一定问题,但在这次事件中,因为 GS
内部结构单一,没有办法真正解决这个问题。
通过这次事件,我计划优化和调整 GS
项目,提供一定的关闭控制逻辑,帮助研发小伙伴更好地关闭服务内部的模块。同时,我也希望通过这个项目,帮助更多的研发小伙伴解决这个问题。
另外,如果有小伙伴对 Pod
关闭过程有更深入的了解,可以阅读我之前写的文章:详细解读 Kubernetes 中 Pod 优雅退出,帮你解决大问题...
痛点分析
在之前提到的优雅关闭例子中,作为一个研发人员,你可能会认为这样的代码已经足够了。
go
复制代码
go func() { <-c fmt.Println("Shutting down...") // 执行关闭流程 // 步骤1: ... // 步骤2: ... // 步骤3: ... os.Exit(0) }()
但是,当你的服务内部有多个模块时,你可能会发现这样的代码并不够用。其中一个关键的原因是:你每一次都要写一次编排逻辑,真正关闭的代码可能就那么几行,但编排逻辑可能会有很多行,最终引入了许多不确定性。用一个 BUG
来解决另一个 BUG
。 用更通俗的话说,我不可能每次都写一遍关闭逻辑,这样的代码太难维护了。而且在以后的迭代中,这么多不同模块的代码交叠在一起,难保不会出现问题。
即便我们日常开发中采用更好的开发习惯,在最后一步仍然面临如何编排关闭的任务,如下:
go
复制代码
type Service1 struct { // ... } func (s *Service1) Close() { // 关闭流程 // 步骤1: ... // 步骤2: ... // 步骤3: ... } type Service2 struct { // ... } func (s *Service2) Close() { // 关闭流程 // 步骤1: ... // 步骤2: ... // 步骤3: ... } type Service3 struct { // ... } func (s *Service3) Close() { // 关闭流程 // 步骤1: ... // 步骤2: ... // 步骤3: ... } func main() { service1 := &Service1{} service2 := &Service2{} service3 := &Service3{} c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) go func() { <-c fmt.Println("Shutting down...") // 关闭服务,顺序正确吗? service1.Close() service2.Close() service3.Close() os.Exit(0) }() fmt.Println("Server is running...") select {} }
从上面的代码中,可以详细看到在关闭部分,如果按照代码中的编写方式,关闭的顺序被明确指定了。我个人觉得痛点有如下几个方面:
- 代码重复:每次都要编写关闭逻辑,维护困难。
- 顺序编排难:关闭服务时难以正确安排模块关闭顺序。
- 种类单一:无法同时关闭多个服务。
- 无法控制:无法准确控制模块关闭顺序,可能导致错误。
- 无法扩展:关闭逻辑无法扩展,可能导致模块未完全关闭。
显然,这不是我们想要的,我们需要一个更好的方式来解决这个问题。
预期的关闭
说了这么多,不如来一个明确的预期,因为真正的预期才