Генерация PDF в Django: разбор кейса с таймаутами под нагрузкой
Отчёт на 40 страниц собирался 12 секунд и валил воркеры Gunicorn по таймауту. Разбираем, почему синхронная генерация PDF ломает Django-приложение и как её вынести из цикла запроса.

Endpoint /reports/<id>/pdf/ отдавал 502 на проде примерно раз в час. В логах Gunicorn — WORKER TIMEOUT. Отчёт формировался из выгрузки на 40–60 страниц: таблицы, графики, логотип. Локально всё собиралось за 1–2 секунды, на проде под параллельной нагрузкой — 8–14 секунд, и воркер, занятый рендером PDF, переставал отвечать на health-check. Ниже — разбор, почему это происходит и три уровня решения, от быстрого патча до выноса генерации в очередь. Версии: Django 5.0, Gunicorn 21.x, WeasyPrint 62, Celery 5.4.
Почему синхронная генерация PDF убивает воркер
Gunicorn с синхронным воркером обрабатывает один запрос за раз. Пока воркер рендерит PDF, он не берёт другие запросы и не отвечает арбитру. По умолчанию таймаут воркера — 30 секунд, но многие ставят его ниже (10–15), чтобы отлавливать зависшие запросы. Рендер на 12 секунд под нагрузкой упирается в этот лимит: арбитр считает воркер мёртвым, шлёт SIGKILL, клиент получает 502.
Проблема не в скорости конкретной библиотеки. Проблема в том, что тяжёлая CPU-задача выполняется внутри цикла обработки HTTP-запроса. Даже если ускорить рендер вдвое, при десятке одновременных запросов на отчёты пул воркеров исчерпается, и приложение перестанет отдавать даже лёгкие страницы.
Что именно тормозит внутри рендера
В связке Django + WeasyPrint время съедают три вещи, и их полезно разделять, прежде чем оптимизировать:
- Сбор данных. Шаблон отчёта обращается к ORM внутри цикла по строкам — классический N+1. На 60 страницах это сотни запросов к базе.
- Рендер HTML в шаблоне. Обычно быстро, но раздувается, если внутри тяжёлые теги и фильтры.
- Верстка HTML в PDF. WeasyPrint парсит CSS, раскладывает страницы, тянет внешние ресурсы. Если картинки и шрифты грузятся по HTTP, каждый запрос добавляет задержку.
Прежде чем менять библиотеку рендера, замерьте, сколько времени уходит на SQL, а сколько на саму верстку. В нашем кейсе 9 из 12 секунд съедал N+1 в шаблоне, а не WeasyPrint.
Как замерить, а не гадать
Оберните этапы в замеры времени и посчитайте запросы к базе. На стенде, приближённом к проду, это выглядит так:
import time
from django.db import connection, reset_queries
from django.template.loader import render_to_string
from weasyprint import HTML
def profile_report(report_id):
reset_queries()
t0 = time.perf_counter()
context = build_report_context(report_id) # сбор данных из ORM
t1 = time.perf_counter()
html = render_to_string("reports/report.html", context)
t2 = time.perf_counter()
pdf_bytes = HTML(string=html).write_pdf()
t3 = time.perf_counter()
print(f"данные: {t1 - t0:.2f} c, {len(connection.queries)} запросов")
print(f"шаблон: {t2 - t1:.2f} c")
print(f"pdf: {t3 - t2:.2f} c")
return pdf_bytes
Замер запускайте с DEBUG = True — иначе connection.queries будет пустым. На проде для подсчёта запросов используйте django-silk или логирование на уровне django.db.backends, а не отладочный режим.
Три уровня решения
Уровень 1: убрать N+1 из сбора данных
Если основное время съедает база, вынос в очередь маскирует проблему, но не убирает её: фоновая задача будет молотить те же секунды, просто вне запроса. Сначала соберите данные пачкой.
# было: обращение к report.items внутри шаблона тянуло связанные объекты по одному
# стало: подтягиваем всё заранее
queryset = (
ReportItem.objects
.filter(report_id=report_id)
.select_related("product", "warehouse")
.prefetch_related("tags")
)
context = {"items": list(queryset)}
В нашем кейсе это сократило сбор данных с 9 секунд до 0.4. Общее время рендера упало до 3.5 секунд — уже в рамках таймаута, но воркер по-прежнему занят на всё это время.
Уровень 2: не блокировать воркер синхронным ответом
Даже 3.5 секунды на воркер — это плохо, если отчёты запрашивают часто. Пока задача решается через один синхронный воркер, каждый параллельный отчёт занимает отдельный процесс. Промежуточный шаг — асинхронный воркер (gevent или uvicorn с ASGI), но он не спасает от CPU-нагрузки: рендер PDF держит GIL и всё равно блокирует поток.
Вывод: тяжёлый CPU-рендер не место в веб-процессе вообще. Его нужно вынести из цикла запроса.
Уровень 3: генерация в фоне через Celery
Запрос на отчёт ставит задачу в очередь и сразу возвращает статус «готовится». Клиент опрашивает статус или получает готовый файл по ссылке. Воркер веб-приложения свободен через миллисекунды.
# tasks.py
from celery import shared_task
from django.core.files.base import ContentFile
from weasyprint import HTML
from django.template.loader import render_to_string
@shared_task
def render_report_pdf(report_id):
report = Report.objects.get(pk=report_id)
context = build_report_context(report_id)
html = render_to_string("reports/report.html", context)
pdf_bytes = HTML(string=html).write_pdf()
report.pdf_file.save(
f"report_{report_id}.pdf",
ContentFile(pdf_bytes),
save=True,
)
report.status = "ready"
report.save(update_fields=["status"])
# views.py
from django.http import JsonResponse
from .tasks import render_report_pdf
def request_report_pdf(request, report_id):
render_report_pdf.delay(report_id)
# 202 Accepted: приняли, но результат ещё не готов
return JsonResponse({"status": "processing"}, status=202)
Готовый файл клиент забирает отдельным запросом, когда статус переключится на ready. Так веб-воркер отвечает мгновенно, а рендер идёт на отдельных Celery-воркерах, которые можно масштабировать независимо.
Сравнение подходов
| Подход | Время ответа веб-воркера | Риск при параллельной нагрузке | Сложность |
|---|---|---|---|
| Синхронно в view | = время рендера (секунды) | Пул воркеров исчерпывается, 502 | Минимальная |
| Синхронно + правка N+1 | секунды, но меньше | Тот же риск при частых запросах | Низкая |
| Async-воркер (gevent) | секунды | CPU держит поток, спасает частично | Средняя |
| Очередь (Celery) | миллисекунды | Веб не блокируется, масштаб отдельно | Выше: брокер, воркеры, статусы |
Где подход ломается
- Внешние ресурсы в шаблоне. Если CSS ссылается на картинки и шрифты по абсолютным URL, WeasyPrint тянет их по сети при каждом рендере. Складывайте статику локально и указывайте
base_url, чтобы пути резолвились с диска, а не через HTTP. - Память воркера. Большой PDF целиком держится в памяти как
bytes. Несколько параллельных отчётов на 60 страниц могут раздуть RSS воркера. Ограничивайте конкурентность Celery и следите за памятью процесса. - Гонка статусов. Если пользователь дважды нажмёт «сформировать», встанут две одинаковые задачи. Дедуплицируйте по ключу отчёта или проверяйте статус перед постановкой.
- Шрифты на проде. Локально шрифт есть в системе, на сервере — нет, и PDF выходит с подстановкой. Ставьте нужные шрифты в образ и проверяйте вывод именно на проде.
Что настроить дальше
После выноса в очередь смотрите на длину очереди Celery и время выполнения задачи render_report_pdf — это ваши новые метрики вместо таймаутов веб-воркера. Настройте отдельную очередь для тяжёлых отчётов, чтобы они не блокировали лёгкие фоновые задачи вроде отправки писем. Если отчёты повторяются с теми же данными, добавьте кеширование готового PDF по хешу входных параметров — тогда повторный запрос отдаст файл без рендера. Инвалидация такого кеша — отдельная тема, здесь её не разворачиваем.
Видеомейкер: Ролик по описанию или оживление вашего фото. От пяти секунд, со звуком, вертикально или горизонтально.
Спросить за 190 ₽Источники: WeasyPrint — официальная документация, Celery — Django integration
Частые вопросы
А если у меня не WeasyPrint, а ReportLab или xhtml2pdf?
Не сработало: Celery-задача ставится, но файл не появляется. Что смотреть?
Обязательно ли поднимать Celery ради генерации PDF?
Почему нельзя просто поднять таймаут Gunicorn до 60 секунд?
Как отдать пользователю готовый файл после фоновой генерации?
Правка N+1 через select_related и prefetch_related всегда помогает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.