Kompetenzen Portfolio Referenzen Lebenslauf Blog Termin buchen

Wie du einen PDF-Generator-Service für dein Backend baust

Gepostet von: Felix Dziekan in: Blog am 

Der schnellste Weg, PDF-Erzeugung in ein PHP-Backend zu bekommen, ist: Binary in den Anwendungscontainer installieren und per Shell aufrufen. Das dauert zehn Minuten und funktioniert.

Es ist auch die Entscheidung, die du bereuen wirst, und die Reue kommt in einer bestimmten Reihenfolge. Zuerst verdoppelt sich die Größe deines Anwendungs-Images. Dann erzwingt ein Sicherheitsupdate des PDF-Tools einen Rebuild und ein Deployment deiner kompletten Anwendung. Dann will jemand ein schöneres Layout, die alte Engine kann es nicht rendern, und der Wechsel der Engine bedeutet, das Image anzufassen, aus dem jeder deiner Services gebaut wird.

Also: Der Renderer ist ein eigener Service. So baue ich ihn.

Die Form

Ein Container, der genau eine Sache macht: HTML rein, PDF raus, über HTTP. Deine Anwendung sieht das Binary nie, weiß nie, welche Engine drinsteckt, und spricht mit ihm wie mit jedem anderen Service.

Die Anwendung schickt HTML und bekommt ein PDF zurück — welche Engine drinsteckt, erfährt sie nie

Der gesamte Vertrag ist ein POST. Genau das macht die Engine austauschbar: Solange der nächste Renderer HTML annimmt und ein PDF zurückgibt, ändert sich in der Anwendung überhaupt nichts.

Der Renderer-Container

Für dokumentenförmige Ausgaben — Rechnungen, Verträge, Reports — nehme ich wkhtmltopdf, weil das Image klein bleibt (meins misst gebaut rund 350 MB) und Seitenzahlen und Ränder als Flags behandelt statt als CSS. (Wenn du modernes CSS oder JavaScript brauchst, nimm Chrome; die Architektur in diesem Artikel ist in beiden Fällen identisch.)

Der Container ist eine Alpine-Basis mit dem Binary und einer kleinen WSGI-App davor. Eine Korrektur zu der Version, die ich ursprünglich veröffentlicht habe: Sie fing mit FROM alpine:latest an und machte schlicht apk add wkhtmltopdf. Das baut nicht mehr — Alpine hat das Paket entfernt, und es fehlt in jedem aktuell unterstützten Branch, nicht nur in Edge. Und weil das Projekt archiviert ist, stellt es auch niemand zurück.

Das Binary kommt deshalb aus einem gepflegten Image, in einer Build-Stage:

 

FROM surnet/alpine-wkhtmltopdf:3.20.3-0.12.6-full AS wkhtmltopdf

FROM alpine:3.20

