Prometheus 每个被控目标暴露一个 endpoint 供 server 抓取,要获知这些 endpoint 有多种方式,最简单的是在配置文件里静态配置,还有基于 k8s、consul、dns 等多种方式,基于文件的服务发现是比较灵活普遍的一种方式。当监控目标量比较大,变化的频率和量也比较大的时候,用 file SD 比较合适,我尝试过 consul,因为每次更新都要删除全量数据重新填充,所以不太适用这个场景。下文以 file SD 为例研究服务发现。
概要介绍(from README.md)
SD 的作用是提取所有信息提供给用户,用户可以用 relabeling 过滤出他们有用的。这些信息称即元数据。
每个 target 通过一组键值对暴露元数据。键有__meta前缀:__meta_<sdname>_<key>
,还有一个 __address__
标签包含 host:port。
SD 应该是泛用于一类服务发现的,不要硬编码任何定制化设置,而应该通过 relabel 实现过滤、转换或者业务逻辑。
如果一次 SD 处理发生异常导致失败,可能已经获取了部分结果,放弃这次处理而不要返回部分结果。继续使用旧的 target 数据好过部分或者错误的元数据。 这一点很有意义,我有两万多 targets,有时候写 SD 文件写错,如果 Prometheus 读取了一部分或者读出错就清空 target,后果就惨了。
Prometheus 不保证 SD 数据的安全。
写 SD 需要实现 Discoverer 接口(Run)方法,Prometheus 调用 Run()
初始化服务发现,发送所有 target group 到一个 channel 里,然后开始监视 SD 的变化,每次更新会发送全部或者只发送变化和新增的 target group 到 channel,这由 Manager
处理。
每个 target group 有一个全局唯一的 Source
。例如一个文件名 file1
,如果有两个 target group,某一次更新后其中一个发生了变化,那么就只把这个 target group 发送到 channel 中。如果某个 target group 中的 target 都不在了,就向 channel 发送一个空的 Targets
。
目录结构
.
├── README.md
├── config
├── manager.go
├── manager_test.go
├── targetgroup
└── discoverer
其中discoverer 是各种具体的服务发现机制的实现。
文件的开头声明了多个指标类型变量,在各个动作点进行相应的指标记录。
manager
manager 有一个构造函数和四个方法会被 main.go 调用
NewManager
构造函数,main.go 构造了两个 discovery.Manage 对象:discoveryManagerScrape
和discoveryManagerNotify
Name
构造函数的参数ApplyConfig
配置构造函数Run
执行服务发现SyncCh
获取 target group 的 channel
1. manager 结构体
manager.go 头部的导入显示它依赖各个具体的 Discoverer 实现。每个 discovery provider 有一个 channel,每次更新向其中发送 target group。
// Manager maintains a set of discovery providers and sends each update to a map channel.
// Targets are grouped by the target set name.
// Manager 维护一组服务发现的 provider, 将更新发送至一个 map channel。
type Manager struct {
logger log.Logger
name string
mtx sync.RWMutex
ctx context.Context
discoverCancel []context.CancelFunc
// Some Discoverers(eg. k8s) send only the updates for a given target group
// so we use map[tg.Source]*targetgroup.Group to know which group to update.
// 有些 Discoverers(例如 k8s)只发送给定的某个 taget group 的更新
// 所以利用这个字典得知更新哪个 target group
// targets 中保存着全部 target 数据
targets map[poolKey]map[string]*targetgroup.Group
// providers keeps track of SD providers.
// 可以配置多个服务发现器
providers []*provider
// The sync channel sends the updates as a map where the key is the job value from the scrape config.
// channel 元素是 map, key 是 prometheus 配置文件的 job_name,value 是其对应的 targetgroup。
syncCh chan map[string][]*targetgroup.Group
// How long to wait before sending updates to the channel. The variable
// should only be modified in unit tests.
updatert time.Duration
// The triggerSend channel signals to the manager that new updates have been received from providers.
// 这是一个用于通知 manager 有 provider 进行了更新的 channel
triggerSend chan struct{}
}
比较重要的成员是 targets,它保存了全量的 target,poolKey 是一个结构体,由 job_name 和 provider_name 组成
type poolKey struct {
setName string
provider string
}
通过 m.registerProviders
可以看到 setName 就是 “file”/“dns”/“consul”…,provider 是 provider 对象的 name 字段,是 “file”/“dns”/“consul”… 后面跟上这个 m.provider 有多少个发现文件,比如 file_SD_discovrer 配置了3个yml文件,poolKey 的 provider 字段就是 “file/3”
2. NewManager()
manager 构造函数,main.go 中调用它,初始化成员变量,updateert 默认5秒钟。可选配置的初始换使用了 functional optianls 模式,值得学习。
// NewManager is the Discovery Manager constructor.
func NewManager(ctx context.Context, logger log.Logger, options ...func(*Manager)) *Manager {
if logger == nil {
logger = log.NewNopLogger()
}
mgr := &Manager{
logger: logger,
syncCh: make(chan map[string][]*targetgroup.Group),
targets: make(map[poolKey]map[string]*targetgroup.Group),
discoverCancel: []context.CancelFunc{},
ctx: ctx,
updatert: 5 * time.Second,
triggerSend: make(chan struct{}, 1),
}
for _, option := range options {
option(mgr)
}
return mgr
}
// Name sets the name of the manager.
func Name(n string) func(*Manager) {
return func(m *Manager) {
m.mtx.Lock()
defer m.mtx.Unlock()
m.name = n
}
}
3. ApplyConfig()
- 配置 provider
discoveredTargets
和failedConfigs
是 prometheus 自己的 metric。registerProviders
方法向Manager.providers
中追加各类 provider,每类可以有多个,如果已经有了这个 provider,就追加它的订阅者(subscribers)
// ApplyConfig removes all running discovery providers and starts new ones using the provided config.
// 停止所有正在运行的服务发现器,启动配置文件(prometheus.yml)设置的新发现器。cfg 的 key 是 job name。
func (m *Manager) ApplyConfig(cfg map[string]sd_config.ServiceDiscoveryConfig) error {
m.mtx.Lock()
defer m.mtx.Unlock()
for pk := range m.targets {
if _, ok := cfg[pk.setName]; !ok {
discoveredTargets.DeleteLabelValues(m.name, pk.setName)
}
}
// 停止正在运行的服务发现
m.cancelDiscoverers()
// 初始化 targets map
m.targets = make(map[poolKey]map[string]*targetgroup.Group)
// reset 各个字段
m.providers = nil
m.discoverCancel = nil
failedCount := 0
// name 是 job_name,scfg 是 SD_name
for name, scfg := range cfg {
// 注册服务发现器(provider),配置文件配了几个就注册几个
failedCount += m.registerProviders(scfg, name)
discoveredTargets.WithLabelValues(m.name, name).Set(0)
}
failedConfigs.WithLabelValues(m.name).Set(float64(failedCount))
// 启动各个服务发现器
for _, prov := range m.providers {
m.startProvider(m.ctx, prov)
}
return nil
}
provider
是对 Discoverer
的一层包装,除了 Discoverer
以外还持有对 Discoverer
的订阅者。一个 SD 类型可以有多个 provider,一个 provider 如果有多个 job 配置了它,注意 provider 的 config 要相同,就追加它的订阅者(subs)。name
字段是 provider 类型名加当前 m
中包含的 provider 数量,其实就是这种类型 provider 的序号。
// provider holds a Discoverer instance, its configuration and its subscribers.
type provider struct {
name string
d Discoverer
subs []string
config interface{}
}
registerProviders
太长了,我就不列代码了,其中的 add
函数用法很值得学习。
startProvider
- 初始化 updates channel 用于传递 target group
- 启动 goroutine 开始运行服务发现,将更新的 target group 发送到 updates channel
- 启动 goroutine,从 updates channel 接收数据,向 m.trggerSend 发送更新信号。
func (m *Manager) startProvider(ctx context.Context, p *provider) {
level.Debug(m.logger).Log("msg", "Starting provider", "provider", p.name, "subs", fmt.Sprintf("%v", p.subs))
ctx, cancel := context.WithCancel(ctx)
updates := make(chan []*targetgroup.Group)
m.discoverCancel = append(m.discoverCancel, cancel)
go p.d.Run(ctx, updates)
go m.updater(ctx, p, updates)
}
func (m *Manager) updater(ctx context.Context, p *provider, updates chan []*targetgroup.Group) {
for {
select {
case <-ctx.Done():
return
case tgs, ok := <-updates:
receivedUpdates.WithLabelValues(m.name).Inc()
if !ok {
level.Debug(m.logger).Log("msg", "Discoverer channel closed", "provider", p.name)
return
}
for _, s := range p.subs {
// 更新 Manager 持有的 target group
m.updateGroup(poolKey{setName: s, provider: p.name}, tgs)
}
select {
// 发送更新信号
case m.triggerSend <- struct{}{}:
default:
}
}
}
}
updater
方法当 provider 更新从通道接受 target group 保存到 Manager 的 targets 字段中。发送信号的时候使用 select case
是因为 triggerSend
是一个无缓冲通道,要有人接收才能发送。Discoverer 的 Run 方法后面单独分析。
4. Run()
在新的 goroutine 运行 sender
方法
// Run starts the background processing
func (m *Manager) Run() error {
go m.sender()
for range m.ctx.Done() {
m.cancelDiscoverers()
return m.ctx.Err()
}
return nil
}
sender
通过一个计时器达到限制更新速率的目的,因为有些 discoverer 可能会过于频繁的更新 target。每次 Run() 都会根据 context 执行取消发现的操作。周期计时器用法值得学,注意创建以后要延迟关闭。
每5秒检查一次 m.triggerSend
中有没有更新的信号,如果有更新的信号,就组装 map[string][]*targetgroup.Group
发送到 m.SyncCh
中,由于 m.SyncCh
是无缓冲通道,如果没能接收的话,就等到下次检查到更新信号再重试发送,这里的嵌套 select case
非常值得学习。
func (m *Manager) sender() {
ticker := time.NewTicker(m.updatert)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C: // Some discoverers send updates too often so we throttle these with the ticker.
select {
case <-m.triggerSend:
sentUpdates.WithLabelValues(m.name).Inc()
select {
case m.syncCh <- m.allGroups():
default:
delayedUpdates.WithLabelValues(m.name).Inc()
level.Debug(m.logger).Log("msg", "Discovery receiver's channel was full so will retry the next cycle")
select {
case m.triggerSend <- struct{}{}:
default:
}
}
default:
}
}
}
}
m.allGroups
方法读取自身的 targets
成员变量中的值组装成 map 返回给调用者,用于向自身的 syncCh
发送这个 map,最终通知给 scraper。
当 SD 发现删除了某个 target group 时会发送一个空的 target group,此处对这个动作的意义做了说明,空的 target group 会通知 scraper 停止再抓取这些 target。
func (m *Manager) allGroups() map[string][]*targetgroup.Group {
m.mtx.RLock()
defer m.mtx.RUnlock()
tSets := map[string][]*targetgroup.Group{}
for pkey, tsets := range m.targets {
var n int
for _, tg := range tsets {
// Even if the target group 'tg' is empty we still need to send it to the 'Scrape manager'
// to signal that it needs to stop all scrape loops for this target set.
tSets[pkey.setName] = append(tSets[pkey.setName], tg)
n += len(tg.Targets)
}
discoveredTargets.WithLabelValues(m.name, pkey.setName).Set(float64(n))
}
return tSets
}
5. SyncCh()
返回一个只读的 channel,调用者通过这个 channel 接收更新的 target
// SyncCh returns a read only channel used by all the clients to receive target updates.
func (m *Manager) SyncCh() <-chan map[string][]*targetgroup.Group {
return m.syncCh
}
6. 小结
至此,discover.Manager 的主要功能就捋出来了
- 主程序调用 NewManager() Manager 实例
- 主程序调用 m.ApplyConfig() 根据配置文件配置并启动 Manager 实例,Manager 实例包括一组 Provider,其持有具体的 Discoverer,Discoverer 在运行时定期刷新target group,通过 channel 发送给 Manager 将其保存在 m.targets 中,并向 m.triggerSend channel 发送通知信号
- m.Run() 按照 m.updatert 设定的时间间隔检查有没有更新的信号,有的话就把自己的 targets 字段中的 targets 发送给自身的 m.syncCh channel
- 主程序调用 m.SyncCh() 方法获取 channel 并从中取得 target
我配置了 file SD
scrape_configs:
- job_name: node1
file_sd_configs:
- files:
- 'node_targets1.yml'
refresh_interval: 10s
- files:
- 'node_targets2.yml'
refresh_interval: 20s
- job_name: node2
file_sd_configs:
- files:
- 'node_targets1.yml'
refresh_interval: 10s
- files:
- 'node_targets3.yml'
refresh_interval: 40s
启动 Prometheus 后 debug 到如下日志
level=debug ts=2021-01-19T06:28:25.951Z caller=manager.go:224 component="discovery manager scrape" msg="Starting provider" provider=*file.SDConfig/1 subs="[node1 node2]"
level=debug ts=2021-01-19T06:28:25.951Z caller=manager.go:224 component="discovery manager scrape" msg="Starting provider" provider=*file.SDConfig/2 subs=[node1]
level=debug ts=2021-01-19T06:28:25.951Z caller=manager.go:224 component="discovery manager scrape" msg="Starting provider" provider=*file.SDConfig/3 subs=[node2]
可以有多个不同类型的 provider,每种类型又可以有多个 provider。一共启动了三个 scrape manager 一个 notify manager,channel 在启动后就关闭了,这个后面研究一下。
Discoverer
discoverer 是个接口,只有一个 Run() 方法,m.startProvider() 会执行这个方法,其参数有一个传递更新的 targetgroup 的通道。
// Discoverer provides information about target groups. It maintains a set
// of sources from which TargetGroups can originate. Whenever a discovery provider
// detects a potential change, it sends the TargetGroup through its channel.
//
// Discoverer does not know if an actual change happened.
// It does guarantee that it sends the new TargetGroup whenever a change happens.
//
// Discoverers should initially send a full set of all discoverable TargetGroups.
// Discoverer 提供监控目标的信息。当服务发现的提供者(具体的file/consul等discovery)发现监控目标变化的时候
// 会通过通道发送监控目标。
// Discoverer 不知道具体发生了哪些变化,它只保证当变化发生后发送当前全量的监控目标。
// Discoverer 初始发送可发现的全量监控目标。
type Discoverer interface {
// Run hands a channel to the discovery provider (Consul, DNS etc) through which it can send
// updated target groups.
// Must returns if the context gets canceled. It should not close the update
// channel on returning.
// Run 交给 discovey 提供者(Consul、DNS...)一个 channel,通过这个 channel 传递更新的监控目标。
// context 取消时必须马上返回。返回时不应该关闭 channel
Run(ctx context.Context, up chan<- []*targetgroup.Group)
}
file Discovery
discovery/file.go 中是 file Discovery 的具体实现
1. Discovery
- 内部的同步保护锁是值类型,所以方法的接收者要是指针类型
// Discovery provides service discovery functionality based
// on files that contain target groups in JSON or YAML format. Refreshing
// happens using file watches and periodic refreshes.
// Discovery 提供基于文件的服务发现,文件格式可以是 JSON 或 YAML。
// 通过监视文件变化和定期执行来刷新监控目标
type Discovery struct {
// 文件路径列表
paths []string
// 监视器
watcher *fsnotify.Watcher
// 更新间隔
interval time.Duration
// 每个文件的最后一次更新时间
timestamps map[string]float64
// 内部保护同步的锁,注意不是 *sync.RWMutex,
// 所以 Discovery 结构体的方法定义的 receiver 必须是 *Discovery。
lock sync.RWMutex
// lastRefresh stores which files were found during the last refresh
// and how many target groups they contained.
// This is used to detect deleted target groups.
// 最后一次刷新发现的文件以及这个文件包含的监控目标数量。
// 用于发现删除的监控目标
lastRefresh map[string]int
logger log.Logger
}
2. NewDiscovery()
m.registerProviders 调用这个构造函数,
// NewDiscovery returns a new file discovery for the given paths.
func NewDiscovery(conf *SDConfig, logger log.Logger) *Discovery {
if logger == nil {
logger = log.NewNopLogger()
}
disc := &Discovery{
// 服务发现文件名
paths: conf.Files,
// 服务发现间隔
interval: time.Duration(conf.RefreshInterval),
// 每个文件的最后一次更新时间
timestamps: make(map[string]float64),
logger: logger,
}
fileSDTimeStamp.addDiscoverer(disc)
return disc
}
conf 是 *SDConfig 类型,文件名和更新间隔定义了一个 SDConfig 实例
// SDConfig is the configuration for file based discovery.
type SDConfig struct {
Files []string `yaml:"files"`
RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"`
}
3. Run()
- 其中 refresh() 是刷新 target group 的方法,Run() 是一个后台常驻的 goroutine,第一次被调用的时候初始填充一次监控对象,然后定期更新。
- 如果监控到文件权限变更就忽略,我第一次读到这里认为更名操作也应该被忽略,后来明白更名操作要更新文件名对应的监控目标,所以一旦更名就要刷新一次。
// Run implements the Discoverer interface.
// Run 实现 Discoverer 接口
func (d *Discovery) Run(ctx context.Context, ch chan<- []*targetgroup.Group) {
// fsnotify 监控视文件(夹)的变化,发生变化的时候向通道发送通知事件
watcher, err := fsnotify.NewWatcher()
if err != nil {
level.Error(d.logger).Log("msg", "Error adding file watcher", "err", err)
return
}
d.watcher = watcher
defer d.stop()
// 初始填充一次监控目标
d.refresh(ctx, ch)
// 无论是否监控到文件(夹)发生变化,都在一段时间间隔后执行一次服务发现。
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
// context 取消时立即返回
case <-ctx.Done():
return
// 监控到文件(夹)变化
case event := <-d.watcher.Events:
// fsnotify sometimes sends a bunch of events without name or operation.
// It's unclear what they are and why they are sent - filter them out.
// fsnotify 有时会发送一批没有名称或者操作类型的事件,忽略它们。
if len(event.Name) == 0 {
break
}
// Everything but a chmod requires rereading.
// 如果操作类型只是一个权限变更,忽略它。
if event.Op^fsnotify.Chmod == 0 {
break
}
// Changes to a file can spawn various sequences of events with
// different combinations of operations. For all practical purposes
// this is inaccurate.
// The most reliable solution is to reload everything if anything happens.
// 对一个文件的变化会触发一系列不同操作类型组合的事件,不管发生什么变化都重新加载
// 监控目标
d.refresh(ctx, ch)
case <-ticker.C:
// Setting a new watch after an update might fail. Make sure we don't lose
// those files forever.
// 在一次更新之后设置新的文件监视器可能会失败。设置定期更新保证不会永远错失更新。
d.refresh(ctx, ch)
case err := <-d.watcher.Errors:
if err != nil {
level.Error(d.logger).Log("msg", "Error watching file", "err", err)
}
}
}
}
3.1 d.refresh()
- d.listFiles() 提取配置文件中配置的正则表达式匹配的文件列表
- d.readFile() 逐个读取每个文件,解析出 target group,更新这个文件的最后一次刷新时间
- 把 target group 发送给传递的通道
- 处理比上次刷新缺少的文件和 target group
- 设置文件监视器
// refresh reads all files matching the discovery's patterns and sends the respective
// updated target groups through the channel.
// 读取配置文件里的匹配服务发现模板的全部文件,通过通道发送变更后的监控目标。
func (d *Discovery) refresh(ctx context.Context, ch chan<- []*targetgroup.Group) {
// 这里会计算和更新 Prometheus 自身的监控指标,prometheus_sd_file_scan_duration_seconds_count,
// 记录刷新监控目标花费的总耗时和每次刷新的耗时分布。
t0 := time.Now()
defer func() {
fileSDScanDuration.Observe(time.Since(t0).Seconds())
}()
// ref 是临时的,最终赋值给 Discovery 结构体的 lastRefresh 变量。
ref := map[string]int{}
// 列举监控目标文件,匹配文件名的正则表达式,找出全部的监控目标文件,
// p 是具体的监控目标文件的文件名。
for _, p := range d.listFiles() {
// 读取这个文件的全量 target
tgroups, err := d.readFile(p)
if err != nil {
// 增加 prometheus_sd_file_read_errors_total 指标计数。
fileSDReadErrorsCount.Inc()
level.Error(d.logger).Log("msg", "Error reading file", "path", p, "err", err)
// Prevent deletion down below.
// 如果读取文件发生错误,保留上次刷新该文件的结果并
// 跳过本次对这个文件的后续处理。
ref[p] = d.lastRefresh[p]
continue
}
select {
// 将 target 发送给 channel
case ch <- tgroups:
// context 取消时立即返回
case <-ctx.Done():
return
}
// 读取完一个文件后将记录这个文件包含的 target 数量
ref[p] = len(tgroups)
}
// Send empty updates for sources that disappeared.
// 对于删除的监控目标文件发送空的更新。
// f 和 n 是本次刷新前的监控目标文件和其中的 target 数量。
for f, n := range d.lastRefresh {
// 如果本次刷新后还有这个文件, ok 就是 true,m 是本次刷新后的 target 数量。
m, ok := ref[f]
// 如果删除了这个文件,或者这个文件当前的 target 数量较刷新前减少了,即删除了一些 target。
if !ok || n > m {
level.Debug(d.logger).Log("msg", "file_sd refresh found file that should be removed", "file", f)
// 删除最后刷新时间字典中的该文件名为键的字典项。
d.deleteTimestamp(f)
// 每减少一个 target group 就像 channel 发送一个没有 Targets 和 Labels,
// 仅有 Source 的 targetgroup
for i := m; i < n; i++ {
select {
case ch <- []*targetgroup.Group{{Source: fileSource(f, i)}}:
case <-ctx.Done():
return
}
}
}
}
// 更新 d.lastRefresh 为 ref
d.lastRefresh = ref
// 设置文件监视器
d.watchFiles()
}
d.readFile()
// readFile reads a JSON or YAML list of targets groups from the file, depending on its
// file extension. It returns full configuration target groups.
// 读取单个监控目标文件,根据扩展名进行 JSON 或者 YAML 的相应解析,返回全部的监控目标。
func (d *Discovery) readFile(filename string) ([]*targetgroup.Group, error) {
fd, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fd.Close()
content, err := ioutil.ReadAll(fd)
if err != nil {
return nil, err
}
info, err := fd.Stat()
if err != nil {
return nil, err
}
var targetGroups []*targetgroup.Group
// 根据扩展名进行相应解析
switch ext := filepath.Ext(filename); strings.ToLower(ext) {
case ".json":
if err := json.Unmarshal(content, &targetGroups); err != nil {
return nil, err
}
case ".yml", ".yaml":
if err := yaml.UnmarshalStrict(content, &targetGroups); err != nil {
return nil, err
}
default:
panic(errors.Errorf("discovery.File.readFile: unhandled file extension %q", ext))
}
for i, tg := range targetGroups {
if tg == nil {
err = errors.New("nil target group item found")
return nil, err
}
// 每一组 target 的 Source
tg.Source = fileSource(filename, i)
if tg.Labels == nil {
tg.Labels = model.LabelSet{}
}
// LabelSet 的 fileSDFilepathLabel 字段设为文件名
tg.Labels[fileSDFilepathLabel] = model.LabelValue(filename)
}
// 每读取一个文件就更新这个文件的最后一次刷新时间
d.writeTimestamp(filename, float64(info.ModTime().Unix()))
return targetGroups, nil
}
d.watchFiles()
// watchFiles sets watches on all full paths or directories that were configured for
// this file discovery.
// 为配置的路径设置监视器
func (d *Discovery) watchFiles() {
if d.watcher == nil {
panic("no watcher configured")
}
for _, p := range d.paths {
if idx := strings.LastIndex(p, "/"); idx > -1 {
// 如果是目录就去掉最后的斜线,应该是 fsnotify.Watcher 的要求
p = p[:idx]
} else {
// 当前目录
p = "./"
}
if err := d.watcher.Add(p); err != nil {
level.Error(d.logger).Log("msg", "Error adding file watch", "path", p, "err", err)
}
}
}
小结
- 整个代码模块全部使用 unbuffered channel,
- Functional options
- manager 依赖 discoverer