Appearance
Last- und Sicherheitstests
Vor dem Launch wurden das öffentliche Collector-API unter Last gemessen und die öffentlichen Endpunkte mit OWASP ZAP und gezielten Prüfungen untersucht – beides gegen die Produktions-Images (docker-compose.prod.yml, APP_ENV=prod, FrankenPHP im Worker-Modus), nicht gegen den Dev-Stack.
| Was | Wie | Werkzeug |
|---|---|---|
| Lasttest + Rollup-Prüfung | make load-test (ci/load/run.sh) | k6 grafana/k6:1.3.0, Details in ci/load/README.md |
| Security-Scan | make security-scan (ci/security/zap-scan.sh) | ZAP ghcr.io/zaproxy/zaproxy:stable + k6, Details in ci/security/README.md |
Beide laufen als eigenes Compose-Projekt pollyou-load (Ports 8690–8692, 5390–5391) und stören make prod-check nicht. Rohberichte (ZAP-HTML, k6-Messpunkte) werden nicht eingecheckt.
Ergebnis in Kürze
- Ziel 500 Stimmen/s pro Instanz mit p95 < 150 ms: nicht erreicht. Das API selbst ist schnell (Median 3–4 ms, p95 im ruhigen Betrieb ~6,5 ms), aber unter Dauerlast gibt es wiederkehrende Stauphasen von 10–30 s, in denen alle Endpunkte gleichzeitig auf 0,5–3 s gehen. Bei 250 Stimmen/s wird das Ziel erreicht (p95 79 ms), bei 500 Stimmen/s nicht (p95 566 ms). Fehlerquote in allen Läufen 0 %.
- Stauphasen behoben (Oktober 2026, Stauphasen: WAL und Redis): Ursache waren die synchronen Commits des Collectors (Warten auf den WAL-Flush, dabei gehaltene Zeilensperren) und die gemeinsame Redis-Instanz mit AOF und RDB-Snapshots. Mit asynchronen Commits nur für den Collector, PostgreSQL-Tuning und getrennten Redis-Instanzen: 250 Stimmen/s p95 8 ms ohne eine einzige Stauphase (vorher bis 32 s), 500 Stimmen/s p95 17 ms ohne Stauphase (vorher 74 s am Stück, p95 2,3 s).
- Engpass Nr. 1 ist die Auswertungs-Pipeline (Messenger-Worker): Ein Worker schafft netto nur ~45 Stimmen/s; die Rollups hinken bei 250 Stimmen/s nach 4 Minuten Last über 10 Minuten hinterher, Bestätigungs-Mails stecken hinter der Warteschlange fest. Stand 12.10.2026: gebündelte Rollups, Prioritäts-Transport, skalierbare Worker und gebündelte Live-Ergebnisse mit Mercure
transport local– ein Worker schafft ~315 Stimmen/s, mit 4 Workern bleiben 500 Stimmen/s ohne Rückstau (Rollup-Verzug 14 s, siehe Live-Ergebnisse und Mercure). - Rollups sind exakt: Nach dem Leerlaufen der Queue stimmen alle Rollups mit den Rohdaten überein (zwei Läufe mit 54 551 bzw. 97 496 Antworten und 286 391 bzw. 511 852 Events).
- Security: keine offenen High-Befunde. Ein echter Fehler (500 statt 400 beim Login ohne JSON) und zwei Härtungen (Framing des Direktlinks, CSP der Share-Seite, Standard-Header des API) sind behoben;
/s/und/p/haben inzwischen eine vollständige CSP; die übrigen Medium-Befunde sind bewusste Ausnahmen (siehe Tabelle).
Die Engpässe und offenen Punkte stehen als Tickets im Board (siehe Offene Punkte).
Maschine und Konfiguration
| CPU | AMD Ryzen 9 9900X, 12 Kerne / 24 Threads |
| RAM | 60 GiB |
| System | Linux 7.2.9 (zen), Docker 29.8.2, alle Container auf einem Host |
| Commit | 62046ba (main) |
| API | 1 Container, FrankenPHP Worker-Modus, FRANKENPHP_WORKER_COUNT Standard = 2 × CPUs = 48 Worker |
| Worker | 1 Messenger-Worker (async + Scheduler), wie in Produktion |
| PostgreSQL | 17.11, Standardwerte: max_connections=100, shared_buffers=128MB, synchronous_commit=on |
| Redis | 7, appendonly yes (wie docker-compose.prod.yml) |
| Lastgenerator | k6 im Docker-Netz desselben Rechners (bis 4000 VUs, ~0,6–2 Kerne, 3 GiB RAM) |
Gemeinsam genutzter Rechner
Die Messungen liefen auf einer Entwickler-Workstation, auf der parallel andere Stacks und Testläufe aktiv waren (Last ~10–19). Absolute Zahlen sind daher eher pessimistisch, die Stauphasen können teilweise von dort stammen. Die Engpässe der Pipeline (Nachrichtenmenge, WAL-Flushes, Lock-Contention) sind davon unabhängig. Vor dem Launch auf der Zielhardware wiederholen.
Lasttest
Szenario
Ein virtueller Teilnehmer durchläuft den Ablauf des Embeds: Umfrage laden → Events impression/view → Session starten → Events start/page_view → (Umfrage: Seite 1 speichern + page_view) → abschließen → Event complete, mit 1–2 s Denkzeit pro Seite. Eine Iteration ist eine Stimme (≈ 6,6 Requests). 320 veröffentlichte Umfragen in 20 Organisationen (je 12 Polls und 4 zweiseitige Umfragen) – damit bleibt jede Organisation unter ihrem Rate-Limit und jede Umfrage unter der survey_spike-Schwelle (siehe unten).
Rate-Limits pro IP werden nicht verändert: k6 läuft im privaten Docker-Netz, dem das Produktions-Image als Proxy vertraut (TRUSTED_PROXIES=PRIVATE_SUBNETS), und setzt pro Teilnehmer eine zufällige X-Forwarded-For-Adresse aus 198.18.0.0/15.
Ergebnisse
| Lauf | Ziel | Konfiguration | Stimmen | Requests/s (Mittel) | p50 | p95 | p99 | Fehler | Rollup-Verzug |
|---|---|---|---|---|---|---|---|---|---|
| A | 500/s | Standard | 107 304 | 2413 | 4,3 ms | 566 ms | 1,55 s | 0 % | nicht abgebaut¹ |
| B | 250/s | Standard | 54 551 | 1192 (Plateau ~1625) | 2,9 ms | 79 ms | 508 ms | 0 % | 627 s² |
| C | 500/s | PG synchronous_commit=off, 2 GB shared_buffers, 8 Consumer | 97 496 | 2201 | 11 ms | 1,79 s | 3,19 s | 0 % | 123 s |
Dauer jeweils 1 min Rampe + 3 min Plateau. ¹ Nach Lauf A lagen 555 000 Nachrichten in der Queue, der eine Worker baute sie praktisch nicht ab. ² Lauf B: Nach 9 Minuten waren noch 245 000 Nachrichten offen; erst mit 7 zusätzlichen Consumern war die Queue in 15 s leer (der Rest bestand fast nur aus AnalyticsChanged).
Pro Endpunkt liegen die Perzentile in allen Läufen fast gleichauf (Lauf A: p95 zwischen 522 ms für POST /events und 664 ms für GET /surveys/{publicId}; Mediane 1,6–8,7 ms, Abschluss am langsamsten). Das spricht gegen einen langsamen Endpunkt und für Stauphasen, die alle Requests gleichzeitig treffen.
Zeitverlauf Lauf B (10-s-Fenster): Im Plateau 1625 Requests/s mit p95 6–7 ms; dazwischen drei Stauphasen (bei 70 s, 120–140 s, 240 s) mit p95 bis 1,08 s. Sie fallen zeitlich mit Redis-AOF-Rewrites/RDB-Snapshots (Redis meldet aof-write-Latenzen bis 130 ms) und hoher WAL-Last von PostgreSQL zusammen; FrankenPHPs eigene Warteschlange blieb leer (queue_depth 0).
Ressourcen bei 500 Stimmen/s (Lauf A): API ⌀ 5,2 Kerne (max. 7,7), PostgreSQL ⌀ 5,0 (max. 8,9), Redis 0,2, Worker 0,2 – zusammen ~10,6 von 24 Kernen; RAM API 645 MiB, PostgreSQL 366 MiB, Redis 626 MiB (wächst mit der Queue). PostgreSQL wartete vor allem auf LWLock:WALWrite / IO:WalSync (Commits); mit synchronous_commit=off (Lauf C) verschwand das, stattdessen dominierte LWLock:LockManager (Sperren auf den vielen Partitionen und deren Indizes) – und die 8 Consumer konkurrierten zusätzlich mit dem API, daher war Lauf C insgesamt langsamer.
Rollup-Prüfung
ci/load/rollup-check.sql vergleicht nach dem Leerlaufen der Queue Starts, Abschlüsse und Dauersumme pro Umfrage und Stunde, Events pro Umfrage/Stunde/Typ, Antworten pro Frage/Option/Wert und die Buchhaltungsspalten (rollup_state, rolled_up).
| Lauf | Antworten | Events | Antwortzeilen | Abweichungen | fehlgeschlagene Nachrichten |
|---|---|---|---|---|---|
| B | 54 551 | 286 391 | 95 459 | 0 | 0 |
| C | 97 496 | 511 852 | 170 612 | 0 | 0 |
Auch doppelt zugestellte Nachrichten (mehrere Consumer mit gleichem Namen, siehe unten) änderten nichts – die Rollups sind idempotent.
Beobachtungen und Engpässe
- Nachrichtenmenge der Auswertung. Pro Stimme entstehen ~5 Rollup-Nachrichten (
ResponseStarted,ResponseCompleted, 3–4 ×SurveyEventsRecorded), und jede verarbeitete schickt einAnalyticsChangedhinterher (Live-Modus), dazuPublishLiveResults/PublishAnalyticsUpdate– zusammen ~10 Nachrichten pro Stimme. Ein Worker verarbeitet brutto ~490 Nachrichten/s, die Queue schrumpft aber netto nur um ~56/s. Jede Rollup-Nachricht ist eine eigene Transaktion mit WAL-Flush. - Mails hinter der Analytics-Queue. Während des Rückstands kam die Bestätigungs-Mail einer Registrierung nicht innerhalb von 60 s an (gleiche
async-Queue, FIFO). Behoben: Mails, Webhooks und Benachrichtigungen laufen über den Transportasync_priority, den jeder Worker vorasyncabarbeitet. - Mehrere Consumer brauchen eigene Namen. Mit dem Standardnamen
consumerholt ein zweiter Worker die offenen Nachrichten des ersten ab, beide verarbeiten sie, und das zweite Ack scheitert („Could not acknowledge redis message“, Worker beendet sich). Behoben: Jeder Container liest unter seinem Hostnamen, der Scheduler läuft in einem eigenen Container; Worker lassen sich beliebig skalieren (WORKER_REPLICAS). - Virale Umfragen werden als Spam markiert. In einem Vorlauf mit nur 16 Umfragen (~8 Stimmen/s je Umfrage) markierte die Anomalie-Erkennung 86 % der Antworten als
survey_spike(mehr als 120 Abschlüsse/min ohne Vorgeschichte); sie zählen in keinem Ergebnis. Behoben: Die Spitzenerkennung berücksichtigt jetzt die Verteilung auf Adressen und eine mitwachsende Grundlast, das Minimum hängt vom Plan ab und ist pro Umfrage einstellbar (siehe Missbrauchsschutz). - Rate-Limit pro Organisation (damals fest 6000 Starts + Abschlüsse pro Minute) begrenzte eine Organisation auf ~50 Stimmen/s – über alle ihre Umfragen. Behoben: planabhängig, Standard 24.000 und intern 60.000 pro Minute (siehe Durchsatz je Plan); der Lasttest stellt seine Organisationen auf den Plan
LOAD_PLANum. - Stauphasen (siehe Zeitverlauf): WAL-Flushes, Sperren auf Partitionen und Redis- Persistenz (AOF-Rewrite bei wachsender Queue) auf derselben Platte. Behoben, siehe Stauphasen: WAL und Redis.
Stauphasen: WAL und Redis
Nachmessung im Oktober 2026 (Commit-Basis 57ed1ac, gleiche Maschine, 2 Worker-Container, 48 FrankenPHP-Worker; je 1 min Rampe + 3 min Plateau; Auswertung ci/load/stalls.py: Stauphase = Sekunden, in denen das p95 aller Collector-Requests über 500 ms liegt). Geändert wurde:
- Asynchrone Commits nur für den Collector (
COLLECT_ASYNC_COMMIT=1): Session-Start, Zwischenspeichern, Abschluss und Events committen mitSET LOCAL synchronous_commit = off. Abwägung: Ein Absturz von PostgreSQL oder des Servers kann die Stimmen der letzten ≤ 600 ms verlieren (siehe Deployment). - PostgreSQL-Einstellungen für einen CPX41 (
deploy/postgres/20-pollyou-tuning.sql:shared_buffers4 GB,wal_buffers64 MB,max_wal_size8 GB,checkpoint_timeout15 min,wal_compression=lz4,commit_delay20 µs). - Zwei Redis-Instanzen (
deploy/redis): Queue mit AOFeverysec, ohne RDB-Snapshots und mit Rewrite erst ab 1 GB; Cache/Rate-Limiter ohne AOF, mit LRU-Verdrängung.
| Lauf | Ziel | Konfiguration | p50 | p95 | p99 | max | s mit p95 > 500 ms | längste Stauphase | erreicht¹ | Last (Median/Max) |
|---|---|---|---|---|---|---|---|---|---|---|
| D | 250/s | vorher (main) | 22 ms | 784 ms | 1,20 s | 1,91 s | 46 | 32 s | 54 077 | 8,6 / 15,7 |
| E | 250/s | neue PG-/Redis-Konfiguration, synchrone Commits | 18 ms | 818 ms | 1,14 s | 1,67 s | 62 | 17 s | 53 887 | 17,3 / 29,4 |
| F | 250/s | neue Konfiguration + asynchrone Commits | 2,3 ms | 8,2 ms | 9,9 ms | 44 ms | 0 | – | 54 764 | 7,3 / 12,0 |
| G | 500/s | vorher (main) | 653 ms | 2,29 s | 3,49 s | 4,25 s | 163 | 74 s | 88 636 | 22,7 / 35,5 |
| H | 500/s | neue Konfiguration + asynchrone Commits | 3,5 ms | 17 ms | 63 ms | 272 ms | 0 | – | 109 499 | 16,7 / 25,2 |
¹ Abgeschlossene Iterationen (Stimmen) inkl. Rampe; Soll 54 764 bzw. 109 499. Lauf G verwarf 20 863 Iterationen, weil alle 4000 VUs in Stauphasen festhingen. Fehlerquote überall 0 %. Wiederholungen: F zweimal (p95 8,6 bzw. 8,2 ms, beide ohne Stauphase), D zweimal (19 s bzw. 32 s längste Phase). Eine erste Wiederholung von H (p95 725 ms) hatte eine Stauphase von 50 s (LWLock:LockManager, siehe unten), während die Last des Rechners durch andere Prozesse von 25 auf 52 stieg; die zweite Messung (Tabelle) blieb ohne Stauphase. Die Vorher-Läufe bei 500/s liefen teils parallel zu einem anderen Lasttest (eine Messung mit Last ~52 erreichte nur 186 Stimmen/s) – die Tabelle zeigt den ruhigeren Lauf.
Wartezustände (Summe aktiver PostgreSQL-Sessions über alle 5-s-Stichproben):
| Lauf | LWLock:WALWrite | IO:WalSync | Lock:transactionid | LWLock:LockManager | CPU |
|---|---|---|---|---|---|
| D | 579 | 31 | 41 | 2 | 102 |
| E | 533 | 39 | 27 | 3 | 111 |
| F | 6 | 16 | 0 | 0 | 80 |
| G | 743 | 34 | 102 | 41 | 211 |
| H | 4 | 12 | 0 | 16 | 250 |
CPU (Kerne im Plateau, Mittel/Max): API 2,9/4,2 und PostgreSQL 3,5/5,5 (D) gegenüber API 2,3/2,7 und PostgreSQL 3,0/3,8 (F); bei 500/s API 5,8/7,4 und PostgreSQL 7,2/9,3 (H, volle Rate) gegenüber 4,7/9,4 und 5,7/11,2 (G, nur 295 Stimmen/s). Redis-Instanzen je ≤ 0,2 Kerne.
Redis: Vorher in jedem Lauf 5 RDB-Snapshots und 8–10 AOF-Rewrites in ~5 min (Fork des ganzen Datenbestands samt Queue-Rückstand), aof-write-Spitzen 41–85 ms. Nachher keine Snapshots und keine Rewrites in der Queue-Instanz, die Cache-Instanz meldet in keinem Lauf Latenzereignisse > 25 ms. Die Queue-Instanz meldete in 2 von 5 Läufen einzelne aof-write-Spitzen (44–113 ms; das fsync teilt sich die Platte mit dem WAL) – ohne Auswirkung auf die API, da Rate-Limiter und Cache nicht mehr dort liegen.
Ursache und Wirkung: Die neue PostgreSQL-/Redis-Konfiguration allein (E) beseitigt die Redis-Spitzen, aber nicht die Stauphasen: Jede Collector-Transaktion wartet auf ihr eigenes WAL-fsync, hunderte pro Sekunde stauen sich auf WALWrite, und während des Wartens halten sie Zeilensperren (u. a. den Monatszähler der Organisation, Lock:transactionid), sodass sich ein kurzer fsync-Hänger zu 10–30 s Stau aufschaukelt. Erst die asynchronen Commits (F, H) entkoppeln die Antwortzeit vom fsync. Der Durchsatz der Platte bleibt gleich (~480 MB WAL pro Lauf bei 250/s, ~900 MB bei 500/s), die Commits warten nur nicht mehr darauf.
Verbleibend: LWLock:LockManager bei 500/s. Die Partitionen von response haben je 12 Indizes (5 Partitionen in einer frischen Datenbank: laufender Monat + Vorlauf). Abfragen wie SELECT … FROM response WHERE id = ? AND started_at >= ? schließen nur ältere Partitionen aus; die übrigen samt Indizes übersteigen die 16 „Fast-Path“-Sperren je Transaktion von PostgreSQL 17, sodass jede dieser Abfragen über den gemeinsamen Lock-Manager geht. Das trat nur in einer von zwei Messungen bei 500/s auf. Gegenmaßnahmen (eigene Tickets): obere Schranke für started_at in den Collector-Abfragen (Abfragen treffen nur noch die laufende Partition) und PostgreSQL 18 (dort wachsen die Fast-Path-Plätze mit max_locks_per_transaction). Umgesetzt und nachgemessen: Partitionssperren.
Auf der Zielhardware wiederholen. Diese Werte stammen von einer geteilten Workstation (24 Threads, NVMe, parallel laufende Stacks); ein CPX41 hat 8 vCPU. Vor dem Launch make load-test (250 und 500 Stimmen/s) auf einem Server gleicher Größe laufen lassen und stalls.txt, pg-activity.tsv und redis-latency.txt mit dieser Tabelle vergleichen. Der Rollup-Verzug ist davon unabhängig (Worker-Durchsatz, eigenes Ticket); die Läufe D–H brachen das Leerlaufen der Queue nach 30–60 s ab.
Rollup-Pipeline nach dem Umbau (12.10.2026)
Umgesetzt: Batch-Handler (bis 100 Rollup-Nachrichten in einer Transaktion, Ack erst nach dem Commit), Live-Modus ohne AnalyticsChanged (ein Mercure-Update pro Batch), Events pro Umfrage statt pro Request (SurveyEventsPending, höchstens eine Nachricht pro Umfrage und Sekunde). Details: backend/src/Analytics/README.md („Messages“). Gleiche Maschine, aber parallel lief zeitweise ein zweiter Lasttest eines anderen Stacks (Last 8–100); das API schaffte nur 280–345 statt 500 Stimmen/s.
| Lauf | Stand | Stimmen/s (erreicht) | Nachrichten pro Stimme | Worker brutto (Abbau ohne Last) | Rollup-Verzug | Rollup-Prüfung |
|---|---|---|---|---|---|---|
| vorher | e563840 | 353 | ~10 (7,2 bis Abbruch) | ~320 Nachr./s, netto ≈ 0 | nicht abgebaut | – |
| 1 | Batch, ohne AnalyticsChanged | 301 | 5,7 | 500–580 Nachr./s (≈ 95 Stimmen/s) | > 600 s | – |
| 2 | + Events pro Umfrage | 166 (zweiter Lasttest parallel) | 2,3 | ~100 Nachr./s (Mercure-Latenz) | 674 s | exakt |
| 3 | + ein Mercure-Update pro Batch | 308 | 2,5 | 110–330 Nachr./s | 626 s | exakt |
| 4 | wie 3, Mercure transport local | 344 | 2,2 | ~500 Nachr./s (≈ 225 Stimmen/s) | 366 s | exakt |
| 5 | wie 3, 4 Consumer | 285 | – | ~2000 Nachr./s zusammen | 127 s | exakt |
| 6 | nach Merge (RLS, Prioritäts-Transport), 1 Worker | 178 (Last 13–25) | 2,2 | 100–300 Nachr./s | 638 s | exakt, 0 Ack-Fehler |
Neuer Engpass ist nicht mehr die Rollup-Pipeline (≈ 1,5 ms pro Stimme), sondern LiveResultsHandler (Ergebnisabfrage + Mercure-Publish pro Umfrage und Sekunde): Mercures bolt-Transport schreibt jedes Update mit fsync; auf der ausgelasteten Platte dauerte ein Publish 20–350 ms (Mittel 18,7 ms) statt ~1 ms. Während der Last selbst bekam der eine Worker neben API und k6 zu wenig CPU, die Queue wuchs bei allen Läufen; 500 Stimmen/s mit weniger als 60 s Verzug brauchen mehrere Consumer (Ticket Worker-Skalierung) und eine Mercure-Konfiguration ohne fsync pro Update (oder eigene Platte).
Live-Ergebnisse und Mercure (12.10.2026)
Umgesetzt: Mercure mit transport local (kein fsync pro Update, dev/prod/Lasttest/CapRover), Live-Ergebnisse umfrageübergreifend gebündelt (ein Veröffentlichungslauf pro Sekunde für alle Umfragen mit neuen Antworten, Ergebnisse aller Umfragen einer Organisation mit einer Abfrage, volle Ergebnisse nur bei öffentlicher Sichtbarkeit; Details backend/src/Collect/README.md „Live results“), Worker mit --keepalive und redeliver_timeout 300 s statt 3600 s (docs/guide/deployment.md „Worker“). Gleiche Maschine, diesmal ohne parallelen zweiten Lasttest (Last vor dem Start < 1), 500 Stimmen/s, 1 min Rampe, 3 min Plateau.
Gemessen im Worker (Lauf „vorher“, Mittel über die 100 größten Umfragen): ein Publish pro Umfrage kostete ~2,7 ms (Umfrage laden 0,5 ms, Ergebnisabfrage 1,8 ms, zwei Mercure-Requests mit transport local 0,4 ms). Weil jede der 320 Umfragen weniger als eine Stimme pro Sekunde bekam, griff die Drossel pro Umfrage kaum: etwa ein Publish pro Stimme – der größte Posten pro Stimme im Worker.
| Lauf | Stand | Worker | Stimmen/s (erreicht) | API p95 | Nachrichten pro Stimme | Abbau ohne Last (1 Worker) | Rollup-Verzug | Rollup-Prüfung |
|---|---|---|---|---|---|---|---|---|
| vorher | 65c7402 + Mercure transport local | 1 | 500 | 9,9 ms | 2,65 | ~480 Nachr./s ≈ 180 Stimmen/s | 328 s | exakt, 0 Ack-Fehler |
| nachher | gebündelte Live-Ergebnisse, Keepalive | 1 | 500 ¹ | – ¹ | 2,46 | ~780 Nachr./s ≈ 315 Stimmen/s | 170 s | exakt, 0 Ack-Fehler |
| nachher | + volle Ergebnisse nur bei öffentlicher Sichtbarkeit | 4 | 500 | 12,3 ms | 3,1 ² | Rückstand nie über ~530 Nachr. | 14 s | exakt, 0 Ack-Fehler |
¹ Während des Laufs liefen auf derselben Maschine statische Analysen (PHPStan) – zwei Stauphasen (p95 bis 1 s); der Worker-Durchsatz im Abbau danach ist davon unberührt. ² Mehr Nachrichten pro Stimme, weil die Worker mithalten: Die Nachrichten pro Umfrage und Sekunde (SurveyEventsPending, verzögerte Live-Updates der Auswertung) fallen dann jede Sekunde an statt gebündelt hinter einem Rückstau.
Ergebnis: Pro Worker ~75 % mehr Stimmen/s (180 → ~315 im Abbau). 500 Stimmen/s mit 4 Workern: Die Queue läuft während der Last nicht voll (höchstens ~530 Nachrichten), der Rollup-Verzug nach Lastende beträgt 14 s (Ziel < 60 s), Rollups exakt, keine Stauphase, keine Ack-Fehler, keine Neustarts. Live-Ergebnisse und Live-Modus der Auswertung danach gegen denselben Stack geprüft (e2e/embed-live-results.spec.ts, e2e/analytics.spec.ts: grün).
Auf der Zielhardware (CPX41, 8 vCPU) offen: Hier teilten sich API, 4 Worker, PostgreSQL und Redis 24 Threads (13–21 Kerne belegt). Auf 8 vCPU reicht die CPU für 500 Stimmen/s plus 4 Worker voraussichtlich nicht; dort mit WORKER_REPLICAS=2…4 messen und den Rollup-Verzug gegen die API-Latenz abwägen (Worker eventuell auf einen eigenen Knoten).
Security-Scan
ZAP
Passiver Baseline-Scan auf Dashboard, Direktlink /s/, iframe-Seite /p/ und Share-Seite /r/; aktiver API-Scan auf das öffentliche API v1 (mit Lese-Key) und die öffentlichen Pfade (Collector, Auth, oEmbed – 37 URLs). Endstand nach den Korrekturen (keine High-Befunde, zap-scan.sh endet erfolgreich):
| Risiko | Befund (ZAP-Regel) | Wo | Bewertung |
|---|---|---|---|
| High | SQL Injection (40018) | POST /api/auth/login, Feld password | Fehlalarm (in 2 von 6 API-Scans gemeldet, im Endstand als Fehlalarm markiert): Das Passwort erreicht nie SQL (Benutzer per E-Mail-Parameter, Hash-Vergleich in PHP). Die unterschiedlichen Antworten kamen vom Login-Captcha/Rate-Limit; mit curl liefern x' AND 1=1 -- und x' AND 1=2 -- identische 401. In rules.tsv/hooks.py dokumentiert. |
| Medium | CSP: Wildcard Directive / style-src unsafe-inline / Failure to Define Directive (10055) | /s/, /p/ | Vollständige CSP seit dem CSP-Ticket (default-src 'none', Script-Nonce, form-action 'none', Permissions-Policy). Übrig und bewusst: img-src https: (Bilder der Bildauswahl und Theme-Logos sind beliebige https-URLs; http: nur im HTTP-Teststack), style-src 'unsafe-inline' (Lit-Templates, Custom-CSS im Shadow DOM), frame-ancestors * bei uneingeschränkten Umfragen (Einbetten überall ist der Zweck). Akzeptiert. |
| Medium | CSP: Wildcard Directive / style-src unsafe-inline (10055) | Dashboard | Bewusst: img-src https: für Bilder aus Bildauswahl-Fragen, style-src 'unsafe-inline' für PrimeVue. Akzeptiert. |
| Medium | CSP: style-src unsafe-inline (10055) | /r/ | Bewusst: die Balken der Share-Seite sind Inline-Styles; die Seite hat kein JavaScript. Akzeptiert. |
| Medium | CSP Header Not Set (10038) | API-Antworten (JSON) | Für JSON ohne Wirkung. Akzeptiert. |
| Low | Permissions-Policy fehlt (10063), Cross-Origin-Resource-/Embedder-/Opener-Policy (90004) | API-Seiten, /s/, /p/ | Permissions-Policy fehlt nur noch auf API-Antworten (/, robots.txt, sitemap.xml: JSON/404). COEP/COOP/CORP auf /s/, /p/ bewusst nicht: COEP würde fremde Bilder ohne CORP blockieren, /p/ soll von fremden Seiten geladen werden. Akzeptiert. |
| Low | Unexpected Content-Type (100001) | 404/405 auf erfundenen Pfaden | HTML-Fehlerseiten für Requests ohne Accept: application/json. Akzeptiert. |
| – | Cross-Domain JavaScript (10017), Sub Resource Integrity fehlt (90003) | /s/, /p/ | Fehlalarm/bewusst: embed.js kommt vom eigenen Embed-CDN; der Alias /v1/embed.js ändert sich mit jedem Release, versionierte Dateien haben Hashes in integrity.json. |
| – | Timestamp Disclosure (10096) | Captcha, expiresAt | Fehlalarm: gewollte API-Daten. |
Vorher zusätzlich gefunden und behoben:
| Befund | Korrektur | Test |
|---|---|---|
POST /api/auth/login ohne JSON-Body (Formular, leer) → 500 (LogicException, CRITICAL im Log) | 400 „Expected a JSON body …“ | LoginTest::testRequestsWithoutJsonBodyAreRejectedWith400 |
Missing Anti-clickjacking Header auf /s/: Umfragen, deren Embeds alle auf bestimmte Origins beschränkt sind, ließen sich über den Direktlink trotzdem überall einbetten | /s/ sendet frame-ancestors wie /p/ (aus den Embed-Konfigurationen), plus base-uri 'none'; object-src 'none' | DirectLinkTest::testFramingFollowsTheEmbedConfigurations |
Missing Anti-clickjacking Header + CSP Header Not Set auf /r/ | strikte CSP: default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none' | ShareLinkTest |
| X-Content-Type-Options fehlte auf JSON-Antworten, kein HSTS vom API | FrankenPHP-Caddyfile setzt X-Content-Type-Options: nosniff und Strict-Transport-Security: max-age=31536000, sofern die Anwendung sie nicht selbst setzt | ci/security/checks.js |
Gezielte Prüfungen
ci/security/checks.js (56 Prüfungen, alle grün):
- CORS: Dashboard-API nur für die Dashboard-Origin (mit Credentials), fremde Origins ohne
Access-Control-Allow-Origin; öffentliches API v1 ohne CORS; Collector für offene Umfragen jede Origin, nie mit Credentials; eingeschränkte Embeds lehnen fremde Origins ab (Preflight ohne ACAO, Request 403origin_not_allowed). - Framing:
/p/frame-ancestors *bzw. nur die erlaubten Origins;/s/folgt den Embed-Konfigurationen;/r/und Dashboard nie (frame-ancestors 'none', Dashboard zusätzlichX-Frame-Options: DENY). - Header: Dashboard mit CSP, HSTS, nosniff, Referrer-Policy, ohne
Server; API und Direktlink mit nosniff und HSTS; keine Stacktraces, kein Profiler. - Cookies: Refresh-Cookie
HttpOnly; Secure; SameSite=Strict; Path=/api/auth; Direktlink setzt keine Cookies. - Rate-Limits (429 ab Versuch): Login 6, Registrierung 11, Passwort vergessen 21, Collector schreiben 61 / Events 121 / lesen 301 (je IP und Umfrage), Share-Link-Passwort 11, Public-API 601 pro Key und Minute.
- Open Redirects: OAuth-
returnTo(//evil, absolute URLs,/\, CRLF) führt nie auf fremde Hosts; Export-Download mit gefälschter Signatur → 403 ohne Weiterleitung. - SSRF: Webhook-Ziele
http://,127.0.0.1,localhost,10.0.0.5,169.254.169.254,[::1]undfile://werden beim Speichern mit 422 abgelehnt. Hostnamen (auch interne wieapiund Schreibweisen wie0x7f000001, die kein IP-Literal sind) werden erst vor jeder Zustellung aufgelöst: Das Speichern gelingt, das Testereignis endet mit „Blocked: …“ ohne Request. Die Zustellung pinnt die geprüfte IP und folgt keinen Weiterleitungen.
Externer Pentest (Empfehlung)
Vor dem öffentlichen Launch einen externen Pentest beauftragen (Grey-Box, ca. 5–8 Personentage), Schwerpunkte:
- Mandantentrennung: Organisationswechsel per
X-Organization-Id, Rollen/Permissions, IDOR auf allen/api/*-Ressourcen, API-Keys mit Scopes, Export- und Share-Links. - Authentifizierung: Refresh-Token-Rotation, 2FA, OAuth (Account-Linking,
state/PKCE), Passwort-Reset und E-Mail-Wechsel. - Öffentliche Fläche: Collector (Session-Tokens, Identity-Tokens, Dedup-Umgehung, Captcha), Embed im fremden DOM (XSS über Umfrageinhalte, Themes, Markdown), oEmbed, Badges.
- Infrastruktur: CapRover/nginx, TLS, Header auf allen Subdomains, Mercure (anonyme Subscriber, private Topics), Gotenberg/Webhook-SSRF, Backups.
Grundlage: diese Seite, die OpenAPI-Dokumente und ein Staging-Zugang mit Testkonten.
Partitionssperren (12.10.2026)
Umgesetzt (44be27a, Details backend/src/Collect/README.md „Partition pruning and locks“): konstante Schranken auf dem Partitionsschlüssel in allen heißen Abfragen (Pruning schon beim Planen, 1–2 Partitionen je Tabelle statt aller), Mandanten-Lookup per dynamischem SQL, fünf Indizes weniger auf den partitionierten Tabellen, nur noch zwei Monate Vorlauf.
Vergleich: „vorher“ = main (542f150) mit zurückgenommenem Commit 44be27a (Code und Migration; alles andere – asynchrone Commits, getrennte Redis-Instanzen, PostgreSQL-Tuning, Mercure transport local, gebündelte Live-Ergebnisse – identisch), „nachher“ = main. Beide mit WORKER_REPLICAS=4 und HISTORY_MONTHS=12 (ci/load/history.sql: 12 vergangene Monatspartitionen mit je 20 000 Antworten, 40 000 Antworten-Zeilen und 60 000 Events – wie nach einem Jahr Betrieb; damit 16 Partitionen je Tabelle in beiden Varianten), 1 min Rampe
- 3 min Plateau, Läufe abwechselnd. Wartezustände = Summe aktiver PostgreSQL-Sessions über alle 5-s-Stichproben (
pg-activity.tsv, ~43 Stichproben je Lauf); Last = Lastmittel (1 min) des Rechners während des Laufs (Median/Max, inklusive des Stacks selbst; 24 Threads).
| Lauf | Ziel | Stand | LWLock:LockManager (Stichproben > 0, max. gleichzeitig) | CPU (aktiv) | andere Waits¹ | p50 | p95 | p99 | s mit p95 > 500 ms (längste Phase) | erreicht² | PostgreSQL Kerne³ | Last (Median/Max) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 500/s | vorher | 306 (24, 27) | 465 | 51 | 281 ms | 2,09 s | 3,52 s | 114 (113 s) | 94 832 | 10,2 | 30,9 / 59,5 |
| 2 | 500/s | nachher | 26 (7, 10) | 326 | 60 | 11 ms | 208 ms | 277 ms | 0 | 109 419 | 8,4 | 30,4 / 64,2 |
| 3 | 500/s | nachher | 26 (7, 9) | 360 | 74 | 835 ms | 1,87 s⁴ | 2,61 s | 201 (163 s)⁴ | 92 573 | 7,1 | 70,8 / 82,8 |
| 4 | 500/s | vorher | 362 (25, 26) | 452 | 37 | 936 ms | 1,78 s | 3,29 s | 208 (212 s) | 91 541 | 9,6 | 40,5 / 59,5 |
| 9 | 500/s | nachher | 0 | 239 | 61 | 4,0 ms | 17 ms | 34 ms | 0 | 109 499 | 8,0 | 12,8 / 28,8 |
| 10 | 500/s | vorher | 335 (26, 26) | 469 | 48 | 630 ms | 1,26 s | 1,88 s | 124 (124 s) | 99 907 | 10,6 | 32,3 / 55,1 |
| 11 | 500/s | vorher | 354 (26, 26) | 474 | 45 | 304 ms | 708 ms | 758 ms | 66 (64 s) | 107 972 | 11,3 | 30,0 / 51,7 |
| 12 | 500/s | nachher | 0 | 248 | 69 | 3,8 ms | 16 ms | 31 ms | 0 | 109 499 | 8,1 | 19,6 / 36,5 |
| 5 | 250/s | vorher | 16 (3, 14) | 174 | 41 | 3,3 ms | 242 ms | 526 ms | 6 (6 s) | 54 494 | 5,4 | 18,8 / 31,9 |
| 6 | 250/s | nachher | 3 (1, 3) | 103 | 39 | 3,4 ms | 33 ms | 71 ms | 0 | 54 764 | 3,8 | 17,4 / 56,7 |
| 7 | 250/s | nachher | 0 | 101 | 20 | 2,2 ms | 7,6 ms | 9,3 ms | 0 | 54 764 | 3,0 | 8,0 / 16,1 |
| 8 | 250/s | vorher | 0 | 120 | 34 | 2,6 ms | 12 ms | 14 ms | 0 | 54 764 | 4,6 | 7,8 / 10,5 |
Reihenfolge: 1–8 nacheinander, danach 9–12 (ruhigerer Rechner). ¹ Summe aller übrigen Waits, fast nur Client:ClientRead (17–52, Sessions zwischen zwei Statements einer Transaktion) und Lock:transactionid (0–20, Monatszähler der Organisation); IO:WalSync, WALWrite, BufferContent je ≤ 8. ² Abgeschlossene Iterationen (Stimmen) inkl. Rampe; Soll 109 499 bzw. 54 764, der Rest wurde verworfen, weil die VUs in Stauphasen festhingen. ³ Mittel des oberen Zwei-Drittels der docker stats-Stichproben (≈ Plateau). ⁴ Lauf 3 lief, während andere Prozesse den Rechner auslasteten (Lastmedian 71 statt 13–40; parallel u. a. Desktop-Indexer und Builds anderer Arbeitsverzeichnisse): Stau durch CPU-Mangel, nicht durch Sperren (nur 26 LockManager-Stichproben). Fehlerquote überall 0 %, Rollup-Prüfung in allen 12 Läufen exakt, Rollup-Verzug 7–31 s, keine Ack-Fehler, keine Worker-Neustarts.
Ergebnis bei 500 Stimmen/s: LWLock:LockManager sinkt von 306–362 auf 0–26 (−93 bis −100 %). Vorher warteten in praktisch jeder Plateau-Stichprobe ~26 Sessions auf die Lock-Manager-Partitionen – mehr als die Hälfte aller aktiven Sessions; nachher tritt der Wait nur noch vereinzelt auf. Damit verschwinden die Stauphasen: In allen vier Vorher-Läufen 66–212 s mit p95 > 500 ms (p95 0,7–2,1 s, bis 18 000 verworfene Stimmen), in den Nachher-Läufen auf ruhigem Rechner keine (p95 16–17 ms, p99 31–34 ms, volle Rate). PostgreSQL braucht nachher ~8 statt ~10–11 Kerne, die aktiven Sessions (CPU) halbieren sich etwa (239–360 statt 452–474). Bei 250 Stimmen/s spielt der Lock-Manager kaum eine Rolle (vorher 0–16, nachher 0–3); die Nachher-Läufe sind trotzdem etwas schneller (p95 7,6 statt 12 ms im ruhigen Paar 7/8, PostgreSQL 3,0–3,8 statt 4,6–5,4 Kerne: weniger Partitionen geplant und gesperrt, weniger Indizes gepflegt).
Offene Punkte
Als Tickets erfasst:
Rollup-Pipeline: Nachrichtenmenge reduzieren, Rollup-Nachrichten bündeln.(erledigt, siehe oben)Live-Ergebnisse: Mercure-Transport ohne fsync pro Update und Ergebnisabfrage pro Publish verbilligen.(erledigt, siehe Live-Ergebnisse und Mercure)- 500 Stimmen/s mit Workern auf der Zielhardware (8 vCPU) wiederholen.
Eigener Transport mit Priorität für Mails und Webhooks.(erledigt)Worker skalierbar machen: eindeutige Consumer-Namen, Scheduler in eigenem Container.(erledigt)- Anomalie-Erkennung für virale Umfragen anpassen (
survey_spike). - Rate-Limit pro Organisation konfigurierbar/planabhängig.
Stauphasen: PostgreSQL-Tuning (WAL), Redis für Queue und Cache trennen.(erledigt, siehe Stauphasen: WAL und Redis)Lock-Manager-Konkurrenz auf den Partitionen bei 500 Stimmen/s: obere Schranke für(erledigt, siehe Partitionssperren)started_atin Collector-Abfragen bzw. PostgreSQL 18.- Lasttest auf der Zielhardware (CPX41) wiederholen.