Jaeger Tracing пример на Django

Мини-лаборатория для исследования производительности узких мест в Django с помощью Jaeger, Redis и PostgreSQL.

⚙️ Стек

  • Django
  • Redis
  • PostgreSQL
  • Jaeger (OpenTelemetry)
  • Docker Compose

📦 Установка

git clone git@gitlab.thenextgen.store:infra/jaeger-otel-django-example.git
cd jaeger-otel-django-example
git checkout tutorial
docker compose up -d db redis
docker compose run web python manage.py migrate
docker compose down --remove-orphans && docker compose up

Открыть http://127.0.0.1:8000

Генерируем один миллион записей в бд

Останавливаем все контейнеры:

docker compose down --remove-orphans

Добавляем пакет в requiremens.txt для генерации фейковых данных:

Faker

Создаем management таску todos/management/commands/gen_tasks.py:

from django.core.management.base import BaseCommand
from django.db import transaction
from todos.models import Task
from faker import Faker
fake = Faker()

class Command(BaseCommand):
    help = "Generate N fake tasks"

    def add_arguments(self, parser):
        parser.add_argument("count", type=int, help="How many tasks")

    def handle(self, *args, count, **kwargs):
        CHUNK = 10_000                      # bulk size
        created = 0
        while created < count:
            batch = [
                Task(title=fake.sentence(nb_words=6))
                for _ in range(min(CHUNK, count - created))
            ]
            with transaction.atomic():
                Task.objects.bulk_create(batch, CHUNK)
            created += len(batch)
            self.stdout.write(f"-> {created}/{count}")

Запускаем все и ребилдим докер образ django сервиса:

docker compose up -d --build

Непосредственная генерация данных:

docker compose run web python manage.py gen_tasks 1000000

Подключаем opentelemetry и jaeger сервис

Обновляем docker-compose.yml:

services:
  jaeger:
    image: jaegertracing/all-in-one:1.54
    ports:
      - "16686:16686"      # UI
      - "4317:4317"        # OTLP gRPC
  web …  ← внутри web-контейнера появится env-переменная
    environment:
      …
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317

Добавляем пакеты opentelemetry в requirements.txt для инструментации Django:

opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-instrumentation-django
opentelemetry-instrumentation-redis

Редактируем core/setting.py, нужно добавить следующее:

from opentelemetry import trace
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor

# 1. включаем авто-инструментацию
DjangoInstrumentor().instrument()
RedisInstrumentor().instrument()

