Postgres PVC hit 100% → crash-looped → GitLab webservice couldn't verify SSH keys → all git operations failed. Root cause: 10Gi PVC outgrown by GitLab database. Fixes applied: - Expand postgres PVC 10Gi → 20Gi - Expand minio PVC 100Gi → 150Gi (was 81.8% full) - Expand prometheus PVC 2Gi → 10Gi, retentionSize 1500MB → 8GB - Scale gitlab-webservice to 2 replicas for HA - Add PVC autoscaler CronJob (every 15min, auto-expand at 85%) - Add daily postgres backup CronJob (pg_dump → MinIO, 7d + 4w retention) - Add PrometheusRule storage alerts (75% warning, 90% critical) - Add network policies for maintenance job egress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
8.6 KiB
YAML
250 lines
8.6 KiB
YAML
# 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"<Key>([^<]+)</Key>", 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
|