django-prometheus和prometheus_client源码分析(一)

背景

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

  • django_prometheus源码
  • prometheus_client源码

你可能感兴趣的:(django-prometheus和prometheus_client源码分析(一))