Backup: daglig PG-dump, STDB-krasj-recovery, helsesjekk (oppgave 12.2)
Tre ting implementert: 1. PG-dump rutine (scripts/backup-pg.sh): - Daglig cron kl. 03:00 UTC via /etc/cron.d/synops-backup - pg_dump -Fc (custom format, komprimert), konsistent uten nedetid - Rotasjon: beholder 30 dager, sletter eldre - Verifiserer at dump-filen er gyldig (ikke tom) 2. STDB → PG gjenoppbygging ved krasj (stdb_monitor.rs): - Bakgrunnsmonitor sjekker STDB hvert 30. sekund - Oppdager krasj (var oppe → nå nede) - Venter på at containeren restarter (maks 10 min) - Kjører warmup (PG → STDB) automatisk - Hele prosessen logges 3. Forbedret backup-helsesjekk (health.rs): - Sjekker /srv/synops/backup/pg/ for nyeste dump - Rapporterer ok/stale/missing i /admin/health
This commit is contained in:
parent
4d7852f466
commit
0f03886091
6 changed files with 344 additions and 37 deletions
113
docs/infra/backup.md
Normal file
113
docs/infra/backup.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Backup og gjenoppbygging
|
||||
|
||||
**Filsti:** `docs/infra/backup.md`
|
||||
|
||||
Synops sin backup-strategi bygger på én innsikt: **PostgreSQL er den
|
||||
eneste autoriteten.** SpacetimeDB er en sanntidscache som gjenoppbygges
|
||||
fra PG ved behov. Media-filer i CAS er innholdsadresserte og immutable.
|
||||
|
||||
## Arkitektur
|
||||
|
||||
```
|
||||
PostgreSQL (autoritativ kilde)
|
||||
│
|
||||
├──→ pg_dump daglig (03:00 UTC)
|
||||
│ └──→ /srv/synops/backup/pg/sidelinja_YYYYMMDD_HHMMSS.dump
|
||||
│ └──→ Rotasjon: 30 dager
|
||||
│
|
||||
└──→ SpacetimeDB (sanntidscache)
|
||||
└──→ Gjenoppbygges fra PG ved krasj (warmup)
|
||||
```
|
||||
|
||||
## 1. PG-dump (daglig)
|
||||
|
||||
**Script:** `scripts/backup-pg.sh`
|
||||
**Cron:** `/etc/cron.d/synops-backup` — `0 3 * * *`
|
||||
**Logg:** `/srv/synops/logs/backup-pg.log`
|
||||
**Dumper:** `/srv/synops/backup/pg/`
|
||||
|
||||
Prosess:
|
||||
1. Sjekker at PG-containeren kjører
|
||||
2. `pg_dump -Fc` (custom format, komprimert) — konsistent snapshot uten nedetid
|
||||
3. Verifiserer at dump-filen ikke er tom
|
||||
4. Sletter dumper eldre enn 30 dager
|
||||
|
||||
Manuell kjøring:
|
||||
```bash
|
||||
/home/vegard/synops/scripts/backup-pg.sh
|
||||
```
|
||||
|
||||
Verifiser dump:
|
||||
```bash
|
||||
docker cp /srv/synops/backup/pg/DUMP.dump sidelinja-postgres-1:/tmp/test.dump
|
||||
docker exec sidelinja-postgres-1 pg_restore --list /tmp/test.dump
|
||||
docker exec sidelinja-postgres-1 rm /tmp/test.dump
|
||||
```
|
||||
|
||||
## 2. STDB-gjenoppbygging ved krasj
|
||||
|
||||
**Modul:** `maskinrommet/src/stdb_monitor.rs`
|
||||
|
||||
SpacetimeDB er en sanntidscache. Hvis den krasjer, tapes ingen data
|
||||
fordi all skriving går gjennom maskinrommet som skriver til PG først
|
||||
(asynkront, men alltid). Gjenoppbygging skjer automatisk:
|
||||
|
||||
### Ved oppstart
|
||||
Maskinrommet kjører `warmup::run()` i `main.rs` — laster alle noder,
|
||||
edges og node_access fra PG til STDB.
|
||||
|
||||
### Ved krasj under drift
|
||||
`stdb_monitor` kjører i bakgrunnen og sjekker STDB hvert 30. sekund:
|
||||
|
||||
1. **Oppdager** at STDB ikke svarer (var oppe, nå nede)
|
||||
2. **Venter** opptil 10 minutter på at containeren restarter
|
||||
3. **Kjører warmup** (PG → STDB) når STDB svarer igjen
|
||||
4. **Logger** hele hendelsesforløpet
|
||||
|
||||
Prosessen er automatisk og krever ingen manuell inngripen så lenge
|
||||
Docker restarter containeren (restart-policy: `unless-stopped`).
|
||||
|
||||
## 3. Restore fra backup
|
||||
|
||||
### PostgreSQL
|
||||
```bash
|
||||
# Stopp maskinrommet (unngå skrivinger under restore)
|
||||
sudo systemctl stop maskinrommet
|
||||
|
||||
# Restore fra dump
|
||||
docker cp /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump sidelinja-postgres-1:/tmp/restore.dump
|
||||
docker exec sidelinja-postgres-1 pg_restore -U sidelinja -d sidelinja --clean /tmp/restore.dump
|
||||
docker exec sidelinja-postgres-1 rm /tmp/restore.dump
|
||||
|
||||
# Start maskinrommet (warmup laster PG → STDB automatisk)
|
||||
sudo systemctl start maskinrommet
|
||||
```
|
||||
|
||||
### Komplett gjenoppbygging
|
||||
Ved total serversvikt (ny VPS):
|
||||
1. Installer OS og Docker (se `docs/setup/produksjon.md`)
|
||||
2. Start PG-container
|
||||
3. Restore dump (se over)
|
||||
4. Start maskinrommet (warmup håndterer STDB)
|
||||
5. Avledede data (segmenter, søkeindeks) regenereres fra kildene
|
||||
|
||||
## 4. Overvåking
|
||||
|
||||
Health-dashboardet (`/admin/health`) viser backup-status:
|
||||
- **ok** — dump-fil er fersk (< 25 timer gammel)
|
||||
- **stale** — dump-fil er eldre enn 25 timer
|
||||
- **missing** — ingen dump-filer funnet
|
||||
|
||||
Metrikk-endepunktet (`/metrics`) inkluderer STDB-status som del av
|
||||
helsesjekken.
|
||||
|
||||
## 5. Hva som IKKE backupes (bevisst)
|
||||
|
||||
- **SpacetimeDB** — sanntidscache, gjenoppbygges fra PG
|
||||
- **Redis** — cache, regenereres automatisk
|
||||
- **Caddy-data** — sertifikater regenereres av Let's Encrypt
|
||||
- **Whisper-modeller** — re-download fra HuggingFace
|
||||
- **Logger** — rulleres med logrotate
|
||||
|
||||
Se `docs/setup/produksjon.md` § 11 for fullstendig backup-spesifikasjon
|
||||
inkludert off-site backup (rclone) og WAL-arkivering (fremtidig).
|
||||
|
|
@ -336,16 +336,10 @@ async fn collect_pg_stats(db: &PgPool) -> PgStats {
|
|||
// =============================================================================
|
||||
|
||||
fn check_backups() -> Vec<BackupInfo> {
|
||||
// Sjekk om det finnes PG-dumper i standard backup-kataloger
|
||||
let backup_paths = [
|
||||
"/srv/synops/backups",
|
||||
"/srv/synops/data/backups",
|
||||
"/var/backups/synops",
|
||||
];
|
||||
|
||||
let mut backups = Vec::new();
|
||||
|
||||
// PG-dump
|
||||
// PG-dump — sjekk /srv/synops/backup/pg/ for nyeste .dump-fil
|
||||
let pg_dir = "/srv/synops/backup/pg";
|
||||
let mut pg_backup = BackupInfo {
|
||||
backup_type: "PostgreSQL dump".to_string(),
|
||||
last_success: None,
|
||||
|
|
@ -353,41 +347,37 @@ fn check_backups() -> Vec<BackupInfo> {
|
|||
status: "missing".to_string(),
|
||||
};
|
||||
|
||||
for dir in &backup_paths {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
if let Ok(entries) = std::fs::read_dir(pg_dir) {
|
||||
// Finn nyeste dump-fil
|
||||
let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.contains("pg") || name.ends_with(".sql") || name.ends_with(".dump") {
|
||||
if name.ends_with(".dump") {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if newest.as_ref().map_or(true, |(t, _)| modified > *t) {
|
||||
newest = Some((modified, entry.path()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((modified, path)) = newest {
|
||||
let age = modified.elapsed().unwrap_or_default();
|
||||
let ts = chrono::DateTime::<chrono::Utc>::from(modified);
|
||||
pg_backup.last_success = Some(ts.to_rfc3339());
|
||||
pg_backup.path = Some(entry.path().to_string_lossy().to_string());
|
||||
pg_backup.status = if age.as_secs() < 86400 {
|
||||
pg_backup.path = Some(path.to_string_lossy().to_string());
|
||||
pg_backup.status = if age.as_secs() < 86400 + 3600 {
|
||||
// Litt slakk: 25 timer (cron kjører daglig)
|
||||
"ok".to_string()
|
||||
} else if age.as_secs() < 7 * 86400 {
|
||||
"stale".to_string()
|
||||
} else {
|
||||
"stale".to_string()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backups.push(pg_backup);
|
||||
|
||||
// CAS (media) backup
|
||||
backups.push(BackupInfo {
|
||||
backup_type: "CAS media".to_string(),
|
||||
last_success: None,
|
||||
path: None,
|
||||
status: "missing".to_string(),
|
||||
});
|
||||
|
||||
backups
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod transcribe;
|
|||
pub mod tts;
|
||||
pub mod usage_overview;
|
||||
pub mod user_usage;
|
||||
mod stdb_monitor;
|
||||
mod warmup;
|
||||
mod workspace;
|
||||
|
||||
|
|
@ -183,6 +184,9 @@ async fn main() {
|
|||
// Start nattlig bandwidth-parsing (oppgave 15.7)
|
||||
bandwidth::start_bandwidth_parser(db.clone());
|
||||
|
||||
// Start STDB-overvåker: oppdager krasj og gjenoppbygger fra PG (oppgave 12.2)
|
||||
stdb_monitor::start_stdb_monitor(db.clone(), stdb.clone());
|
||||
|
||||
// Start periodisk CAS tmp-opprydding (oppgave 17.6)
|
||||
cas::start_tmp_cleanup_loop(cas.clone());
|
||||
|
||||
|
|
|
|||
145
maskinrommet/src/stdb_monitor.rs
Normal file
145
maskinrommet/src/stdb_monitor.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// STDB-overvåker: oppdager SpacetimeDB-krasj og gjenoppbygger fra PG.
|
||||
//
|
||||
// Kjører i bakgrunnen med jevnlig helsesjekk. Hvis STDB var oppe og
|
||||
// deretter feiler, kjøres warmup automatisk for å gjenoppbygge tilstand.
|
||||
//
|
||||
// Sekvens ved krasj:
|
||||
// 1. Oppdage at STDB er nede (helsesjekk feiler)
|
||||
// 2. Vente til STDB er tilbake (container restarter)
|
||||
// 3. Kjøre warmup (PG → STDB)
|
||||
// 4. Logge hendelsen
|
||||
//
|
||||
// Ref: docs/infra/backup.md, docs/infra/synkronisering.md
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::stdb::StdbClient;
|
||||
|
||||
/// Start STDB-overvåker i bakgrunnen.
|
||||
/// Sjekker STDB-helse hvert 30. sekund og kjører warmup ved krasj.
|
||||
pub fn start_stdb_monitor(db: PgPool, stdb: StdbClient) {
|
||||
tokio::spawn(async move {
|
||||
monitor_loop(db, stdb).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Intern tilstand for overvåkeren.
|
||||
struct MonitorState {
|
||||
/// Var STDB oppe ved forrige sjekk?
|
||||
was_up: bool,
|
||||
/// Pågår det en recovery akkurat nå?
|
||||
recovering: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
async fn monitor_loop(db: PgPool, stdb: StdbClient) {
|
||||
let mut state = MonitorState {
|
||||
was_up: true, // Antar oppe etter warmup ved oppstart
|
||||
recovering: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
// Vent litt etter oppstart slik at warmup fullføres først
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
tracing::info!("STDB-overvåker startet (sjekker hvert 30s)");
|
||||
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Ikke sjekk hvis recovery allerede pågår
|
||||
if state.recovering.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_up = check_stdb_health(&stdb).await;
|
||||
|
||||
match (state.was_up, is_up) {
|
||||
(true, false) => {
|
||||
// STDB gikk ned! Logg og start recovery-venting.
|
||||
tracing::error!("STDB-overvåker: SpacetimeDB er NEDE — starter recovery-prosess");
|
||||
state.recovering.store(true, Ordering::Relaxed);
|
||||
|
||||
let db_clone = db.clone();
|
||||
let stdb_clone = stdb.clone();
|
||||
let recovering = state.recovering.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
recover_stdb(db_clone, stdb_clone, recovering).await;
|
||||
});
|
||||
}
|
||||
(false, true) => {
|
||||
// STDB kom tilbake uten vår hjelp (recovery-tasken fikset det)
|
||||
tracing::info!("STDB-overvåker: SpacetimeDB er tilbake");
|
||||
state.was_up = true;
|
||||
}
|
||||
(false, false) => {
|
||||
// Fortsatt nede — recovery-tasken håndterer dette
|
||||
}
|
||||
(true, true) => {
|
||||
// Alt OK
|
||||
}
|
||||
}
|
||||
|
||||
if is_up {
|
||||
state.was_up = true;
|
||||
} else if !state.recovering.load(Ordering::Relaxed) {
|
||||
state.was_up = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sjekk om STDB svarer på en enkel reducer-kall.
|
||||
async fn check_stdb_health(stdb: &StdbClient) -> bool {
|
||||
stdb.delete_node("__healthcheck_nonexistent__").await.is_ok()
|
||||
}
|
||||
|
||||
/// Vent til STDB er tilbake, deretter kjør warmup.
|
||||
async fn recover_stdb(db: PgPool, stdb: StdbClient, recovering: Arc<AtomicBool>) {
|
||||
let max_wait = std::time::Duration::from_secs(600); // Maks 10 min
|
||||
let check_interval = std::time::Duration::from_secs(10);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
tracing::info!("STDB-recovery: venter på at SpacetimeDB starter opp igjen (maks 10 min)");
|
||||
|
||||
// Vent til STDB svarer
|
||||
loop {
|
||||
if start.elapsed() > max_wait {
|
||||
tracing::error!(
|
||||
"STDB-recovery: SpacetimeDB kom ikke tilbake innen {} sekunder — gir opp",
|
||||
max_wait.as_secs()
|
||||
);
|
||||
recovering.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(check_interval).await;
|
||||
|
||||
if check_stdb_health(&stdb).await {
|
||||
tracing::info!(
|
||||
"STDB-recovery: SpacetimeDB svarer igjen etter {}s",
|
||||
start.elapsed().as_secs()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// STDB er tilbake — kjør warmup
|
||||
tracing::info!("STDB-recovery: kjører warmup (PG → STDB)");
|
||||
match crate::warmup::run(&db, &stdb).await {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
"STDB-recovery: warmup fullført ({} noder, {} edges, {} access)",
|
||||
stats.nodes,
|
||||
stats.edges,
|
||||
stats.access
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("STDB-recovery: warmup feilet: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
recovering.store(false, Ordering::Relaxed);
|
||||
}
|
||||
56
scripts/backup-pg.sh
Executable file
56
scripts/backup-pg.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/bin/bash
|
||||
# backup-pg.sh — Daglig PostgreSQL-dump med rotasjon.
|
||||
#
|
||||
# Kjøres via cron (03:00 UTC). Dumper synops-databasen med pg_dump -Fc
|
||||
# (custom format, komprimert). Beholder 30 dager, sletter eldre.
|
||||
#
|
||||
# Ref: docs/setup/produksjon.md § 11.1, docs/infra/backup.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/srv/synops/backup/pg"
|
||||
CONTAINER="sidelinja-postgres-1"
|
||||
DB_USER="sidelinja"
|
||||
DB_NAME="sidelinja"
|
||||
RETAIN_DAYS=30
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DUMP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.dump"
|
||||
LOG_FILE="/srv/synops/logs/backup-pg.log"
|
||||
|
||||
log() {
|
||||
echo "$(date -Iseconds) $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Sjekk at backup-katalog finnes
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Sjekk at PG-containeren kjører
|
||||
if ! docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then
|
||||
log "FEIL: PostgreSQL-container $CONTAINER kjører ikke"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kjør pg_dump (konsistent snapshot, ingen nedetid)
|
||||
log "Starter PG-dump → $DUMP_FILE"
|
||||
if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DUMP_FILE" 2>>"$LOG_FILE"; then
|
||||
# Verifiser at filen ikke er tom
|
||||
FILESIZE=$(stat -c%s "$DUMP_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$FILESIZE" -lt 100 ]; then
|
||||
log "FEIL: Dump-filen er for liten (${FILESIZE} bytes), noe gikk galt"
|
||||
rm -f "$DUMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
log "PG-dump ferdig: ${DUMP_FILE} ($(numfmt --to=iec "$FILESIZE"))"
|
||||
else
|
||||
log "FEIL: pg_dump feilet"
|
||||
rm -f "$DUMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Rotasjon: slett dumper eldre enn $RETAIN_DAYS dager
|
||||
DELETED=$(find "$BACKUP_DIR" -name "*.dump" -mtime +${RETAIN_DAYS} -print -delete | wc -l)
|
||||
if [ "$DELETED" -gt 0 ]; then
|
||||
log "Rotasjon: slettet $DELETED dump(er) eldre enn $RETAIN_DAYS dager"
|
||||
fi
|
||||
|
||||
log "Backup fullført OK"
|
||||
3
tasks.md
3
tasks.md
|
|
@ -267,7 +267,6 @@ kaller dem direkte. Samme verktøy, to brukere.
|
|||
## Fase 12: Herding
|
||||
|
||||
- [x] 12.1 Observerbarhet: strukturert logging, metrikker (request latency, queue depth, AI cost).
|
||||
- [~] 12.2 Backup: PG-dump rutine, STDB → PG gjenoppbygging ved krasj.
|
||||
> Påbegynt: 2026-03-18T11:05
|
||||
- [x] 12.2 Backup: PG-dump rutine, STDB → PG gjenoppbygging ved krasj.
|
||||
- [ ] 12.3 Feilhåndtering: retry med backoff i skrivestien, dead letter queue for feilede PG-skrivinger.
|
||||
- [ ] 12.4 Ytelse: profiler STDB-spørringer, optimaliser node_access-oppdatering.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue