django-prometheus和prometheus_client源碼分析(一) 背景 源碼分析 References

背景

Prometheus是最近流行的監控報警系統,具體大家可以搜網上的文章來了解,而由於我司目前的應用使用了Django框架來做爲後端應用,因此需要研究如何將Prometheus與Django結合在一起使用,因此有了接下來的源碼研究。

在分析源代碼之前,先要知道爲什麼需要分析源代碼,對於我來說,有幾個問題是我想要搞明白的:

  1. django-prometheus是如何註冊/metrics uri並通過接口提供服務的?
  2. django-prometheus到底是怎樣將數據從不同的接口收集上來的?
  3. django-prometheus收集上來Metrics後是否需要存儲,如果需要,那麼存儲在什麼地方了?

而在搞清楚這些問題的時候,發現django-prometheus又調用了prometheus_client,又不可避免的有了針對prometheus_client的問題,所以又不得不去看prometheus_client的源碼,也因此有了本文。

接下來就分別從這三個問題出發,看下django-prometheus的內部實現究竟是怎麼樣的?


源碼分析

django-prometheus註冊/metrics URL

首先在使用django-prometheus的時候需要如下注冊URL

urlpatterns = [
    ...
    url('', include('django_prometheus.urls')),
]

因此我們找到這個urls文件,看下究竟是啥

# django_prometheus/urls.py

from django.urls import path
from django_prometheus import exports

urlpatterns = [
    path("metrics", exports.ExportToDjangoView, name="prometheus-django-metrics")
]

看到這裏註冊了一個/metrics API,而實際的view函數是這個exports.ExportToDjangoView,再找到這個函數,如下:

# django_prometheus/exports.py

def ExportToDjangoView(request):
    """Exports /metrics as a Django view.
    You can use django_prometheus.urls to map /metrics to this view.
    """
    if "prometheus_multiproc_dir" in os.environ:
        # 多進程適用,暫時不考慮
        registry = prometheus_client.CollectorRegistry()
        multiprocess.MultiProcessCollector(registry)
    else:
        # 重點是這裏
        registry = prometheus_client.REGISTRY
    metrics_page = prometheus_client.generate_latest(registry)
    return HttpResponse(
        metrics_page, content_type=prometheus_client.CONTENT_TYPE_LATEST
    )

這個view函數幹了什麼事呢?它主要是生成一個registry對象,並通過這個registry對象來獲取最新的metrics頁,最後再返回這個最新的metrics頁。看到這裏大家應該能猜到了,這個metrics_page應該包含的就是我們的metrics信息。那麼這個registry是幹什麼的呢?

Registry

在回答這個問題之前,我們先想一下,我們怎麼收集不同接口以及不同collector的數據,是不是需要有個地方來存儲這些信息呢?那麼這個registry是不是就是這個作用呢?那麼我們再來看下prometheus_client的源碼

# prometheus_client/registry.py 部分源碼


class CollectorRegistry(object):
    """Metric collector registry.
    Collectors must have a no-argument method 'collect' that returns a list of
    Metric objects. The returned metrics should be consistent with the Prometheus
    exposition formats.
    """

    def __init__(self, auto_describe=False, target_info=None):
        self._collector_to_names = {}
        self._names_to_collectors = {}
        self._auto_describe = auto_describe
        self._lock = Lock()
        self._target_info = {}
        self.set_target_info(target_info)

    def register(self, collector):
        """Add a collector to the registry."""
        with self._lock:
            names = self._get_names(collector)
            duplicates = set(self._names_to_collectors).intersection(names)
            if duplicates:
                raise ValueError(
                    'Duplicated timeseries in CollectorRegistry: {0}'.format(
                        duplicates))
            for name in names:
                # 本段代碼的核心,就是要把collector對象存儲到字典當中,從而實現註冊的功能
                self._names_to_collectors[name] = collector
            self._collector_to_names[collector] = names
    ...
    

# 在ExportToDjangoView中使用的REGISTRY來源於此    
REGISTRY = CollectorRegistry(auto_describe=True)

首先我們清楚了ExportToDjangoView函數中的REGISTRY本質上也是一個CollectorRegistry對象。而通過CollectorRegistryregister method,我們可以發現它實際上就是用於註冊collector對象的,它會將傳入的collector對象保存於_names_to_collectors這個字典當中。

