Vorlagen in einem frischen Laravel 12 eingebaut (30 Tests grün), deploy.sh gegen ein simuliertes Repository durchgespielt (Deploy, Abbruch bei Migrationen, Drift, Rollback, fehlender Worker). GlitchTip-Aufbewahrung heißt seit Version 6 GLITCHTIP_RETENTION_DAYS — in der Projekt-Doku korrigiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
9 KiB
Markdown
222 lines
9 KiB
Markdown
# 03 — Betriebsüberwachung: /health, Herzschlag, Worker, Alarme, Uptime Kuma
|
||
|
||
GlitchTip (02) sieht nur Fehler, die PHP noch melden kann. Dieser Teil deckt den
|
||
Rest ab: Scheduler steht, Worker steht, Platte voll, Server weg — und meldet es
|
||
aufs Handy.
|
||
|
||
---
|
||
|
||
## 1. Bausteine in der App
|
||
|
||
Dateien aus `vorlagen/laravel/` kopieren:
|
||
|
||
| Datei | Zweck |
|
||
|---|---|
|
||
| `config/monitoring.php` | Alarme (Mail, ntfy), Browser-DSN, Health-Token, Schwellen |
|
||
| `app/Support/OperationalAlert.php` | Alarm: immer `Log::critical` (Kanal `monitoring`), dazu Text-Mail und ntfy — unabhängig voneinander, Best-Effort |
|
||
| `app/Services/Monitoring/HealthProbe.php` | Prüfungen DB, Cache, Queue-Rückstau, Scheduler-Herzschlag, Plattenplatz |
|
||
| `app/Http/Controllers/HealthController.php` | `GET /health` mit Token → JSON, 200 oder 503 |
|
||
| `app/Console/Commands/MonitoringHeartbeat.php` | `monitoring:heartbeat`, minütlich vom Scheduler |
|
||
| `app/Console/Commands/MonitoringQueueProbe.php` + `app/Jobs/Monitoring/QueueProbe.php` | `monitoring:queue-probe`: Test-Job, wartet bis zum Abholen (Ende von `deploy.sh`) |
|
||
| `tests/Feature/Monitoring/HealthEndpointTest.php`, `QueueProbeTest.php` | Tests |
|
||
|
||
Log-Kanal `monitoring` in `config/logging.php` ergänzen (eigene Datei, rotierend):
|
||
|
||
```php
|
||
'monitoring' => [
|
||
'driver' => 'daily',
|
||
'path' => storage_path('logs/monitoring.log'),
|
||
'level' => 'debug',
|
||
'days' => 14,
|
||
],
|
||
```
|
||
|
||
### Verdrahtung
|
||
|
||
`bootstrap/app.php` — Route außerhalb der `web`-Gruppe (keine Session, kein CSRF):
|
||
|
||
```php
|
||
use App\Http\Controllers\HealthController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
->withRouting(
|
||
web: __DIR__.'/../routes/web.php',
|
||
commands: __DIR__.'/../routes/console.php',
|
||
health: '/up',
|
||
then: function (): void {
|
||
// Tiefer Health-Check für den externen Monitor
|
||
Route::get('/health', HealthController::class)
|
||
->middleware('throttle:30,1')
|
||
->name('health.deep');
|
||
},
|
||
)
|
||
```
|
||
|
||
Hat das Projekt Basic Auth (Staging) oder eine Host-Whitelist (`trustHosts`):
|
||
`/health` von Basic Auth ausnehmen (der Monitor schickt seinen eigenen
|
||
`Authorization`-Header) und IP/`localhost` als Host zulassen.
|
||
|
||
`routes/console.php`:
|
||
|
||
```php
|
||
use App\Console\Commands\MonitoringHeartbeat;
|
||
use App\Support\OperationalAlert;
|
||
use Illuminate\Console\Scheduling\Event;
|
||
use Illuminate\Support\Facades\Schedule;
|
||
|
||
// Betriebs-Alert, wenn ein geplanter Task fehlschlägt — an JEDEN Task hängen
|
||
$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void {
|
||
OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", ['exit_code' => $event->exitCode]);
|
||
};
|
||
|
||
// Herzschlag: fällt der Scheduler aus (Cron weg, Server hängt), greift kein
|
||
// onFailure mehr — dann meldet /health den fehlenden Herzschlag
|
||
Schedule::command(MonitoringHeartbeat::class)
|
||
->everyMinute()
|
||
->withoutOverlapping(2)
|
||
->onFailure($alertOnFailure('monitoring:heartbeat'));
|
||
|
||
// Fehlgeschlagene Jobs nach 30 Tagen entfernen
|
||
Schedule::command('queue:prune-failed', ['--hours=720'])
|
||
->dailyAt('03:55')
|
||
->withoutOverlapping(30)
|
||
->onFailure($alertOnFailure('queue:prune-failed'));
|
||
```
|
||
|
||
Bei allen eigenen Tasks: `withoutOverlapping(<Minuten>)` mit **kurzer** Sperrdauer
|
||
(Standard wäre 24 h — ein abgestürzter Lauf blockiert sonst einen Tag) und
|
||
`->onFailure($alertOnFailure('<name>'))`.
|
||
|
||
`app/Providers/AppServiceProvider.php` → `boot()`:
|
||
|
||
```php
|
||
use App\Support\OperationalAlert;
|
||
use Illuminate\Foundation\Console\AboutCommand;
|
||
use Illuminate\Queue\Events\JobFailed;
|
||
use Illuminate\Support\Facades\Queue;
|
||
|
||
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
|
||
Queue::failing(function (JobFailed $event): void {
|
||
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
|
||
'connection' => $event->connectionName,
|
||
'queue' => $event->job->getQueue(),
|
||
'exception' => $event->exception->getMessage(),
|
||
]);
|
||
});
|
||
|
||
// Ausgelieferte Version in `php artisan about` (Abschnitt-Name = ABOUT_SECTION in deploy.sh)
|
||
AboutCommand::add('App', fn (): array => ['Version' => config('app.version') ?? '—']);
|
||
```
|
||
|
||
### Die Prüfungen von `/health`
|
||
|
||
| Prüfung | `critical` (→ 503) | `warn` (200) |
|
||
|---|---|---|
|
||
| `database` | `select 1` scheitert | — |
|
||
| `cache` | Schreiben/Lesen scheitert | — |
|
||
| `queue` | ältester wartender Job ≥ `MONITORING_QUEUE_BACKLOG_SECONDS` (300) — nur Treiber `database` | — |
|
||
| `scheduler` | kein Herzschlag oder ≥ 10 min alt | ≥ 3 min alt |
|
||
| `disk` | freier Platz < `MONITORING_DISK_MIN_FREE_PERCENT` (10 %) | < 20 % |
|
||
|
||
Ohne konfiguriertes `MONITORING_HEALTH_TOKEN` gibt es den Endpunkt nicht (404),
|
||
falsches Token → 404. Token im Header `Authorization: Bearer …` oder `?token=`.
|
||
Antwort enthält `version`.
|
||
|
||
Der Herzschlag liegt in `storage/framework/scheduler-heartbeat` (übersteht
|
||
`cache:clear`, keine Migration). **Mehrere App-Server:** stattdessen eine
|
||
DB-Zeile, sonst prüft jeder Server nur seine eigene Datei.
|
||
|
||
Andere Queue-Treiber (Redis, SQS): `queue`-Prüfung meldet „nicht prüfbar“;
|
||
dann eine eigene Prüfung ergänzen (z. B. `Queue::size()` plus Alter des
|
||
ältesten Jobs) oder Horizon-Status abfragen.
|
||
|
||
---
|
||
|
||
## 2. ntfy — Push aufs Handy
|
||
|
||
Server einmalig: `vorlagen/server/ntfy-compose.yml` + `ntfy-server.yml`
|
||
(`auth-default-access: deny-all` — ohne Anmeldung kein Lesen, kein Schreiben).
|
||
|
||
Je Projekt:
|
||
|
||
```bash
|
||
ntfy user add meineapp-alerts # Passwort wird nicht gebraucht
|
||
ntfy access meineapp-alerts meineapp-ops write-only # darf nur schreiben
|
||
ntfy access <dein-benutzer> meineapp-ops read-only # entfällt bei Admin-Rolle
|
||
ntfy token add meineapp-alerts # → tk_… in MONITORING_NTFY_TOKEN
|
||
```
|
||
|
||
Test: `curl -H "Authorization: Bearer tk_…" -d "Test" https://push.example.com/meineapp-ops`.
|
||
Handy: ntfy-App, Server hinzufügen (mit eigenem Benutzer), Topic abonnieren.
|
||
|
||
Warum eigener Benutzer mit Token: gerät der Token in falsche Hände, kann damit
|
||
niemand mitlesen; `ntfy token remove` sperrt ihn ohne Passwortwechsel.
|
||
|
||
`OperationalAlert` sendet JSON an die Basis-URL (`topic`, `title`, `message`,
|
||
`priority`, `tags`) — UTF-8-sicher, anders als Header-basierte Aufrufe.
|
||
|
||
---
|
||
|
||
## 3. Uptime Kuma — externer Monitor
|
||
|
||
Auf einem **anderen** Server als die App (`vorlagen/server/uptime-kuma-compose.yml`).
|
||
Admin-Konto anlegen, 2FA aktivieren.
|
||
|
||
| Monitor | Typ / Ziel | Intervall | Wiederholungen |
|
||
|---|---|---|---|
|
||
| `<app> /health` | HTTP(s), `https://meineapp.de/health`, Header `{"Authorization": "Bearer <MONITORING_HEALTH_TOKEN>"}`, Status `200-299` | 60 s | 2 |
|
||
| `<app> /up` je Domain | HTTP(s), `https://<domain>/up` | 5 min | 1 |
|
||
| Zertifikate | Settings ▸ Certificate Expiry Notification | 14 Tage vorher | — |
|
||
|
||
Benachrichtigung: ntfy (Server, Topic, Priorität 4, Access Token) und SMTP-Mail.
|
||
|
||
Ein stehender Worker fällt so nach etwa 5 + 2 Minuten auf (Queue-Schwelle plus
|
||
Wiederholungen) — ein zusätzlicher Alarm aus dem Scheduler ist dann überflüssig.
|
||
|
||
**Test:** zweiter Monitor mit falschem Token → 404 → Alarm muss auf Handy und
|
||
per Mail ankommen; danach löschen.
|
||
|
||
**Zweite Ebene (empfohlen):** kostenloser externer Dienst (z. B. UptimeRobot)
|
||
auf `https://meineapp.de/up` mit Mail — meldet auch, wenn der Monitoring-Server
|
||
selbst ausfällt.
|
||
|
||
---
|
||
|
||
## 4. Worker-Prüfung im Deploy
|
||
|
||
`deploy.sh` ruft am Ende `php artisan monitoring:queue-probe --wait=60` auf:
|
||
Test-Job auf die Standard-Queue, Abholung wird über den Cache quittiert. Kein
|
||
Worker → Exit 3 mit Hinweis. Von Hand jederzeit:
|
||
|
||
```bash
|
||
php artisan monitoring:queue-probe
|
||
ps -eo pid,lstart,args | grep "queue:work" | grep -v grep
|
||
```
|
||
|
||
Voraussetzung: Der Worker bedient die Standard-Queue, und App und Worker teilen
|
||
denselben Cache-Store (nicht `array`).
|
||
|
||
---
|
||
|
||
## 5. Erweiterungen (Muster aus Pressekonto, nicht in den Vorlagen)
|
||
|
||
- **Admin-Systemstatus-Seite**: Kacheln aus `HealthProbe::checks()` plus
|
||
projektspezifische Prüfungen (fehlende Permissions, `APP_DEBUG` an,
|
||
ausstehende Datenmigrationen, letzte fehlgeschlagene Jobs).
|
||
- **Fehlgeschlagene Logins**: falsches Passwort / Konto ohne Passwort /
|
||
Sperre in einen Aktivitäts-Verlauf, mit Einstieg (`livewire`/`fortify`) und
|
||
gekürztem HMAC der IP statt Klartext. Unbekannte Adressen **nur zählen**
|
||
(Tag × Einstieg × IP-Hash), sonst verdecken Credential-Stuffing-Wellen
|
||
(bei Pressekonto 250/Tag aus 50 Quellen) die relevanten Einträge.
|
||
- **Webanalyse**: Umami selbst gehostet, cookielos; Query-Parameter mit
|
||
`data-before-send` filtern (signierte Links!), im Admin und bei
|
||
Impersonation nicht tracken.
|
||
- **Nginx-Fehlerlog** gelegentlich zusammenfassen:
|
||
|
||
```bash
|
||
sed -E 's/^[0-9\/]+ [0-9:]+ //; s/[0-9]+#[0-9]+: \*[0-9]+ //; s/client: [0-9a-f.:]+/client: X/; s/, (server|request|upstream|host|referrer): .*//' \
|
||
/var/log/nginx/<site>-error.log | cut -c1-180 | sort | uniq -c | sort -rn | head -25
|
||
```
|
||
|
||
`access forbidden by rule` = Scanner suchen `/.env` (gut so);
|
||
`recv() failed (104…)` nur zu Deploy-Zeiten = Reload, unkritisch.
|