Zum Inhalt springen

Symfony Messenger in Produktion: systemd-Service, RabbitMQ und Preload-Header

Veröffentlicht am 5. Nov. 2025 | ca. 1 Min. Lesezeit |

In jeder Webanwendung gibt es Aufgaben, die nicht sofort erledigt werden müssen: E-Mail-Versand, PDF-Generierung, API-Synchronisation. Die Symfony Messenger-Komponente bietet eine elegante Lösung — doch der Teufel steckt im Produktionsbetrieb. In diesem Artikel zeige ich das komplette Setup, das ich auf meinem eigenen Server einsetze.

Das Konzept: Messages und Handler

Messenger basiert auf einem einfachen Prinzip: Sie erstellen eine Message-Klasse (ein reines DTO) und einen zugehörigen Handler.

<?php

declare(strict_types=1);

namespace App\Message;

class SendNotificationMessage
{
    public function __construct(
        private readonly int $userId,
        private readonly string $subject,
        private readonly string $content,
    ) {
    }

    public function getUserId(): int
    {
        return $this->userId;
    }

    public function getSubject(): string
    {
        return $this->subject;
    }

    public function getContent(): string
    {
        return $this->content;
    }
}

Der Handler verarbeitet die Message:

<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SendNotificationMessage;
use App\Service\MailerService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class SendNotificationHandler
{
    public function __construct(
        private readonly MailerService $mailer,
    ) {
    }

    public function __invoke(SendNotificationMessage $message): void
    {
        $this->mailer->sendNotification(
            $message->getUserId(),
            $message->getSubject(),
            $message->getContent()
        );
    }
}

RabbitMQ als Transport

Für den Produktionsbetrieb empfehle ich RabbitMQ statt der Doctrine-basierten Transport-Variante. Gründe:

  • Keine Datenbank-Locks: Doctrine-Transport belastet die Hauptdatenbank mit Polling-Queries
  • Echtes Queuing: RabbitMQ nutzt AMQP Blocking Consume — der Consumer wartet ohne CPU-Last
  • Skalierung: Mehrere Consumer parallel möglich, automatische Lastverteilung
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
                    max_delay: 60000
        routing:
            App\Message\SendNotificationMessage: async
            App\Message\GeneratePdfMessage: async

In der .env (Produktion):

MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages

Der systemd-Service: Automatisch, robust, überwacht

Der wichtigste Teil für den Produktionsbetrieb: Ein systemd-Service sorgt dafür, dass der Messenger-Consumer automatisch startet, bei Fehlern neu startet und sauber herunterfährt.

Hier ist der Service, den ich auf meinem openSUSE-Server einsetze:

# /etc/systemd/system/app-messenger.service
[Unit]
Description=App Messenger Consumer
After=network.target nginx.service rabbitmq-server.service
Wants=nginx.service
Requires=rabbitmq-server.service

[Service]
Type=simple
User=nginx
Group=nginx
WorkingDirectory=/srv/www/vhosts/example.de/httpdocs

ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=7200 --memory-limit=256M

Restart=always
RestartSec=5

KillSignal=SIGTERM
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

Erklärung der entscheidenden Parameter

--time-limit=7200: Der Consumer startet alle 2 Stunden neu. Warum? PHP-Prozesse können über lange Laufzeiten Memory Leaks entwickeln — insbesondere bei Doctrine-EntityManager, der Referenzen auf alle geladenen Entities im UnitOfWork behält. Durch den regelmäßigen Neustart wird der Speicher sauber freigegeben.

--memory-limit=256M: Sicherheitsnetz — falls ein einzelner Message-Handler unerwartet viel Speicher verbraucht, stoppt der Consumer kontrolliert statt den Server zu gefährden.

Restart=always + RestartSec=5: Nach --time-limit oder --memory-limit beendet sich der Consumer mit Exit-Code 0. systemd startet ihn nach 5 Sekunden sofort neu. Effektiv gibt es keinen Downtime.

KillSignal=SIGTERM + TimeoutStopSec=30: Bei systemctl stop sendet systemd SIGTERM. Der Messenger-Consumer fängt dieses Signal ab und beendet die aktuelle Message sauber, bevor er stoppt. Nach 30 Sekunden wird SIGKILL gesendet — nur als letzter Ausweg.

User=nginx: Gleicher User wie PHP-FPM. Damit gibt es keine Dateirechte-Probleme bei Cache-Dateien oder Logs.

