刚部署开源项目就发现了 Bug,这第一 P 不就有了吗?

写在前面

笔者前段时间机缘巧合下,成为了 nightgale 的贡献者,之后会分享一些关于参加开源的一些经历,个人同名公粽号:Ciusyan,欢迎志合者一起交流啊~

说来也真凑巧,当时想看看夜莺的代码和设计理念,然后就按照文档,将项目部署了起来。在体验它将告警事件发送邮件的功能时,发现了一些异常情况,后续证实,发现是一个 Bug。

这个问题是什么呢?先简单概述下:

  • 问题描述:如果初次 SMTP 配置有问题,即使改成正确的之后,之前的错误配置仍然会打印错误日志。
  • 问题原因:对 Select 语句的使用不当,有死循环分支,导致 Channel 关闭时,无法进入 Select 另外的退出 case。
  • 解决思路:对其死循环进行控制,适当时机可退出循环,并且将 Channel 的 close 行为,改成直接发送信号的形式。

下面,读者可以带着上面的问题概述,跟着我的思路,一起来探索一下,我是如何发现问题,排查问题,并解决问题的。

p.s. 上面的问题,夜莺的版本是:v6.0.1,但当时的最新版本仍未修复这个 Bug

0x00 背景

既然是一个开源的监控告警系统,肯定得体验下他的监控能力和告警能力。 将各环境配置完毕后,监控看板也配置完成了,配置完告警规则后,到达阈值后,能够及时报警。

但是在配置将报警事件发送至邮件时,不能成功。后面通过一系列排查,发现了一个系统 Bug 先跟着我的思路,来还原一下现场。

0x000 还原现场

先还原当时的操作是:

  1. 配置告警 SMTP 设置。
SMTP 配置.png
  1. 更改报警规则
通知媒介选择 email .png
  1. 将人员设置正确的邮箱并且配置到对应的告警组:
为用户配置邮箱、加入团队(报警组).png

当这些前置操作配置完成后,去触发告警测试。然后出现了一些问题。下面是复现问题的过程。

0x001 问题复现

当时触发告警后,并不能将邮件发送至对应的邮箱。 然后下面就开始了长时间的观察日志过程,跟随我的思路,看我怎么将这个问题复现。

  • 原来是配置配错了,认证不通过。
配置出错,发送失败.png

配置有问题,那就去改配置咯。

  • 期间又改配置,又改错了(故意再次配错,之后会说明一个问题)
再次配置错误.png

可以看到上面的日志,原先那条报错还在继续,改配置后,因为配置是错误的,又多了新的配置。 既然配置又配错了,那就再次修改,尽量别又配错了!!!

  • 再次修改 smtp 配置,这次修改为正确的。
配置正确,发送成功.png

可以看到,将配置修改正确后,触发报警、恢复事件,都能够正常的推送邮件。发送邮件成功,打印的日志也是正常的。 但是我们也可以看到,这条日志周围,还是有很多错误日志,(还是刚刚配置错误的那几次触发的,然后还在不断的重试。) SMTP 的配置也配置正确了,告警也恢复了。应该万事大吉了?

  • 再看日志,怎么刚刚那些错误还是存在啊
告警已经治理了,配置也正确了,为什么还有错误?.png

而且一直存在,明明现在没有告警,配置也没有错误,为什么还是一直有错误在报呢? 而且是每秒报一次,依次有好几条 ERROR 的 email_sender 的错误。 无果,没法解决。

  • 只能被迫重启服务
被迫重启服务.png
  • 系统恢复正常
系统恢复正常.png

在重启服务后,再次查看日志,一切正常。 并且后续报警,也能正常发送邮件。

0x002 现象总结

当复现完问题后,其中有什么问题呢?我们从上面问题的角度来简单总结一下:

  1. SMTP 配置错误,触发告警时,不能接受到告警信息,并且后台每隔一秒,就会产生一条 email_sender 的错误信息。
  2. SMTP 再次配置错误,触发告警时,不能接受告警信息,并且后台每隔一秒,就会产生好几条 email_sender 的错误信息。
  3. SMTP 配置成功正确,触发告警时,能正常收到告警信息,但是在告警恢复后,每隔一秒,后台还是一直出现 email_sender 的错误信息。

究竟是什么问题呢?跟着我的思路一起来排查一下。

0x01 排查问题

下面将是看代码排查问题的过程,下面的代码版本,和部署的夜莺版本保持一致,都是:v6.0.1 这个分支。但是经查看,当前最新 main 分支代码也差不多,应该也有同样的问题。

0x010 问题源头&源码

  • 定位问题源头
根据日志,定位错误源头.png

可以看到,这段代码是用来获取 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 来解耦邮件生产和发送。并且对其做了超时的控制以及刷新配置的逻辑。下面简单解释一下,可跳过

  1. 全局变量
  • mailch: 存放待发送邮件的通道。
  • mailQuit: 控制邮件发送服务停止的信号通道。
  1. RestartEmailSender
  • 关闭 mailQuit 通道以停止当前邮件发送服务。
  • 重新创建 mailQuit 通道。
  • 启动邮件发送服务 (StartEmailSender)。
  1. StartEmailSender
  • 创建或重新初始化 mailch 通道。
  • 验证SMTP配置,若无效则终止。
  • 配置并初始化SMTP拨号器 (gomail.Dialer)。
  • 进入监听循环:
    • 如果收到 mailQuit 信号,退出服务。
    • 如果从 mailch 通道接收到邮件:
      • 如果没有打开SMTP连接,通过 dialSmtp 打开。
      • 发送邮件,如果失败,尝试重发。
      • 检查发送批量,达到指定数量后重置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 问题总结

分析了这么久,先对遇到的问题,做一个总结:

  1. 无限重试,导致问题协程一直空转,表现出 "阻塞" 的状态。
  2. 修改配置后开启新的消费协程后,无法退出旧的消费协程。
  3. 修改配置,可能会导致未被消费的 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~

相关推荐
wuyikeer21 分钟前
Spring Framework 中文官方文档
java·后端·spring
Victor35625 分钟前
MongoDB(61)如何避免大文档带来的性能问题?
后端
Victor35634 分钟前
MongoDB(62)如何避免锁定问题?
后端
wuyikeer1 小时前
Spring BOOT 启动参数
java·spring boot·后端
子木HAPPY阳VIP2 小时前
Ubuntu 22.04 VMware 设置固定IP配置
人工智能·后端·目标检测·机器学习·目标跟踪
人间打气筒(Ada)2 小时前
如何基于 Go-kit 开发 Web 应用:从接口层到业务层再到数据层
开发语言·后端·golang
开心就好20252 小时前
使用Wireshark进行TCP数据包抓包分析:三次握手与四次挥手详解
后端·ios
用户4419395054872 小时前
OpenClaw服务器部署保姆级教程
后端
zdl6862 小时前
springboot集成onlyoffice(部署+开发)
java·spring boot·后端
Soofjan2 小时前
sync.Mutex讲解
后端