Генерация PDF из Python: чек-лист перед продакшеном
Локально PDF собирается за секунду, а на сервере отчёт зависает на 30 секунд и течёт память. Разбираем, что проверить в генерации PDF из Python до релиза: библиотеки, шрифты, память, таймауты.

PDF-отчёт, который на ноутбуке разработчика собирается за 0,3 секунды, в проде под сотней одновременных запросов может отдавать 502, съедать всю память воркера и падать на кириллице, которой не было в тестовых данных. Проблема не в коде генерации — он рабочий. Проблема в том, что ни один из этих сценариев не проверяют локально. Этот чек-лист про то, что ломается в PDF-генерации именно на сервере, и про цену каждого решения.
Речь про три самых частых стека в Python: ReportLab (рисуем PDF из кода), WeasyPrint (HTML+CSS в PDF) и связки на базе браузера (Playwright, Puppeteer через pyppeteer). У каждого свои узкие места, и «выбрать библиотеку» — это на самом деле выбрать, чем платить под нагрузкой.
Выбор движка определяет все остальные пункты
Перед чек-листом — короткая развилка, потому что дальнейшие проблемы зависят от неё. Если верстка простая и структура табличная, тянуть в контейнер браузер — избыточно. Если нужен пиксель-в-пиксель как в вебе, ReportLab превратится в ад ручного позиционирования.
| Движок | Модель | Что выигрываете | Чем платите под нагрузкой |
|---|---|---|---|
| ReportLab | Рисование из кода / Platypus flowables | Мало зависимостей, предсказуемая память, быстро | Верстку сложного макета пишете руками |
| WeasyPrint | HTML + CSS 2.1 → PDF | Верстаете как веб-страницу, шаблоны Jinja2 | Не все CSS-фичи; тяжёлые нативные зависимости (Pango, cairo) |
| Браузерный рендер (Playwright) | Настоящий Chromium печатает в PDF | Полная поддержка современного CSS и JS | Сотни МБ на процесс, запуск Chromium, сложный деплой |
Точный список поддерживаемых CSS-свойств у WeasyPrint сверяйте в его документации для вашей версии — он расширяется от релиза к релизу, и «должно же работать flex» часто заканчивается сюрпризом на проде.
Шрифты и кириллица
Классическая продовая история: тесты гоняли на латинице, в бой пошли реальные имена клиентов — и в PDF вместо русских букв чёрные квадраты или пустоты.
У ReportLab встроенные шрифты Type 1 (Helvetica, Times) кириллицу не содержат в нужной кодировке. Нужно регистрировать TTF-шрифт с поддержкой Unicode явно:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
# ReportLab 4.x. Шрифт должен физически лежать по этому пути в образе
pdfmetrics.registerFont(TTFont("DejaVuSans", "/app/fonts/DejaVuSans.ttf"))
c = canvas.Canvas("report.pdf", pagesize=A4)
c.setFont("DejaVuSans", 12)
c.drawString(72, 800, "Отчёт по заказу №4821, клиент Иванов")
c.save()Три вещи, которые ломаются именно на сервере, а не локально:
- Шрифт не попал в Docker-образ. Локально он есть в системе, в slim-образе — нет. Кладите TTF в репозиторий или ставьте пакет со шрифтами в Dockerfile, не полагайтесь на системные.
- Нет жирного и курсива. Регистрируете один DejaVuSans — а шаблон просит bold. Нужно регистрировать каждое начертание отдельным TTF и связывать их через
registerFontFamily. - WeasyPrint и Pango. WeasyPrint подбирает шрифты через системный fontconfig. Если в образе не установлены нужные семейства, он молча подставит fallback, и верстка «поедет» по ширине.
Правило, которое экономит часы: PDF в тестах должен собираться из тех же данных и в том же образе, что и в проде. Кириллица, длинные строки, эмодзи в комментарии клиента — всё это должно быть в фикстурах, а не всплыть от первого реального заказа.
Память и время рендера
Второй продовый сюрприз — отчёт на 5000 строк. Локально таблица на 20 строк, в проде — выгрузка за квартал.
ReportLab
Platypus держит все flowables в памяти до вызова build(). Таблица на десятки тысяч строк — это десятки тысяч Python-объектов плюс расчёт разбивки по страницам. Память растёт линейно, а расчёт переносов строк в ячейках — нелинейно.
WeasyPrint
Рендерит весь HTML целиком: сначала строит дерево, потом раскладывает. На большом документе это и время, и память. Многостраничные таблицы с repeat header считаются заметно дольше плоского текста.
Браузерный рендер
Каждый экземпляр Chromium — сотни мегабайт. Запускать по процессу на запрос — прямой путь к OOM-killer. Нужен пул страниц в одном браузере и жёсткий лимит параллельных рендеров.
Что с этим делать до релиза:
- Возьмите реальный максимальный объём данных (не средний) и замерьте время и пиковую память одного рендера. Ставьте измерение через
tracemallocили по RSS процесса, а не на глаз. - Определите, сколько таких рендеров пойдут параллельно, и умножьте пиковую память на это число. Это ваш реальный аппетит по RAM на воркер.
- Если аппетит не влезает — выносите генерацию в фоновую задачу (Celery, RQ, arq), а не держите её в web-воркере под таймаутом.
Не блокируйте web-воркер
Генерация PDF — CPU-bound операция. Если она идёт синхронно внутри обработчика HTTP-запроса, один тяжёлый отчёт занимает воркер целиком, а gunicorn/uvicorn с дефолтным таймаутом (обычно 30 секунд) убьёт запрос на середине. Пользователь получит 502, а недописанный файл — мусор.
Рабочая схема для чего угодно тяжелее пары страниц:
- Запрос ставит задачу в очередь и сразу возвращает
202с идентификатором. - Воркер очереди генерирует PDF, кладёт в объектное хранилище (S3-совместимое) или временную папку.
- Клиент забирает готовый файл по ссылке или получает уведомление.
Синхронно отдавать PDF допустимо только для маленьких документов с предсказуемым размером — квитанция, билет, справка на страницу.
Санитизация и временные файлы
Если PDF собирается из пользовательского ввода — имена, адреса, комментарии, — есть два риска, которые не видны на happy-path.
- SSRF и подтяжка ресурсов. WeasyPrint и браузерный рендер по умолчанию загружают внешние ресурсы: картинки, шрифты, стили по URL из HTML. Пользователь, влияющий на HTML, может заставить сервер сходить во внутреннюю сеть. Отключайте загрузку внешних ресурсов или白 whitelist-ьте домены; в WeasyPrint это делается через собственный
url_fetcher— смотрите актуальную сигнатуру в документации вашей версии. - Мусор во /tmp. Промежуточные файлы и незакрытые дескрипторы под нагрузкой заполняют диск. Используйте
tempfile.NamedTemporaryFileсdelete=Trueили чистите за собой вfinally.
Чек-лист перед релизом
- PDF собирается в проде-образе, а не только локально — шрифты и нативные зависимости внутри образа.
- Кириллица, длинные строки и спецсимволы есть в тестовых фикстурах.
- Замерены время и пиковая память на максимальном реальном объёме данных.
- Тяжёлая генерация вынесена в фоновую очередь, web-воркер не блокируется.
- Ограничено число параллельных рендеров (особенно для Chromium).
- Отключена или ограничена загрузка внешних ресурсов из пользовательского HTML.
- Временные файлы гарантированно удаляются.
- Есть таймаут на саму генерацию — зависший рендер не должен висеть вечно.
- Версии библиотек зафиксированы в lock-файле — поведение рендера меняется между минорными версиями.
Что мерить в проде
После релиза смотрите на три показателя: p95 времени генерации (растёт — данные пухнут или пул забит), RSS воркеров очереди (утечка проявится ступенькой вверх) и долю задач, упавших по таймауту. Отдельно логируйте размер входных данных на каждую генерацию — так вы поймаете «отчёт за весь год» до того, как он положит воркер.
Следующий шаг после стабилизации — кеширование готовых PDF для повторяющихся запросов: одинаковый счёт незачем рендерить дважды. Инвалидация такого кеша — отдельная тема, здесь её не разворачиваем.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: ReportLab User Guide (официальная документация), WeasyPrint documentation
Частые вопросы
А если у меня старая версия ReportLab — код с TTFont сработает?
Что выбрать, если верстка сложная, но Chromium в проде держать не хочется?
Грозит ли генерация PDF внутри web-запроса реальными проблемами, если отчёты небольшие?
Почему локально всё работает, а в Docker кириллица ломается?
Как понять, что у меня утечка памяти на генерации, а не просто большой отчёт?
Опасно ли рендерить PDF из HTML, введённого пользователем?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.