使用 Django,Prometheus,和 Kubernetes 定制应用指标

点击查看目录

本文为翻译文章,点击查看原文

编者按

本文强调了应用程序定制指标的重要性,用代码实例演示了如何设计指标并整合 Prometheus 到 Django 项目中,为使用 Django 构建应用的开发者提供了参考。

为什么自定义指标很重要?

尽管有大量关于这一主题的讨论,但应用程序的自定义指标的重要性怎么强调都不为过。和为 Django 应用收集的核心服务指标(应用和 web 服务器统计数据、关键数据库和缓存操作指标)不同,自定义指标是业务特有的数据点,其边界和阈值只有你自己知道,这其实是很有趣的事情。

什么样的指标才是有用的?考虑下面几点:

  • 运行一个电子商务网站并追踪平均订单数量。突然间订单的数量不那么平均了。有了可靠的应用指标和监控,你就可以在损失殆尽之前捕获到 Bug。
  • 你正在写一个爬虫,它每小时从一个新闻网站抓取最新的文章。突然最近的文章并不新了。可靠的指标和监控可以更早地揭示问题所在。
  • 我认为你已经理解了重点。

设置 Django 应用程序

除了明显的依赖(pip install Django)之外,我们还需要为宠物项目(译者注:demo)添加一些额外的包。继续并安装pip install django-prometheus-client。这将为我们提供一个 Python 的 Prometheus 客户端,以及一些有用的 Django hook,包括中间件和一个优雅的 DB 包装器。接下来,我们将运行 Django 管理命令来启动项目,更新我们的设置来使用 Prometheus 客户端,并将 Prometheus 的 URL 添加到 URL 配置中。

启动一个新的项目和应用程序

为了这篇文章,并且切合代理的品牌,我们建立了一个遛狗服务。请注意,它实际上不会做什么事,但足以作为一个教学示例。执行如下命令:

django-admin.py startproject demo
python manage.py startapp walker

#settings.py

INSTALLED_APPS = [
    ...
    'walker',
    ...
]

现在,我们来添加一些基本的模型和视图。简单起见,我只实现将要验证的部分。如果想要完整地示例,可以从这个demo 应用 获取源码。


# walker/models.py
from django.db import models
from django_prometheus.models import ExportModelOperationsMixin


class Walker(ExportModelOperationsMixin('walker'), models.Model):
    name = models.CharField(max_length=127)
    email = models.CharField(max_length=127)

    def __str__(self):
        return f'{self.name} // {self.email} ({self.id})'


class Dog(ExportModelOperationsMixin('dog'), models.Model):
    SIZE_XS = 'xs'
    SIZE_SM = 'sm'
    SIZE_MD = 'md'
    SIZE_LG = 'lg'
    SIZE_XL = 'xl'
    DOG_SIZES = (
        (SIZE_XS, 'xsmall'),
        (SIZE_SM, 'small'),
        (SIZE_MD, 'medium'),
        (SIZE_LG, 'large'),
        (SIZE_XL, 'xlarge'),
    )

    size = models.CharField(max_length=31, choices=DOG_SIZES, default=SIZE_MD)
    name = models.CharField(max_length=127)
    age = models.IntegerField()

    def __str__(self):
        return f'{self.name} // {self.age}y ({self.size})'


class Walk(ExportModelOperationsMixin('walk'), models.Model):
    dog = models.ForeignKey(Dog, related_name='walks', on_delete=models.CASCADE)
    walker = models.ForeignKey(Walker, related_name='walks', on_delete=models.CASCADE)

    distance = models.IntegerField(default=0, help_text='walk distance (in meters)')

    start_time = models.DateTimeField(null=True, blank=True, default=None)
    end_time = models.DateTimeField(null=True, blank=True, default=None)

    @property
    def is_complete(self):
        return self.end_time is not None

    @classmethod
    def in_progress(cls):
        """ get the list of `Walk`s currently in progress """
        return cls.objects.filter(start_time__isnull=False, end_time__isnull=True)

    def __str__(self):
        return f'{self.walker.name} // {self.dog.name} @ {self.start_time} ({self.id})'
# walker/views.py
from django.shortcuts import render, redirect
from django.views import View
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponseNotFound, JsonResponse, HttpResponseBadRequest, Http404
from django.urls import reverse
from django.utils.timezone import now
from walker import models, forms


class WalkDetailsView(View):
    def get_walk(self, walk_id=None):
        try:
            return models.Walk.objects.get(id=walk_id)
        except ObjectDoesNotExist:
            raise Http404(f'no walk with ID {walk_id} in progress')


class CheckWalkStatusView(WalkDetailsView):
    def get(self, request, walk_id=None, **kwargs):
        walk = self.get_walk(walk_id=walk_id)
        return JsonResponse({'complete': walk.is_complete})


