深⼊浅出prometheus之服务发现(sd)记得⼤学刚毕业那年看了侯俊杰的《深⼊浅出MFC》,就对深⼊浅出这四个字特别偏好,并且成为了⾃⼰对技术的要求标准——对于技术的理解要⾜够的深刻以⾄于可以⽤很浅显的道理给别⼈讲明⽩。以下内容为个⼈见解,如有雷同,纯属巧合,如有错误,烦请指正。
本⽂基于prometheus2.3版本,后续会根据prometheus版本更新及时更新⽂档,所有代码引⽤为了简洁都去掉了⽇志打印相关的代码,尽量只保留有价值的内容。
⽬录
服务发现介绍
本⽂不对prometheus的基本概念做介绍,直接奔主题。scrape是prometheus表⽰抓取监控信息的动作,为了避免翻译造成的信息失真,本⽂直接使⽤scrape单词。prometheus所有scrape的⽬标需要通过配置⽂件(⽐如l)告知prometheus,试想⼀下,如果我们监控的⽬标是动态的(⽐如PaaS平台按需创建中间件),我们总不能每次都去修改配置⽂件然后再通知prometheus重新加载吧(prometheus提供了重新加载配置的接⼝,不需要重新启动)?服务发现(service discovery)就是为了解决此类需求出现的,prometheus 能够主动感知系统增加、删除、更新的服务,然后⾃动将⽬标加⼊到监控队列中。想⼀想,是不是⼀件很酷的事情?那我们就剖析⼀下prometheus的服务发现机制是如何实现的?
特此声明:后⾯会⼤量提到服务、⽬标,其实⼆者是同⼀个内容,由于代码主要⽤的是Target单词,⽽功能是服务发现,所以当看到⽂档中⼀会⼉出现服务,⼀会⼉出现⽬标时不要疑惑,可以想象成⼀个事物。
如何发现各类系统的服务
prometheus默认已经⽀持了很多常⽤系统的服务发现能⼒,这⼀点可以通过官⽅⽂档中到,我这⾥通过代码说明prometheus服务发现的系统:
// 代码源于prometheus/discovery/
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对于各个系统的抽象是什么?
// 所有的系统只要实现Discoverer这个interface就可以了,藐视很简单的样⼦
type Discoverer interface {
Run(ctx context.Context, up chan<- []*targetgroup.Group)
}
// 代码源⾃prometheus/discovery/
type Group struct {
Targets []model.LabelSet // 由具体Discoverer实现为⽬标定义的⼀组标签,以kubernetes的Pod为例,包括Pod的IP、地址
Labels model.LabelSet    // ⽬标的其他标签,以kubernetes为例,就是我们写yaml⽂件metadata.labels字段的内容
Source string            // ⽬标在系统中唯⼀的名字
}
// 代码源⾃prometheus/vendor/github/prometheus/common/
type LabelSet map[LabelName]LabelValue
Discoverer的具体实现和Manager之间唯⼀的沟通渠道就是up这个chan(ctx⽤于系统退出使⽤,所以不做过多说明),从名字基本能看出来,就是所有上线的服务。上⾯代码中LabelSet⽤map实现的kv对,很好理解。targetgroup.Group就是对发现服务的具体定义,为甚⽤Group,我猜是因为有Targets、Labels和Source多个属性的原因。
对于服务发现管理者来说,只要系统有任何⽬标变化告诉管理者就⾏了,其他的⼀律不关⼼。然后再根据不同系统实现Discoverer,那么我们就来看看prometheus是如何管理这些Discoverer的。
// 代码源于prometheus/
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
}
上⾯的代码就是服务发现管理者的定义,我只对重要的⼏个参数进⾏说明,其他的都是配合实现业务的就不再解释了:
1. targets:所有服务(⽬标),后⾯代码会有这个变量的存储格式的详细说明;
2. syncCh:targets的快照的chan,prometheus真正需要监控⽬标通过该chan发送scrape模块,prometheus每隔⼀段时间就会对
targets做⼀次快照,前提是targets发⽣了变化才会执⾏;
接下来,我就要看看prometheus是如构造各种Discoverer。我们知道,prometheus最初始的配置来⾃于配置⽂件,下⾯的代码就是应⽤配置信息的实现:
func (m *Manager) ApplyConfig(cfg map[string]sd_config.ServiceDiscoveryConfig) error {
// 加锁解锁使⽤defer的技巧就不多说明了
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., poolKey{setName: name, provider: provName}, prov)
}
}
return nil
}
从配置信息构造Discoverer的实现如下:
// 代码源⾃prometheus/
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的:
// 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/
// 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秒做⼀次快照,所以此处做标记不代表⽴刻执⾏
}
}
}
/
/ poolKey来⾃runProvider的调⽤者
// tgs来⾃具体的Discoverer
func (m *Manager) updateGroup(poolKey poolKey, tgs []*targetgroup.Group) {
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:
// 看看服务对象是不是有更新
lyUpdated {
/
/ 做快照输出到chan中,并且清除更新标记
m.syncCh <- m.allGroups()
深入浅出mfc
}
}
}
}
// 对所有的服务⽬标做快照
func (m *Manager) allGroups() map[string][]*targetgroup.Group {
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
}
上⾯的代码虽然有些长,但是分了⼏个函数,每个函数功能⽐较简单,所以整体理解难度不⼤。现在信息量已经挺⼤了,我们是时候⼩总结⼀下了:
1. prometheus从配置⽂件获取配置信息,需要发现哪些系统的服务写在配置⽂件中;
2. prometheus通过配置⽂件构造具体的Discoverer,⽀持的类型包括kubernetes、EC2、GCE等等;
3. prometheus为每个Discoverer创建了3个协程,⼀个⽤于执⾏Discoverer.Run(),⼀个⽤于从chan获取服务对象,⼀个⽤于定时对
所有的服务对象做快照。每个Discoverer实例对应prometheus配置⽂件_sd_config[i],有没有发现问题,对所有服务对象做快照只要⼀个协程就够了,为什么创建⼀个Discoverer就要创建⼀个快照协程?我们是不是发现了prometheus的⼀个bug(机智的我已经向社区提交了BUG)?毕竟做了锁,所以这个bug没有对系统造成太⼤影响,只要配置⽂件不频繁变化,就不会出现运⾏时间长了协程泄漏;
4. prometheus管理所有服务对象使⽤两层map,第⼀层是按照poolKey分组,第⼆层按照⽬标名称分组(Group.Source);但是做快照
时就只有⼀层map,key是job_name,value是服务对象的数组;做两层map的⽬的我个⼈理解是⽅便快速定位,同时可以避免Discoverer实现者出现BUG造成的⽬标重复,可以利⽤map保证⽬标的唯⼀性;
以上总结可以⽤如下图表达:

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。