FrankenPHP in Produktion: Was sich ändert, wenn Symfony-State Requests überlebt

FrankenPHP hält Symfony-Application-State zwischen Requests am Leben. Was das in Code bricht, der auf klassisches Request-Teardown setzt.

Ein leuchtender PHP-Elefant, eingenäht in eine langlaufende Runtime, durch die mehrere Requests durch eine persistente Application-Instanz fließen statt durch separate Request-Lifecycles

PHP hat den Großteil von drei Jahrzehnten damit verbracht, Request-State zwischen Requests abzubauen. Im klassischen PHP-FPM-Modell kann der Worker-Prozess selbst weiterlaufen, aber jeder Request bekommt einen frischen Application-Lifecycle: frische Superglobals, frischer Symfony-Kernel, frischer Container-State, frischer request-scoped Speicher. Der Bug, den Sie gestern geschrieben haben, konnte heute nicht durchsickern, weil der Application-State am Ende des Requests weggeworfen wurde. PHP, als Sprache und als Runtime, wurde rund um diese Wegwerfbarkeit gebaut. Symfony auch. Und vor allem: Ihr Code auch.

FrankenPHP ändert diesen Vertrag. Im Worker-Mode bootet ein langlaufender Prozess den Symfony-Kernel einmal und bedient dann viele Requests gegen dieselbe In-Memory-Anwendung. Der Cold-Start-Aufwand verschwindet. Der Durchsatz pro Server steigt, weil der Kernel-Boot, der Autoloader-Scan und das Container-Instanziieren, die früher bei jedem Request passierten, jetzt einmal pro Worker-Lebenszeit passieren. Die Latenz der billigen Requests, also derer, die vorher den Großteil ihrer Zeit mit dem Booten von Symfony statt mit Arbeit verbrachten, fällt in sich zusammen.

Das ist der Marketing-Pitch, und er stimmt. Was der Marketing-Pitch verschweigt: Eine Symfony-Codebasis, die jahrelang per Zufall sicher war, weil PHP den Request-State am Ende jedes Requests abgebaut hat, läuft plötzlich in einer Umgebung, die sich an alles erinnert, was Sie getan haben. Bugs, die unsichtbar waren, werden produktionsformend. Muster, die idiomatisch waren, werden zu Fußabzügen.

Dieser Essay ist das, was wir gelernt haben, als wir Symfony-Codebasen in den FrankenPHP-Worker-Mode gebracht und beobachtet haben, was sich tatsächlich ändert. Es ist kein Einsteiger-Tutorial; das decken die FrankenPHP-Docs ab. Es ist die Liste der Dinge, die zubeißen, sobald die Konfiguration stimmt und der Prozess läuft.

Was der Worker-Mode tatsächlich tut

Überspringen Sie diesen Abschnitt, wenn Sie es schon wissen.

FrankenPHP führt PHP als langlebigen Prozess aus und nutzt die frankenphp_handle_request-Funktion, um HTTP-Requests in einer Schleife entgegenzunehmen. Jede Iteration dieser Schleife ist ein Request. Kernel, Container, Ihre Services, Ihr Autoloader, Ihr OPcache werden alle einmal beim Worker-Boot initialisiert und leben so lange wie der Worker.

In einer Symfony-Anwendung ist der Entrypoint eine Runtime plus ein Worker-Skript. Seit Symfony 7.4 wird FrankenPHP-Worker-Mode nativ unterstützt; ältere Symfony-Versionen nutzen runtime/frankenphp-symfony. Der Worker ruft wiederholt Kernel::handle() auf und resettet den Kernel zwischen Requests, was wiederum die konfigurierte Reset-Methode auf Services mit kernel.reset aufruft (Services mit Symfony\Contracts\Service\ResetInterface werden automatisch getaggt).

Zwei Fakten zum Verinnerlichen:

  1. Kernel und Container überleben zwischen Requests. Es sind dieselben Objektinstanzen.
  2. Symfony resettet Services, die es kennt. Es resettet keine, die es nicht kennt.

Das meiste, was folgt, ist Punkt zwei in verschiedenen Kostümen.