class CompleteWalkView(WalkDetailsView):
    def get(self, request, walk_id=None, **kwargs):
        walk = self.get_walk(walk_id=walk_id)
        return render(request, 'index.html', context={'form': forms.CompleteWalkForm(instance=walk)})

    def post(self, request, walk_id=None, **kwargs):
        try:
            walk = models.Walk.objects.get(id=walk_id)
        except ObjectDoesNotExist:
            return HttpResponseNotFound(content=f'no walk with ID {walk_id} found')

        if walk.is_complete:
            return HttpResponseBadRequest(content=f'walk {walk.id} is already complete')

        form = forms.CompleteWalkForm(data=request.POST, instance=walk)

        if form.is_valid():
            updated_walk = form.save(commit=False)
            updated_walk.end_time = now()
            updated_walk.save()

            return redirect(f'{reverse("walk_start")}?walk={walk.id}')

        return HttpResponseBadRequest(content=f'form validation failed with errors {form.errors}')


class StartWalkView(View):
    def get(self, request):
        return render(request, 'index.html', context={'form': forms.StartWalkForm()})

    def post(self, request):
        form = forms.StartWalkForm(data=request.POST)

        if form.is_valid():
            walk = form.save(commit=False)
            walk.start_time = now()
            walk.save()

            return redirect(f'{reverse("walk_start")}?walk={walk.id}')

        return HttpResponseBadRequest(content=f'form validation failed with errors {form.errors}')

更新应用设置并添加 Prometheus urls

现在我们有了一个 Django 项目以及相应的设置,可以为 django-prometheus添加需要的配置项了。在 settings.py中添加下面的配置:

INSTALLED_APPS = [
    ...
    'django_prometheus',
    ...
]

MIDDLEWARE = [
    'django_prometheus.middleware.PrometheusBeforeMiddleware',
    ....
    'django_prometheus.middleware.PrometheusAfterMiddleware',
]

# we're assuming a Postgres DB here because, well, that's just the right choice :)
DATABASES = {
    'default': {
        'ENGINE': 'django_prometheus.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST'),
        'PORT': os.getenv('DB_PORT', '5432'),
    },
}

添加 url 配置到 urls.py

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

现在我们有了一个配置好的基本应用,并为整合做好了准备。


添加 Prometheus 指标

由于django-prometheus提供了开箱即用功能,我们可以立即追踪一些基本的模型操作,比如插入和删除。可以在/metricsendpoint 看到这些:

django-prometheus default metrics
django-prometheus default metrics
django-prometheus 提供的默认指标

让我们把它变得更有趣点。

添加一个walker/metrics.py文件,定义一些要追踪的基本指标。

# walker/metrics.py
from prometheus_client import Counter, Histogram


walks_started = Counter('walks_started', 'number of walks started')
walks_completed = Counter('walks_completed', 'number of walks completed')
invalid_walks = Counter('invalid_walks', 'number of walks attempted to be started, but invalid')

walk_distance = Histogram('walk_distance', 'distribution of distance walked', buckets=[0, 50, 200, 400, 800, 1600, 3200])

很简单,不是吗?Prometheus 文档很好地解释了每种指标类型的用途,简言之,我们使用计数器来表示严格随时间增长的指标,使用直方图来追踪包含值分布的指标。下面开始验证应用的代码。

# walker/views.py
...
from walker import metrics
...

class CompleteWalkView(WalkDetailsView):
    ...
    def post(self, request, walk_id=None, **kwargs):
        ...
        if form.is_valid():
            updated_walk = form.save(commit=False)
            updated_walk.end_time = now()
            updated_walk.save()

            metrics.walks_completed.inc()
            metrics.walk_distance.observe(updated_walk.distance)

            return redirect(f'{reverse("walk_start")}?walk={walk.id}')

        return HttpResponseBadRequest(content=f'form validation failed with errors {form.errors}')

...

class StartWalkView(View):
    ...
    def post(self, request):
        if form.is_valid():
            walk = form.save(commit=False)
            walk.start_time = now()
            walk.save()

            metrics.walks_started.inc()

            return redirect(f'{reverse("walk_start")}?walk={walk.id}')

        metrics.invalid_walks.inc()

        return HttpResponseBadRequest(content=f'form validation failed with errors {form.errors}')

发送几个样例请求,可以看到新指标已经产生了。

custom metrics coming in
custom metrics coming in
显示散步距离和创建散步的指标

prometheus custom metrics
prometheus custom metrics
定义的指标此时已经可以在 prometheus 里查找到了

至此,我们已经在代码中添加了自定义指标,整合了应用以追踪指标,并验证了这些指标已在/metrics 上更新并可用。让我们继续将仪表化应用部署到 Kubernetes 集群。

使用 Helm 部署应用

我只会列出和追踪、导出指标相关的配置内容,完整的 Helm chart 部署和服务配置可以在 demo 应用中找到。作为起点,这有一些和导出指标相关的 deployment 和 configmap 的配置:

# helm/demo/templates/nginx-conf-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "demo.fullname" . }}-nginx-conf
  ...
