presseportale/dev/git-deploy-monitoring/03-betriebsueberwachung.md
Kevin Adametz 32e1349517 dev: Baukasten Git-Deploy und Betriebsüberwachung für andere Laravel-Projekte (dev/git-deploy-monitoring) — Anleitungen, Checkliste, Vorlagen für Laravel, deploy.sh und Monitoring-Server
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>
2026-10-07 09:45:16 +00:00

222 lines
9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.