Warum In-Memory-Storage bei mehreren Server-Instanzen versagt und wann Redis als geteilter Zustand nötig wird
Der Symfony RateLimiter schützt Endpunkte zuverlässig vor Missbrauch, aber nur, wenn der gewählte Storage-Backend zur tatsächlichen Infrastruktur passt. In-Memory-Storage ist für Tests gedacht und zählt Anfragen nur innerhalb eines einzelnen PHP-Prozesses, was hinter einem Load Balancer mit mehreren Server-Instanzen faktisch bedeutet, dass ein Angreifer das Limit einfach mit der Anzahl der Instanzen multiplizieren kann. Dieser Artikel vergleicht In-Memory-Storage, den generischen Cache-Adapter und Redis als geteilten Zustand, zeigt die konkrete Konfiguration der RateLimiterFactory und erklärt den praktischen Unterschied zwischen Sliding-Window- und Token-Bucket-Policy.
Inhaltsverzeichnis
- 1. Die RateLimiterFactory: Grundkonfiguration
- 2. In-Memory-Storage: warum es bei mehreren Server-Instanzen scheitert
- 3. Cache-Adapter als Storage und Redis-Konfiguration im Detail
- 4. RateLimiterFactory mit Redis in der Praxis
- 5. Sliding-Window-Policy konkret konfigurieren
- 6. Token-Bucket-Policy konkret konfigurieren
- 7. Fixed-Window und No-Limit als weitere Optionen
- 8. Anonyme und authentifizierte Clients getrennt limitieren
- 9. Fehlerbehandlung und der Retry-After-Header
- 10. Zusammenfassung
- 11. FAQ
1. Die RateLimiterFactory: Grundkonfiguration
Ein Rate Limiter wird in config/packages/rate_limiter.yaml unter framework.rate_limiter.limiters definiert, wobei jeder benannte Limiter eine eigene Policy, ein Limit und ein Zeitintervall erhält. Für jeden konfigurierten Limiter registriert Symfony automatisch einen Service vom Typ RateLimiterFactory, der über Autowiring anhand des Konstruktor-Parameternamens injiziert werden kann, wobei die Namenskonvention den Limiter-Schlüssel in camelCase mit dem Suffix Limiter erwartet. Ein Limiter mit dem Schlüssel login_attempts wird also über einen Konstruktor-Parameter mit dem Namen $loginAttemptsLimiter automatisch aufgelöst, ganz ohne zusätzliches Attribut oder manuelle Service-Konfiguration.
Aus der Factory erzeugt man über create($key) einen konkreten Limiter für einen bestimmten Schlüssel, etwa die IP-Adresse eines Clients oder eine Benutzer-ID, und ruft darauf consume() auf, um einen oder mehrere Tokens zu verbrauchen. Der Rückgabewert von consume() ist ein RateLimit-Objekt mit den Methoden isAccepted(), getRetryAfter() und getRemainingTokens(), die zusammen alle Informationen liefern, um sowohl die Zugriffsentscheidung zu treffen als auch dem Client per Retry-After-Header mitzuteilen, wann ein erneuter Versuch sinnvoll ist.
2. In-Memory-Storage: warum es bei mehreren Server-Instanzen scheitert
InMemoryStorage hält den Zählerstand ausschließlich im Speicher des aktuellen PHP-Prozesses und geht bei jedem neuen Request unter einem klassischen PHP-FPM- oder PHP-CLI-Setup wieder verloren, weil jeder Request in einem frischen Prozess ohne gemeinsamen Speicher mit vorherigen Requests startet. In der Praxis bedeutet das, dass In-Memory-Storage außerhalb von Langzeitprozessen wie Swoole oder RoadRunner faktisch nutzlos ist, weil das Limit bei jedem Request effektiv wieder auf null zurückgesetzt wird und niemals tatsächlich greift.
Selbst in einem Langzeitprozess-Setup mit einem einzigen Worker bleibt das Problem bestehen, sobald mehr als eine Server-Instanz hinter einem Load Balancer läuft, was in jeder produktiven, horizontal skalierten Umgebung der Regelfall ist. Jede Instanz führt ihren eigenen, unabhängigen Zähler, sodass ein Angreifer bei drei Instanzen effektiv das dreifache Limit ausnutzen kann, einfach indem Anfragen gleichmäßig über die Instanzen verteilt werden, was bei den meisten Load-Balancer-Konfigurationen automatisch und ohne bewusstes Zutun des Angreifers geschieht. InMemoryStorage eignet sich deshalb ausschließlich für Unit-Tests und lokale Entwicklung, niemals für Produktionsumgebungen mit mehr als einer laufenden Instanz.
3. Cache-Adapter als Storage und Redis-Konfiguration im Detail
Standardmäßig nutzt der Rate Limiter den Cache-Pool cache.rate_limiter, der wiederum auf demselben Adapter basiert wie der allgemeine Anwendungscache, sofern nichts anderes konfiguriert ist. Zeigt dieser Standard-Cache-Pool auf einen dateisystembasierten oder APCu-basierten Adapter, gilt dieselbe Einschränkung wie bei InMemoryStorage: Der Filesystem-Adapter ist zwar prozessübergreifend auf einer einzelnen Maschine, aber nicht instanzübergreifend, wenn mehrere Server oder Container jeweils ein eigenes lokales Dateisystem besitzen. APCu-basierte Caches sind sogar noch enger begrenzt, da sie ausschließlich pro PHP-Worker-Prozess gelten und sich nicht einmal innerhalb derselben Maschine über mehrere PHP-FPM-Worker teilen.
Für verteiltes Rate-Limiting über mehrere Server-Instanzen hinweg ist deshalb ein zentraler, von allen Instanzen erreichbarer Cache-Adapter nötig, wofür sich Redis als De-facto-Standard etabliert hat. Dazu definiert man einen eigenen Cache-Pool mit dem Redis-Adapter, etwa unter dem Namen cache.rate_limiter, und referenziert ihn im jeweiligen Limiter über den Schlüssel cache_pool in rate_limiter.yaml. Redis bietet dabei nicht nur einen geteilten Speicher, sondern durch seine nativen, atomaren Operationen wie INCR auch eine natürliche Lösung für das Race-Condition-Problem, das bei gleichzeitigen Zugriffen mehrerer Requests auf denselben Zähler entstehen kann.
4. RateLimiterFactory mit Redis in der Praxis
Das folgende Beispiel zeigt einen Service, der einen konfigurierten Limiter für Login-Versuche nutzt, wobei der zugrunde liegende Storage bereits über die YAML-Konfiguration auf einen Redis-basierten Cache-Pool zeigt und im PHP-Code selbst nicht sichtbar ist. Genau das ist der Vorteil dieser Architektur: Der eigentliche Anwendungscode bleibt vollkommen unverändert, egal ob im Hintergrund InMemoryStorage, ein lokaler Cache-Adapter oder Redis arbeitet, denn die Storage-Wahl ist reine Infrastrukturkonfiguration und nicht Teil der Geschäftslogik.
Diese Trennung erlaubt es, lokal mit einem einfachen Cache-Adapter zu entwickeln und zu testen, während in Staging und Produktion automatisch der Redis-basierte Pool greift, ohne dass eine Zeile Anwendungscode angepasst werden muss. Wichtig ist dabei, dass die Redis-Instanz selbst hochverfügbar konfiguriert ist, denn fällt sie aus, fällt in vielen Konfigurationen auch der Rate Limiter aus, was je nach Fail-Open- oder Fail-Closed-Strategie entweder ungebremsten Zugriff oder eine komplett blockierte Anwendung bedeuten kann.
<?php
declare(strict_types=1);
namespace App\Security;
use App\Security\Exception\TooManyLoginAttemptsException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
final class LoginAttemptGuard
{
public function __construct(
private readonly RateLimiterFactory $loginAttemptsLimiter,
) {
}
public function guard(Request $request): void
{
$limiter = $this->loginAttemptsLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
throw new TooManyLoginAttemptsException($limit->getRetryAfter());
}
}
}
5. Sliding-Window-Policy konkret konfigurieren
Die Sliding-Window-Policy vermeidet den bekannten Randeffekt der Fixed-Window-Policy, bei dem ein Client kurz vor Ende eines Fensters und kurz nach Beginn des nächsten jeweils das volle Limit ausschöpfen kann, was effektiv das doppelte Limit innerhalb einer sehr kurzen Zeitspanne erlaubt. Stattdessen gewichtet Sliding-Window die Anzahl der Anfragen im vorherigen Fenster anteilig, je nachdem, wie weit das aktuelle Fenster bereits fortgeschritten ist, und approximiert dadurch ein tatsächlich gleitendes Zeitfenster, ohne für jede einzelne Anfrage einen exakten Zeitstempel speichern zu müssen.
Konfiguriert wird die Policy in rate_limiter.yaml mit policy: 'sliding_window' zusammen mit limit und interval, etwa limit: 100 und interval: '1 minute' für maximal einhundert Anfragen pro gleitender Minute. Diese Policy eignet sich besonders für öffentliche API-Endpunkte, bei denen ein gleichmäßig verteiltes Limit über die Zeit wichtiger ist als eine exakte Burst-Kontrolle, etwa bei einer allgemeinen Endpunkt-Absicherung gegen automatisierte Scraper, die versuchen, das Limit durch geschicktes Timing an Fenstergrenzen auszunutzen.
6. Token-Bucket-Policy konkret konfigurieren
Die Token-Bucket-Policy modelliert einen Eimer mit einer festen Kapazität, aus dem jede Anfrage einen oder mehrere Tokens entnimmt, während der Eimer sich kontinuierlich mit einer festgelegten Rate wieder auffüllt, bis maximal zur Kapazitätsgrenze. Konfiguriert wird das über policy: 'token_bucket' mit limit als Kapazität des Eimers und einem verschachtelten rate-Block mit interval und amount, etwa limit: 10 für eine Kapazität von zehn Tokens und rate: { interval: '5 seconds', amount: 1 } für eine Auffüllrate von einem Token alle fünf Sekunden.
Der praktische Unterschied zur Sliding-Window-Policy liegt in der Behandlung von Bursts: Token-Bucket erlaubt bewusst einen kurzen Burst bis zur vollen Kapazität des Eimers, etwa wenn ein Client längere Zeit inaktiv war und sich der Eimer wieder vollständig gefüllt hat, während Sliding-Window eher eine gleichmäßige, geglättete Verteilung über die Zeit erzwingt. Token-Bucket eignet sich deshalb gut für Anwendungsfälle, bei denen kurzfristige Lastspitzen legitim sind, etwa ein Benutzer, der nach längerer Pause mehrere Aktionen kurz hintereinander auslöst, während Sliding-Window besser passt, wenn eine konstante, vorhersehbare Obergrenze über die Zeit wichtiger ist als Flexibilität bei Lastspitzen.
7. Fixed-Window und No-Limit als weitere Optionen
Neben Sliding-Window und Token-Bucket bietet Symfony auch fixed_window als einfachste und rechengünstigste Policy an, bei der ein Zähler zu Beginn jedes festen Zeitfensters auf null zurückgesetzt wird. Diese Policy ist am einfachsten zu verstehen und benötigt die wenigsten Storage-Operationen pro Anfrage, hat aber genau den bereits erwähnten Randeffekt an Fenstergrenzen, weshalb sie sich vor allem für großzügige, weniger sicherheitskritische Limits eignet, etwa eine grobe Absicherung eines internen Batch-Jobs gegen versehentliche Endlosschleifen.
Die Policy no_limit schließlich deaktiviert das Rate-Limiting vollständig, behält aber die einheitliche RateLimiterFactory-Schnittstelle bei, was sich hervorragend eignet, um einen Limiter in bestimmten Umgebungen wie der lokalen Entwicklung über eine Umgebungsvariable komplett abzuschalten, ohne den Anwendungscode mit if-Abfragen für diesen Fall zu verschmutzen. So kann etwa %env(RATE_LIMITER_POLICY)% als Wert für policy dienen und je nach Umgebung zwischen sliding_window in Produktion und no_limit in der lokalen Entwicklung wechseln.
8. Anonyme und authentifizierte Clients getrennt limitieren
Der an create() übergebene Schlüssel bestimmt, wie granular das Limit angewendet wird, und in der Praxis lohnt es sich, für anonyme und authentifizierte Clients unterschiedliche Schlüssel und teils sogar unterschiedliche Limiter-Konfigurationen zu verwenden. Ein anonymer Client wird üblicherweise über die IP-Adresse limitiert, was aber problematisch ist, sobald viele Nutzer hinter demselben NAT-Gateway oder Firmen-Proxy sitzen und sich dadurch fälschlich ein gemeinsames Limit teilen, obwohl sie eigentlich unabhängige Nutzer sind.
Ein authentifizierter Client dagegen sollte nach Nutzer-ID oder API-Schlüssel limitiert werden, was eine deutlich präzisere und fairere Zuordnung ermöglicht und gleichzeitig erlaubt, unterschiedlichen Nutzergruppen unterschiedliche Limits zuzuweisen, etwa ein höheres Limit für zahlende Kunden mit einem eigenen Limiter-Namen wie api_premium im Vergleich zu api_free. Diese Trennung nach Nutzergruppe erfordert in Symfony lediglich mehrere unabhängig konfigurierte Limiter in derselben rate_limiter.yaml, die je nach ermitteltem Nutzerstatus im Code ausgewählt werden, ohne dass dafür zusätzliche Logik im RateLimiter selbst nötig wäre.
9. Fehlerbehandlung und der Retry-After-Header
Wird ein Limit überschritten, sollte die Antwort nicht nur den HTTP-Statuscode 429 Too Many Requests liefern, sondern auch einen Retry-After-Header, der dem Client mitteilt, nach wie vielen Sekunden ein erneuter Versuch sinnvoll ist. Der Wert lässt sich direkt aus getRetryAfter() des RateLimit-Objekts ableiten, das ein DateTimeImmutable-Objekt mit dem nächsten möglichen Erfolgszeitpunkt zurückgibt, aus dem sich die Sekundenanzahl bis zu diesem Zeitpunkt einfach berechnen lässt. Ein gut gepflegter Retry-After-Header reduziert unnötige Wiederholungsversuche erheblich, weil wohlerzogene Clients und Bibliotheken diesen Header respektieren, statt sofort erneut zu versuchen.
In einer Symfony-Anwendung bietet sich dafür ein zentraler Exception-Listener an, der eine eigene Exception wie die im Codebeispiel gezeigte TooManyLoginAttemptsException abfängt und daraus konsistent eine JsonResponse mit Statuscode 429 und gesetztem Retry-After-Header erzeugt, statt diese Logik in jedem einzelnen Controller zu duplizieren. Dieser zentrale Ansatz stellt zudem sicher, dass alle rate-limitierten Endpunkte der Anwendung ein einheitliches Antwortformat liefern, was insbesondere für externe API-Konsumenten wichtig ist, die sich auf ein konsistentes Fehlerformat verlassen können müssen.
| Storage-Backend | Geteilt über Instanzen? | Typischer Einsatz |
|---|---|---|
| InMemoryStorage | Nein, nur ein Prozess | Unit-Tests, lokale Entwicklung |
| Cache-Adapter (Filesystem/APCu) | Nein, nur eine Maschine bzw. ein Worker | Single-Instance-Deployments |
| Cache-Adapter mit Redis | Ja, über alle Instanzen | Produktive, horizontal skalierte Umgebungen |
| CacheStorage + RedisAdapter explizit | Ja, mit voller Kontrolle über Verbindung | Dedizierte Redis-Instanz für Rate Limiting |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Rate Limiter Storage-Backends: Das Wichtigste auf einen Blick
In-Memory
Nur für Tests, geht bei jedem Request oder jeder weiteren Instanz verloren.
Redis als Storage
Geteilter, atomarer Zustand über alle Server-Instanzen hinweg, De-facto-Standard.
Sliding Window
Vermeidet Randeffekte an Fenstergrenzen, gut für gleichmäßige API-Limits.
Token Bucket
Erlaubt kontrollierte Bursts bis zur Eimer-Kapazität, gut bei legitimer Lastspitze.