Das erste, was bricht: statischer State

Statische Properties, Singleton-State, Funktions-Level-Static-Variablen, alles, was auf Klassen- oder Funktionsebene gecacht ist. All das persistiert jetzt zwischen Requests.

Im Wegwerf-PHP war dieser Code in Ordnung:

PHP
final class SlugGenerator
{
    private static array $cache = [];

    public static function for(string $title): string
    {
        if (isset(self::$cache[$title])) {
            return self::$cache[$title];
        }

        $slug = \mb_strtolower(\preg_replace('/[^a-z0-9]+/i', '-', $title));
        self::$cache[$title] = $slug;

        return $slug;
    }
}

Ein Request-scoped Cache, harmlos, weil klassisches PHP den Request-State abgebaut hat. In einem Worker ist das ein Memory Leak ohne obere Grenze. Jeder eindeutige Titel, der von diesem Worker je verarbeitet wird, bleibt in self::$cache, bis der Worker neu gestartet wird. Nach einem Tag Traffic auf einer Content-lastigen Seite hält der Worker zehnermegabytes an Slugs.

Die Lösung ist nicht, Cache-Invalidierung hinzuzufügen. Die Lösung ist, zu erkennen, dass “Request-scoped Static Cache” in langlaufendem PHP keine Sache ist. Entweder heben Sie den Cache in einen echten Service, getaggt mit kernel.reset, und leeren ihn in reset(), oder Sie scopen den Cache auf einen einzelnen Call (Array lokal aufbauen und zurückgeben).

Dasselbe gilt für jede Library, von der Sie abhängen. Auditieren Sie Ihre composer.json. Alles, was Klassen-Level-Statics oder Funktions-Level-Statics für Caching nutzt, ist ein Kandidat. Häufige Übeltäter, die wir gesehen haben: gecachte Reflection-Metadaten in älteren Serializer-Libraries, Validator-Caches, von denen niemand erwartet hatte, dass sie über einen Request hinaus leben, eigene Twig-Extensions, die Template-Lookups memoizen.

Das zweite: Doctrine und veraltete Entities

Der Doctrine-ORM-EntityManager ist der wichtigste Service, den Symfony zwischen Requests resettet, wenn Sie ihn richtig verdrahten. Die doctrine/doctrine-bundle-Integration taggt den EntityManager mit kernel.reset, was die Identity Map und die Unit of Work leert. Solange Sie EntityManagerInterface über DI injizieren und ihn normal nutzen, sind Sie sicher.

Die Probleme beginnen an den Rändern.

Wenn Sie einen Service haben, der den EntityManager einmal im Konstruktor injiziert und Entities in seinen eigenen Properties cacht, überleben diese Entities jetzt den Request. Sie sind nach dem Reset vom neuen EntityManager detached, und jeder Versuch, sie zu flushen, produziert EntityNotManagedException oder, schlimmer, schreibt still nichts.

PHP
final class FeatureFlagService
{
    private array $flags = []; // wird lazy aus der DB befüllt

    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function isEnabled(string $name): bool
    {
        if ([] === $this->flags) {
            $this->flags = $this->em->getRepository(Flag::class)->findAll();
        }

        // ... Lookup
    }
}

In Wegwerf-PHP ein vollkommen vernünftiger Lazy Cache. Im Worker-Mode befüllt Request 1 $this->flags mit Entities, die von EM-Instanz-A verwaltet werden. Der Kernel resettet zwischen Requests. Request 2 hat $this->flags immer noch befüllt (die Service-Instanz hat überlebt), mit Entities, die der neue EM nicht kennt. Beim ersten Mal, dass jemand eines dieser Flags zu modifizieren und zu persistieren versucht, brechen die Dinge auf verwirrende Weise.

Die Lösung ist, ResetInterface auf dem Service zu implementieren, oder bei jedem Aufruf nach den Flag-Daten über einen Repository-Call zu greifen und sich auf einen ordentlichen Second-Level-Cache darunter zu verlassen.

