写在前面
笔者前段时间机缘巧合下,成为了 nightgale 的贡献者,之后会分享一些关于参加开源的一些经历,个人同名公粽号:Ciusyan,欢迎志合者一起交流啊~
说来也真凑巧,当时想看看夜莺的代码和设计理念,然后就按照文档,将项目部署了起来。在体验它将告警事件发送邮件的功能时,发现了一些异常情况,后续证实,发现是一个 Bug。
这个问题是什么呢?先简单概述下:
- 问题描述:如果初次 SMTP 配置有问题,即使改成正确的之后,之前的错误配置仍然会打印错误日志。
- 问题原因:对 Select 语句的使用不当,有死循环分支,导致 Channel 关闭时,无法进入 Select 另外的退出 case。
- 解决思路:对其死循环进行控制,适当时机可退出循环,并且将 Channel 的 close 行为,改成直接发送信号的形式。
下面,读者可以带着上面的问题概述,跟着我的思路,一起来探索一下,我是如何发现问题,排查问题,并解决问题的。
p.s. 上面的问题,夜莺的版本是:v6.0.1,但当时的最新版本仍未修复这个 Bug
0x00 背景
既然是一个开源的监控告警系统,肯定得体验下他的监控能力和告警能力。 将各环境配置完毕后,监控看板也配置完成了,配置完告警规则后,到达阈值后,能够及时报警。
但是在配置将报警事件发送至邮件时,不能成功。后面通过一系列排查,发现了一个系统 Bug 先跟着我的思路,来还原一下现场。
0x000 还原现场
先还原当时的操作是:
- 配置告警 SMTP 设置。
- 更改报警规则
- 将人员设置正确的邮箱并且配置到对应的告警组:
当这些前置操作配置完成后,去触发告警测试。然后出现了一些问题。下面是复现问题的过程。
0x001 问题复现
当时触发告警后,并不能将邮件发送至对应的邮箱。 然后下面就开始了长时间的观察日志过程,跟随我的思路,看我怎么将这个问题复现。
- 原来是配置配错了,认证不通过。
配置有问题,那就去改配置咯。
- 期间又改配置,又改错了(故意再次配错,之后会说明一个问题)
可以看到上面的日志,原先那条报错还在继续,改配置后,因为配置是错误的,又多了新的配置。 既然配置又配错了,那就再次修改,尽量别又配错了!!!
- 再次修改 smtp 配置,这次修改为正确的。
可以看到,将配置修改正确后,触发报警、恢复事件,都能够正常的推送邮件。发送邮件成功,打印的日志也是正常的。 但是我们也可以看到,这条日志周围,还是有很多错误日志,(还是刚刚配置错误的那几次触发的,然后还在不断的重试。) SMTP 的配置也配置正确了,告警也恢复了。应该万事大吉了?
- 再看日志,怎么刚刚那些错误还是存在啊
而且一直存在,明明现在没有告警,配置也没有错误,为什么还是一直有错误在报呢? 而且是每秒报一次,依次有好几条 ERROR 的 email_sender 的错误。 无果,没法解决。
- 只能被迫重启服务
- 系统恢复正常
在重启服务后,再次查看日志,一切正常。 并且后续报警,也能正常发送邮件。
0x002 现象总结
当复现完问题后,其中有什么问题呢?我们从上面问题的角度来简单总结一下:
- SMTP 配置错误,触发告警时,不能接受到告警信息,并且后台每隔一秒,就会产生一条 email_sender 的错误信息。
- SMTP 再次配置错误,触发告警时,不能接受告警信息,并且后台每隔一秒,就会产生好几条 email_sender 的错误信息。
- SMTP 配置成功正确,触发告警时,能正常收到告警信息,但是在告警恢复后,每隔一秒,后台还是一直出现 email_sender 的错误信息。
究竟是什么问题呢?跟着我的思路一起来排查一下。
0x01 排查问题
下面将是看代码排查问题的过程,下面的代码版本,和部署的夜莺版本保持一致,都是:v6.0.1 这个分支。但是经查看,当前最新 main 分支代码也差不多,应该也有同样的问题。
0x010 问题源头&源码
- 定位问题源头
可以看到,这段代码是用来获取 SMTP 客户端实例的。 如果连接失败,会记录错误日志,并在等待一秒后重试,直到成功连接为止。
这里的逻辑是持续尝试建立连接,直到成功获取一个可以用于发送邮件的 SMTP 客户端连接。 那么在哪里需要获取这样一个 SMTP 客户端呢?其实是在 StartEmailSender
方法中调用的。下面是发送邮件的逻辑:
go
// 定义邮件发送通道,用于存放待发送的邮件对象
var mailch chan *gomail.Message
// 标记是否需要重置的开关
var mailQuit = make(chan struct{})
// RestartEmailSender 当配置变更的时候,重置邮件发送客户端
func RestartEmailSender(smtp aconf.SMTPConfig) {
// 去退出上一次的 go StartEmailSender
close(mailQuit)
// 给下一次使用
mailQuit = make(chan struct{})
StartEmailSender(smtp)
}
// StartEmailSender 具体发送邮件
func StartEmailSender(smtp aconf.SMTPConfig) {
mailch = make(chan *gomail.Message, 100000)
conf := smtp
if conf.Host == "" || conf.Port == 0 {
logger.Warning("SMTP configurations invalid")
return
}
logger.Infof("start email sender... %+v", conf)
d := gomail.NewDialer(conf.Host, conf.Port, conf.User, conf.Pass)
if conf.InsecureSkipVerify {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
var s gomail.SendCloser
var open bool
var size int
// 开始监听,准备发送消息
for {
select {
case <-mailQuit:
return
case m, ok := <-mailch:
if !ok {
return
}
if !open {
// 第一次进来,会去获取发送邮件的客户端
s = dialSmtp(d)
open = true
}
// 来到这里,肯定有客户端 s 了,去发送邮件
if err := gomail.Send(s, m); err != nil {
logger.Errorf("email_sender: failed to send: %s", err)
// close and retry
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}
// 发失败了,又获取一遍客户端,再发一遍
s = dialSmtp(d)
open = true
if err := gomail.Send(s, m); err != nil {
logger.Errorf("email_sender: failed to retry send: %s", err)
}
} else {
logger.Infof("email_sender: result=succ subject=%v to=%v", m.GetHeader("Subject"), m.GetHeader("To"))
}
size++
if size >= conf.Batch {
// 发送多少次邮件后,之后就得重新获取 客户端了,
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}
open = false
size = 0
}
// Close the connection to the SMTP server if no email was sent in
// the last 30 seconds.
case <-time.After(30 * time.Second):
// 超时控制
if open {
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}
open = false
}
}
}
}
这段代码的逻辑比较简单,核心点事使用了 Channel 来解耦邮件生产和发送。并且对其做了超时的控制以及刷新配置的逻辑。下面简单解释一下,可跳过:
- 全局变量:
mailch
: 存放待发送邮件的通道。mailQuit
: 控制邮件发送服务停止的信号通道。
- RestartEmailSender:
- 关闭
mailQuit
通道以停止当前邮件发送服务。 - 重新创建
mailQuit
通道。 - 启动邮件发送服务 (
StartEmailSender
)。
- StartEmailSender:
- 创建或重新初始化
mailch
通道。 - 验证SMTP配置,若无效则终止。
- 配置并初始化SMTP拨号器 (
gomail.Dialer
)。 - 进入监听循环:
- 如果收到
mailQuit
信号,退出服务。 - 如果从
mailch
通道接收到邮件:- 如果没有打开SMTP连接,通过
dialSmtp
打开。 - 发送邮件,如果失败,尝试重发。
- 检查发送批量,达到指定数量后重置SMTP连接。
- 如果没有打开SMTP连接,通过
- 如果30秒内无邮件发送,关闭SMTP连接。
- 如果收到
0x011 问题分析
既然这是告警相关的逻辑,那么先简单看看告警模块整体的架构。
0x0110 告警架构
下面告警模块,对接的通知媒介,将以邮件模块举例,不必在意其中省略的许多细节:
其中核心点是:定期根据告警规则,查询对应的数据,如果需要告警,就会去生产 Event 事件 (告警/恢复) 并且将其时间放置在一个 Queue 队列里面,等待消费。 消费事件时,根据对应告警规则的通知媒介,去执行具体的通知。比如上方的通知媒介是 Email,然后去 Process 这个事件。
当到达了 Email 通知媒介后,他会将这个事件携带的通知信息,打包成 Message 邮件,发送至 Channel 里面,等待消费。 消费消息时,根据这个消息的原信息,获取对应的客户端,发送对应的消息。
对告警模块有一个大致的了解后,来分析原因了。
0x0111 问题原因
在消费 Email Message 的协程中,会一直监听三个 Channel。
现象一:配置错误,发送失败,每秒产生错误日志
当产生告警信息后,会将其发送告警的 Massage 进入 mailch,消费协程就会执行对应的逻辑。 首先第一步就是去获取 SMTP 客户端,在发送前,还会进行 dial 认证,但由于我们的配置是错误的,肯定会失败。 但由于是无限重试,重试的频率是 1s。对于这样的场景,重试其实是没有意义的,因为配置是错误的,永远不可能成功。所以当前 Goroutine 就会一直被 "阻塞" 到这里,并且每隔一秒就记录一条日志。
上述描述,如下图所示:
既然配置错了,正常思维就是去修改配置。
现象二:修改错误配置,发送失败,错误日志增多
当修改配置后,会一步步调用到 RestartEmailSender
方法,执行这个方法时,会重新开启一个 Goroutine, 并且它会去关闭 mailQuit 这个管道,想要退出之前消费 Massage 的那个协程。 然后新建一个 mailQuit 管道,又去调用消费逻辑,也就是将当前协程,变成新的消费 Massage 的消费者。
但是原先那个协程由于配置错误,一直在 "阻塞" 的重试。即使当前协程将 mailQuit 协程关闭,旧的消费者协程也不会退出 。然后又在当前协程重新调用了消费的逻辑,即又会在当前协程开启监听,等待消费 Message 发送邮件。
当系统又产生报警的时候,当前协程又会去获取客户端,进行 dial 操作。但好巧不巧,又配错了,结果呢?也和上面的表现是一样的,当前协程,又会 "阻塞" 在这里,去无限重试。
上述如下图所示:
现象三:修改正确配置,发送成功,仍持续报错
当了解了前面两个现象产生的原因,其实这里就好理解了。 前面有两个 Goroutine,还一直 "阻塞" 在每隔一秒的重试中,这里又修改了配置,又会开启一个新的协程去调用消费逻辑。所以现在系统的现状是:
由于这一次 SMTP 的配置被调整正确了,所以当再次产生告警的时候,能够发送成功。 并且在产生了「恢复」Event 的时候,也能成功接收到通知。 那系统中为什么仍然会持续的产生错误日志呢?其实这时候的错误日志,是 "旧消费者的协程" 产生的,和当前协程无关。
当分析完现象产生的原因后,当时又发现了一个问题。
引申问题:每次修改配置的时候,会丢掉还未被消费的数据
p.s. 对于这个延伸问题,是基于当前的 v6.0.1 版本。同期的 lastest 版本,已经修复了,但前面分析的几个问题,依旧存在,并由笔者修复合并。
为什么这么说呢?当用户进行配置修改的时候,就会调用 RestartEmailSender()
方法。如下图所示:
当调用了这个方法,我们可以知道,他会重新调用消费的逻辑。而消费逻辑的第一步,就是去构建一个全新的 mailch:
如上图所示,当旧的 mailch 被丢掉后,若旧的 mailch 还有没被消费的 Message 数据,也会一同被丢掉。
当然,可能由于监控告警,对于时间很敏感。如果都积压了很多消息没有被发送,当过了一定时间后再发送这种 "过期告警",其实意义可能也不是特别大。 但这也是设计的时候,其中一个缺陷所在,而且也可能会出现那种临界时刻,即丢掉的数据也是里当前时间不久远的。
0x0112 问题总结
分析了这么久,先对遇到的问题,做一个总结:
- 无限重试,导致问题协程一直空转,表现出 "阻塞" 的状态。
- 修改配置后开启新的消费协程后,无法退出旧的消费协程。
- 修改配置,可能会导致未被消费的 Message 丢失。
0x0112 代码复现
上面提到的一些现象和问题,可能和这个业务场景相关。我下面将简单实现两段逻辑,可以帮助你快速理解上面提到的几个问题。直接运行起来,观察输出的日志,即可验证:
- 模拟更换 channel,数据丢失
go
// @Author: Ciusyan 4/14/24
package sender
import (
"sync"
"testing"
"time"
)
// 模拟数据丢失的问题
func TestChannel(t *testing.T) {
// 模拟的 channel,中途会重建
ch := make(chan int, 100)
// 先将缓冲区塞满,模拟等待消费的数据。
for i := 1; i <= 100; i++ {
ch <- i
}
// 每隔一秒就消费一个数据。
go func() {
for {
select {
case num := <-ch:
t.Log(num)
time.Sleep(time.Second)
}
}
}()
// 3s 后,模拟被重启:重新设置 channel
go func() {
time.Sleep(3 * time.Second)
ch = make(chan int, 100)
}()
// 过 5s 后,将其 channel 关闭
time.Sleep(5 * time.Second)
close(ch)
// 然后看看 ch 里面的数据是否被丢掉了!
// 观察打印结果,数据已经被丢掉了~
for num := range ch {
t.Log(num)
}
}
- 模拟无限重试,协程 "阻塞" 问题
go
// 模拟无限重试,协程 "阻塞" 问题
func TestGoroutine(t *testing.T) {
var ch chan int
quit := make(chan struct{})
start := func(idx int) {
t.Logf("start: %d", idx)
ch = make(chan int, 100000)
for {
inner := 0
select {
case <-quit:
t.Logf("退出了 start ch:%d", idx)
return
case num := <-ch:
inner++
// 模拟去进行前置获取
for {
nowStr := time.Now().Format("15:04:05")
// 假设失败了,一直重试
t.Logf("[%s] 获取第 %d 批,消费第 %d 个内容: %d", nowStr, idx, inner, num)
time.Sleep(time.Second)
}
case <-time.After(5 * time.Second):
// 超时控制
t.Logf("超时,退出:%d", idx)
}
}
}
// 模拟重新去获取对应的操作。
restart := func(idx int) {
t.Logf("restart: %d", idx)
close(quit)
quit = make(chan struct{})
start(idx)
}
// 等待这个生产协程退出,就退出整个程序
var wg sync.WaitGroup
wg.Add(1)
// 模拟一分钟,自行观察日志
producer := func() {
defer wg.Done()
// 生产内容,每隔一秒就生成一个消息
for i := 1; i <= 60; i++ {
ch <- i
time.Sleep(1 * time.Second)
}
}
// 消费
go start(1)
// 确保 ch 准备好了
time.Sleep(time.Millisecond * 500)
// 生产
go producer()
// 模拟修改了三次配置
time.Sleep(3 * time.Second)
go restart(2)
time.Sleep(3 * time.Second)
go restart(3)
time.Sleep(3 * time.Second)
go restart(4)
// 等待生产数据的协程退出
wg.Wait()
}
对于上面两段逻辑,不算复杂,并且也只是简单验证,不用关心具体的代码风格,规范啥的。 运行起来,快速验证问题即可!
啰嗦了这么久,怎么解决呢?其实还挺简单,最后来看看是如何解决的吧。
0x02 解决方案
我们对问题进行分析,可以发现,其实核心点是:对于 select 监听的某一个 case,其中有无限循环的逻辑,占用了 select,无法进入其他 case。
所以,我们只需要将其死循环,可以有退出条件即可解决。什么时候退出呢?只要 mailQuit 有信号时,退出即可。因为当 mailQuit 收到信号时,现在再来 dail 也没有太大的意义了。 那么我们对重试 dail 的协程,也监听 mailQuit 的信号:
go
func dialSmtp(d *gomail.Dialer) gomail.SendCloser {
for {
select {
case <-mailQuit:
// Note that Sendcloser is not obtained below,
// and the outgoing signal (with configuration changes) exits the current dial
return nil
default:
if s, err := d.Dial(); err != nil {
logger.Errorf("email_sender: failed to dial smtp: %s", err)
} else {
return s
}
time.Sleep(time.Second)
}
}
}
经过这样改造后,正常情况下,能够一直去重试,避免因为网络波动导致的 dail 失败。 但当收到 mailQuit 发出的信号后,也能够同步的去退出这个无限循环。
监听了信号的接收,那么什么时候发送信号呢? 以前试图通过 close 的方式来告知监听其管道的 case,执行后续的退出逻辑。 但若刚好在 close 的时候,select 还处于被占用状态,那么当重建 mailQuit 后,依旧会有问题。 所以,我们将 close mailQuit,更改为直接发送信号的方式。
go
func RestartEmailSender(smtp aconf.SMTPConfig) {
// Notify internal start exit
mailQuit <- struct{}{}
startEmailSender(smtp)
}
写在最后
好了,至此文章就结束了,其实问题并不复杂,解决思路也不复杂。只是按照自己的思路,一步步的让你:发现问题、排查问题、解决问题。
你看,这是不是和我们的生活很像?不断的发现问题,不断的排查问题,不断的解决问题。周而复始,循环往复。技术如此,人生更当然!
如果对你产生了一定的思考,可以来公粽号椒牛鸭:Ciusyan~