stack.basicstack.de/apps/monitoring/prometheus-backup-cronjob.yaml

209 lines
9.2 KiB
YAML
Raw Normal View History

---
# Prometheus data backup (DEV-465).
#
# Prometheus data lives on the RWO PVC `prometheus-data-encrypted` in
feat(monitoring): retire legacy backup-volumes CronJob + backup-storage PVC (DEV-489) The restic pipeline (loki/grafana/k8s-resources) is proven end-to-end after the DEV-488 restore drill, so the legacy rsync/tar pipeline into the 100 Gi local-path `backup-storage` PVC is removed. - Delete `apps/monitoring/backup-volumes-cronjob.yaml`. - Remove the DEV-483 bridge `nodeSelector: k3s-worker-2` from `apps/monitoring/loki-deployment.yaml`. Loki's data protection now runs via `backup-loki-restic`, which follows the pod via podAffinity regardless of which node the RWO CSI volume attaches on. The `Recreate` rollout strategy stays — it is unrelated (avoids the attach-deadlock during a rollout). Resolves the RWO/nodeSelector attach race that was blocking DEV-478 weekly OS updates. - Update `apps/monitoring/README.md` to drop the `backup-volumes` section, link the restic restore runbook, and record the pin removal. - Clean stale coexistence comments in the restic/prometheus CronJob manifests now that the legacy job is gone. Cluster-side (already applied out-of-band, since these manifests are `kubectl apply`-based, not Argo-managed): - `kubectl -n monitoring delete cronjob backup-volumes` -> NotFound. - `kubectl -n monitoring delete pvc backup-storage` -> gone; local-path PV `pvc-d0db0ba9-8f89-4f66-9e65-d573ebe1085a` reclaimed automatically (Delete policy). Two stale pre-DEV-487 `backup-k8s-resources` job pods that still referenced the PVC were deleted to release the `pvc-protection` finalizer. - `kubectl -n monitoring apply -f loki-deployment.yaml` -> Recreate rollout, new pod Ready in ~60s, no nodeSelector on the new spec. - No `VolumeAttachment` for the retired PV. - Restic CronJobs (`backup-loki-restic`, `backup-grafana-restic`, `backup-k8s-resources`, `prometheus-backup`) intact. Pre-delete snapshots retained on k3s-cp-1 under `/root/dev489-snapshots-20260816T155411Z/` for post-mortem. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:58:43 +00:00
# namespace `monitoring`. That PVC is mounted by the Prometheus pod, and
# the Hetzner CSI volume can only be attached to one node at a time.
#
# This CronJob co-schedules with the Prometheus pod via podAffinity, so it
# lands on whichever node currently holds `prometheus-data-encrypted`. The
# PVC is mounted read-only alongside the running Prometheus pod (RWO permits
# additional read-only mounts on the same node) and streamed to Hetzner S3
# via rclone under `basicstack-backup/prometheus/prometheus-<DATE>/`. Old
# snapshots are pruned after 7 days.
apiVersion: batch/v1
kind: CronJob
metadata:
name: prometheus-backup
namespace: monitoring
labels:
app: backup
type: prometheus
spec:
feat(monitoring): retire legacy backup-volumes CronJob + backup-storage PVC (DEV-489) The restic pipeline (loki/grafana/k8s-resources) is proven end-to-end after the DEV-488 restore drill, so the legacy rsync/tar pipeline into the 100 Gi local-path `backup-storage` PVC is removed. - Delete `apps/monitoring/backup-volumes-cronjob.yaml`. - Remove the DEV-483 bridge `nodeSelector: k3s-worker-2` from `apps/monitoring/loki-deployment.yaml`. Loki's data protection now runs via `backup-loki-restic`, which follows the pod via podAffinity regardless of which node the RWO CSI volume attaches on. The `Recreate` rollout strategy stays — it is unrelated (avoids the attach-deadlock during a rollout). Resolves the RWO/nodeSelector attach race that was blocking DEV-478 weekly OS updates. - Update `apps/monitoring/README.md` to drop the `backup-volumes` section, link the restic restore runbook, and record the pin removal. - Clean stale coexistence comments in the restic/prometheus CronJob manifests now that the legacy job is gone. Cluster-side (already applied out-of-band, since these manifests are `kubectl apply`-based, not Argo-managed): - `kubectl -n monitoring delete cronjob backup-volumes` -> NotFound. - `kubectl -n monitoring delete pvc backup-storage` -> gone; local-path PV `pvc-d0db0ba9-8f89-4f66-9e65-d573ebe1085a` reclaimed automatically (Delete policy). Two stale pre-DEV-487 `backup-k8s-resources` job pods that still referenced the PVC were deleted to release the `pvc-protection` finalizer. - `kubectl -n monitoring apply -f loki-deployment.yaml` -> Recreate rollout, new pod Ready in ~60s, no nodeSelector on the new spec. - No `VolumeAttachment` for the retired PV. - Restic CronJobs (`backup-loki-restic`, `backup-grafana-restic`, `backup-k8s-resources`, `prometheus-backup`) intact. Pre-delete snapshots retained on k3s-cp-1 under `/root/dev489-snapshots-20260816T155411Z/` for post-mortem. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:58:43 +00:00
schedule: "30 3 * * *" # daily 03:30, offset from the loki/grafana restic runs
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
metadata:
labels:
app: backup
type: prometheus
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600 # 60 min hard cap; Prometheus data is ~9GB
template:
metadata:
labels:
app: backup
type: prometheus
spec:
restartPolicy: OnFailure
# Co-schedule with the Prometheus pod so the RWO PVC attaches on
# the same node. This survives Prometheus being rescheduled to a
# different worker (the backup follows).
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- prometheus
topologyKey: kubernetes.io/hostname
containers:
- name: prometheus-backup
image: rclone/rclone:1.68
command:
- /bin/sh
- -c
- |
set -eu
DATE=$(date +%Y%m%d-%H%M%S)
echo "=== Prometheus backup started at $(date) (target prefix: prometheus-${DATE}) ==="
mkdir -p /root/.config/rclone
cat > /root/.config/rclone/rclone.conf <<EOC
[hetzner-s3]
type = s3
provider = Other
access_key_id = ${S3_ACCESS_KEY}
secret_access_key = ${S3_SECRET_KEY}
endpoint = ${S3_ENDPOINT}
acl = private
EOC
SOURCE_BYTES=$(du -sb /source 2>/dev/null | cut -f1 || echo 0)
SOURCE_HUMAN=$(du -sh /source 2>/dev/null | cut -f1 || echo unknown)
echo "Source /source size: ${SOURCE_HUMAN} (${SOURCE_BYTES} bytes)"
# Prometheus TSDB is largely immutable chunk files plus an
# append-only WAL. rclone sync is safe with the database
# live: on restore the WAL is replayed. HOWEVER Prometheus
# compacts blocks every ~2h and deletes their source dirs,
# which races with the copy and produces "no such file or
# directory" errors mid-run. Those are expected and do
# not indicate data loss — the compacted successor blocks
# are picked up on the same or the next daily run. We
# therefore do not fail the job on rclone's non-zero exit
# from those transient errors; instead we validate the
# backup by comparing destination size to source (must be
# >= 80% of source bytes and > 100 MiB).
DEST="hetzner-s3:${S3_BUCKET}/prometheus/prometheus-${DATE}/"
echo "Streaming Prometheus data to ${DEST} ..."
RCLONE_EXIT=0
rclone sync /source "${DEST}" \
--transfers 4 \
--checkers 4 \
--stats 30s \
--stats-log-level NOTICE \
--s3-chunk-size 32M \
--s3-upload-concurrency 4 \
--retries 3 \
--retries-sleep 30s || RCLONE_EXIT=$?
echo "rclone sync exit code: ${RCLONE_EXIT}"
echo "Measuring destination size..."
DEST_BYTES=$(rclone size "${DEST}" --json 2>/dev/null | \
sed -n 's/.*"bytes":\s*\([0-9]\+\).*/\1/p' | head -1)
DEST_BYTES=${DEST_BYTES:-0}
DEST_HUMAN=$(rclone size "${DEST}" 2>/dev/null | \
grep -oE 'Total size:.*' || echo "Total size: unknown")
echo "Destination bytes: ${DEST_BYTES}"
echo "Destination summary: ${DEST_HUMAN}"
MIN_ACCEPTABLE=$(( SOURCE_BYTES * 80 / 100 ))
FLOOR=104857600 # 100 MiB absolute floor
echo "Acceptance threshold: dest >= ${MIN_ACCEPTABLE} bytes and > ${FLOOR} bytes"
if [ "${DEST_BYTES}" -lt "${FLOOR}" ]; then
echo "ERROR: destination is below hard floor (100 MiB) — backup failed."
exit 2
fi
if [ "${DEST_BYTES}" -lt "${MIN_ACCEPTABLE}" ]; then
echo "ERROR: destination is < 80% of source (${DEST_BYTES} < ${MIN_ACCEPTABLE}) — backup incomplete."
exit 3
fi
echo "OK: destination size acceptable."
# Prune by prefix name, NOT by file mtime. rclone preserves
# each source file's mtime on upload, and Prometheus TSDB
# chunk files retain very old mtimes (weeks-old blocks),
# so `rclone delete --min-age 7d` would eat the just-
# uploaded backup. Every top-level prefix is named
# `prometheus-YYYYMMDD-HHMMSS`, so we compare the encoded
# date to a 7-day threshold and purge whole prefixes.
echo "Pruning prometheus backup prefixes older than 7 days..."
# BusyBox date lacks GNU's `-d "7 days ago"` and BSD's `-v -7d`,
# so compute the cutoff via @epoch which BusyBox does support.
CUTOFF_EPOCH=$(( $(date +%s) - 7 * 86400 ))
CUTOFF=$(date -d "@${CUTOFF_EPOCH}" +%Y%m%d)
echo "Cutoff (delete prefixes with date < ${CUTOFF}):"
KEEP=0
PRUNE=0
for PREFIX in $(rclone lsf --dirs-only "hetzner-s3:${S3_BUCKET}/prometheus/" 2>/dev/null | sed 's:/$::'); do
case "${PREFIX}" in
prometheus-*)
PDATE=$(echo "${PREFIX}" | sed -n 's/^prometheus-\([0-9]\{8\}\)-.*/\1/p')
if [ -z "${PDATE}" ]; then
echo " SKIP ${PREFIX} (unparseable name)"
continue
fi
if [ "${PDATE}" -lt "${CUTOFF}" ]; then
echo " PURGE ${PREFIX} (date ${PDATE} < ${CUTOFF})"
rclone purge "hetzner-s3:${S3_BUCKET}/prometheus/${PREFIX}" \
|| echo " WARN purge failed for ${PREFIX} (continuing)"
PRUNE=$((PRUNE + 1))
else
echo " KEEP ${PREFIX} (date ${PDATE})"
KEEP=$((KEEP + 1))
fi
;;
*)
echo " SKIP ${PREFIX} (not a prometheus- prefix)"
;;
esac
done
echo "Prune summary: kept ${KEEP}, purged ${PRUNE}."
echo "Post-run inventory (prometheus/ prefixes):"
rclone lsd "hetzner-s3:${S3_BUCKET}/prometheus/" || true
echo "=== Prometheus backup completed at $(date) ==="
env:
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: monitoring-s3-backup
key: access-key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: monitoring-s3-backup
key: secret-key
- name: S3_ENDPOINT
valueFrom:
secretKeyRef:
name: monitoring-s3-backup
key: endpoint
- name: S3_BUCKET
valueFrom:
secretKeyRef:
name: monitoring-s3-backup
key: bucket
volumeMounts:
- name: prometheus-data
mountPath: /source
readOnly: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 512Mi
volumes:
- name: prometheus-data
persistentVolumeClaim:
claimName: prometheus-data-encrypted