Kompetenzen Portfolio Referenzen Lebenslauf Blog Termin buchen

Chrome headless für die PDF-Erzeugung

Gepostet von: Felix Dziekan in: Blog am 

Wenn du dich entschieden hast, dass Chrome die richtige Engine für deine PDFs ist, baust du sie hier. Es ist ein kleiner Service — spannend ist nicht der Code, sondern die vier Dinge, die ihn kaputt machen, sobald er deinen Laptop verlässt.

Der Container

Erste Entscheidung: Nimm nicht das Chromium, das Puppeteer herunterlädt. Installier stattdessen ordentlich Google Chrome und sag Puppeteer, wo es liegt. Dem mitgelieferten Chromium fehlt in einem schlanken Base-Image ein Haufen Systembibliotheken, und die zu jagen ist keine gute Verwendung für einen Nachmittag.

 

FROM node:slim

# wir brauchen das eigenständige Chromium nicht
# (ältere Tutorials sagen PUPPETEER_SKIP_CHROMIUM_DOWNLOAD — den Namen gibt es nicht mehr)
ENV PUPPETEER_SKIP_DOWNLOAD=true

RUN apt-get update && apt-get install gnupg wget -y && \
    wget --quiet --output-document=- dl-ssl.google.com \
      | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg && \
    sh -c 'echo "deb [arch=amd64] dl.google.com stable main" \
      >> /etc/apt/sources.list.d/google.list' && \
    apt-get update && \
    apt-get install google-chrome-stable -y --no-install-recommends && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY server.js ./

EXPOSE 3000
CMD ["node", "server.js"]

 

Chrome aus Googles eigenem Repository zu installieren zieht außerdem die Schriften und Bibliotheken mit, die es erwartet — was dir das erste der vier Probleme unten teilweise erspart.

Der Service

Der Service selbst ist wirklich klein. HTML rein, PDF zurück:

 

const express = require('express');
const puppeteer = require('puppeteer');

const app = express();
app.use(express.json({ limit: '10mb' }));

app.post('/pdf', async (req, res) => {
    const browser = await puppeteer.launch({
        headless: true,
        executablePath: '/usr/bin/google-chrome',
        args: ['--no-sandbox', '--disable-gpu'],
    });

    try {
        const page = await browser.newPage();
        await page.setContent(req.body.html, { waitUntil: 'networkidle0' });

        const pdf = await page.pdf({
            format: 'A4',
            printBackground: true,
            margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        });

        res.set('Content-Type', 'application/pdf');
        res.send(pdf);
    } finally {
        await browser.close();
    }
});

app.listen(3000, () => console.log('Listening on port 3000'));

 

Das ist alles. Jetzt zu den Teilen, die zubeißen.

Die vier Dinge, die einen Puppeteer-PDF-Service in Produktion kaputt machen

1. printBackground steht standardmäßig auf false

Das erwischt wirklich jeden genau einmal. Ohne printBackground: true wirft Chrome jede Hintergrundfarbe und jedes Hintergrundbild weg — dasselbe Verhalten wie die Checkbox "Hintergrundgrafiken" im Druckdialog.

Das Symptom ist ein PDF, in dem dein Markenband im Kopf weiß ist, die Zebrastreifen deiner Tabelle fehlen und deine farbigen Callouts verschwunden sind, während dieselbe Seite im Browser perfekt aussieht. Es ist ein Boolean.

2. Die Wartebedingung entscheidet, ob dein Inhalt da ist

page.setContent() ist fertig, sobald das HTML geparst ist — was normalerweise vor deinen Bildern, Schriften und jedem clientseitigen Rendering passiert. Druck in diesem Moment, und du bekommst ein halb gezeichnetes Dokument.

waitUntil: 'networkidle0' wartet, bis eine halbe Sekunde lang keine Netzwerkverbindungen mehr offen waren, was Bilder und Webfonts abdeckt. Wenn du Charts in JavaScript zeichnest, reicht das immer noch nicht — Netzwerkruhe sagt nichts darüber aus, ob deine Chart-Library fertig ist. Warte dafür lieber auf etwas Echtes:

 

