1. 程式人生 > >深入淺出prometheus之服務發現(sd)

深入淺出prometheus之服務發現(sd)

記得大學剛畢業那年看了侯俊傑的《深入淺出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
}

上面的程式碼就是服務發現管理者的定義,我只對重要的幾個引數進行說明,其他的都是配合實現業務的就不再解釋了:

  1. targets:所有服務(目標),後面程式碼會有這個變數的儲存格式的詳細說明;
  2. 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
}

上面的程式碼雖然有些長,但是分了幾個函式,每個函式功能比較簡單,所以整體理解難度不大。現在資訊量已經挺大了,我們是時候小總結一下了:

  1. prometheus從配置檔案獲取配置資訊,需要發現哪些系統的服務寫在配置檔案中;
  2. prometheus通過配置檔案構造具體的Discoverer,支援的型別包括kubernetes、EC2、GCE等等;
  3. 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沒有對系統造成太大影響,只要配置檔案不頻繁變化,就不會出現執行時間長了協程洩漏;
  4. 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之類的中介軟體,那麼對於我的系統的監控提出了要求:

  1. 系統內所有的節點要監控,擴容、縮容要能自動發現,通過kubernetes主動發現node實現;
  2. 部署的中介軟體也要被監控,所有的中介軟體也要自動發現,這個可以為每個中介軟體繫結一個exporter的容器,然後這類的容器打上特殊標籤,具備此類標籤的pod自動被監控,但讓這部分需要prometheus的relabel的功能,我會有單獨的文章講解;
  3. 我們自己的業務系統也要被監控,這一點可以通過擴充套件sd方式來做,這個做起來明顯比較繁瑣,我是通過和中介軟體一樣的方式打標籤,然後實現exporter的方式解決的;

prometheus的服務發現功能是一個非常強大的功能,由於我能力有限,只能理解到這個程度了~