RUN apk add --no-cache \
      bash python3 py3-pip \
      libstdc++ libx11 libxrender libxext libssl3 ca-certificates \
      fontconfig freetype ttf-freefont \
    && rm -rf /var/cache/apk/*

COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf
COPY --from=wkhtmltopdf /lib/libwkhtmltox* /lib/

RUN python3 -m venv /venv \
    && /venv/bin/pip install --no-cache-dir werkzeug executor gunicorn

WORKDIR /root
COPY app.py .

EXPOSE 9000
ENTRYPOINT ["/venv/bin/gunicorn"]
CMD ["-b", "0.0.0.0:9000", "--log-file", "-", "app:application"]

 

Drei Dinge darin entscheiden darüber, ob das baut oder nicht.

Pinne die Basis, nimm kein latest. Das war der eigentliche Fehler in meiner ursprünglichen Datei. Ein Renderer-Image ist genau die Sorte Ding, die du einmal baust und dann zwei Jahre vergisst — und latest macht aus "vergessen" ein "hört an einem Tag auf zu bauen, an dem du gar nicht hinschauen wolltest".

Das Virtual Environment ist nicht mehr optional. Auf aktuellem Alpine verweigert ein einfaches pip install den Dienst mit externally-managed-environment (PEP 668). Leg ein venv an und ruf gunicorn daraus auf — oder nimm --break-system-packages, was genau so eine gute Idee ist, wie es klingt.

Kein xvfb. Das alte Rezept zog einen virtuellen X-Server mit, weil normale wkhtmltopdf-Builds ein Display wollen. Der Patched-Qt-Build in diesem Image ist echt headless, also fliegen der X-Server, dbus und der xvfb-run-Wrapper raus — ein ordentliches Stück Image für nichts. fontconfig, freetype und ttf-freefont bleiben: Sie geben dir wenigstens eine echte Schriftfamilie, damit Text gerendert wird statt still auf irgendetwas Unbrauchbares zurückzufallen.

Der HTTP-Wrapper

Der Wrapper macht drei Dinge: Request annehmen, HTML in eine temporäre Datei schreiben, Binary aufrufen, Ergebnis zurückstreamen.

 

@Request.application
def application(request):
    if request.method != 'POST':
        return Response('Only POST method is supported.')

    with tempfile.NamedTemporaryFile(suffix='.html', mode='wb') as source_file:
        payload = json.loads(request.data)
        source_file.write(base64.b64decode(payload['contents']))
        options = payload.get('options', {})
        source_file.flush()

        args = ['wkhtmltopdf']
        for option, value in options.items():
            args.append('--{}'.format(option) if len(option) > 1 else '-{}'.format(option))
            if value:
                args.append('{}'.format(value))
        args += [source_file.name, source_file.name + '.pdf']

        execute(' '.join(args))

        return Response(
            wrap_file(request.environ, open(source_file.name + '.pdf', 'rb')),
            mimetype='application/pdf')

 

Das HTML kommt base64-kodiert im JSON an. Das wirkt wie unnötige Zeremonie — bis zum ersten Mal, dass ein Template ein Zeichen enthält, das einen Multipart-Body zerlegt. Die Kodierung nimmt dir diese ganze Problemklasse ab.

Eine Sache, die man klar sagen sollte: Diese Schleife baut ein Shell-Kommando aus Optionsnamen, die der Aufrufer liefert. In einem Service, den nur dein eigenes Backend erreicht, ist das in Ordnung. Sobald er von irgendwo sonst erreichbar ist, validiere die Optionen gegen eine Allowlist — sonst hast du Command Execution verschenkt.

Der Aufruf aus PHP

Auf Anwendungsseite gibt es keine Library, keine Extension, kein Binary. Nur einen HTTP-Aufruf:

 

$body = json_encode([
    'contents' => base64_encode($html),
    'options'  => ['page-size' => 'A4', 'margin-top' => '20mm'],
]);

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => 'http://pdf:9000/',
    CURLOPT_POST           => 1,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$pdf = curl_exec($ch);
curl_close($ch);

 

In echtem Code liegt das hinter einem Interface — PdfRenderer::render(string $html): string — mit dem curl-Aufruf als einer Implementierung. Dieses Interface ist das, was dir später den Wechsel zu Chrome durch eine zweite Klasse erlaubt, und es macht das Ganze in Tests trivial mockbar.

Achte auf den Host: pdf, nicht localhost. Innerhalb eines Containers ist localhost dieser Container. Der Servicename aus docker-compose ist das, was Dockers DNS auflöst.

Verkabeln

 

services:
  php:
    build:
      context: ./env/dev/Php
    links:
      - "pdf:pdf"

  pdf:
    image: serie3/wkhtmltopdf:latest
    # kein ports: — nichts außerhalb des Compose-Netzwerks muss ihn erreichen

 

Bewusst kein veröffentlichter Port. Der Renderer ist ein interner Service. Wenn er aus dem Internet erreichbar ist, hast du einen offenen HTML-zu-PDF-Konverter für Fremde gebaut, und sie werden ihn finden.

Die vier Regeln, die das stabil halten

Setz immer ein Timeout. Ein Renderer, der sich an einem kaputten Template aufhängt, hält sonst einen Anwendungs-Worker offen, bis irgendetwas anderes in ein Timeout läuft. 30 Sekunden sind großzügig.

Erzeuge PDFs nach Möglichkeit nie im Request-Zyklus. Ein User, der auf "Rechnung herunterladen" klickt, kann eine Sekunde warten. Ein Batchjob, der 400 Abrechnungen erzeugt, gehört in eine Queue, die Dateien schreibt — nicht in 400 synchrone HTTP-Aufrufe.

Halte die Schriften im Renderer-Image. Nicht in der Anwendung, nicht auf einem gemeinsamen Volume — in dem Image, das rendert. Das ist immer noch die häufigste Ursache für "das PDF sieht in Produktion falsch aus und keiner weiß warum".

Logge Fehler zusammen mit dem Input. Wenn ein Render fehlschlägt, willst du das Template haben, das ihn ausgelöst hat. Ein generischer 500er von einem PDF-Service ist ohne das nahezu nicht debuggbar.

Was du für den zusätzlichen Container bekommst

Dein Anwendungs-Image bleibt klein und frei von Browser oder Render-Binary. Der Renderer lässt sich unabhängig skalieren, neu starten oder ersetzen. Ein Engine-Wechsel ist eine neue Klasse hinter einem bestehenden Interface. Und andere Services im Stack können denselben Renderer mitbenutzen, statt sich jeweils eine eigene Kopie zuzulegen.

Das ist eine Menge Gegenwert für eine Servicedefinition und ungefähr sechzig Zeilen Klebe-Code.

Wenn du dich noch nicht für eine Engine entschieden hast, fang mit dem Vergleich von wkhtmltopdf und Chrome headless an.

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.