Troubleshooting
Diagnose- und Notfall-Anleitungen fuer nginx, PHP-FPM und PostgreSQL auf dem Forge-Server.
PHP-FPM
Symptome: nginx antwortet nicht / 502 Bad Gateway
Wenn nginx keine Requests mehr annimmt, obwohl der Server CPU-seitig nicht ausgelastet ist, liegt das fast immer an PHP-FPM Worker Exhaustion: Alle Worker haengen in langsamen DB-Queries und es bleibt keiner fuer neue Requests.
graph TD
A["Browser-Request / Livewire wire:poll"] --> B[nginx]
B --> C{PHP-FPM Worker frei?}
C -->|Ja| D["Worker verarbeitet Request"]
C -->|Nein| E["nginx queued Request"]
E --> F["Timeout → 502 Bad Gateway"]
D --> G["DB-Query an PostgreSQL"]
G -->|"DB unter Last (5-8s statt <100ms)"| H["Worker blockiert"]
H -->|"Alle 20 Worker blockiert"| C
Diagnose
# 1. FPM-Status pruefen (active vs. idle Workers)
sudo systemctl status php8.4-fpm
# Ausgabe: "Processes active: 20, idle: 0" → Problem!
# 2. Aktive FPM-Worker zaehlen
ps aux | grep php-fpm | grep -c pool
# 3. Laufzeit der Worker pruefen (Worker die Stunden laufen sind STUCK)
ps -eo pid,etime,args | grep "php-fpm: pool" | grep -v grep
# Normal: Sekunden bis Minuten. Kritisch: Worker > 1 Stunde
# 4. Pool-Konfiguration pruefen
grep -E "^pm\." /etc/php/8.4/fpm/pool.d/www.conf
# 5. nginx Error-Log auf Upstream-Timeouts pruefen
tail -50 /var/log/nginx/error.log | grep -i "upstream\|fpm\|502"
Ursachen-Kette
| Schicht | Problem | Diagnose-Befehl |
|---|---|---|
| PostgreSQL | Schwere Queries (Rollups, Explore) belasten DB | pg_stat_activity (siehe unten) |
| Queue Workers | 10+ ai-detection Worker + Scheduled Commands lasten DB aus | ps aux | grep artisan |
| PHP-FPM | Worker warten auf langsame DB-Queries | systemctl status php8.4-fpm |
| nginx | Keine freien FPM-Worker → queued → 502 | tail /var/log/nginx/error.log |
Sofortmassnahmen
# PHP-FPM neustarten (killt haengende Worker, schnellste Loesung)
sudo systemctl restart php8.4-fpm
# Oder: nginx + FPM zusammen neustarten
sudo systemctl restart nginx php8.4-fpm
FPM Pool-Konfiguration optimieren
Location: /etc/php/8.4/fpm/pool.d/www.conf
| Setting | Default (Forge) | Empfohlen (48 Kerne) | Beschreibung |
|---|---|---|---|
pm | dynamic | dynamic | Pool-Modus |
pm.max_children | 20 | 150 | Max. gleichzeitige Worker |
pm.start_servers | 2 | 30 | Worker beim Start |
pm.min_spare_servers | 1 | 15 | Mindest-Idle-Worker |
pm.max_spare_servers | 3 | 50 | Max. Idle-Worker |
pm.max_requests | 0 (unlimited) | 1000 | Requests pro Worker vor Respawn (Memory-Leak-Schutz) |
# Anpassen
sudo nano /etc/php/8.4/fpm/pool.d/www.conf
# Aenderungen anwenden
sudo systemctl restart php8.4-fpm
# Verifizieren
sudo systemctl status php8.4-fpm
Faustregeln:
max_children= Verfuegbarer RAM (GB) / 0.1 GB (bei ~100 MB pro Worker)- Auf einem 48-Kern-Server mit 64 GB RAM:
max_children = 150-300 pm.max_requests = 1000verhindert Memory-Bloat durch langlebige Worker
Alte PHP-Versionen bereinigen
Forge installiert bei Upgrades die neue PHP-Version, stoppt aber nicht immer die alte:
# Pruefen ob mehrere PHP-FPM Master laufen
ps -eo pid,etime,args | grep "php-fpm: master"
# Beispiel-Output:
# 3862716 1-03:50:48 php-fpm: master process (/etc/php/8.3/fpm/php-fpm.conf) ← ALT
# 3862728 1-03:50:48 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf) ← AKTIV
# Alte Version stoppen und deaktivieren
sudo systemctl stop php8.3-fpm
sudo systemctl disable php8.3-fpm
nginx
Symptome: Seite laedt nicht, aber Server reagiert via SSH
# 1. nginx-Status pruefen
sudo systemctl status nginx
# 2. Error-Log pruefen
tail -100 /var/log/nginx/error.log
# 3. Offene Verbindungen zaehlen
ss -s
# oder spezifisch:
ss -tnp | grep :443 | wc -l
# 4. nginx-Konfiguration testen
sudo nginx -t
Haeufige Fehler im Error-Log
| Fehler | Ursache | Loesung |
|---|---|---|
connect() to unix:/run/php/php-fpm.sock failed (11: Resource temporarily unavailable) | FPM-Worker erschoepft | max_children erhoehen |
upstream timed out (110: Connection timed out) | FPM-Worker haengt in DB-Query | PostgreSQL-Last reduzieren |
no live upstreams | PHP-FPM nicht erreichbar | systemctl restart php8.4-fpm |
worker_connections are not enough | nginx-Limit erreicht | worker_connections in nginx.conf erhoehen |
nginx Worker-Konfiguration
Location: /etc/nginx/nginx.conf
# Empfohlen fuer 48-Kern-Server:
worker_processes auto; # = Anzahl CPU-Kerne
worker_connections 4096; # Pro Worker (Default: 768)
nginx-Neustart
# Graceful Reload (bestehende Connections laufen aus)
sudo systemctl reload nginx
# Hard Restart (trennt alle Connections sofort)
sudo systemctl restart nginx
PostgreSQL
Symptome: Seite langsam, FPM-Worker haengen
Wenn Seiten 5-10+ Sekunden laden oder gar nicht antworten, sind oft schwere PostgreSQL-Queries die Ursache — ausgeloest durch Scheduled Commands oder Queue-Worker.
Aktive Queries anzeigen
-- Alle aktiven Queries mit Laufzeit (laengste zuerst)
SELECT
pid,
now() - pg_stat_activity.query_start AS duration,
state,
wait_event_type,
LEFT(query, 200) AS query
FROM pg_stat_activity
WHERE state != 'idle'
AND datname = 'postbox_db3_prod'
ORDER BY duration DESC;
# Als One-Liner via psql
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT pid, now() - query_start AS duration, state, LEFT(query, 200) AS query
FROM pg_stat_activity WHERE state != 'idle' AND query NOT LIKE '%pg_stat%'
ORDER BY duration DESC LIMIT 20;
"
Oder per Artisan (DB-Monitoring)
# Top 50 langsamste Queries erfassen
php artisan db:snapshot --slow-queries --top=50
Connections zaehlen
-- Gesamte Connection-Uebersicht
SELECT
state,
COUNT(*) as count,
COALESCE(usename, 'system') as user
FROM pg_stat_activity
GROUP BY state, usename
ORDER BY count DESC;
-- Maximale Connections pruefen
SHOW max_connections;
-- Default: 200. Bei Exhaustion: "FATAL: too many connections"
Blockierte Queries identifizieren
-- Queries die auf Locks warten
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
AND kl.database IS NOT DISTINCT FROM bl.database
AND kl.relation IS NOT DISTINCT FROM bl.relation
AND kl.page IS NOT DISTINCT FROM bl.page
AND kl.tuple IS NOT DISTINCT FROM bl.tuple
AND kl.pid != bl.pid
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE NOT bl.granted;
Parallel Workers erkennen
PostgreSQL startet bei grossen Queries automatisch Parallel Workers. Diese erscheinen in top als separate Prozesse mit 100% CPU:
-- Parallel Workers und ihre Parent-Queries identifizieren
SELECT
pid,
leader_pid,
state,
LEFT(query, 200) AS query,
now() - query_start AS duration
FROM pg_stat_activity
WHERE leader_pid IS NOT NULL
ORDER BY leader_pid, pid;
Schwere Queries abbrechen
# Einzelne Query beenden (graceful)
SELECT pg_cancel_backend(<PID>);
# Einzelne Query beenden (force — trennt Connection)
SELECT pg_terminate_backend(<PID>);
# ALLE Queries die laenger als 3 Stunden laufen beenden
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '3 hours'
AND query NOT LIKE '%pg_stat%';
# ALLE Queries die laenger als X Minuten laufen beenden
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '30 minutes'
AND query NOT LIKE '%pg_stat%';
Haeufige Last-Verursacher (Scheduled Commands)
Diese Commands erzeugen die schwersten DB-Queries. Wenn mehrere gleichzeitig laufen, kann die DB ueberlastet werden:
| Command | Schedule (UTC) | Typische Laufzeit | DB-Impact |
|---|---|---|---|
dashboard:rollup-daily-metrics | 00:45 | ~2 Stunden | Sehr hoch (DISTINCT ON, SUM, JOINs) |
dashboard:rollup-leaderboards | 02:45 | 10-30 Min | Hoch |
dashboard:rollup-global-leaderboards | 04:45 | 10-30 Min | Hoch |
scores:calculate | 05:45 | 15-45 Min | Mittel-Hoch |
explore:calculate --type=all | 06:00 | 15-45 Min | Mittel-Hoch |
social:scrape-daily-followers | 09:00, 11:00, ... | 30-60 Min | Mittel (viele kleine Queries) |
social:queue-daily-instagram | 08:00, 10:00, ... | 5-15 Min | Niedrig-Mittel |
# Pruefen welche Commands aktuell laufen
ps aux | grep artisan | grep -v grep | grep -v "queue:work\|pulse:\|reverb:\|nightwatch:"
Queue Worker DB-Last
Queue Worker halten permanente DB-Connections. Bei vielen Workern summiert sich die Last:
# Alle Queue Worker zaehlen
ps aux | grep "queue:work" | grep -v grep | wc -l
# Worker nach Queue gruppiert zaehlen
ps aux | grep "queue:work" | grep -v grep | awk -F'--queue=' '{print $2}' | sort | uniq -c | sort -rn
Typische Worker-Verteilung (Postbox):
| Queue | Anzahl Worker | DB-Impact |
|---|---|---|
ai-detection | 10 | Hoch (Gemini + DB pro Job) |
imports-youtube | 1 | Niedrig |
imports-youtube-priority | 1 | Niedrig |
imports-youtube-video | 1 | Niedrig |
imports-youtube-video-priority | 1 | Niedrig |
youtube-related-channels | 1 | Niedrig |
instagram-related-profiles | 1 | Niedrig-Mittel |
cross-platform-related | 1 | Niedrig-Mittel |
emails | 1 | Minimal |
matomo | 1 | Minimal |
default | 1 | Niedrig |
Tabellen-Groesse pruefen
# Top 20 groesste Tabellen
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid)) as total,
pg_size_pretty(pg_relation_size(relid)) as data,
n_live_tup as live_rows,
n_dead_tup as dead_rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 20;
"
Dead Tuples und Autovacuum
# Tabellen mit vielen Dead Tuples (brauchen VACUUM)
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT relname, n_dead_tup, last_vacuum, last_autovacuum,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) as dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
"
# Manuelles VACUUM (non-blocking, markiert Platz als wiederverwendbar)
VACUUM ANALYZE <table_name>;
# VACUUM FULL (blocking, gibt Platz ans OS zurueck — nur bei grossem Bloat)
VACUUM FULL <table_name>;
Index-Nutzung pruefen
-- Tabellen mit schlechter Index-Nutzung (viele Seq-Scans)
SELECT relname,
seq_scan,
idx_scan,
ROUND(100.0 * idx_scan / NULLIF(seq_scan + idx_scan, 0), 1) as idx_pct
FROM pg_stat_user_tables
WHERE seq_scan + idx_scan > 1000
ORDER BY seq_scan DESC
LIMIT 20;
Kompletter Diagnose-Workflow
Bei "Seite laedt nicht" oder "502 Bad Gateway" systematisch vorgehen:
graph TD
A["502 / Seite laedt nicht"] --> B["1. PHP-FPM Status"]
B --> C{"active: 20, idle: 0?"}
C -->|Ja| D["FPM Worker exhausted"]
C -->|Nein| E["nginx Error-Log pruefen"]
D --> F["2. Worker-Laufzeit pruefen"]
F --> G{"Worker > 1h?"}
G -->|Ja| H["Worker haengen in DB-Queries"]
G -->|Nein| I["Kurzfristige Last-Spitze"]
H --> J["3. pg_stat_activity pruefen"]
J --> K{"Schwere Queries aktiv?"}
K -->|Ja| L["4. Verursacher identifizieren"]
K -->|Nein| M["Connection-Pool oder Lock-Problem"]
L --> N["ps aux | grep artisan"]
N --> O["Scheduled Command oder Queue Worker"]
O --> P["Sofortmassnahmen"]
P --> P1["sudo systemctl restart php8.4-fpm"]
P --> P2["max_children erhoehen"]
P --> P3["Ggf. schwere Queries abbrechen"]
I --> P1
Schritt-fuer-Schritt Checkliste
# 1. PHP-FPM Status (idle Workers?)
sudo systemctl status php8.4-fpm
# 2. FPM Worker Laufzeiten (> 1h = stuck)
ps -eo pid,etime,args | grep "php-fpm: pool" | grep -v grep
# 3. Aktive DB-Queries (was blockiert die Worker?)
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT pid, now() - query_start AS duration, state, LEFT(query, 200) AS query
FROM pg_stat_activity WHERE state != 'idle' AND query NOT LIKE '%pg_stat%'
ORDER BY duration DESC LIMIT 20;
"
# 4. Laufende Artisan-Commands (Scheduled Jobs?)
ps aux | grep artisan | grep -v grep | grep -v "queue:work\|pulse:\|reverb:\|nightwatch:"
# 5. Queue Worker zaehlen
ps aux | grep "queue:work" | grep -v grep | wc -l
# 6. nginx Error-Log
tail -50 /var/log/nginx/error.log
# 7. Connections-Uebersicht
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT state, COUNT(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;
"
Sofortmassnahmen (Eskalationsstufen)
| Stufe | Aktion | Befehl | Auswirkung |
|---|---|---|---|
| 1 | FPM neustarten | sudo systemctl restart php8.4-fpm | Alle laufenden Requests abgebrochen |
| 2 | nginx + FPM neustarten | sudo systemctl restart nginx php8.4-fpm | Alle Connections getrennt |
| 3 | Schwere Queries killen | SELECT pg_terminate_backend(pid) ... | Query wird abgebrochen, Job schlaegt fehl |
| 4 | Scheduled Command stoppen | kill <PID> des Artisan-Prozesses | Command wird abgebrochen |
| 5 | Queue Worker neustarten | php artisan queue:restart | Graceful Restart aller Worker |
Praeventive Massnahmen
PHP-FPM: Ausreichend Worker konfigurieren
Siehe FPM Pool-Konfiguration optimieren weiter oben.
PostgreSQL: Statement Timeout setzen
Verhindert, dass einzelne Queries die DB stundenlang blockieren:
-- Global: Queries nach 5 Minuten abbrechen
ALTER DATABASE postbox_db3_prod SET statement_timeout = '300s';
-- Oder nur fuer den web-User (FPM-Requests)
ALTER ROLE forge SET statement_timeout = '60s';
-- Queue-Worker-Queries brauchen laenger:
-- → Statement Timeout nur fuer FPM sinnvoll, nicht global
PostgreSQL: Connection Limits
-- Maximale Connections pruefen
SHOW max_connections; -- Default: 200
-- Aktuelle Nutzung
SELECT COUNT(*) FROM pg_stat_activity;
-- Per-User-Limit setzen (optional)
ALTER ROLE forge CONNECTION LIMIT 100;
Monitoring-Endpunkt
Der Health-Endpunkt /up_system prueft automatisch DB, Queue und Cron-Heartbeats:
curl -s -H "X-Health-Token: <token>" https://app.postbox.so/up_system
Location: app/Http/Controllers/SystemHealthController.php
PHP Memory-Limit: OOM bei Artisan Commands
Hintergrund
Der Server hat ein PHP memory_limit von 128 MB. Bei ~1M Social Profiles, Millionen Daily Metrics und Hunderttausenden YouTube Videos koennen Artisan Commands leicht an diese Grenze stossen.
Haeufige OOM-Ursachen
| Anti-Pattern | Warum problematisch | Sichere Alternative |
|---|---|---|
$query->get() | Laedt alle Rows als Eloquent-Modelle (~200 Bytes/Stueck) | chunkById(1000, ...) oder cursor() |
$query->pluck('id')->all() | Laedt alle IDs in ein Array | chunkById() mit Callback, oder Subquery |
collect(DB::select(...))->pluck() | Materialisiert grosses Resultset in PHP | Temp-Table + JOIN, oder CTE |
$localDisk->allFiles() | Laedt alle Dateipfade (820K+) in Array | RecursiveDirectoryIterator |
$array[] = ... in chunkById | Akkumuliert Daten ueber alle Chunks | Pro Chunk verarbeiten und freigeben |
| Fehlender Date-Filter | Laedt historische Daten statt aktuellem Fenster | Immer WHERE date >= ? hinzufuegen |
Diagnose
# Memory-Limit pruefen
php -i | grep memory_limit
# Artisan Command mit Memory-Tracking ausfuehren
php -d memory_limit=128M artisan <command> 2>&1
# Memory-Peak eines laufenden Commands beobachten
watch -n1 'ps -o pid,rss,command -p $(pgrep -f "artisan <command>")'
Praeventive Patterns (Pflicht bei neuen Commands)
DB::disableQueryLog()— Global inAppServiceProvider::boot()fuer alle Console-Commands aktiv. Kein manuelles Disablen noetig.chunkById(N)stattget()— Fuer alle Queries ueber Tabellen mit >10K Rows.cursor()fuer Read-Only — Wenn Rows nur einmal gelesen werden (kein Update noetig).gc_collect_cycles()— Nach jedem Chunk bei Eloquent-Modellen (zirkulaere Referenzen).toBase()fuer leichtgewichtige Queries — GibtstdClassstatt Eloquent-Modell zurueck (~10x weniger Memory).- Temp-Tables fuer grosse ID-Listen — Statt 100K+ IDs in PHP-Array:
CREATE TEMP TABLE, dann JOIN. unset($var)fuer grosse Variablen — Explizit freigeben wenn nicht mehr benoetigt.select(['id', 'name'])— Nur benoetigte Spalten laden, nieSELECT *bei grossen Tabellen.
Referenz: Durchgefuehrte Fixes (2026-03-23)
Alle 99+ Commands wurden in zwei Deep Audits auf OOM-Risiken geprueft. 15 Commands/Services wurden gefixt:
| Command/Service | Problem | Fix |
|---|---|---|
ExploreMetricsCalculator | Fehlender Date-Filter (365x zu viele Rows) | Exakter (id, date) IN (...) Lookup |
AggregateVideoStatsTracking | 100K+ Video-IDs im Array | Temp-Table + JOIN |
RecalculateRelatedScores | 448K Profile-IDs via pluck() | chunkById(1000) |
ConsolidateTags | 1M+ Profiles via get() | cursor() + SQL-Filter |
VerifyR2Storage | 820K Dateipfade via allFiles() | RecursiveDirectoryIterator |
IndexNowSubmitUpdated | 60K URLs akkumuliert | Chunk-weiser Submit |
QueueDailyInstagramScrapes | 200K Profile-IDs via pluck() | count() (IDs nicht benoetigt) |
RetryFailedCollectorJobs | Alle Failed Jobs via get() | SQL-Aggregation + chunkById() |
RetryFailedQueueJobs | Alle Job-IDs via pluck() | chunkById(500) |
PruneWatchersToMax | Orphans mit eager-loaded Images | chunkById(100) + GC |
ServerRollupHourly | get() im While-Loop (8760x) | SQL-Aggregation |
UpdateKeywordStopwords | pluck()->all() | cursor()->each() |
AggregatePublishingStats | Eloquent + get() + groupBy() | Cursor + leichtgewichtige Arrays |
RunNightlyPipeline | Trending-Job-Array ohne GC | gc_collect_cycles() pro Chunk |
Bekannte Probleme
FPM-Worker haengen waehrend naechtlicher Rollups (00:45-07:00 UTC)
Problem: Die naechtlichen Dashboard-Rollups (dashboard:rollup-daily-metrics, ~2h) belasten PostgreSQL stark. Livewire wire:poll Requests, die waehrenddessen eintreffen, brauchen 5-10s statt <100ms. Bei nur 20 FPM-Workern werden alle Worker blockiert.
Loesung:
pm.max_childrenauf mindestens 150 erhoehen- Langfristig: Rollup-Queries optimieren (Indizes, Partitioning)
YouTube-Worker mit --tries=3 statt --tries=0
Problem: Forge-Worker fuer YouTube-Queues laufen mit --tries=3. Jeder release() Aufruf (z.B. bei Quota-Pause) zaehlt als Versuch. Nach 3 Releases → MaxAttemptsExceededException.
Loesung: Worker-Config in Forge auf --tries=0 aendern (Jobs steuern Retries selbst via $tries, $maxExceptions, retryUntil()).
# RICHTIG (Jobs steuern Retries selbst):
php8.4 artisan queue:work database --tries=0 --queue=imports-youtube
# FALSCH (release() zaehlt als Versuch):
php8.4 artisan queue:work database --tries=3 --queue=imports-youtube
Doppelte PHP-FPM Master-Prozesse nach PHP-Upgrade
Problem: Nach einem PHP-Versions-Upgrade (z.B. 8.3 → 8.4) laeuft der alte FPM-Master-Prozess weiter und haelt Ressourcen.
Diagnose:
ps -eo pid,etime,args | grep "php-fpm: master"
Loesung:
sudo systemctl stop php8.3-fpm
sudo systemctl disable php8.3-fpm