Die andere Doctrine-Falte ist die Liveness der Verbindung. Die DBAL-Verbindung wird über Requests hinweg gehalten. Wenn der Worker ein paar Minuten idle steht, hat der Datenbankserver möglicherweise seine Seite des Sockets geschlossen. Der nächste Request trifft die Verbindung, bekommt “MySQL server has gone away” oder “Connection reset by peer” und schlägt fehl.

DoctrineBundle resettet oder leert initialisierte EntityManager zwischen Requests, aber das ist keine portable Garantie, dass jede Verbindung zu Beginn jedes Requests gepingt wird. Eigene DBAL-Verbindungen, zweite Datenbanken und Verbindungen, die außerhalb des Standard-EntityManager-Flows genutzt werden, brauchen weiterhin explizite Aufmerksamkeit. Wir haben Produktions-Incidents gesehen, in denen eine sekundäre Read-Replica-Verbindung stundenlang offen gehalten wurde und den ersten Request jedes Traffic-Spikes failen ließ, weil die Verbindung tot war.

Der robuste Fix ist, stale Verbindungen bewusst zu schließen und neu zu öffnen, oder kritische First-Use-Queries in einen begrenzten One-Retry-Reconnect-Block zu wickeln. Doctrine DBAL bietet Verbindungsoptionen und Connection::close(), aber keinen generischen Cross-Driver-Schalter für Auto-Reconnect.

Das dritte: Services, die Handles halten

Jenseits von Doctrine: Alles, was bei der Konstruktion ein File-Handle, einen Socket, einen Stream oder eine TCP-Verbindung öffnet, hält diese Ressource jetzt für die Lebenszeit des Workers.

Die üblichen Verdächtigen:

  • HTTP-Clients mit langlebigem Connection-Pool. Symfonys HttpClient ist in Ordnung; er verwaltet seinen eigenen Pool. Ein eigener Curl-Handle, der als Singleton injiziert wird, nicht.
  • Cache-Adapter, die eine Redis-Verbindung öffnen. Der Symfony-Adapter handhabt Reconnects. Eine rohe \Redis-Instanz, die Sie in einem Service abgelegt haben, nicht.
  • Logger-Handler, die eine Datei oder einen Syslog-Socket öffnen. Der Monolog-Stream-Handler ist in Ordnung. Eigene Handler, die im Speicher buffern, sind ein Leak-Risiko; siehe nächster Abschnitt.
  • Alles, was mit RabbitMQ, Kafka oder einem anderen Broker über eine persistente Verbindung spricht.

Das Muster: Wenn ein Service eine Ressource hält, die ein Timeout, ein Expiry oder einen “die Gegenseite könnte verschwinden”-Failure-Mode hat, muss dieser Service entweder Reconnects intern handhaben oder so getaggt sein, dass der Kernel ihn zwischen Requests resetten kann.

Logger-Handler und gepufferter Output

Monolog ist eine der häufigsten Quellen von Memory-Creep im Worker-Mode. Die Standard-Symfony-Konfiguration nutzt in Produktion einen FingersCrossedHandler: Der Handler puffert Log-Records im Speicher und flusht sie nur, wenn ein Record auf Error-Level durchkommt. In langlaufenden Prozessen muss Monologs In-Memory-State zwischen Jobs oder Requests resettet werden; Symfonys Standardintegration verdrahtet das für normales App-Logging, aber eigene Logger, Processoren und Handler brauchen trotzdem ein Audit.

In Wegwerf-PHP wurde der Buffer mit dem Prozess weggeworfen. Im Worker-Mode wächst der Buffer für immer, wenn der Flush-Mechanismus falsch konfiguriert ist oder wenn ein eigener Processor Records anhäuft, ohne zu flushen.

Das ist einer der Bugs, die Sie nur im Worker-Mode sehen. Das Symptom ist linear steigender Speicher mit Request-Count, sogar auf einem “tut nichts”-Health-Check-Endpoint. Die Lösung ist, zu verifizieren, dass der FingersCrossedHandler zwischen Requests resettet wird (die Bundle-Integration handhabt das für die Standardkonfiguration) und alle eigenen Processoren oder Handler auf State zu auditieren, der nicht geleert wird.

