# Postgres backup — daily pg_dump to MinIO S3 # Uses initContainer (postgres:16-alpine) for pg_dump, then main container # (python:3.12-alpine, already cached) uploads to MinIO via S3 API. # Retains 7 daily + 4 weekly backups. # Apply: kubectl apply -f postgres-backup.yaml --- apiVersion: v1 kind: ConfigMap metadata: name: postgres-backup-script namespace: foxhunt labels: app.kubernetes.io/name: postgres-backup app.kubernetes.io/part-of: foxhunt data: upload.py: | #!/usr/bin/env python3 """Upload pg_dump backup to MinIO, prune old backups.""" import hashlib, hmac, os, sys, glob, urllib.request, urllib.error from datetime import datetime, timezone ENDPOINT = os.environ["MINIO_ENDPOINT"] # http://minio.foxhunt...:9000 ACCESS_KEY = os.environ["MINIO_ACCESS_KEY"] SECRET_KEY = os.environ["MINIO_SECRET_KEY"] BUCKET = "foxhunt-backups" BACKUP_DIR = "/backup" def s3_sign(method, path, headers, payload_hash): """AWS Signature V4 for MinIO.""" now = datetime.now(timezone.utc) date_stamp = now.strftime("%Y%m%d") amz_date = now.strftime("%Y%m%dT%H%M%SZ") region = "us-east-1" service = "s3" scope = f"{date_stamp}/{region}/{service}/aws4_request" headers["x-amz-date"] = amz_date headers["x-amz-content-sha256"] = payload_hash signed_headers = ";".join(sorted(headers.keys())) canonical_headers = "".join(f"{k}:{headers[k]}\n" for k in sorted(headers.keys())) canonical_request = f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}" string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{hashlib.sha256(canonical_request.encode()).hexdigest()}" def sign(key, msg): return hmac.new(key, msg.encode(), hashlib.sha256).digest() signing_key = sign(sign(sign(sign( f"AWS4{SECRET_KEY}".encode(), date_stamp), region), service), "aws4_request") signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest() headers["Authorization"] = ( f"AWS4-HMAC-SHA256 Credential={ACCESS_KEY}/{scope}, " f"SignedHeaders={signed_headers}, Signature={signature}" ) return headers def s3_put(key, data): path = f"/{BUCKET}/{key}" url = f"{ENDPOINT}{path}" payload_hash = hashlib.sha256(data).hexdigest() host = ENDPOINT.split("//")[1] headers = {"host": host, "content-length": str(len(data))} headers = s3_sign("PUT", path, headers, payload_hash) req = urllib.request.Request(url, data=data, method="PUT", headers=headers) urllib.request.urlopen(req, timeout=120) def s3_list(prefix): path = f"/{BUCKET}?prefix={prefix}&list-type=2" url = f"{ENDPOINT}{path}" host = ENDPOINT.split("//")[1] headers = {"host": host} headers = s3_sign("GET", f"/{BUCKET}", headers, hashlib.sha256(b"").hexdigest()) req = urllib.request.Request(f"{url}", method="GET", headers=headers) try: resp = urllib.request.urlopen(req, timeout=30).read().decode() import re return sorted(re.findall(r"([^<]+)", resp)) except Exception: return [] def s3_delete(key): path = f"/{BUCKET}/{key}" url = f"{ENDPOINT}{path}" host = ENDPOINT.split("//")[1] headers = {"host": host} headers = s3_sign("DELETE", path, headers, hashlib.sha256(b"").hexdigest()) req = urllib.request.Request(url, method="DELETE", headers=headers) urllib.request.urlopen(req, timeout=30) def s3_ensure_bucket(): path = f"/{BUCKET}" url = f"{ENDPOINT}{path}" host = ENDPOINT.split("//")[1] headers = {"host": host} headers = s3_sign("PUT", path, headers, hashlib.sha256(b"").hexdigest()) req = urllib.request.Request(url, method="PUT", headers=headers) try: urllib.request.urlopen(req, timeout=30) except urllib.error.HTTPError as e: if e.code not in (409, 200): # 409 = bucket exists raise # Find the backup file files = glob.glob(f"{BACKUP_DIR}/*.sql.gz") if not files: print("ERROR: No backup file found in /backup/") sys.exit(1) backup_file = files[0] filename = os.path.basename(backup_file) size_mb = os.path.getsize(backup_file) / (1024 * 1024) print(f"Uploading {filename} ({size_mb:.1f} MB)") s3_ensure_bucket() with open(backup_file, "rb") as f: data = f.read() # Upload daily s3_put(f"daily/{filename}", data) print(f" Uploaded daily/{filename}") # On Sundays, also upload weekly if datetime.now(timezone.utc).isoweekday() == 7: s3_put(f"weekly/{filename}", data) print(f" Uploaded weekly/{filename}") # Prune old dailies (keep 7) dailies = s3_list("daily/") if len(dailies) > 7: for old in dailies[:len(dailies) - 7]: s3_delete(old) print(f" Pruned {old}") # Prune old weeklies (keep 4) weeklies = s3_list("weekly/") if len(weeklies) > 4: for old in weeklies[:len(weeklies) - 4]: s3_delete(old) print(f" Pruned {old}") print("=== Backup upload complete ===") --- apiVersion: batch/v1 kind: CronJob metadata: name: postgres-backup namespace: foxhunt labels: app.kubernetes.io/name: postgres-backup app.kubernetes.io/part-of: foxhunt spec: schedule: "30 2 * * *" successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 600 template: metadata: labels: app: postgres-backup app.kubernetes.io/part-of: foxhunt spec: nodeSelector: k8s.scaleway.com/pool-name: platform restartPolicy: OnFailure # Step 1: pg_dump in initContainer → shared /backup volume initContainers: - name: pg-dump image: postgres:16-alpine command: - sh - -c - | TIMESTAMP=$(date -u +%Y%m%d-%H%M%S) echo "=== Postgres Backup ${TIMESTAMP} ===" PGPASSWORD="${POSTGRES_PASSWORD}" pg_dumpall \ -h "${POSTGRES_HOST}" \ -p 5432 \ -U "${POSTGRES_USER}" \ --clean --if-exists \ | gzip > "/backup/foxhunt-${TIMESTAMP}.sql.gz" ls -lh /backup/ echo "Dump complete" env: - name: POSTGRES_HOST value: "postgres.foxhunt.svc.cluster.local" - name: POSTGRES_USER value: "foxhunt" - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: db-credentials key: password volumeMounts: - name: backup mountPath: /backup resources: requests: cpu: 50m memory: 128Mi limits: cpu: 500m memory: 512Mi # Step 2: Upload to MinIO in main container (python cached on nodes) containers: - name: upload image: python:3.12-alpine command: ["python3", "-u", "/scripts/upload.py"] env: - name: MINIO_ENDPOINT value: "http://minio.foxhunt.svc.cluster.local:9000" - name: MINIO_ACCESS_KEY valueFrom: secretKeyRef: name: minio-credentials key: access-key - name: MINIO_SECRET_KEY valueFrom: secretKeyRef: name: minio-credentials key: secret-key volumeMounts: - name: backup mountPath: /backup - name: script mountPath: /scripts resources: requests: cpu: 10m memory: 64Mi limits: cpu: 100m memory: 128Mi volumes: - name: backup emptyDir: sizeLimit: 2Gi - name: script configMap: name: postgres-backup-script items: - key: upload.py path: upload.py defaultMode: 0755