Requires=rabbitmq-server.service: Der Consumer startet erst, wenn RabbitMQ läuft. Ohne diese Zeile würden Verbindungsfehler in den ersten Sekunden nach dem Booten auftreten.

Installation und Management

sudo systemctl daemon-reload
sudo systemctl enable app-messenger
sudo systemctl start app-messenger

# Logs in Echtzeit
sudo journalctl -u app-messenger -f

# Status prüfen
sudo systemctl status app-messenger

Fehlerbehandlung und Failure-Queue

Ein großer Vorteil von Messenger ist die eingebaute Fehlerbehandlung. Fehlgeschlagene Messages landen nach den konfigurierten Retry-Versuchen in der Failure-Queue:

framework:
    messenger:
        failure_transport: failed
        transports:
            failed:
                dsn: 'doctrine://default?queue_name=failed'
# Fehlgeschlagene Messages anzeigen
php bin/console messenger:failed:show

# Details einer bestimmten Message
php bin/console messenger:failed:show 42

# Erneut versuchen
php bin/console messenger:failed:retry 42

# Alle erneut versuchen
php bin/console messenger:failed:retry --force

Preload-Header statt HTTP/2 Server Push

HTTP/2 Server Push war früher eine Möglichkeit, CSS- und JavaScript-Dateien proaktiv an den Browser zu schicken. Für neue Projekte sollte man ihn nicht mehr einplanen: Chrome hat die Unterstützung entfernt, HTTP/3 hat das Feature nicht übernommen, und aktuelle nginx-Versionen führen die alte Push-Konfiguration nicht mehr fort. Sinnvoll bleibt dagegen der Link: rel=preload Header: Er signalisiert dem Browser früh, welche Ressourcen wichtig sind.

Die alte nginx-Konfiguration sah so aus und ist nur noch als Legacy-Kontext relevant:

location ~ \.php$ {
    fastcgi_pass unix:/run/php-fpm/www.sock;
    http2_push_preload on;
}

In aktuellen Setups setzen Sie stattdessen direkt Link: <...>; rel=preload HTTP-Header. In Symfony können Sie diese Header über die WebLink-Komponente oder direkt auf der Response setzen:

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\WebLink\GenericLinkProvider;
use Symfony\Component\WebLink\Link;

class PreloadSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::RESPONSE => 'onResponse'];
    }

    public function onResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $response = $event->getResponse();
        $linkProvider = new GenericLinkProvider();

        $linkProvider = $linkProvider
            ->withLink(new Link('preload', '/assets/css/all.css', ['as' => 'style']))
            ->withLink(new Link('preload', '/assets/js/app.js', ['as' => 'script']));

        $response->headers->set(
            'Link',
            '<' . '/assets/css/all.css' . '>; rel=preload; as=style, '
            . '<' . '/assets/js/app.js' . '>; rel=preload; as=script'
        );
    }
}

Hinweis: 103 Early Hints kann Preload-Header noch früher an den Browser senden. Der Link: rel=preload Header bleibt auch ohne Early Hints sinnvoll, weil der Browser die Ressourcen dann priorisiert lädt.

Stamps und Middleware

Messenger lässt sich über Middleware erweitern. So können Sie z.B. Logging einfügen, bevor eine Message verarbeitet wird:

<?php

declare(strict_types=1);

namespace App\Messenger\Middleware;

use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;

class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        if ($envelope->last(ReceivedStamp::class) !== null) {
            $this->logger->info('Processing message: {class}', [
                'class' => get_class($envelope->getMessage()),
            ]);
        }

        return $stack->next()->handle($envelope, $stack);
    }
}

Symfony Messenger hat sich in meinen Projekten als unverzichtbar erwiesen. Die saubere Trennung von synchroner und asynchroner Logik macht Anwendungen robuster und skalierbarer — und mit einem sauber konfigurierten systemd-Service läuft das Ganze zuverlässig in Produktion.

Thomas Wunner

Thomas Wunner

Fachinformatiker für Anwendungsentwicklung mit Ausbildereignungsprüfung und über 14 Jahre Erfahrung im Aufbau skalierbarer Webanwendungen mit Symfony und Shopware. Abseits der Tastatur ist Thomas als Rettungsschwimmer in der Wasserwacht aktiv, legt als DJ auf und erkundet die Umgebung auf dem Motorrad.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.