Die allgemeine Regel für Logging in langlaufendem PHP: Nehmen Sie an, jeder buffernde Handler braucht einen Reset-Hook, und verifizieren Sie, dass er einen hat.

Symfony-Runtime, RequestStack, TokenStorage

Das Framework-Bundle von Symfony tut für seine eigenen request-scoped Services das Richtige. RequestStack, TokenStorage, der Security-Context, der locale-aware Translator, das aktive Request-Locale, die aktive Session: all das wird beim Kernel-Reset zwischen Requests resettet.

Die Falle ist der Code, der von diesen Services abhängt, ohne über sie zu gehen. Das klassische Beispiel: den aktuellen User als Property auf Ihrem eigenen Service zu cachen.

PHP
final class AuditLogger
{
    private ?User $currentUser = null;

    public function __construct(
        private TokenStorageInterface $tokens,
    ) {}

    public function log(string $action): void
    {
        $this->currentUser ??= $this->tokens->getToken()?->getUser();

        // ... Audit-Zeile schreiben
    }
}

In Wegwerf-PHP: faul. Im Worker-Mode: Request 1 sieht Userin Alice, Request 2 sieht User Bob, aber AuditLogger denkt immer noch, der aktuelle User sei Alice, weil die Property den Request überlebt hat. Audit-Zeilen sind jetzt falsch, und der Bug taucht erst auf, wenn zwei User denselben Worker treffen.

Die Lösung: den aktuellen User niemals cachen. Lesen Sie ihn bei jedem Aufruf aus TokenStorage. Die Kosten des Aufrufs sind trivial; die Kosten, falsch zu liegen, enorm.

Wenden Sie dieselbe Logik auf alles andere an, das logisch request-scoped ist: aktuelles Locale, aktueller Tenant, aktuelle Correlation-ID. Lesen Sie es durch das Framework, cachen Sie es nicht in Ihrem eigenen Service.

Speicher: winzige Leaks werden produktionsformend

In Wegwerf-PHP war ein Leak von 200 KB pro Request unsichtbar, weil der Request-State am Ende abgebaut wurde. Im Worker-Mode wird ein Leak von 200 KB pro Request beim fünftausendsten Request zum Problem: 1 GB geleakter Speicher in einem einzigen Worker. Der Worker erreicht sein Speicherlimit und wird neu gestartet. Wenn Sie kein Speicherlimit konfiguriert haben, wächst der Worker, bis das OS ihn killt, mitsamt aller laufenden Requests.

Zwei operative Gewohnheiten machen das handhabbar.

Erstens: ein Restart-Limit auf dem Worker setzen (FrankenPHPs experimentelle max_requests-Direktive oder MAX_REQUESTS in einer eigenen Worker-Loop) und PHPs memory_limit realistisch halten, damit Worker vorhersehbar zyklen. Auch ein gesunder Worker sollte regelmäßig zykliert werden, nicht weil etwas falsch ist, sondern weil Zyklen eine billige Versicherung sind.

Zweitens: RSS pro Worker monitoren. Sie wollen eine flache Linie, keine Steigung. Wenn der Speicher auf einem Tu-Nichts-Endpoint linear mit Request-Count wächst, haben Sie einen Leak; finden Sie ihn, bevor er Sie findet.

Das Werkzeug, nach dem wir zur Leak-Suche greifen, ist dasselbe wie in jedem langlaufenden PHP-Kontext: Profiler-Snapshots unter einem replayten Request-Mix, explizite memory_get_usage()-Checkpoints rund um verdächtige Services und Middleware nacheinander deaktivieren, bis die Steigung flach wird.

Sessions, Cookies und Request-Isolation

Symfonys Session-Handling ist über RequestStack request-scoped, also handhabt das Framework die Isolation korrekt. Die Falle ist wieder alles, was die Session außerhalb des Framework-Lifecycles liest, oder alles, was PHPs natives $_SESSION-Superglobal direkt nutzt.

