Блог

PDF в Cloudflare Workers генерируется медленно? Диагностика за 5 минут

Workers быстры, пока в них не ставят PDF-стек для долгоживущих серверов. Вот реальные узкие места edge-генерации PDF и как их обойти.

Вы перенесли сервис счетов, этикеток или квитанций в Cloudflare Workers, потому что остальная инфраструктура уже там. По latency все выглядело красиво: 5 ms до ближайшего colo, 1 ms CPU, запрос завершен.

Потом появилась генерация PDF. p99 стал 800 ms, worker bundle начал предупреждать о 50 MB, и возникло ощущение, что runtime выбран неправильно. Обычно проблема не в Workers, а в PDF-стеке, который проектировали для долгоживущих серверов, а не для isolate runtime.

Workers — не Lambda, и это важно

Cloudflare Workers — это не serverless containers. Это V8 isolates с другими ограничениями:

  • CPU time limit: 50 ms на запрос в Free, 30 seconds в Workers Paid Bundled, 5 minutes в Unbound. Wall time может быть больше, но он billable.
  • Memory cap: 128 MB на isolate.
  • Bundle size: 1 MB в Free, 10 MB в Paid.
  • Нет filesystem. Нет fs.readFileSync; все в памяти или через fetch.
  • Нет native binaries. Только pure JavaScript / WebAssembly; без node-canvas, native zlib и Ghostscript через shell.
  • Cold start около 5 ms. Очень быстро, потому что нечему долго стартовать.

Большинство жалоб “PDF медленный в Workers” нарушают одно из этих ограничений, чаще всего CPU или bundle size.

Пять вещей, которые реально тормозят

1. Попытка принести Chromium в Workers

Это не работает. Puppeteer нужен примерно 250 MB Chromium и настоящая ОС. Cloudflare Browser Rendering API или Browserless могут рендерить, но это не Workers: Worker вызывает внешний сервис и платит примерно 500 ms round-trip плюс время рендера.

Если ваш “PDF в Worker” на деле “Worker вызывает удаленный браузер”, нижняя граница latency около 500 ms. Это не проблема Worker, это налог браузера.

Диагностика: проверьте, нет ли fetch("https://browser-rendering.cloudflare.com/...") или аналога. Если есть, вы меряете upstream, а не Worker.

2. Layout на JavaScript

Если вы написали свой JS layout engine, который считает позиции боксов и переносы текста, вы упираетесь в CPU. JS быстрый, но layout для 30+ элементов с text wrapping легко выходит за 50 ms на Workers Free и может дать 100-300 ms на Bundled.

Такая pipeline дорогая:

JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit

Четыре CPU-bound прохода по одним данным, все в JS, с GC pressure и промежуточными деревьями.

Диагностика: посмотрите wrangler tail для одного render. Если до любого I/O уже больше 50 ms CPU, это compute bottleneck.

3. Загрузка шрифтов на каждый запрос

Шрифт весит 50-250 KB. Если renderer читает их из KV / R2 на каждый render, вы добавляете network round-trip на каждый шрифт и запрос. Пять шрифтов — это 50-150 ms до начала рендера.

Диагностика: добавьте timing вокруг загрузки шрифтов. Если она доминирует p50, причина найдена.

Исправление: грузите шрифты один раз на module init, в верхнем уровне Worker-файла, не внутри request handler. Isolate сохранит bytes на время своей жизни.

// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);

export default {
  async fetch(request) {
    // Per-request: zero font I/O
    return renderPdf({ fontBytes: FONT_BYTES, ...data });
  }
};

Если bundler инлайнит шрифт как bytes, еще лучше: ноль I/O.

4. JS PDF library, которая не была сделана для Workers

pdfkit, pdf-lib, jsPDF запускаются в Workers, но с компромиссами:

  • pdfkit требует Node Buffer shims. Работает, но добавляет около 500 KB и замедляет compute примерно на 30%.
  • pdf-lib хорош для редактирования существующих PDF, но хуже для генерации с нуля; abstraction layer добавляет примерно 10 ms на страницу.
  • jsPDF browser-first, с той же проблемой Buffer и большим API surface, который трудно tree-shake.

Для pipeline “прочитать JSON, записать PDF bytes” специализированный engine, который пишет PDF напрямую, обычно быстрее в 5-20 раз. Rust или C++ в WebAssembly хорошо ложатся на tight loops.

5. Bundle незаметно стал 4 MB

Workers Free ограничивает bundle 1 MB, Workers Bundled — 10 MB. Многие узнают об этом, когда wrangler deploy падает с “Script exceeds size limit”. Иногда это видно раньше по холодному старту: V8 должен компилировать слишком много кода.

wrangler показывает bundle size в deploy output. Все выше 500 KB стоит проверять:

  • Упакованные шрифты. Перенесите их в Workers Assets и fetch один раз на module init.
  • node: shim layer. Если в source map видны __cf_KV или polyfills:, bundler имитирует Node APIs, которые могут быть не нужны.
  • Неиспользуемые зависимости. В Wrangler 4+ npm run build -- --analyze дает treemap.

Как выглядит быстрый PDF в Workers

Edge-native renderer для структурированных документов, например gPdf, обычно укладывается в такие числа:

Metric Typical Why
Cold-start 5-20 ms V8 isolate boot + WASM module first-load
Per-render CPU 1-4 ms WASM tight loop, no GC pressure
Per-render wall 3-8 ms CPU + a few microseconds of crypto for PDF object IDs
Bundle size 4-6 MB Renderer + bundled fonts (Latin + CJK NotoSans)
Memory peak 8-20 MB Document tree + emitted PDF buffer

Сравните с типичным путем “Worker вызывает удаленный Puppeteer/browser rendering”: p50 500-1000 ms, 1-2 GB browser memory где-то в другом сервисе и upstream cost около $0.001/render.

Быстрый triage

Если PDF в Workers уже тормозит, сначала пройдите чеклист:

  1. Куда уходит время? Добавьте timestamps в wrangler tail и отделите CPU, upstream fetch и cold start.
  2. Есть JS layout? Если да, это, скорее всего, основная часть CPU. Перейдите на renderer внутри isolate.
  3. Шрифты грузятся на каждый request? Перенесите на module init.
  4. Вы зовете внешний браузер? Тогда latency floor равен этому сервису. Используйте same-isolate renderer.
  5. Bundle больше 1 MB? Cold start растет вместе с размером. Уберите лишние deps.

Самый быстрый PDF в Workers — когда document data превращается в PDF bytes внутри одного вызова fetch handler, без внутренних fetch() и без тяжелого JS layout.

Коротко

Cloudflare Workers могут рендерить PDF за single-digit milliseconds, но только если renderer сделан под isolate runtime. JS PDF libs для Node, удаленный browser rendering, загрузка шрифтов на каждый request и layout в JavaScript поднимают p50 до уровня медленнее origin.

Если не хотите строить это сами, попробуйте gPdf Playground. Он работает на таком же edge runtime. Нажмите Render PDF, посмотрите Network tab — это скорость Workers, когда pipeline не спорит со средой.