# 2. задаём provider + экспорт в Jaeger
trace.set_tracer_provider(
    TracerProvider(
        resource=Resource.create({SERVICE_NAME: "django-todo"})
    )
)
span_processor = BatchSpanProcessor(
    OTLPSpanExporter(endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
)
trace.get_tracer_provider().add_span_processor(span_processor)

TRACER = trace.get_tracer(__name__)

Снова ребилдим и запускаем все после добавления пакетов:

docker compose down --remove-orphans && docker compose up --build

Снова открываем http://127.0.0.1:8000, страница будет лагать и может 9-12 секунд открываться из-за отображения 1 миллиона записей.

Переходим в Jaeger UI http://127.0.0.1:16686 Выбираем нужный Service. Нам интересны трассировки нашего django приложения, а не самого jaeger:

Жмакаем Find Traces:

Видим наш трейс на 12 с лишним секунд, автозахват произошел благодаря ранее включенной автоинструментации, тыкаем на него как показано на картинке:

Не очень информативно, что же внутри конкретно тормозит, подключаем автоинструментацию postgres, добавим пакет opentelemetry для psycopg2, редактируем requirements.txt:

opentelemetry-instrumentation-psycopg2

Редактируем core/settings.py:

from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
Psycopg2Instrumentor().instrument()

Перезапускаем и ребилдим web сервис, чтобы подтянуть изменения в requirements.txt:

docker compose down --remove-orphans && docker compose up --build

Снова открываем http://127.0.0.1:8000 и после http://127.0.0.1:16686, ищем трейсы из раздела Operations:

Переходим в свежий последний трейс и смотрим его:

После включения автоинструментации видим два select-а, развернем их, видим первый запрос получает timezone и длится 526 микросекунд( не путать с миллисекундами в интерфейсе jaeger), второй наш тяжеленький на получение миллион строк занимает 940мс(миллисекунд):

С запросами стало понятнее, что происходит, но остается один жирный кусок, который что-то делает больше 12 секунд, и средства автоинструментаций уже недостаточно, чтобы понять что происходит:

Открываем todos/view.py:

from django.shortcuts import render, redirect
from django.http import JsonResponse
from .models import Task
def task_list(request):
    q = request.GET.get("q", "")
    if q:
        tasks = Task.objects.filter(title__icontains=q)
    else:
        tasks = Task.objects.all()
    return render(request,'task_list.html',{'tasks':tasks, 'q': q})

def add_task(request):
    if request.method=='POST':
        title=request.POST.get('title')
        if title:
            Task.objects.create(title=title)
        return redirect('task_list')
    return redirect('task_list')

def api_tasks(request):
    return JsonResponse(list(Task.objects.values()), safe=False)

Добавляем созданный TRACER из settings:

from django.conf import settings

TRACER = settings.TRACER

Меняем task_list метод:

def task_list(request):
    with TRACER.start_as_current_span("task_list_common"):
        q = request.GET.get("q", "")
        if q:
            tasks = Task.objects.filter(title__icontains=q)
            return render(request,'task_list.html',{'tasks':tasks, 'q': q})
        else:
            tasks = Task.objects.all()
        return render(request,'task_list.html',{'tasks':tasks, 'q': q})

Ребилд и перезапуск:

docker compose down --remove-orphans && docker compose up --build

Снова открываем http://127.0.0.1:8000, после http://127.0.0.1:16686, и открываем свежий трейс:

Появился новый общий span, который захватывает и отдачу всех миллионов объектов и поиск, если им воспользуются и рендер шаблона. Средства автоинструментации определили его как дочерний спан, для метода GET task_list. Теперь изменим код, чтобы спаны были разные и для выдачи всех объектов, поиска и рендера шаблона:

def task_list(request):
    q = request.GET.get("q", "").strip()
    trace_name = f"task_list_all" if not q else f"task_list_search"
    with TRACER.start_as_current_span(trace_name) as span:
        if q:
            tasks = Task.objects.filter(title__icontains=q)
        else:
            tasks = Task.objects.all()
    with TRACER.start_as_current_span(f"render_template") as render_span:
        response = render(request,'task_list.html',{'tasks':tasks, 'q': q})
    return response

Ребилд и перезапуск:

docker compose down --remove-orphans && docker compose up --build

Снова открываем http://127.0.0.1:8000, после http://127.0.0.1:16686, и открываем свежий трейс:

Ух, давно на Django программировал, для меня стало неожиданностью, что в спане task_list_all ленивые объекты создаются, а фактически запросы sql происходят в момент render шаблона(если быть точнее в момент обращения к объекту).

Попробуем кэширование в лоб, редактируем todos/view.py

from django.core.cache import cache
import hashlib

def task_list(request):
    q = request.GET.get("q", "").strip()
    trace_name = "task_list_all" if not q else "task_list_search"

    # создаем уникальный ключ (например: task_list::all или task_list::search::<hash_q>)
    if q:
        key = f"task_list::search::{hashlib.md5(q.encode()).hexdigest()}"
    else:
        key = "task_list::all"

    with TRACER.start_as_current_span(trace_name) as span:
        tasks = cache.get(key)
        if tasks is None:
            if q:
                tasks = list(Task.objects.filter(title__icontains=q))
            else:
                tasks = list(Task.objects.all())
            cache.set(key, tasks, timeout=60)  # кэш на 60 секунд

    with TRACER.start_as_current_span("render_template") as render_span:
        response = render(request, 'task_list.html', {'tasks': tasks, 'q': q})

    return response

Автоинструментацию редиса мы включили в самом начале статьи, так что спан должен поймать что происходит на стороне редиса. Перезапускаем, снова снимаем спан и смотрим:

Поздравляю! Мы только что увеличили latency наивным подходом к кэшированию, подозреваю, что дело в tasks = list(Task.objects.all()), создание списка на миллион объектов. Попробуем под это дело завести отдельный дочерний спан:

with TRACER.start_as_current_span(trace_name) as span:
    tasks = cache.get(key)
    if tasks is None:
        if q:
            tasks = list(Task.objects.filter(title__icontains=q))
        else:
            with TRACER.start_as_current_span("tasks_list_creation"):
                tasks = list(Task.objects.all())
        cache.set(key, tasks, timeout=5*60)  # кэш на 5 минут

Смотрим новый спан:

Опять есть пустое непокрытое место, попробуем и его покрыть спаном:

with TRACER.start_as_current_span(trace_name) as span:
    tasks = cache.get(key)
    if tasks is None:
        if q:
            tasks = list(Task.objects.filter(title__icontains=q))
        else:
            with TRACER.start_as_current_span("tasks_list_creation"):
                tasks = list(Task.objects.all())
        with TRACER.start_as_current_span("redis_serialization"):
                cache.set(key, tasks, timeout=5*60)  # кэш на 5 минут

Спан ловит пустое место:

Пробуем оптимизации, грузим из Task только id, title, completed, а не пытаемся запихнуть весь Python object в список:

--- tasks = list(Task.objects.all())
+++ tasks = list(Task.objects.values("id", "title", "completed"))

Смотрим спаны:

Формирование списка после sql 1.84 секунды вместо 5.25 ранее, ~3x быстрее. Передача списка в редис 644мс вместо 13.25 секунд ранее ~20x быстрее.

Пробуем перейти на json вместо pickle:


import json

import json
import hashlib
from django.core.cache import cache
from django.shortcuts import render
from .models import Task
from django.conf import settings

TRACER = settings.TRACER

def task_list(request):
    q = request.GET.get("q", "").strip()
    trace_name = "task_list_all" if not q else "task_list_search"

    if q:
        key = f"task_list::search::{hashlib.md5(q.encode()).hexdigest()}"
    else:
        key = "task_list::all"

    with TRACER.start_as_current_span(trace_name) as span:
        cached = cache.get(key)
        if cached:
            with TRACER.start_as_current_span("redis_deserialization"):
                tasks = json.loads(cached)
        else:
            if q:
                tasks = list(Task.objects.filter(title__icontains=q).values("id", "title", "completed"))
            else:
                with TRACER.start_as_current_span("tasks_list_creation"):
                    tasks = list(Task.objects.values("id", "title", "completed"))
            with TRACER.start_as_current_span("redis_serialization"):
                cache.set(key, json.dumps(tasks), timeout=5 * 60)

    with TRACER.start_as_current_span("render_template") as render_span:
        response = render(request, 'task_list.html', {'tasks': tasks, 'q': q})

    return response

Смотрим спан:

Не очень помогло, а в случае передачи списка в редис, так еще и хуже стало, пробуем orjson, добавляем в requirements.txt, добавляем его в requirements.txt:

orjson

Заменяем вызовы json.load, json.dumps на orjson.load, orjson.dumps, замеряем снова:

Создание списка после sql 815 мс, передача его в редис 129.51 мс, прирост производительности есть.

Повторно заходим на страницу, операция GET из Redis 206.93мс, десериализация 360.56мс

Попробуем закэшировать рендер страницы целиком, 6 секунд все таки:

from django.http import HttpResponse


def task_list(request):
    q = request.GET.get("q", "").strip()
    trace_name = "task_list_all" if not q else "task_list_search"

    base_key = f"task_list::search::{hashlib.md5(q.encode()).hexdigest()}" if q else "task_list::all"
    html_key = f"{base_key}::html"

    with TRACER.start_as_current_span(trace_name):
        cached_html = cache.get(html_key)
        if cached_html:
            return HttpResponse(cached_html)

        # если нет HTML, проверим кэш с задачами
        cached = cache.get(base_key)
        if cached:
            with TRACER.start_as_current_span("redis_deserialization"):
                tasks = orjson.loads(cached)
        else:
            if q:
                tasks = list(Task.objects.filter(title__icontains=q).values("id", "title", "completed"))
            else:
                with TRACER.start_as_current_span("tasks_list_creation"):
                    tasks = list(Task.objects.values("id", "title", "completed"))
            with TRACER.start_as_current_span("redis_serialization"):
                cache.set(base_key, orjson.dumps(tasks), timeout=5 * 60)

        with TRACER.start_as_current_span("render_template"):
            html = render(request, 'task_list.html', {'tasks': tasks, 'q': q}).content
            cache.set(html_key, html, timeout=5 * 60)
            return HttpResponse(html)

Смотрим спан, при первом вызове все без изменений, смотрим второй трейс:

Спанов дочерних стало меньше, мы до них даже не доходим, отдаем сразу отрендеренный html, таким образом сократили до 93.58мс ответ демостенда.