FrankenPHP resettet die Request-Superglobals ($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER, $_REQUEST) bei jedem verarbeiteten Request, weist aber ausdrücklich darauf hin, dass $_ENV nicht resettet wird. Session-State hat einen eigenen Lifecycle, und direkter Zugriff auf $_SESSION umgeht Symfonys Session-Abstraktion. Die Lösung ist dieselbe wie für die Aktueller-User-Falle: nutzen Sie das Session-Objekt des Frameworks, greifen Sie nicht in $_SESSION.

Dieselbe Disziplin gilt für $_COOKIE, $_REQUEST, $_SERVER und $_ENV: Der korrekte Weg für Request-Daten führt über das Request-Objekt, der korrekte Weg für Prozesskonfiguration über Ihre Konfigurationsschicht. Direkter Superglobal-Zugriff macht Request-Isolation schwerer nachvollziehbar.

OPcache, File-Watching und der Dev-Loop

In Produktion wollen Sie OPcache mit validate_timestamps=0 aktiviert haben; ein Preload-Skript kann helfen, wenn Sie gemessen haben, dass es relevant ist. Der Worker bootet einmal, OPcache füllt sich, und nachfolgende Requests bekommen kompilierten Bytecode aus dem Speicher. Der größere Worker-Mode-Gewinn ist separat: Symfony-Kernel und Container müssen nicht für jeden Request neu gebaut werden.

In Development würde das bedeuten, dass Änderungen an PHP-Dateien unsichtbar sind, bis der Worker neu startet. FrankenPHPs --watch-Flag handhabt das: Es beobachtet den Source-Tree und startet Worker bei Änderungen neu. Konfigurieren Sie es so, dass es var/, node_modules/ und alles andere ignoriert, was sich aus unverwandten Gründen häufig ändert, sonst verbringen Sie den Tag mit Workern, die bei jedem Cache-Write neu starten.

Twig-Templates können über Twigs Dev-Einstellungen reloaden, aber PHP-Code und Symfony-Konfigurationsänderungen brauchen einen Worker-Restart, weil der laufende Kernel und Container bereits im Speicher liegen. Wenn Sie services.yaml editieren, muss der Worker neu starten.

Die Dev-Experience mit --watch ist nah an der Wegwerf-PHP-Experience, solange der Watcher richtig konfiguriert ist. Wenn Sie das erste Mal eine Service-Definition editieren und Ihre Änderung nicht erscheint, prüfen Sie, ob der Watcher den Worker neu startet.

Deploys: Rolling Restart, nicht In-Place-Reload

Ein Worker ist ein langlaufender Prozess, der kompilierten Bytecode im Speicher hält. Neuen Code zu deployen heißt, den Worker neu zu starten, sonst serviert der Worker weiter die alte Version.

Die zwei Muster, die wir nutzen:

Rolling Restart. Neue Worker werden mit dem neuen Code gestartet; alten Workern wird SIGTERM geschickt und eine Grace Period gegeben, um laufende Requests zu beenden; der Load Balancer drainiert Traffic. Das ist das sichere Muster und funktioniert genauso, wie Sie jeden langlaufenden Service deployen würden.

Graceful Reload. FrankenPHP unterstützt, Workern ein Signal zum Reload zu schicken (der genaue Mechanismus hängt davon ab, ob Sie Caddy-managed FrankenPHP oder Standalone laufen lassen). Innerhalb des Workers prüfen Sie oben in der Request-Loop, ob ein Reload pending ist; wenn ja, brechen Sie aus der Loop aus und lassen den Worker sauber beenden, damit der Supervisor ihn mit neuem Code neu startet.

Das Muster, das Sie vermeiden sollen: OPcache-Reset, ohne den Worker neu zu starten. Sie können OPcache aus dem Worker heraus leeren, aber der Worker hält in seinem Container immer noch Referenzen auf die alten Klassendefinitionen. Das Ergebnis ist eine halb-reloadete Anwendung, die meistens funktioniert, bis sie spektakulär nicht mehr.

Wann Sie nicht umsteigen sollten

FrankenPHP ist kein kostenloses Upgrade. Die Entscheidungsmatrix, die wir mit Kunden nutzen:

