Aller au contenu

Sauvegardes Neon prod (S3)

Copie hors Neon de bricks-prod. Le PITR Neon reste la restauration courte. Ce dump sert à la reprise hors plateforme.

Rétention

7 jours glissants sur le préfixe bricks-prod-backup/ (lifecycle S3, pas un prune GitHub). Un nightly laisse ~7 dumps datés.

En plus, chaque dump réussi écrase bricks-prod-last-good.dump (hors préfixe, pas d’expiration). Sept fails d’affilée n’effacent pas le dernier dump valide.

Cette copie last-good est un deuxième cp serveur du dump (plusieurs dizaines de Go). Coût S3 assumé. Si le cp last-good échoue après un mv réussi, le dump daté est déjà en place : Slack le dit, il ne parle pas d’un dump manquant.

  • Bucket : bricks-prod-neon-s3-backup (eu-central-1)
  • Préfixe daté : bricks-prod-backup/ — current 7 j, noncurrent 1 j, delete markers expirés. Versioning ne doit pas transformer 7 j en 37 j.
  • Dernier dump valide : bricks-prod-last-good.dump — pas d’expiration current. Noncurrent 30 j (filtre préfixe sur cette clé). C’est la seule raison du versioning.
  • Multipart inachevés : abort après 1 jour, sur tout le bucket

Règles lifecycle à poser (versioning on) :

  1. expire-bricks-prod-backup-7d — préfixe bricks-prod-backup/ — Expiration.Days = 7
  2. expire-bricks-prod-backup-noncurrent-1d — même préfixe — NoncurrentVersionExpiration.Days = 1 + Expiration.ExpiredObjectDeleteMarker = true (aws s3 rm d’un .incomplete ne laisse pas un objet d’un mois)
  3. expire-last-good-noncurrent-30d — préfixe bricks-prod-last-good.dump — NoncurrentVersionExpiration.NoncurrentDays = 30 et NewerNoncurrentVersions = 7 (sinon ~30 copies last-good en plus des 7 dumps datés)
  4. abort-incomplete-multipart-1d — bucket entier — AbortIncompleteMultipartUpload.DaysAfterInitiation = 1

Changer la rétention = éditer ces règles sur le bucket, puis cette page.

Job

Workflow : neon-bricks-prod-backup.yml

  • Cron 0 4 * * * (4h UTC) + workflow_dispatch
  • Env GitHub API Prod no validation
  • Branche Neon éphémère s3-backup-<run_id>, --parent main (prod). Un changement de branche par défaut du projet ne change pas la source. Compute isolé, --expires-at +24 h, --suspend-timeout 6 h (filet facturation si le delete de branche est sauté). pg_dump ne tourne pas sur le compute prod. La branche est supprimée en always() ; l’expiration 24 h est le filet si le job meurt avant.
  • pg_dump -Fc -Z1 --no-owner --no-acl --no-tablespaces --lock-wait-timeout=20s (connection string Neon unpooled). La branche main n’est pas protected : l’enfant hérite du mot de passe prod bricks_admin. Le job le masque (::add-mask::) avant $GITHUB_ENV. Ne pas activer ACTIONS_STEP_DEBUG sur ce workflow. Client pg_dump = PG_VERSION (16) : le bumper si Neon prod passe en majeur supérieur.
  • Stream vers *.dump.incomplete, head-object sur cette clé, puis mv en .dump seulement si le plancher passe. Un dump trop petit reste .incomplete et always() le supprime. Il n’apparaît pas sous bricks-prod-backup/ avec un nom de dump réussi.
  • Plancher : 80 % de bricks-prod-last-good.dump si l’objet existe, sinon 1 GiB (MIN_DUMP_BYTES). Un head-object last-good autre qu’un 404 fait échouer le job (pas de repli silencieux sur 1 GiB). Heuristique anti-troncature, pas une preuve d’intégrité. Le drill trimestriel est le vrai contrôle. 1 GiB ≈ 1 % d’une base ~130 Go : ça n’attrape que la quasi-totalité d’un dump raté, d’où le plancher relatif.
  • Last-good : aws s3 cp du dump daté (étape séparée)
  • Timeout job timeout-minutes: 330 : GitHub annule et laisse ~5 min aux steps always() / cancelled(). Le cap plateforme 6 h termine et fail sans cleanup. Le timeout job doit rester en-dessous. Nettoyage S3 incomplet en always(). Slack en failure() || cancelled(). chat.postMessage : parser .ok (HTTP 200 + ok: false = fail, ex. not_in_channel).

Secrets Doppler api-ci (prd / prd_no_validate) :

Secret Rôle
NEON_S3_BACKUP_AWS_ACCOUNT_ID Compte AWS
NEON_S3_BACKUP_AWS_REGION Région du bucket
NEON_S3_BACKUP_BUCKET_NAME Nom du bucket
NEON_S3_BACKUP_IAM_ROLE Rôle OIDC GitHub Actions (neon-s3-backup-github-actions)
NEON_API_KEY Déjà présent (neonctl)
NEON_PROJECT_ID Déjà présent (projet Neon prod)
SLACK_TOKEN Déjà présent (alerte #internal-production-alerts)

IAM

Trust policy du rôle neon-s3-backup-github-actions (sujet OIDC = env GitHub, pas un wildcard de ref) :

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:brickssas/monorepo:environment:API Prod no validation"
      }
    }
  }]
}