data:
  demo.conf: |
    upstream app_server {
      server 127.0.0.1:8000 fail_timeout=0;
    }

    server {
      listen 80;
      client_max_body_size 4G;

      # set the correct host(s) for your site
      server_name{{ range .Values.ingress.hosts }} {{ . }}{{- end }};

      keepalive_timeout 5;

      root /code/static;

      location / {
        # checks for static file, if not found proxy to app
        try_files $uri @proxy_to_app;
      }

      location ^~ /metrics {
        auth_basic           "Metrics";
        auth_basic_user_file /etc/nginx/secrets/.htpasswd;

        proxy_pass http://app_server;
      }

      location @proxy_to_app {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        # we don't want nginx trying to do something clever with
        # redirects, we set the Host: header above already.
        proxy_redirect off;
        proxy_pass http://app_server;
      }
    }    

# helm/demo/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
...
    spec:
      metadata:
        labels:
          app.kubernetes.io/name: {{ include "demo.name" . }}
          app.kubernetes.io/instance: {{ .Release.Name }}
          app: {{ include "demo.name" . }}
      volumes:
        ...
        - name: nginx-conf
          configMap:
            name: {{ include "demo.fullname" . }}-nginx-conf
        - name: prometheus-auth
          secret:
            secretName: prometheus-basic-auth
        ...
      containers:
        - name: {{ .Chart.Name }}-nginx
          image: "{{ .Values.nginx.image.repository }}:{{ .Values.nginx.image.tag }}"
          imagePullPolicy: IfNotPresent
          volumeMounts:
            ...
            - name: nginx-conf
              mountPath: /etc/nginx/conf.d/
            - name: prometheus-auth
              mountPath: /etc/nginx/secrets/.htpasswd
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          command: ["gunicorn", "--worker-class", "gthread", "--threads", "3", "--bind", "0.0.0.0:8000", "demo.wsgi:application"]
          env:
{{ include "demo.env" . | nindent 12 }}
          ports:
            - name: gunicorn
              containerPort: 8000
              protocol: TCP
           ...

没什么神奇的,只是一些 YAML 而已。有两个重点需要强调一下:

  1. 我们通过一个 nginx 反向代理将/metrics放在了验证后面,为 location 块设置了 auth_basic 指令集。你可能希望在反向代理之后部署gunicorn ,但这样做可以获得保护指标的额外好处。
  2. 我们使用多线程的 gunicorn 而不是多个 worker。虽然可以为 Prometheus 客户端启用多进程模式,但在 Kubernetes 环境中,安装会更为复杂。为什么这很重要呢?在一个 pod 中运行多个 worker 的风险在于,每个 worker 将在采集时报告自己的一组指标值。但是,由于服务在 Prometheus Kubernetes SD scrape 配置中被设置为 pod 级别,这些(潜在的)跳转值将被错误地分类为计数器重置,从而导致测量结果不一致。你并不一定需要遵循上述所有步骤,但重点是:如果你了解的不多,应该从一个单线程 + 单 worker 的 gunicorn 环境开始,或者从一个单 worker+多线程环境开始。

使用 Helm 部署 Prometheus

基于Helm的帮助文档,部署 Prometheus 非常简单,不需要额外工作:

helm upgrade --install prometheus stable/prometheus

几分钟后,你应该就可以通过 port-forward 进入 Prometheus 的 pod(默认的容器端口是 9090)。

为应用配置 Prometheus scrape 目标

Prometheus Helm chart 有大量的自定义可选项,不过我们只需要设置extraScrapeConfigs。创建一个values.yaml文件。你可以略过这部分直接使用 demo 应用 作为参考。文件内容如下:

extraScrapeConfigs: |
  - job_name: demo
    scrape_interval: 5s
    metrics_path: /metrics
    basic_auth:
      username: prometheus
      password: prometheus
    tls_config:
      insecure_skip_verify: true
    kubernetes_sd_configs:
      - role: endpoints
        namespaces:
          names:
            - default
    relabel_configs:
      - source_labels: [__meta_kubernetes_service_label_app]
        regex: demo
        action: keep
      - source_labels: [__meta_kubernetes_endpoint_port_name]
        regex: http
        action: keep
      - source_labels: [__meta_kubernetes_namespace]
        target_label: namespace
      - source_labels: [__meta_kubernetes_pod_name]
        target_label: pod
      - source_labels: [__meta_kubernetes_service_name]
        target_label: service
      - source_labels: [__meta_kubernetes_service_name]
        target_label: job
      - target_label: endpoint
        replacement: http  

创建完成后,就可以通过下面的操作为 prometheus deployment 更新配置。

helm upgrade --install prometheus -f values.yaml

为验证所有的步骤都配置正确了,打开浏览器输入 http://localhost:9090/targets (假设你已经通过 port-forward进入了运行 prometheus 的 Pod)。如果你看到 demo 应用在 target 的列表中,说明运行正常了。

自己动手试试

我要强调一点:捕获自定义的应用程序指标并设置相应的报告和监控是软件工程中最重要的任务之一。幸运的是,将 Prometheus 指标集成到 Django 应用程序中实际上非常简单,正如本文展示的那样。如果你想要开始监测自己的应用,请参考完整的示例应用程序,或者直接 fork 代码库。祝你玩得开心。

编辑本页