Umsteigen, wenn:

  • Die Anwendung an Cold Starts CPU-bound ist (jeder Request verbringt nennenswerte Zeit mit dem Booten des Kernels, bevor er arbeitet).
  • Durchsatz pro Server Ihr Skalierungs-Constraint ist und Sie Spielraum beim Speicher haben.
  • Das Team die Ops-Reife hat, Speicher pro Worker zu monitoren und auf Leaks zu reagieren.
  • Die Codebasis aktuell ist (Symfony 6.4+, idealerweise 7.x) und frei von den schlimmsten Static-State-Gewohnheiten.

Noch nicht umsteigen, wenn:

  • Die Anwendung an der Datenbank hängt, nicht an PHP. Worker-Mode macht langsame Queries nicht schneller.
  • Die Codebasis schweren Gebrauch von Global State, eigenen Singletons oder Static Caches macht, die niemand auditiert hat.
  • Die Deployment-Story ad-hoc ist und das Team nicht ausgerüstet ist, langlaufende Prozesse zu handhaben.
  • Der Traffic so gering ist, dass Cold Starts nicht der Bottleneck sind (ein kleines internes Tool braucht keinen Worker-Mode).

Die ehrliche Version: Die meisten Symfony-Anwendungen unter dauerhaftem Traffic profitieren vom Worker-Mode, aber die Audit- und Aufräumarbeit, um sicher dort hinzukommen, ist nicht trivial. Planen Sie dafür ein.

Das Migrations-Playbook

Eine verdichtete Version dessen, wie wir das in Kundenmandaten angehen:

  1. Static State auditieren. Greppen Sie die Codebasis nach static -Properties, static function-Cache-Mustern und Singleton-Klassen. Heben Sie alles, was request-scoped sein soll, in echte Services mit reset()-Methoden.
  2. Service-State auditieren. Jeder Service, der eine Entity, einen User, ein Locale oder einen anderen request-scoped Wert als Property cacht, ist ein Bruchkandidat. Implementieren Sie ResetInterface oder entfernen Sie den Cache.
  3. Superglobal-Zugriffe auditieren. Überall, wo Ihr Code $_SESSION, $_COOKIE, $_REQUEST, $_SERVER oder $_ENV direkt anfasst, ist ein Kandidat für schwer nachvollziehbaren State. Routen Sie durch das Framework.
  4. Verbindung-haltende Services auditieren. Alles, was bei der Konstruktion einen Socket, ein File-Handle oder eine DB-Verbindung öffnet, braucht eine explizite Reconnection-Strategie.
  5. Monitoring verdrahten. RSS pro Worker, Requests pro Worker, Zeit seit dem letzten Restart. Ohne das können Sie nicht sagen, ob der Worker-Mode funktioniert.
  6. Speicherlimit und Max-Request-Count wählen. Worker sollen vorhersehbar zyklen, nicht unvorhersehbar.
  7. Mit Rolling Restart deployen. Niemals in-place reloaden.
  8. Eine Woche lang Worker-Mode und klassischen Mode parallel hinter demselben Load Balancer laufen lassen. Fehlerraten, Speicher, Latenz vergleichen. Erst voll umstellen, wenn der Worker-Mode auf jeder Metrik mindestens so gesund ist.

Das Audit ist die Arbeit. Die Konfiguration ist der einfache Teil.


Wenn Sie auf eine Symfony-Codebasis schauen und entscheiden müssen, ob Worker-Mode gut passt, beinhaltet unser Mandat zu Skalierung und Performance ein Audit für langlaufendes PHP: Static-State-Grep mit Triage-Liste, Service-State-Review auf Reset-Compliance und ein Runbook für die Migration. Die erste Woche auf FrankenPHP ist die gefährliche; wir stellen sicher, dass Ihr Team das Monitoring und die Deploy-Story bereit hat, bevor Sie den Schalter umlegen.

Referenzen

Bereit, Ihre Architektur in den Griff zu bekommen?

Buchen Sie ein kostenloses 30-minütiges Gespräch mit Silas. Kein Verkaufsgespräch, nur ein direkter Austausch über Ihre Herausforderungen.

Antwort in der Regel innerhalb von 24 Stunden.

Kostenloses Gespräch buchen