记得大学刚毕业那年看了侯俊杰的《深入浅出MFC》,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准——对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白。以下内容为个人见解,如有雷同,纯属巧合,如有错误,烦请指正。
本文基于prometheus2.3版本,后续会根据prometheus版本更新及时更新文档,所有代码引用为了简洁都去掉了日志打印相关的代码,尽量只保留有价值的内容。
目录
服务发现介绍
本文不对prometheus的基本概念做介绍,直接奔主题。scrape是prometheus表示抓取监控信息的动作,为了避免翻译造成的信息失真,本文直接使用scrape单词。prometheus所有scrape的目标需要通过配置文件(比如promethues.yml)告知prometheus,试想一下,如果我们监控的目标是动态的(比如PaaS平台按需创建中间件),我们总不能每次都去修改配置文件然后再通知prometheus重新加载吧(prometheus提供了重新加载配置的接口,不需要重新启动)?服务发现(service discovery)就是为了解决此类需求出现的,prometheus能够主动感知系统增加、删除、更新的服务,然后自动将目标加入到监控队列中。想一想,是不是一件很酷的事情?那我们就剖析一下prometheus的服务发现机制是如何实现的?
特此声明:后面会大量提到服务、目标,其实二者是同一个内容,由于代码主要用的是Target单词,而功能是服务发现,所以当看到文档中一会儿出现服务,一会儿出现目标时不要疑惑,可以想象成一个事物。
如何发现各类系统的服务
prometheus默认已经支持了很多常用系统的服务发现能力,这一点可以通过官方文档中找到,我这里通过代码说明prometheus服务发现的系统:
// 代码源于prometheus/discovery/config/config.go
type ServiceDiscoveryConfig struct {
StaticConfigs []*targetgroup.Group `yaml:"static_configs,omitempty"`
DNSSDConfigs []*dns.SDConfig `yaml:"dns_sd_configs,omitempty"`
FileSDConfigs []*file.SDConfig `yaml:"file_sd_configs,omitempty"`
ConsulSDConfigs []*consul.SDConfig `yaml:"consul_sd_configs,omitempty"`
ServersetSDConfigs []*zookeeper.ServersetSDConfig `yaml:"serverset_sd_configs,omitempty"`
NerveSDConfigs []*zookeeper.NerveSDConfig `yaml:"nerve_sd_configs,omitempty"`
MarathonSDConfigs []*marathon.SDConfig `yaml:"marathon_sd_configs,omitempty"`
KubernetesSDConfigs []*kubernetes.SDConfig `yaml:"kubernetes_sd_configs,omitempty"`
GCESDConfigs []*gce.SDConfig `yaml:"gce_sd_configs,omitempty"`
EC2SDConfigs []*ec2.SDConfig `yaml:"ec2_sd_configs,omitempty"`
OpenstackSDConfigs []*openstack.SDConfig `yaml:"openstack_sd_configs,omitempty"`
AzureSDConfigs []*azure.SDConfig `yaml:"azure_sd_configs,omitempty"`
TritonSDConfigs []*triton.SDConfig `yaml:"triton_sd_configs,omitempty"`
}
上面的代码我不做注释,从字面上就能看出来。大家有没有发现,静态服务也被纳入到服务发现范围内,我们可以把静态服务想象为动态服务的特例就可以了,这样就可以复用相关的代码,是一种非常漂亮的设计。
这么多系统,接口、机制各不相同,prometheus是如何实现各类系统的统一监控的呢?其实实现方式非常简单,就是对各类系统做统一的抽象,然后再由一个管理器管理起来,基本上属于插件理念。我们来看看prometheus对于各个系统的抽象是什么?
// 代码源自prometheus/discovery/manager.go
// 所有的系统只要实现Discoverer这个interface就可以了,藐视很简单的样子
type Discoverer interface {
Run(ctx context.Context, up chan<- []*targetgroup.Group)
}
// 代码源自prometheus/discovery/targetgroup/targetgroup.go
type Group struct {
Targets []model.LabelSet // 由具体Discoverer实现为目标定义的一组标签,以kubernetes的Pod为例,包括Pod的IP、地址
Labels model.LabelSet // 目标的其他标签,以kubernetes为例,就是我们写yaml文件metadata.labels字段的内容
Source string // 目标在系统中唯一的名字
}
// 代码源自prometheus/vendor/github.com/prometheus/common/model/labelset.go
type LabelSet map[LabelName]LabelValue
Discoverer的具体实现和Manager之间唯一的沟通渠道就是up这个chan(ctx用于系统退出使用,所以不做过多说明),从名字基本能看出来,就是所有上线的服务。上面代码中LabelSet用map实现的kv对,很好理解。targetgroup.Group就是对发现服务的具体定义,为甚用Group,我猜是因为有Targets、Labels和Source多个属性的原因。
对于服务发现管理者来说,只要系统有任何目标变化告诉管理者就行了,其他的一律不关心。然后再根据不同系统实现Discoverer,那么我们就来看看prometheus是如何管理这些Discoverer的。
// 代码源于prometheus/discovery/manager.go
type Manager struct {
logger log.Logger // 写日志用的
mtx sync.RWMutex // 互斥锁
ctx context.Context // 系统退出用的
discoverCancel []context.CancelFunc // 每个Discoverer一个取消函数
targets map[poolKey]map[string]*targetgroup.Group // 所有发现的服务(目标)
syncCh chan map[string][]*targetgroup.Group // 与外部交互chan,当发现服务变化是把全量的在线服务发从到chan中
recentlyUpdated bool // 有服务更新的标记
recentlyUpdatedMtx sync.Mutex // 服务更新用的锁
}
// poolKey定义了每个服务的配置来源,比如job_name、kubernetes、第0个配置
type poolKey struct {
setName string // 可以简单理解为prometheus配置文件的job_name
provider string // 我们在上面的代码中说过的,系统名/索引值,如kubernetes/0
}
上面的代码就是服务发现管理者的定义,我只对重要的几个参数进行说明,其他的都是配合实现业务的就不再解释了:
- targets:所有服务(目标),后面代码会有这个变量的存储格式的详细说明;
- syncCh:targets的快照的chan,prometheus真正需要监控目标通过该chan发送scrape模块,prometheus每隔一段时间就会对targets做一次快照,前提是targets发生了变化才会执行;
接下来,我就要看看prometheus是如构造各种Discoverer。我们知道,prometheus最初始的配置来自于配置文件,下面的代码就是应用配置信息的实现:
// 代码源自prometheus/discovery/manager.go
func (m *Manager) ApplyConfig(cfg map[string]sd_config.ServiceDiscoveryConfig) error {
// 加锁解锁使用defer的技巧就不多说明了
m.mtx.Lock()
defer m.mtx.Unlock()
// 先把所有的Discoverer取消掉,这样做比较简单,毕竟配置文件修改频率非常低,没大毛病
// 实现方式就是我们上面提到的Manager.discoverCancel这个取消函数的数组,遍历调用就是了
m.cancelDiscoverers()
// 遍历所有的配置,有人肯定会说配置文件不是map呀,应该是个数组,因为配置文件中用-job_name
// 一个一个的设置参数,如果我说cfg的key是job_name是不是就能力理解了?后面会有章节介绍配数组转换map的过程
for name, scfg := range cfg {
// providersFromConfig函数会根据配置返回map[string]Discoverer,看这意思可以返回多个Discoverer
// 说明配置文件的一个job_name可以配置多个系统,我是没这么配置过,读者可以试试
for provName, prov := range m.providersFromConfig(scfg) {
// 逐一的启动Discoverer,就是让Discoverer开始执行Run函数
// 注意啦,poolKey.setName=job_name,poolKey.provider="系统名称/索引号",后面有说明
// 为什么要提poolKey,因为后面好多地方引用了poolKey,可以简单理解为:哪个job_name下的哪个xxx_sd_config
m.startProvider(m.ctx, poolKey{setName: name, provider: provName}, prov)
}
}
return nil
}
从配置信息构造Discoverer的实现如下:
// 代码源自prometheus/discovery/manager.go
func (m *Manager) providersFromConfig(cfg sd_config.ServiceDiscoveryConfig) map[string]Discoverer {
providers := map[string]Discoverer{}
// 这里有意思了,相同的系统用"系统名称/索引号"的方式唯一命名,比如kubernetes/0
// 这也说明同一个job_name下可以配置多个相同的xxx_sd_config
app := func(mech string, i int, tp Discoverer) {
providers[fmt.Sprintf("%s/%d", mech, i)] = tp
}
// 是DNS服务发现的配置么?如果是就构造DNS的Discoverer
for i, c := range cfg.DNSSDConfigs {
app("dns", i, dns.NewDiscovery(*c, log.With(m.logger, "discovery", "dns")))
}
// 此处省略一万字,每个系统(如kubernetes、EC2、Azure、GCE)都做一次和上面DNS一样的操作
// 每个系统都在prometheus/discovery/目录下有一个独立的包,用于实现Discoverer
......
// 静态配置并没有专门的包实现,直接就在manager包里实现了
if len(cfg.StaticConfigs) > 0 {
app("static", 0, &StaticProvider{cfg.StaticConfigs})
}
return providers
}
本文不对具体的Discoverer做解释,本文只对服务发现的实现机制进行详细讲解,我会有专门的文章讲解prometheus是如何实现kubernetes的Discoverer的。
我们发现配置文件里面的每个job_name就会有一个相应的Disconverer对象构造出来,以kubernetes为例,每个Discoverer就要有一个kubernetes的客户端(kubernetes.client-go.kubernetes.Clientset),如果地址(kubernetes的地址)相同是否可以合并客户端?好吧,虽然没什么大用,但是感觉有点优化作用。我们再来看看Manager是如何启动各个Discoverer的:
// 代码源自prometheus/discovery/manager.go
// poolKey来自ApplyConfig()
func (m *Manager) startProvider(ctx context.Context, poolKey poolKey, worker Discoverer) {
ctx, cancel := context.WithCancel(ctx)
// 此处构造了目标数组,这个我们在介绍Discoverer类型的说过,每个Discoverer对象都要输出上线的服务
updates := make(chan []*targetgroup.Group)
m.discoverCancel = append(m.discoverCancel, cancel)
// 大手笔,直接开三个协程:
// 第一个协程用于执行Discoverer的Run函数的,是[]*targetgroup.Group的生产者
// 第二个协程用于从updates这个chan同步数据的,是[]*targetgroup.Group的消费者
// 第三个协程定时(5秒)对所有系统的上线服务做个快照,之所以定时是我猜是把5秒内的变化合并处理
// 避免短时间服务频繁变化造成内部频繁更新
go worker.Run(ctx, updates)
go m.runProvider(ctx, poolKey, updates)
go m.runUpdater(ctx)
}
上面的代码中worker.Run()函数是具体Discoverer实现的,我们此处不做说明。我们现在就从代码上分析prometheus从chan中获取到服务的更新后如何处理的,也就是runProvider函数。此处要说明一下,Provider和Discoverer是一个东西,只是视角不同。
// 代码源自prometheus/discovery/manager.go
// poolKey来自startProvider
func (m *Manager) runProvider(ctx context.Context, poolKey poolKey, updates chan []*targetgroup.Group) {
for {
select {
// 退出信号,直接退出
case <-ctx.Done():
return
case tgs, ok := <-updates:
// 看过我关于golang的chan博客的人肯定知道,这是chan被关闭的信号
if !ok {
return
}
// 更新所有的服务,这里面有一个poolKey的概念,poolKey唯一的标识了服务源,前面说过了
// 函数下面有详细说明
m.updateGroup(poolKey, tgs)
// 因为接收到了Discoverer更新服务的数据,所以设置一下标记
// 上面说过了,协程会5秒做一次快照,所以此处做标记不代表立刻执行
m.recentlyUpdatedMtx.Lock()
m.recentlyUpdated = true
m.recentlyUpdatedMtx.Unlock()
}
}
}
// poolKey来自runProvider的调用者
// tgs来自具体的Discoverer
func (m *Manager) updateGroup(poolKey poolKey, tgs []*targetgroup.Group) {
m.mtx.Lock()
defer m.mtx.Unlock()
// 遍历目标数组,这个数组就是Discoverer通过chan发送过来的
for _, tg := range tgs {
if tg != nil {
// 如果该配置项的目标map没有创建就新建
if _, ok := m.targets[poolKey]; !ok {
m.targets[poolKey] = make(map[string]*targetgroup.Group)
}
// 这里面用Group.Source作为目标的名字,这就要求具体的Discoverer保证Group.Source是唯一的
m.targets[poolKey][tg.Source] = tg
}
}
}
// 定时对所有的目标做快照
func (m *Manager) runUpdater(ctx context.Context) {
// 这里写死了5秒钟,也就是发现了新的服务目标,最迟也要在5秒以后才会进入监控队列
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
// 退出信号
case <-ctx.Done():
return
// 5秒时间到
case <-ticker.C:
m.recentlyUpdatedMtx.Lock()
// 看看服务对象是不是有更新
if m.recentlyUpdated {
// 做快照输出到chan中,并且清除更新标记
m.syncCh <- m.allGroups()
m.recentlyUpdated = false
}
m.recentlyUpdatedMtx.Unlock()
}
}
}
// 对所有的服务目标做快照
func (m *Manager) allGroups() map[string][]*targetgroup.Group {
m.mtx.Lock()
defer m.mtx.Unlock()
tSets := map[string][]*targetgroup.Group{}
// 按照poolKey遍历所有目标
for pkey, tsets := range m.targets {
// 按照目标名称(Group.Source)遍历所有服务目标
for _, tg := range tsets {
// 新的数据组织格式key=job_name,value=目标数组
tSets[pkey.setName] = append(tSets[pkey.setName], tg)
}
}
return tSets
}
上面的代码虽然有些长,但是分了几个函数,每个函数功能比较简单,所以整体理解难度不大。现在信息量已经挺大了,我们是时候小总结一下了:
- prometheus从配置文件获取配置信息,需要发现哪些系统的服务写在配置文件中;
- prometheus通过配置文件构造具体的Discoverer,支持的类型包括kubernetes、EC2、GCE等等;
- prometheus为每个Discoverer创建了3个协程,一个用于执行Discoverer.Run(),一个用于从chan获取服务对象,一个用于定时对所有的服务对象做快照。每个Discoverer实例对应prometheus配置文件job_name.xxx_sd_config[i],有没有发现问题,对所有服务对象做快照只要一个协程就够了,为什么创建一个Discoverer就要创建一个快照协程?我们是不是发现了prometheus的一个bug(机智的我已经向社区提交了BUGhttps://github.com/prometheus/prometheus/issues/4470)?毕竟做了锁,所以这个bug没有对系统造成太大影响,只要配置文件不频繁变化,就不会出现运行时间长了协程泄漏;
- prometheus管理所有服务对象使用两层map,第一层是按照poolKey分组,第二层按照目标名称分组(Group.Source);但是做快照时就只有一层map,key是job_name,value是服务对象的数组;做两层map的目的我个人理解是方便快速定位,同时可以避免Discoverer实现者出现BUG造成的目标重复,可以利用map保证目标的唯一性;
以上总结可以用如下图表达:
服务发现参数加载
prometheus加载配置文件部分不是本文重点,读者自行分析代码,本章节介绍prometheus是如何将数组型的配置转换为map类型的,如下代码所示:
// 代码源自prometheus/cmd/prometheus/main.go
// 这个是个匿名函数,放在了一个reloader的数组,一旦配置文件发生变化就会遍历这个数组的所有函数,达到所有组件更新配置的目的
func(cfg *config.Config) error {
c := make(map[string]sd_config.ServiceDiscoveryConfig)
for _, v := range cfg.ScrapeConfigs {
// 这里面做了转换
c[v.JobName] = v.ServiceDiscoveryConfig
}
// 这里调用应用配置
return discoveryManagerScrape.ApplyConfig(c)
},
总结
服务发现让prometheus增加了主动探测系统监控动态目标的能力,我自己开发的系统就有典型的应用:我需要提供一个基础平台,类似于PaaS,为应用提供部署能力。部署的过程中难免会用到MySql、Redis、Kafka之类的中间件,那么对于我的系统的监控提出了要求:
- 系统内所有的节点要监控,扩容、缩容要能自动发现,通过kubernetes主动发现node实现;
- 部署的中间件也要被监控,所有的中间件也要自动发现,这个可以为每个中间件绑定一个exporter的容器,然后这类的容器打上特殊标签,具备此类标签的pod自动被监控,但让这部分需要prometheus的relabel的功能,我会有单独的文章讲解;
- 我们自己的业务系统也要被监控,这一点可以通过扩展sd方式来做,这个做起来明显比较繁琐,我是通过和中间件一样的方式打标签,然后实现exporter的方式解决的;
prometheus的服务发现功能是一个非常强大的功能,由于我能力有限,只能理解到这个程度了~