await page.waitForSelector('#chart svg', { timeout: 5000 });
await page.evaluate(() => document.fonts.ready);

 

Löse das nie mit einem festen sleep. Es ist auf einem ausgelasteten Server zu kurz und verschwendet bei jedem Render Zeit, der ihn nicht gebraucht hätte.

3. --no-sandbox ist ein echter Kompromiss, kein Zauberflag

Jedes Tutorial sagt dir, du sollst --no-sandbox ergänzen, weil Chromes Sandbox Kernel-Capabilities braucht, die ein Standard-Container nicht hat. Es funktioniert. Es schaltet aber auch den Mechanismus ab, der verhindert, dass eine bösartige Seite aus dem Renderer-Prozess ausbricht.

Wenn das HTML, das du renderst, dein eigenes Template mit deinen eigenen Daten ist, ist das in einem isolierten Container ein vertretbares Risiko. Wenn du jemals HTML renderst, das ein Nutzer geliefert hat, ist es das nicht — dann betreib den Container entweder mit dem richtigen seccomp-Profil, damit die Sandbox funktioniert, oder behandle ihn als feindlich und gib ihm weder Netzwerkzugang noch Secrets.

"Es lief nur mit --no-sandbox" ist ein Satz, den man verstanden haben sollte, bevor man ihn deployt.

4. Pro Request einen Browser zu starten überlebt keinen Traffic

Der Code oben startet Chrome, rendert und schließt es wieder. Das ist korrekt und sicher, und es kostet dich einen Browserstart — einen spürbaren Bruchteil einer Sekunde — bei jedem einzelnen Request. Für eine Handvoll PDFs am Tag in Ordnung, bei Volumen nicht.

Die Lösung ist, einmal zu starten und pro Request eine frische Page zu öffnen:

 

let browser;
const getBrowser = async () => {
    if (!browser || !browser.connected) {
        browser = await puppeteer.launch({ /* … */ });
    }
    return browser;
};

 

Danach gelten zwei Regeln. Schließ die Page immer in einem finally-Block, denn eine geleakte Page ist ein geleakter Renderer-Prozess, und der Speicher steigt, bis der Container stirbt. Und begrenz die Nebenläufigkeit — jede offene Page ist echter Speicher, und eine unbegrenzte Schlange davon ist die Art, wie ein PDF-Service den Host mitreißt, auf dem er läuft.

Schriften, noch mal

Wert, wiederholt zu werden, weil es im Verhältnis zu seiner Langweiligkeit die meiste Zeit kostet: Der Container hat fast keine der Schriften, die dein Laptop hat. Chrome ersetzt still, und das PDF kommt subtil falsch heraus, ohne Fehler an irgendeiner Stelle.

Installier die Schriften entweder explizit ins Image, oder bette sie als base64-Webfonts ins Template ein, damit das Dokument seine eigenen mitbringt. Und prüf das Ergebnis irgendwo, wo diese Schriften nie installiert waren — auf der eigenen Maschine zu prüfen beweist nichts.

Wo das hingehört

Dieser Service sollte nicht in deinem Anwendungscontainer leben. Er ist ein eigenes Image, mit dem dein Backend über HTTP spricht, und genau das macht ihn austauschbar und unabhängig skalierbar — die Architektur dazu steht hier. Und falls du dir nicht sicher bist, ob Chrome überhaupt die richtige Engine ist: der Vergleich mit wkhtmltopdf steht hier.

Sie brauchen einen Senior-Entwickler, der liefert?

Über 20 Jahre. Produktionssysteme. Echte Deadlines. Ob Architekt, Entwickler oder DevOps-Engineer — ich habe alle drei Rollen übernommen, oft im selben Projekt. Sparen wir uns den Agentur-Overhead und sprechen direkt.