Permissions : s3:PutObject / s3:GetObject / s3:AbortMultipartUpload sur bricks-prod-neon-s3-backup/*, s3:ListBucket / s3:ListBucketMultipartUploads / s3:GetBucketLocation sur le bucket, s3:DeleteObject uniquement sur bricks-prod-backup/*.incomplete. Le job ne delete jamais un dump daté ni last-good. Le prune 7 j est la lifecycle.

MaxSessionDuration du rôle ≥ 21600. Sinon configure-aws-credentials échoue tout de suite (défaut IAM = 3600).

Avant le premier run

Rien de tout ça n’est dans le repo. Cocher avant le workflow_dispatch :

  1. Versioning on sur bricks-prod-neon-s3-backup
  2. Les 4 règles lifecycle ci-dessus (7 j current + 1 j noncurrent sur bricks-prod-backup/, 30 j noncurrent sur last-good, abort multipart 1 j bucket-wide)
  3. MaxSessionDuration du rôle neon-s3-backup-github-actions ≥ 21600
  4. Bot Slack invité dans #internal-production-alerts (C06NY39CV1D)
  5. Secrets Doppler NEON_S3_BACKUP_* présents dans api-ci prd / prd_no_validate

Plafond 5 h 30

timeout-minutes: 330 est le plafond job. Il doit rester sous les 6 h plateforme. role-duration-seconds: 21600 aussi. Estimation 2–6 h (base ~130 Go, ça grandit) : le haut de fourchette ne peut pas finir. Chronométrer le premier run réel et resserrer l’estimation. À 5 h 30 le job s’annule, Slack alerte, S3 .incomplete et la branche Neon sont nettoyés.

Plan B quand le dump dépasse 5 h 30 : runner plus gros, self-hosted, ou worker Railway, et pg_dump -Fd -j N (format directory, parallèle) au lieu de -Fc streamé.

Alerte Slack → quoi faire

Le message Slack pointe vers le run. Une nuit manquée est couverte par les dumps datés 7 j + last-good.

  1. Ouvrir le run. Si head-object / plancher : dump tronqué, last-good intact. Relancer via workflow_dispatch.
  2. Si timeout 5 h 30 : le dump a dépassé le plafond job. Relancer une fois ; si ça se répète, passer au plan B.
  3. Si last-good cp a échoué : le dump daté est bon, last-good est l’ancienne copie. Relancer n’est pas urgent.
  4. Escalader après 3 fails consécutifs (last-good a alors > 3 j). Vérifier aussi LastModified de bricks-prod-last-good.dump : un schedule GitHub peut ne pas partir (charge, repo inactif, workflow pas sur la branche par défaut) et aucune alerte ne part. Contrôle manuel hebdo, ou monitor Datadog sur cet objet.

Restore

--no-owner --no-acl n’inclut pas les rôles bricks_ro, bricks_ro_metabase, ro_for_ia, databricks-dashboard ni leurs GRANT. Pour une reprise hors plateforme, recréer ces rôles et droits à la main.

CREATE EXTENSION est dans le dump. Sur un Postgres 16 nu, pré-créer la base et les extensions prod : pg_trgm, uuid-ossp, pg_uuidv7, pg_prewarm, pg_stat_statements (sur Neon le schéma est heroku_ext).

La commande ci-dessous restaure vers une connection string Neon unpooled. C’est la cible testée (branche Neon jetable). L’objectif métier est la reprise hors plateforme : viser un Postgres 16 externe (Railway Postgres jetable convient pour un drill, puis on le détruit), pas Neon, et appliquer les rôles/GRANT après.

Contrôle archive sans base : pg_restore -f /dev/null bricks-prod.dump (lit tout le custom format). pg_restore -l ne suffit pas : la TOC est en tête de fichier, un dump tronqué liste encore.

aws s3 cp s3://bricks-prod-neon-s3-backup/bricks-prod-last-good.dump ./bricks-prod.dump
# ou un dump daté : s3://bricks-prod-neon-s3-backup/bricks-prod-backup/<file>.dump
# Pré-créer la base vide (CREATE DATABASE) puis :
pg_restore -v --no-owner --no-acl -j 4 -d "<unpooled connection string>" bricks-prod.dump

Drill trimestriel

Un backup jamais restauré n’est pas un backup. Tous les 3 mois, sur une branche Neon jetable (pas prod) ou un Postgres 16 hors Neon (Railway jetable OK) :

  1. Créer une branche enfant depuis main (ou un Postgres 16 hors Neon).
  2. Télécharger bricks-prod-last-good.dump.
  3. pg_restore -f /dev/null puis pg_restore -v --no-owner --no-acl -j 4 vers cette cible.
  4. Hors Neon : recréer les rôles et GRANT listés plus haut.
  5. Contrôle : pg_restore sans erreur fatale, quelques COUNT(*) métier.
  6. Détruire la branche / l’instance.

Noter la date du drill (Slack #internal-production-alerts ou cette page).