Zum Hauptinhalt springen

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

SchichtProblemDiagnose-Befehl
PostgreSQLSchwere Queries (Rollups, Explore) belasten DBpg_stat_activity (siehe unten)
Queue Workers10+ ai-detection Worker + Scheduled Commands lasten DB ausps aux | grep artisan
PHP-FPMWorker warten auf langsame DB-Queriessystemctl status php8.4-fpm
nginxKeine freien FPM-Worker → queued → 502tail /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

SettingDefault (Forge)Empfohlen (48 Kerne)Beschreibung
pmdynamicdynamicPool-Modus
pm.max_children20150Max. gleichzeitige Worker
pm.start_servers230Worker beim Start
pm.min_spare_servers115Mindest-Idle-Worker
pm.max_spare_servers350Max. Idle-Worker
pm.max_requests0 (unlimited)1000Requests 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 = 1000 verhindert 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

FehlerUrsacheLoesung
connect() to unix:/run/php/php-fpm.sock failed (11: Resource temporarily unavailable)FPM-Worker erschoepftmax_children erhoehen
upstream timed out (110: Connection timed out)FPM-Worker haengt in DB-QueryPostgreSQL-Last reduzieren
no live upstreamsPHP-FPM nicht erreichbarsystemctl restart php8.4-fpm
worker_connections are not enoughnginx-Limit erreichtworker_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:

CommandSchedule (UTC)Typische LaufzeitDB-Impact
dashboard:rollup-daily-metrics00:45~2 StundenSehr hoch (DISTINCT ON, SUM, JOINs)
dashboard:rollup-leaderboards02:4510-30 MinHoch
dashboard:rollup-global-leaderboards04:4510-30 MinHoch
scores:calculate05:4515-45 MinMittel-Hoch
explore:calculate --type=all06:0015-45 MinMittel-Hoch
social:scrape-daily-followers09:00, 11:00, ...30-60 MinMittel (viele kleine Queries)
social:queue-daily-instagram08:00, 10:00, ...5-15 MinNiedrig-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):

QueueAnzahl WorkerDB-Impact
ai-detection10Hoch (Gemini + DB pro Job)
imports-youtube1Niedrig
imports-youtube-priority1Niedrig
imports-youtube-video1Niedrig
imports-youtube-video-priority1Niedrig
youtube-related-channels1Niedrig
instagram-related-profiles1Niedrig-Mittel
cross-platform-related1Niedrig-Mittel
emails1Minimal
matomo1Minimal
default1Niedrig

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)

StufeAktionBefehlAuswirkung
1FPM neustartensudo systemctl restart php8.4-fpmAlle laufenden Requests abgebrochen
2nginx + FPM neustartensudo systemctl restart nginx php8.4-fpmAlle Connections getrennt
3Schwere Queries killenSELECT pg_terminate_backend(pid) ...Query wird abgebrochen, Job schlaegt fehl
4Scheduled Command stoppenkill <PID> des Artisan-ProzessesCommand wird abgebrochen
5Queue Worker neustartenphp artisan queue:restartGraceful 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-PatternWarum problematischSichere 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 ArraychunkById() mit Callback, oder Subquery
collect(DB::select(...))->pluck()Materialisiert grosses Resultset in PHPTemp-Table + JOIN, oder CTE
$localDisk->allFiles()Laedt alle Dateipfade (820K+) in ArrayRecursiveDirectoryIterator
$array[] = ... in chunkByIdAkkumuliert Daten ueber alle ChunksPro Chunk verarbeiten und freigeben
Fehlender Date-FilterLaedt historische Daten statt aktuellem FensterImmer 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)

  1. DB::disableQueryLog() — Global in AppServiceProvider::boot() fuer alle Console-Commands aktiv. Kein manuelles Disablen noetig.
  2. chunkById(N) statt get() — Fuer alle Queries ueber Tabellen mit >10K Rows.
  3. cursor() fuer Read-Only — Wenn Rows nur einmal gelesen werden (kein Update noetig).
  4. gc_collect_cycles() — Nach jedem Chunk bei Eloquent-Modellen (zirkulaere Referenzen).
  5. toBase() fuer leichtgewichtige Queries — Gibt stdClass statt Eloquent-Modell zurueck (~10x weniger Memory).
  6. Temp-Tables fuer grosse ID-Listen — Statt 100K+ IDs in PHP-Array: CREATE TEMP TABLE, dann JOIN.
  7. unset($var) fuer grosse Variablen — Explizit freigeben wenn nicht mehr benoetigt.
  8. select(['id', 'name']) — Nur benoetigte Spalten laden, nie SELECT * 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/ServiceProblemFix
ExploreMetricsCalculatorFehlender Date-Filter (365x zu viele Rows)Exakter (id, date) IN (...) Lookup
AggregateVideoStatsTracking100K+ Video-IDs im ArrayTemp-Table + JOIN
RecalculateRelatedScores448K Profile-IDs via pluck()chunkById(1000)
ConsolidateTags1M+ Profiles via get()cursor() + SQL-Filter
VerifyR2Storage820K Dateipfade via allFiles()RecursiveDirectoryIterator
IndexNowSubmitUpdated60K URLs akkumuliertChunk-weiser Submit
QueueDailyInstagramScrapes200K Profile-IDs via pluck()count() (IDs nicht benoetigt)
RetryFailedCollectorJobsAlle Failed Jobs via get()SQL-Aggregation + chunkById()
RetryFailedQueueJobsAlle Job-IDs via pluck()chunkById(500)
PruneWatchersToMaxOrphans mit eager-loaded ImageschunkById(100) + GC
ServerRollupHourlyget() im While-Loop (8760x)SQL-Aggregation
UpdateKeywordStopwordspluck()->all()cursor()->each()
AggregatePublishingStatsEloquent + get() + groupBy()Cursor + leichtgewichtige Arrays
RunNightlyPipelineTrending-Job-Array ohne GCgc_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:

  1. pm.max_children auf mindestens 150 erhoehen
  2. 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