到這我們大體知道了django-prometheus是如何添加/metrics url以及在view函數中都做了些什麼。到現在還有兩個疑問沒有解決:

  1. CollectorRegistry究竟在什麼時候調用了register函數?
  2. CollectorRegistry究竟是如何獲取到相應的metrics的呢?

我們首先注意到在ExportToDjangoView這個view函數中是通過如下語句獲取到最新的metrics的

metrics_page = prometheus_client.generate_latest(registry)

generate_latest的源碼看下:

def generate_latest(registry=REGISTRY):
    """Returns the metrics from the registry in latest text format as a string."""

    ...

    output = []
    # 通過調用registry.collect()可以獲取到所有collector的metrics
    for metric in registry.collect():
        try:
            ...

            output.append('# HELP {0} {1}\n'.format(
                mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
            output.append('# TYPE {0} {1}\n'.format(mname, mtype))

            om_samples = {}
            for s in metric.samples:
                for suffix in ['_created', '_gsum', '_gcount']:
                    if s.name == metric.name + suffix:
                        # OpenMetrics specific sample, put in a gauge at the end.
                        om_samples.setdefault(suffix, []).append(sample_line(s))
                        break
                else:
                    output.append(sample_line(s))
        except Exception as exception:
            exception.args = (exception.args or ('',)) + (metric,)
            raise

        for suffix, lines in sorted(om_samples.items()):
            output.append('# HELP {0}{1} {2}\n'.format(metric.name, suffix,
                                                       metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
            output.append('# TYPE {0}{1} gauge\n'.format(metric.name, suffix))
            output.extend(lines)
    return ''.join(output).encode('utf-8')

從代碼中可以看出,所有的原始的metrics的獲取都是從registry.collect()這裏得到的,得到這些原始的metrics之後,再加以格式化,格式成Prometheus規定的格式,最後拼成一頁並進行返回。

collect()代碼如下:

# prometheus_client/registry.py 部分源碼

def collect(self):
        """Yields metrics from the collectors in the registry."""
        collectors = None
        ti = None
        with self._lock:
            collectors = copy.copy(self._collector_to_names)
            if self._target_info:
                ti = self._target_info_metric()
        if ti:
            yield ti
            
        # ----- 這裏是核心 -------
        for collector in collectors:
            for metric in collector.collect():
                yield metric
        # ------------------------

這段代碼的前半部分是針對target_info,由於我們的registry在初始化的時候沒有傳遞target_info參數,默認爲None,所以前邊這部分代碼可以忽略。後面的核心代碼可以看到就是要把註冊的collector拿出來一個一個去調用collect()方法,從而獲取到對應collector的metrics。

從上邊這段代碼分析我們已經回答了第二個問題,即我們知道了具體收集metrics的過程,但是對於第一個問題(什麼時候register)仍然沒有找到答案。那麼猜測下在什麼時間註冊collector最合適呢?

Middleware

針對上邊的問題,初步猜測是在PrometheusBeforeMiddleware這個中間件中,那就看看這個中間件是個啥吧,上源碼:

class PrometheusBeforeMiddleware(MiddlewareMixin):
    """Monitoring middleware that should run before other middlewares."""

    metrics_cls = Metrics

    def __init__(self, get_response=None):
        super().__init__(get_response)
        self.metrics = self.metrics_cls.get_instance()

    def process_request(self, request):
        self.metrics.requests_total.inc()
        request.prometheus_before_middleware_event = Time()

    def process_response(self, request, response):
        self.metrics.responses_total.inc()
        if hasattr(request, "prometheus_before_middleware_event"):
            self.metrics.requests_latency_before.observe(
                TimeSince(request.prometheus_before_middleware_event)
            )
        else:
            self.metrics.requests_unknown_latency_before.inc()
        return response

其中初始化方法__init__會在項目啓動的時候就調用,這個時候就是最好的時機去做註冊相關的工作。再進一步的,發現其本質是調用了Metricsget_instance()方法。再進一步看看這個Metrics類:

class Metrics:
    _instance = None

    @classmethod
    def get_instance(cls):
        if not cls._instance:
            cls._instance = cls()
        return cls._instance

    def register_metric(self, metric_cls, name, documentation, labelnames=(), **kwargs):
        return metric_cls(name, documentation, labelnames=labelnames, **kwargs)

    def __init__(self, *args, **kwargs):
        self.register()

    def register(self):
        self.requests_total = self.register_metric(
            Counter,
            "django_http_requests_before_middlewares_total",
            "Total count of requests before middlewares run.",
            namespace=NAMESPACE,
        )
        ...
        

這裏有幾點需要注意:

  • Metrics使用了單例模式,使用時總是通過類方法 get_instance()來獲取其實例。而這個實例在初始化時只做了一件事那就是register(),至此我們終於即將要揭開謎底了。所有的註冊工作就是在Metrics 這個類初始化的時候進行註冊的。
  • 可是當我們進入register_metric方法中發現它只是調用對應的Collector類進行初始化。那麼是不是在collector的初始化的時候就進行了註冊呢?

Collector

帶着這個疑問,看了下prometheus_client定義Collector的文件metrics.py, 在這個文件中我們會發現所有類型的Collector(包括Counter, Gauge, Histogram, Summary等)都繼承自MetricWrapperBase, 而這個基類的部分源碼如下:

# prometheus_client/metrics.py 部分源碼

class MetricWrapperBase(object):
    ...
    
    def collect(self):
        metric = self._get_metric()
        for suffix, labels, value in self._samples():
            metric.add_sample(self._name + suffix, labels, value)
        return [metric]

    ...

    def __init__(self,
                 name,
                 documentation,
                 labelnames=(),
                 namespace='',
                 subsystem='',
                 unit='',
                 registry=REGISTRY, # 注意這裏使用的是默認的REGISTRY
                 labelvalues=None,
                 ):
        self._name = _build_full_name(self._type, name, namespace, subsystem, unit)
        self._labelnames = _validate_labelnames(self, labelnames)
        self._labelvalues = tuple(labelvalues or ())
        self._kwargs = {}
        self._documentation = documentation
        self._unit = unit

        if not METRIC_NAME_RE.match(self._name):
            raise ValueError('Invalid metric name: ' + self._name)

        if self._is_parent():
            # Prepare the fields needed for child metrics.
            self._lock = Lock()
            self._metrics = {}

        if self._is_observable():
            self._metric_init()

        if not self._labelvalues:
            # Register the multi-wrapper parent metric, or if a label-less metric, the whole shebang.
            # 這裏是關鍵,至此謎底揭曉
            if registry:
                registry.register(self)

    ...

通過這部分代碼我們終於明白了其在Collector默認使用了REGISTRY作爲註冊器,並在初始化的時候進行註冊。至此我們已經搞清楚了整個過程,再總結下:

  • Django在啓動的時候會調用middleware的初始化方法。
  • PrometheusBeforeMiddleware中間件會在初始化方法中調用Metrics的get_instance()方法
  • 而這個方法在第一次初始化實例的時候(因爲是單例模式,所以也只調用一次),就會調用register()方法
  • register()方法中會初始化所有的Collector,而Collector的初始化方法會自動調用register,將本Collector本身註冊到默認的REGISTRY

需要注意的是,如果是自定義的Collector,如果不指定registry,那麼默認也是REGISTRY, 所以最終我們在調用generate_latest的時候也會從REGISTRY獲取到自定義的Collector。

到這裏,我們已經基本搞清楚了大部分的流程,但是如果你足夠細心的話,你會注意到ExportToDjangoView這個view方法中還有一種情況並不是用默認的REGISTRY,如下代碼所示:

if "prometheus_multiproc_dir" in os.environ:
    registry = prometheus_client.CollectorRegistry()
    multiprocess.MultiProcessCollector(registry)
  • 首先自定義了一個registry,這個registry在初始化的時候沒有傳遞任何參數,因此auto_describe默認爲False,這是與默認的REGISTRY的區別。
  • 調用了MultiProcessCollector的初始化方法,並將我們創建的registry傳遞給了它的初始化方法。
  • 這種情況主要是適用於多進程的時候,django-prometheusprometheus_client都針對多進程有特殊的處理。這塊作爲一個懸疑點留給有興趣的朋友,後續有機會再針對此處做詳細闡釋。

References

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章