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

9 KiB
Raw Blame History

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):

'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):

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:

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():

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:

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:

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:

    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.