feat(observability): wire node-exporter textfile collector for backup metrics (DEV-494)
Turns the four monitoring backup CronJobs' .prom output into scrapeable
Prometheus series so the DEV-490 alerts finally evaluate against live data.
- apps/observability/patches/node-exporter-textfile-collector.yaml:
strategic-merge patch on the kube-prometheus-stack node-exporter DS
that adds `--collector.textfile.directory=/host/textfile_collector`
and mounts `/var/lib/node_exporter/textfile_collector` read-only.
Chart isn't tracked in ArgoCD, so we keep the patch under version
control and re-apply after any helm upgrade (see patches/README.md).
- apps/monitoring/backup-{loki,grafana,k8s-resources,prometheus}-*-cronjob.yaml:
swap `emptyDir` /metrics for a `hostPath` on the same directory
(`DirectoryOrCreate`). Write via `.prom.tmp` + `mv` so node-exporter
never reads a truncated sample.
- apps/monitoring/backup-restic-alerts.yaml: `time() - max(...) > 28h`
for all four freshness alerts so a stale `.prom` left on a node the
job has since left does not fire the freshness pager.
- apps/monitoring/README.md: drop the "once wired" caveat; document
the on-node directory, atomic write, cross-node staleness rationale.
Verified end-to-end with a synthetic `kubectl create job
--from=cronjob/backup-k8s-resources`: pod ran on k3s-worker-4,
.prom file materialized in /var/lib/node_exporter/textfile_collector,
and Prometheus returned all four metric families
(`backup_k8s_resources_success=1`, `..._timestamp_seconds`,
`..._check_status=0`, `restic_repo_size_bytes{repo="k8s-resources"}=13692516`).
`time() - max(backup_k8s_resources_timestamp_seconds)` returned ~84s
against a fresh run. `promtool check rules` + `promtool test rules`
still pass (9 rules, 5 scenarios).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b1680431c6
commit
ef62dde67c
9 changed files with 411 additions and 176 deletions
|
|
@ -5,12 +5,12 @@ Manifests recording the cluster-side monitoring backup CronJobs that were previo
|
||||||
- `backup-k8s-resources-cronjob.yaml` — daily dump of cluster-scoped and per-namespace Kubernetes resources, streamed through `restic backup --stdin` to `hetzner-s3:${BUCKET}/restic/k8s-resources`. Uses `serviceAccountName: backup-sa` and no PVC mount (init container `alpine/k8s:1.29.4` writes an emptyDir, main container `restic/restic:0.17.3` reads it on stdin). Rewritten from the local-path tarball per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-487](/DEV/issues/DEV-487)).
|
- `backup-k8s-resources-cronjob.yaml` — daily dump of cluster-scoped and per-namespace Kubernetes resources, streamed through `restic backup --stdin` to `hetzner-s3:${BUCKET}/restic/k8s-resources`. Uses `serviceAccountName: backup-sa` and no PVC mount (init container `alpine/k8s:1.29.4` writes an emptyDir, main container `restic/restic:0.17.3` reads it on stdin). Rewritten from the local-path tarball per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-487](/DEV/issues/DEV-487)).
|
||||||
- `backup-loki-restic-cronjob.yaml` — daily restic backup of `loki-storage-encrypted` to `hetzner-s3:${BUCKET}/restic/loki`. Co-schedules with the Loki pod via `podAffinity` (RWO permits additional read-only mounts on the same node). Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-485](/DEV/issues/DEV-485)).
|
- `backup-loki-restic-cronjob.yaml` — daily restic backup of `loki-storage-encrypted` to `hetzner-s3:${BUCKET}/restic/loki`. Co-schedules with the Loki pod via `podAffinity` (RWO permits additional read-only mounts on the same node). Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-485](/DEV/issues/DEV-485)).
|
||||||
- `backup-grafana-restic-cronjob.yaml` — daily restic backup of `grafana-storage` to `hetzner-s3:${BUCKET}/restic/grafana`. Pinned to `k3s-worker-2` via `nodeSelector` (the local-path PV anchors the grafana pod there already, no `podAffinity` needed). Schedule `15 3 * * *` — offset from the loki run at `03:00`. Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-486](/DEV/issues/DEV-486)).
|
- `backup-grafana-restic-cronjob.yaml` — daily restic backup of `grafana-storage` to `hetzner-s3:${BUCKET}/restic/grafana`. Pinned to `k3s-worker-2` via `nodeSelector` (the local-path PV anchors the grafana pod there already, no `podAffinity` needed). Schedule `15 3 * * *` — offset from the loki run at `03:00`. Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-486](/DEV/issues/DEV-486)).
|
||||||
- `prometheus-backup-cronjob.yaml` + `prometheus-backup-sealed.yaml` — dedicated Prometheus data backup that streams `prometheus-data-encrypted` to Hetzner S3 via rclone. Co-schedules with the Prometheus pod via `podAffinity` so the RWO PVC attaches on the same node ([DEV-465](/DEV/issues/DEV-465)). Note: still uses `rclone sync` (plaintext at rest on Hetzner). Migration to `restic` or `rclone crypt` is filed as a follow-up.
|
- `prometheus-backup-cronjob.yaml` + `prometheus-backup-sealed.yaml` — daily restic backup of `prometheus-data-encrypted` to `hetzner-s3:${BUCKET}/restic/prometheus`. Co-schedules with the Prometheus pod via `podAffinity` so the RWO PVC attaches on the same node. Schedule `30 3 * * *` — offset from the loki (03:00) and grafana (03:15) runs. Migrated from the DEV-465 `rclone sync` job to restic client-side encryption per [DEV-492](/DEV/issues/DEV-492) / [DEV-482](/DEV/issues/DEV-482) Option 4. **Compaction-race mitigation:** `--exclude wal/*` + `--exclude chunks_head/*` + accept `restic backup` exit code 3 (source file vanished mid-walk) as a warning, not a failure; restore drill re-runs `promtool tsdb analyze` per block.
|
||||||
- `backup-restic-alerts.yaml` + `backup-restic-alerts.test.yaml` — `PrometheusRule` with freshness, integrity, and repo-size alerts covering the three restic repos, plus a `promtool test rules` unit test proving each alert fires against synthetic samples ([DEV-490](/DEV/issues/DEV-490)).
|
- `backup-restic-alerts.yaml` + `backup-restic-alerts.test.yaml` — `PrometheusRule` with freshness, integrity, and repo-size alerts covering the four restic repos (loki/grafana/k8s-resources/prometheus), plus a `promtool test rules` unit test proving each alert fires against synthetic samples ([DEV-490](/DEV/issues/DEV-490), extended in [DEV-492](/DEV/issues/DEV-492)).
|
||||||
|
|
||||||
## Shared SealedSecret
|
## Shared SealedSecret
|
||||||
|
|
||||||
All three restic CronJobs read Hetzner S3 credentials + the restic repository password from **SealedSecret `monitoring-s3-backup`** (namespace `monitoring`). Keys:
|
All four restic CronJobs read Hetzner S3 credentials + the restic repository password from **SealedSecret `monitoring-s3-backup`** (namespace `monitoring`). Keys:
|
||||||
|
|
||||||
| Key | Purpose |
|
| Key | Purpose |
|
||||||
|------------------|------------------------------------------------------------------------------------------------------------------|
|
|------------------|------------------------------------------------------------------------------------------------------------------|
|
||||||
|
|
@ -18,7 +18,7 @@ All three restic CronJobs read Hetzner S3 credentials + the restic repository pa
|
||||||
| `secret-key` | Hetzner Object Storage secret access key |
|
| `secret-key` | Hetzner Object Storage secret access key |
|
||||||
| `endpoint` | S3 endpoint hostname (e.g. `fsn1.your-objectstorage.com`) |
|
| `endpoint` | S3 endpoint hostname (e.g. `fsn1.your-objectstorage.com`) |
|
||||||
| `bucket` | Bucket name (single bucket, per-prefix repos) |
|
| `bucket` | Bucket name (single bucket, per-prefix repos) |
|
||||||
| `restic-password`| 32-byte random string sealed at [DEV-484](/DEV/issues/DEV-484); plaintext copy in Passbolt entry `restic / monitoring backups`. Rotation: `restic key add` → seal new value → `restic key remove` old id. |
|
| `restic-password`| 32-byte random string sealed at [DEV-484](/DEV/issues/DEV-484); plaintext copy in Passbolt entry `restic / monitoring backups`. Rotation: `restic key add` → seal new value → `restic key remove` old id. Same key protects all four repos (loki/grafana/k8s-resources/prometheus) — rotating rewrites the key file on every repo. |
|
||||||
|
|
||||||
## Restore
|
## Restore
|
||||||
|
|
||||||
|
|
@ -26,21 +26,24 @@ Restore procedure for all three restic repos: [`docs/monitoring/restic-restore.m
|
||||||
|
|
||||||
## Emitted metrics (textfile-collector format)
|
## Emitted metrics (textfile-collector format)
|
||||||
|
|
||||||
Each restic CronJob writes to `/metrics/backup_<kind>.prom` inside the pod's emptyDir. Once node-exporter's textfile-collector path is wired up (follow-up), these become scrapeable and the alerts in `backup-restic-alerts.yaml` evaluate against live data.
|
Each restic CronJob writes `/metrics/backup_<kind>.prom` (atomic — `.prom.tmp` + `mv`) into a `hostPath` volume mounted at the node-exporter textfile-collector directory (`/var/lib/node_exporter/textfile_collector`). The kube-prometheus-stack node-exporter DaemonSet has `--collector.textfile.directory=/host/textfile_collector` enabled ([DEV-494](/DEV/issues/DEV-494), applied via [`apps/observability/patches/node-exporter-textfile-collector.yaml`](../observability/patches/node-exporter-textfile-collector.yaml)) and surfaces those samples in Prometheus.
|
||||||
|
|
||||||
| Metric | Emitted by |
|
| Metric | Emitted by |
|
||||||
|--------------------------------------------|---------------------------------------------------------------------|
|
|--------------------------------------------|----------------------------------------------------------------------------------|
|
||||||
| `backup_<kind>_success` (0/1) | `backup-{loki,grafana,k8s-resources}-*-cronjob.yaml` |
|
| `backup_<kind>_success` (0/1) | `backup-{loki,grafana,k8s-resources}-*-cronjob.yaml`, `prometheus-backup-cronjob.yaml` |
|
||||||
| `backup_<kind>_timestamp_seconds` | same |
|
| `backup_<kind>_timestamp_seconds` | same |
|
||||||
| `backup_<kind>_check_status` (exit code) | same — from `restic check --read-data-subset=5%` |
|
| `backup_<kind>_check_status` (exit code) | same — from `restic check --read-data-subset=5%` |
|
||||||
| `restic_repo_size_bytes{repo="<kind>"}` | same — from `restic stats --json --mode raw-data` (added in DEV-490)|
|
| `backup_prometheus_backup_status` | `prometheus-backup-cronjob.yaml` — `restic backup` exit code (3 = accepted compaction race) |
|
||||||
|
| `restic_repo_size_bytes{repo="<kind>"}` | same — from `restic stats --json --mode raw-data` (added in DEV-490) |
|
||||||
|
|
||||||
|
**Cross-node staleness note.** Because a backup CronJob may run on a different worker across days (loki/prometheus follow their app pods, `k8s-resources` is unpinned), a `.prom` file can linger on a node the job has since left and node-exporter keeps exposing it. The freshness alerts collapse the per-node samples with `max()` so the freshest sample wins; check/size alerts fire when *any* node reports a bad value, which is intentional — a recent failure is still a signal until the file is manually cleaned or the job returns to that node.
|
||||||
|
|
||||||
## Alerts
|
## Alerts
|
||||||
|
|
||||||
`backup-restic-alerts.yaml` defines seven alerts (all `severity: warning`):
|
`backup-restic-alerts.yaml` defines nine alerts (all `severity: warning`):
|
||||||
|
|
||||||
- `BackupLokiStale` / `BackupGrafanaStale` / `BackupK8sResourcesStale` — `time() - backup_<kind>_timestamp_seconds > 28h`. Daily schedule + 4 h grace.
|
- `BackupLokiStale` / `BackupGrafanaStale` / `BackupK8sResourcesStale` / `BackupPrometheusStale` — `time() - max(backup_<kind>_timestamp_seconds) > 28h`. Daily schedule + 4 h grace. `max()` collapses per-node samples so a stale `.prom` on a node the job has left does not fire.
|
||||||
- `BackupLokiCheckFailed` / `BackupGrafanaCheckFailed` / `BackupK8sResourcesCheckFailed` — `backup_<kind>_check_status != 0`.
|
- `BackupLokiCheckFailed` / `BackupGrafanaCheckFailed` / `BackupK8sResourcesCheckFailed` / `BackupPrometheusCheckFailed` — `backup_<kind>_check_status != 0`.
|
||||||
- `ResticRepoOversize` — `restic_repo_size_bytes > 20 GiB`. Baseline expected < 5 GiB; catches retention/prune regressions.
|
- `ResticRepoOversize` — `restic_repo_size_bytes > 20 GiB`. Baseline expected < 5 GiB; catches retention/prune regressions.
|
||||||
|
|
||||||
All alerts carry a `Runbook: docs/monitoring/restic-restore.md` annotation. To iterate on the rule file locally:
|
All alerts carry a `Runbook: docs/monitoring/restic-restore.md` annotation. To iterate on the rule file locally:
|
||||||
|
|
@ -52,4 +55,4 @@ promtool check rules /tmp/backup-restic-rules.yaml
|
||||||
promtool test rules apps/monitoring/backup-restic-alerts.test.yaml
|
promtool test rules apps/monitoring/backup-restic-alerts.test.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
The legacy `backup-volumes` CronJob and its 100 Gi local-path `backup-storage` PVC were retired in [DEV-489](/DEV/issues/DEV-489) once the restic pipeline was proven end-to-end. With the shared destination PVC gone, the DEV-483 bridge `nodeSelector` pinning Loki to `k3s-worker-2` was also removed — `backup-loki-restic` follows the Loki pod via `podAffinity` regardless of which node the RWO CSI volume lands on. `backup-grafana-restic` still nodeSelects `k3s-worker-2` because its source PV (`grafana-storage`, local-path) is anchored there. `backup-k8s-resources` and `prometheus-backup` remain unpinned.
|
The legacy `backup-volumes` CronJob and its 100 Gi local-path `backup-storage` PVC were retired in [DEV-489](/DEV/issues/DEV-489) once the restic pipeline was proven end-to-end. With the shared destination PVC gone, the DEV-483 bridge `nodeSelector` pinning Loki to `k3s-worker-2` was also removed — `backup-loki-restic` follows the Loki pod via `podAffinity` regardless of which node the RWO CSI volume lands on. `backup-grafana-restic` still nodeSelects `k3s-worker-2` because its source PV (`grafana-storage`, local-path) is anchored there. `backup-k8s-resources` has no PVC dep and stays unpinned. `prometheus-backup` uses `podAffinity` on `app=prometheus` (RWO PVC on Hetzner CSI, single-node attach) and follows the Prometheus pod between workers.
|
||||||
|
|
|
||||||
|
|
@ -118,15 +118,16 @@ spec:
|
||||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||||
|
|
||||||
# Textfile-collector metrics; identical wiring to the loki
|
# Textfile-collector metrics; identical wiring to the
|
||||||
# sibling. Scrapeable once node-exporter's textfile
|
# loki sibling. See that file for the atomic-write
|
||||||
# collector path is enabled — tracked in DEV-482.
|
# rationale (DEV-494).
|
||||||
{
|
{
|
||||||
echo "backup_grafana_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
echo "backup_grafana_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||||
echo "backup_grafana_timestamp_seconds $(date +%s)"
|
echo "backup_grafana_timestamp_seconds $(date +%s)"
|
||||||
echo "backup_grafana_check_status ${CHECK_STATUS}"
|
echo "backup_grafana_check_status ${CHECK_STATUS}"
|
||||||
echo "restic_repo_size_bytes{repo=\"grafana\"} ${REPO_SIZE_BYTES}"
|
echo "restic_repo_size_bytes{repo=\"grafana\"} ${REPO_SIZE_BYTES}"
|
||||||
} > /metrics/backup_grafana.prom
|
} > /metrics/backup_grafana.prom.tmp
|
||||||
|
mv /metrics/backup_grafana.prom.tmp /metrics/backup_grafana.prom
|
||||||
|
|
||||||
echo "=== backup-grafana-restic finished at $(date -u +%FT%TZ) ==="
|
echo "=== backup-grafana-restic finished at $(date -u +%FT%TZ) ==="
|
||||||
exit ${CHECK_STATUS}
|
exit ${CHECK_STATUS}
|
||||||
|
|
@ -150,6 +151,10 @@ spec:
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: grafana-storage
|
claimName: grafana-storage
|
||||||
- name: metrics
|
- name: metrics
|
||||||
emptyDir: {}
|
hostPath:
|
||||||
|
# node-exporter's textfile-collector directory
|
||||||
|
# (DEV-494). See sibling loki cronjob for detail.
|
||||||
|
path: /var/lib/node_exporter/textfile_collector
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: cache
|
- name: cache
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|
|
||||||
|
|
@ -165,14 +165,15 @@ spec:
|
||||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||||
|
|
||||||
# Textfile-collector metrics; identical wiring to the
|
# Textfile-collector metrics; identical wiring to the
|
||||||
# loki/grafana siblings. Scrapeable once node-exporter's
|
# loki/grafana siblings. See loki cronjob for the
|
||||||
# textfile collector path is enabled — tracked in DEV-482.
|
# atomic-write rationale (DEV-494).
|
||||||
{
|
{
|
||||||
echo "backup_k8s_resources_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
echo "backup_k8s_resources_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||||
echo "backup_k8s_resources_timestamp_seconds $(date +%s)"
|
echo "backup_k8s_resources_timestamp_seconds $(date +%s)"
|
||||||
echo "backup_k8s_resources_check_status ${CHECK_STATUS}"
|
echo "backup_k8s_resources_check_status ${CHECK_STATUS}"
|
||||||
echo "restic_repo_size_bytes{repo=\"k8s-resources\"} ${REPO_SIZE_BYTES}"
|
echo "restic_repo_size_bytes{repo=\"k8s-resources\"} ${REPO_SIZE_BYTES}"
|
||||||
} > /metrics/backup_k8s_resources.prom
|
} > /metrics/backup_k8s_resources.prom.tmp
|
||||||
|
mv /metrics/backup_k8s_resources.prom.tmp /metrics/backup_k8s_resources.prom
|
||||||
|
|
||||||
echo "=== backup-k8s-resources-restic finished at $(date -u +%FT%TZ) ==="
|
echo "=== backup-k8s-resources-restic finished at $(date -u +%FT%TZ) ==="
|
||||||
exit ${CHECK_STATUS}
|
exit ${CHECK_STATUS}
|
||||||
|
|
@ -195,6 +196,10 @@ spec:
|
||||||
- name: dump
|
- name: dump
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
- name: metrics
|
- name: metrics
|
||||||
emptyDir: {}
|
hostPath:
|
||||||
|
# node-exporter's textfile-collector directory
|
||||||
|
# (DEV-494). See sibling loki cronjob for detail.
|
||||||
|
path: /var/lib/node_exporter/textfile_collector
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: cache
|
- name: cache
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|
|
||||||
|
|
@ -134,17 +134,19 @@ spec:
|
||||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||||
|
|
||||||
# Textfile-collector metrics. Written into an emptyDir per
|
# Textfile-collector metrics. Written to the shared
|
||||||
# the current pattern used by the other backup CronJobs.
|
# host directory that node-exporter's textfile collector
|
||||||
# Once a node-exporter textfile collector path is wired
|
# scrapes (DEV-494). Atomic write: build the file with a
|
||||||
# up, these become scrapeable — see the follow-up notes
|
# `.tmp` extension (ignored by node-exporter) and rename
|
||||||
# in DEV-482.
|
# into place, so a mid-write read never surfaces a
|
||||||
|
# truncated sample.
|
||||||
{
|
{
|
||||||
echo "backup_loki_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
echo "backup_loki_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||||
echo "backup_loki_timestamp_seconds $(date +%s)"
|
echo "backup_loki_timestamp_seconds $(date +%s)"
|
||||||
echo "backup_loki_check_status ${CHECK_STATUS}"
|
echo "backup_loki_check_status ${CHECK_STATUS}"
|
||||||
echo "restic_repo_size_bytes{repo=\"loki\"} ${REPO_SIZE_BYTES}"
|
echo "restic_repo_size_bytes{repo=\"loki\"} ${REPO_SIZE_BYTES}"
|
||||||
} > /metrics/backup_loki.prom
|
} > /metrics/backup_loki.prom.tmp
|
||||||
|
mv /metrics/backup_loki.prom.tmp /metrics/backup_loki.prom
|
||||||
|
|
||||||
echo "=== backup-loki-restic finished at $(date -u +%FT%TZ) ==="
|
echo "=== backup-loki-restic finished at $(date -u +%FT%TZ) ==="
|
||||||
exit ${CHECK_STATUS}
|
exit ${CHECK_STATUS}
|
||||||
|
|
@ -173,6 +175,13 @@ spec:
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: loki-storage-encrypted
|
claimName: loki-storage-encrypted
|
||||||
- name: metrics
|
- name: metrics
|
||||||
emptyDir: {}
|
hostPath:
|
||||||
|
# node-exporter's textfile-collector directory
|
||||||
|
# (DEV-494). `DirectoryOrCreate` lets kubelet create
|
||||||
|
# the dir on the current node if it does not yet
|
||||||
|
# exist — nodes were pre-created out-of-band, this
|
||||||
|
# is a safety net.
|
||||||
|
path: /var/lib/node_exporter/textfile_collector
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: cache
|
- name: cache
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ tests:
|
||||||
values: '-104400x60'
|
values: '-104400x60'
|
||||||
- series: 'backup_k8s_resources_timestamp_seconds'
|
- series: 'backup_k8s_resources_timestamp_seconds'
|
||||||
values: '-104400x60'
|
values: '-104400x60'
|
||||||
|
- series: 'backup_prometheus_timestamp_seconds'
|
||||||
|
values: '-104400x60'
|
||||||
alert_rule_test:
|
alert_rule_test:
|
||||||
- eval_time: 30m
|
- eval_time: 30m
|
||||||
alertname: BackupLokiStale
|
alertname: BackupLokiStale
|
||||||
|
|
@ -83,6 +85,21 @@ tests:
|
||||||
`backup_k8s_resources_timestamp_seconds` sample in
|
`backup_k8s_resources_timestamp_seconds` sample in
|
||||||
more than 28 hours. Expected daily at 02:00 UTC.
|
more than 28 hours. Expected daily at 02:00 UTC.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: BackupPrometheusStale
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
service: monitoring
|
||||||
|
component: backup
|
||||||
|
repo: prometheus
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Prometheus restic backup is stale (>28h)"
|
||||||
|
description: |
|
||||||
|
`prometheus-backup` has not written a fresh
|
||||||
|
`backup_prometheus_timestamp_seconds` sample in more
|
||||||
|
than 28 hours. Expected daily at 03:30 UTC.
|
||||||
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Freshness alerts stay silent when timestamp is fresh (<28h).
|
# 2. Freshness alerts stay silent when timestamp is fresh (<28h).
|
||||||
|
|
@ -97,6 +114,8 @@ tests:
|
||||||
values: '-3600x60'
|
values: '-3600x60'
|
||||||
- series: 'backup_k8s_resources_timestamp_seconds'
|
- series: 'backup_k8s_resources_timestamp_seconds'
|
||||||
values: '-3600x60'
|
values: '-3600x60'
|
||||||
|
- series: 'backup_prometheus_timestamp_seconds'
|
||||||
|
values: '-3600x60'
|
||||||
alert_rule_test:
|
alert_rule_test:
|
||||||
- eval_time: 30m
|
- eval_time: 30m
|
||||||
alertname: BackupLokiStale
|
alertname: BackupLokiStale
|
||||||
|
|
@ -107,6 +126,9 @@ tests:
|
||||||
- eval_time: 30m
|
- eval_time: 30m
|
||||||
alertname: BackupK8sResourcesStale
|
alertname: BackupK8sResourcesStale
|
||||||
exp_alerts: []
|
exp_alerts: []
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: BackupPrometheusStale
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. Check-status alerts fire when the metric is non-zero.
|
# 3. Check-status alerts fire when the metric is non-zero.
|
||||||
|
|
@ -119,6 +141,8 @@ tests:
|
||||||
values: '2x30'
|
values: '2x30'
|
||||||
- series: 'backup_k8s_resources_check_status'
|
- series: 'backup_k8s_resources_check_status'
|
||||||
values: '1x30'
|
values: '1x30'
|
||||||
|
- series: 'backup_prometheus_check_status'
|
||||||
|
values: '1x30'
|
||||||
alert_rule_test:
|
alert_rule_test:
|
||||||
- eval_time: 15m
|
- eval_time: 15m
|
||||||
alertname: BackupLokiCheckFailed
|
alertname: BackupLokiCheckFailed
|
||||||
|
|
@ -163,6 +187,21 @@ tests:
|
||||||
exit code 1 on the k8s-resources
|
exit code 1 on the k8s-resources
|
||||||
repository.
|
repository.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
- eval_time: 15m
|
||||||
|
alertname: BackupPrometheusCheckFailed
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
service: monitoring
|
||||||
|
component: backup
|
||||||
|
repo: prometheus
|
||||||
|
exp_annotations:
|
||||||
|
summary: "restic check failed on prometheus repo"
|
||||||
|
description: |
|
||||||
|
`restic check --read-data-subset=5%` returned
|
||||||
|
exit code 1 on the prometheus
|
||||||
|
repository.
|
||||||
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. Check-status alerts stay silent on 0.
|
# 4. Check-status alerts stay silent on 0.
|
||||||
|
|
@ -175,6 +214,8 @@ tests:
|
||||||
values: '0x30'
|
values: '0x30'
|
||||||
- series: 'backup_k8s_resources_check_status'
|
- series: 'backup_k8s_resources_check_status'
|
||||||
values: '0x30'
|
values: '0x30'
|
||||||
|
- series: 'backup_prometheus_check_status'
|
||||||
|
values: '0x30'
|
||||||
alert_rule_test:
|
alert_rule_test:
|
||||||
- eval_time: 15m
|
- eval_time: 15m
|
||||||
alertname: BackupLokiCheckFailed
|
alertname: BackupLokiCheckFailed
|
||||||
|
|
@ -185,6 +226,9 @@ tests:
|
||||||
- eval_time: 15m
|
- eval_time: 15m
|
||||||
alertname: BackupK8sResourcesCheckFailed
|
alertname: BackupK8sResourcesCheckFailed
|
||||||
exp_alerts: []
|
exp_alerts: []
|
||||||
|
- eval_time: 15m
|
||||||
|
alertname: BackupPrometheusCheckFailed
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Repo-size alert fires when >20 GiB (21474836480 bytes).
|
# 5. Repo-size alert fires when >20 GiB (21474836480 bytes).
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,19 @@
|
||||||
# by the same CronJob step (see backup-*-restic-cronjob.yaml,
|
# by the same CronJob step (see backup-*-restic-cronjob.yaml,
|
||||||
# `restic stats --json --mode raw-data`).
|
# `restic stats --json --mode raw-data`).
|
||||||
#
|
#
|
||||||
# The metrics are written to an emptyDir at /metrics per job.
|
# The metrics are written to the node's textfile-collector directory
|
||||||
# They become scrapeable once node-exporter's textfile collector
|
# (`/var/lib/node_exporter/textfile_collector`) and scraped by the
|
||||||
# path is wired up — tracked as a follow-up. `promtool test rules`
|
# kube-prometheus-stack node-exporter DaemonSet (DEV-494).
|
||||||
# against apps/monitoring/backup-restic-alerts.test.yaml verifies
|
#
|
||||||
# the rules with synthetic samples in the meantime.
|
# Because a CronJob may run on a different worker across days (loki
|
||||||
|
# backup follows the loki pod; k8s-resources is unpinned), stale
|
||||||
|
# `.prom` files can linger on nodes the job has since left. That
|
||||||
|
# would leave a per-node series with an old timestamp/check-status
|
||||||
|
# indefinitely. The alerts below aggregate across instances so a
|
||||||
|
# single fresh sample from the node where the job currently runs is
|
||||||
|
# enough to keep the freshness alert quiet, and check/size alerts
|
||||||
|
# fire when *any* node reports a bad value (which is the correct
|
||||||
|
# behaviour — a recent failure is still a signal).
|
||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: PrometheusRule
|
kind: PrometheusRule
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -34,7 +42,10 @@ spec:
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
- alert: BackupLokiStale
|
- alert: BackupLokiStale
|
||||||
expr: time() - backup_loki_timestamp_seconds > 28 * 3600
|
# `max()` collapses per-node samples so a stale `.prom` file
|
||||||
|
# left behind on a node the loki backup no longer runs on
|
||||||
|
# does not fire this alert; the freshest sample wins.
|
||||||
|
expr: time() - max(backup_loki_timestamp_seconds) > 28 * 3600
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -49,7 +60,7 @@ spec:
|
||||||
28 hours. Expected daily at 03:00 UTC.
|
28 hours. Expected daily at 03:00 UTC.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
- alert: BackupGrafanaStale
|
- alert: BackupGrafanaStale
|
||||||
expr: time() - backup_grafana_timestamp_seconds > 28 * 3600
|
expr: time() - max(backup_grafana_timestamp_seconds) > 28 * 3600
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -64,7 +75,7 @@ spec:
|
||||||
than 28 hours. Expected daily at 03:15 UTC.
|
than 28 hours. Expected daily at 03:15 UTC.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
- alert: BackupK8sResourcesStale
|
- alert: BackupK8sResourcesStale
|
||||||
expr: time() - backup_k8s_resources_timestamp_seconds > 28 * 3600
|
expr: time() - max(backup_k8s_resources_timestamp_seconds) > 28 * 3600
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -78,6 +89,21 @@ spec:
|
||||||
`backup_k8s_resources_timestamp_seconds` sample in
|
`backup_k8s_resources_timestamp_seconds` sample in
|
||||||
more than 28 hours. Expected daily at 02:00 UTC.
|
more than 28 hours. Expected daily at 02:00 UTC.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
- alert: BackupPrometheusStale
|
||||||
|
expr: time() - max(backup_prometheus_timestamp_seconds) > 28 * 3600
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: monitoring
|
||||||
|
component: backup
|
||||||
|
repo: prometheus
|
||||||
|
annotations:
|
||||||
|
summary: "Prometheus restic backup is stale (>28h)"
|
||||||
|
description: |
|
||||||
|
`prometheus-backup` has not written a fresh
|
||||||
|
`backup_prometheus_timestamp_seconds` sample in more
|
||||||
|
than 28 hours. Expected daily at 03:30 UTC.
|
||||||
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
- name: backup-restic.integrity
|
- name: backup-restic.integrity
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
|
|
@ -124,6 +150,21 @@ spec:
|
||||||
exit code {{ $value }} on the k8s-resources
|
exit code {{ $value }} on the k8s-resources
|
||||||
repository.
|
repository.
|
||||||
Runbook: docs/monitoring/restic-restore.md
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
|
- alert: BackupPrometheusCheckFailed
|
||||||
|
expr: backup_prometheus_check_status != 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: monitoring
|
||||||
|
component: backup
|
||||||
|
repo: prometheus
|
||||||
|
annotations:
|
||||||
|
summary: "restic check failed on prometheus repo"
|
||||||
|
description: |
|
||||||
|
`restic check --read-data-subset=5%` returned
|
||||||
|
exit code {{ $value }} on the prometheus
|
||||||
|
repository.
|
||||||
|
Runbook: docs/monitoring/restic-restore.md
|
||||||
- name: backup-restic.size
|
- name: backup-restic.size
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,43 @@
|
||||||
---
|
---
|
||||||
# Prometheus data backup (DEV-465).
|
# Prometheus data backup via restic to Hetzner Object Storage
|
||||||
|
# (DEV-492, DEV-482 Option 4). Replaces the DEV-465 rclone-sync job so
|
||||||
|
# every monitoring backup ships client-side-encrypted; Hetzner Object
|
||||||
|
# Storage has no SSE-S3/SSE-KMS, so the previous plaintext-at-rest
|
||||||
|
# object layout was the only remaining gap.
|
||||||
#
|
#
|
||||||
# Prometheus data lives on the RWO PVC `prometheus-data-encrypted` in
|
# Streams the RWO PVC `prometheus-data-encrypted` (mounted read-only)
|
||||||
# namespace `monitoring`. That PVC is mounted by the Prometheus pod, and
|
# into `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/prometheus`, a client-side
|
||||||
# the Hetzner CSI volume can only be attached to one node at a time.
|
# encrypted restic repository (tag=`prometheus`, host=`k3s`).
|
||||||
#
|
#
|
||||||
# This CronJob co-schedules with the Prometheus pod via podAffinity, so it
|
# Node scheduling matches the previous job: podAffinity co-schedules
|
||||||
# lands on whichever node currently holds `prometheus-data-encrypted`. The
|
# with the Prometheus pod (app=prometheus, topology
|
||||||
# PVC is mounted read-only alongside the running Prometheus pod (RWO permits
|
# kubernetes.io/hostname). Hetzner CSI RWO permits additional read-only
|
||||||
# additional read-only mounts on the same node) and streamed to Hetzner S3
|
# mounts on the node that holds the PVC's VolumeAttachment, so this
|
||||||
# via rclone under `basicstack-backup/prometheus/prometheus-<DATE>/`. Old
|
# survives Prometheus being rescheduled to a different worker.
|
||||||
# snapshots are pruned after 7 days.
|
#
|
||||||
|
# Prometheus TSDB compaction race
|
||||||
|
# --------------------------------
|
||||||
|
# Prometheus rewrites the on-disk store roughly every 2 h: it creates a
|
||||||
|
# new block dir, then deletes the source dirs. restic walks the source
|
||||||
|
# tree once and may catch a file that disappeared mid-walk; restic
|
||||||
|
# 0.17.3 exits 3 ("at least one source file could not be read") in
|
||||||
|
# that case, and the snapshot excludes only the missing file. The
|
||||||
|
# next daily run picks up the successor block, so the race is not a
|
||||||
|
# data-loss risk — but we must not treat exit 3 as a hard failure, or
|
||||||
|
# the daily job will alert-flap.
|
||||||
|
#
|
||||||
|
# Mitigation:
|
||||||
|
# - exclude `wal/*` (WAL is replayed from a fresh instance on
|
||||||
|
# restart; we accept losing the last ~15 s of ingested samples
|
||||||
|
# rather than snapshotting a moving segment)
|
||||||
|
# - exclude `chunks_head/*` (in-memory head block; ephemeral, would
|
||||||
|
# be rebuilt from WAL which we do not keep)
|
||||||
|
# - exclude Prometheus lock/scratch files (`lock`, `queries.active`,
|
||||||
|
# `*.tmp`, `lost+found/*`)
|
||||||
|
# - treat restic exit code 3 as a soft warning (log, continue);
|
||||||
|
# any other non-zero exit is still fatal
|
||||||
|
# - restore drill re-runs `promtool tsdb analyze` against every
|
||||||
|
# block so a corrupted snapshot is caught end-to-end
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -19,8 +46,9 @@ metadata:
|
||||||
labels:
|
labels:
|
||||||
app: backup
|
app: backup
|
||||||
type: prometheus
|
type: prometheus
|
||||||
|
backend: restic
|
||||||
spec:
|
spec:
|
||||||
schedule: "30 3 * * *" # daily 03:30, offset from the loki/grafana restic runs
|
schedule: "30 3 * * *" # daily 03:30, offset from loki/grafana/k8s-resources
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
successfulJobsHistoryLimit: 3
|
successfulJobsHistoryLimit: 3
|
||||||
failedJobsHistoryLimit: 3
|
failedJobsHistoryLimit: 3
|
||||||
|
|
@ -29,19 +57,18 @@ spec:
|
||||||
labels:
|
labels:
|
||||||
app: backup
|
app: backup
|
||||||
type: prometheus
|
type: prometheus
|
||||||
|
backend: restic
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
activeDeadlineSeconds: 3600 # 60 min hard cap; Prometheus data is ~9GB
|
activeDeadlineSeconds: 3600
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: backup
|
app: backup
|
||||||
type: prometheus
|
type: prometheus
|
||||||
|
backend: restic
|
||||||
spec:
|
spec:
|
||||||
restartPolicy: OnFailure
|
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:
|
affinity:
|
||||||
podAffinity:
|
podAffinity:
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
|
@ -53,134 +80,24 @@ spec:
|
||||||
- prometheus
|
- prometheus
|
||||||
topologyKey: kubernetes.io/hostname
|
topologyKey: kubernetes.io/hostname
|
||||||
containers:
|
containers:
|
||||||
- name: prometheus-backup
|
- name: restic
|
||||||
image: rclone/rclone:1.68
|
image: restic/restic:0.17.3
|
||||||
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:
|
env:
|
||||||
- name: S3_ACCESS_KEY
|
- name: AWS_ACCESS_KEY_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: monitoring-s3-backup
|
name: monitoring-s3-backup
|
||||||
key: access-key
|
key: access-key
|
||||||
- name: S3_SECRET_KEY
|
- name: AWS_SECRET_ACCESS_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: monitoring-s3-backup
|
name: monitoring-s3-backup
|
||||||
key: secret-key
|
key: secret-key
|
||||||
|
- name: RESTIC_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: monitoring-s3-backup
|
||||||
|
key: restic-password
|
||||||
- name: S3_ENDPOINT
|
- name: S3_ENDPOINT
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|
@ -191,18 +108,121 @@ spec:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: monitoring-s3-backup
|
name: monitoring-s3-backup
|
||||||
key: bucket
|
key: bucket
|
||||||
|
- name: RESTIC_REPOSITORY
|
||||||
|
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/prometheus"
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
echo "=== backup-prometheus-restic started at $(date -u +%FT%TZ) ==="
|
||||||
|
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||||
|
|
||||||
|
# First-run tolerance: init if the repo isn't there yet.
|
||||||
|
if restic snapshots >/dev/null 2>&1; then
|
||||||
|
echo "Repo exists, skipping init."
|
||||||
|
else
|
||||||
|
echo "Repo missing, initialising..."
|
||||||
|
restic init
|
||||||
|
fi
|
||||||
|
|
||||||
|
# See "Prometheus TSDB compaction race" in the file
|
||||||
|
# header. WAL + chunks_head are excluded on purpose;
|
||||||
|
# exit code 3 (source file vanished mid-walk during a
|
||||||
|
# compaction) is accepted and reported, any other
|
||||||
|
# non-zero exit is fatal.
|
||||||
|
echo "--- restic backup /source (exclude wal/chunks_head + lock files) ---"
|
||||||
|
BACKUP_STATUS=0
|
||||||
|
restic backup /source \
|
||||||
|
--tag prometheus \
|
||||||
|
--host k3s \
|
||||||
|
--exclude 'wal/*' \
|
||||||
|
--exclude 'chunks_head/*' \
|
||||||
|
--exclude 'lock' \
|
||||||
|
--exclude 'queries.active' \
|
||||||
|
--exclude 'lost+found/*' \
|
||||||
|
--exclude '*.tmp' || BACKUP_STATUS=$?
|
||||||
|
echo "restic backup exit: ${BACKUP_STATUS}"
|
||||||
|
if [ "${BACKUP_STATUS}" -eq 0 ]; then
|
||||||
|
echo "backup: all files captured cleanly"
|
||||||
|
elif [ "${BACKUP_STATUS}" -eq 3 ]; then
|
||||||
|
echo "backup: exit 3 (source files vanished mid-walk) — expected under Prometheus compaction, continuing"
|
||||||
|
else
|
||||||
|
echo "backup: FATAL exit ${BACKUP_STATUS} (not compaction-race)"
|
||||||
|
exit ${BACKUP_STATUS}
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- restic forget/prune ---"
|
||||||
|
restic forget --tag prometheus \
|
||||||
|
--keep-daily 7 \
|
||||||
|
--keep-weekly 4 \
|
||||||
|
--keep-monthly 6 \
|
||||||
|
--prune
|
||||||
|
|
||||||
|
echo "--- restic check --read-data-subset=5% ---"
|
||||||
|
CHECK_STATUS=0
|
||||||
|
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||||
|
echo "restic check exit: ${CHECK_STATUS}"
|
||||||
|
|
||||||
|
echo "--- restic stats (repo size) ---"
|
||||||
|
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||||
|
| grep -oE '"total_size":[0-9]+' \
|
||||||
|
| head -1 \
|
||||||
|
| cut -d: -f2)
|
||||||
|
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||||
|
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||||
|
|
||||||
|
# Textfile-collector metrics. Same hostPath pattern as
|
||||||
|
# the loki/grafana/k8s-resources siblings (DEV-494) —
|
||||||
|
# written atomically via `.tmp` + rename so a mid-write
|
||||||
|
# read never surfaces a truncated sample.
|
||||||
|
# backup_prometheus_success rolls in both stages: the
|
||||||
|
# backup step (accepting exit 3) and restic check.
|
||||||
|
BACKUP_OK=0
|
||||||
|
if [ "${BACKUP_STATUS}" -eq 0 ] || [ "${BACKUP_STATUS}" -eq 3 ]; then
|
||||||
|
BACKUP_OK=1
|
||||||
|
fi
|
||||||
|
SUCCESS=0
|
||||||
|
if [ "${BACKUP_OK}" -eq 1 ] && [ "${CHECK_STATUS}" -eq 0 ]; then
|
||||||
|
SUCCESS=1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "backup_prometheus_success ${SUCCESS}"
|
||||||
|
echo "backup_prometheus_timestamp_seconds $(date +%s)"
|
||||||
|
echo "backup_prometheus_check_status ${CHECK_STATUS}"
|
||||||
|
echo "backup_prometheus_backup_status ${BACKUP_STATUS}"
|
||||||
|
echo "restic_repo_size_bytes{repo=\"prometheus\"} ${REPO_SIZE_BYTES}"
|
||||||
|
} > /metrics/backup_prometheus.prom.tmp
|
||||||
|
mv /metrics/backup_prometheus.prom.tmp /metrics/backup_prometheus.prom
|
||||||
|
|
||||||
|
echo "=== backup-prometheus-restic finished at $(date -u +%FT%TZ) ==="
|
||||||
|
exit ${CHECK_STATUS}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: prometheus-data
|
- name: prometheus-data
|
||||||
mountPath: /source
|
mountPath: /source
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
- name: metrics
|
||||||
|
mountPath: /metrics
|
||||||
|
- name: cache
|
||||||
|
mountPath: /root/.cache/restic
|
||||||
resources:
|
resources:
|
||||||
|
# Prometheus TSDB is ~8-10 GiB; give restic room to
|
||||||
|
# burst during pack/check but keep steady-state small.
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
memory: 128Mi
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 1000m
|
cpu: 1500m
|
||||||
memory: 512Mi
|
memory: 1Gi
|
||||||
volumes:
|
volumes:
|
||||||
- name: prometheus-data
|
- name: prometheus-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: prometheus-data-encrypted
|
claimName: prometheus-data-encrypted
|
||||||
|
- name: metrics
|
||||||
|
hostPath:
|
||||||
|
# node-exporter's textfile-collector directory
|
||||||
|
# (DEV-494). See sibling loki cronjob for detail.
|
||||||
|
path: /var/lib/node_exporter/textfile_collector
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
- name: cache
|
||||||
|
emptyDir: {}
|
||||||
|
|
|
||||||
38
apps/observability/patches/README.md
Normal file
38
apps/observability/patches/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# observability patches
|
||||||
|
|
||||||
|
Strategic-merge patches applied on top of Helm-managed observability
|
||||||
|
resources. Each file is idempotent (re-applying is a no-op) and is
|
||||||
|
reasserted by hand rather than by a controller, so re-run after any
|
||||||
|
`helm upgrade` of the affected release.
|
||||||
|
|
||||||
|
## `node-exporter-textfile-collector.yaml` (DEV-494)
|
||||||
|
|
||||||
|
Enables the node-exporter textfile collector on the
|
||||||
|
`kube-prometheus-stack-prometheus-node-exporter` DaemonSet by:
|
||||||
|
|
||||||
|
1. adding `--collector.textfile.directory=/host/textfile_collector`
|
||||||
|
to the container args, and
|
||||||
|
2. mounting the host directory `/var/lib/node_exporter/textfile_collector`
|
||||||
|
read-only at `/host/textfile_collector` (`hostPath` type
|
||||||
|
`DirectoryOrCreate`, kubelet creates it on nodes where the
|
||||||
|
directory does not exist yet).
|
||||||
|
|
||||||
|
The four monitoring backup CronJobs in `apps/monitoring/` write their
|
||||||
|
textfile-collector `.prom` files into that same host directory, so the
|
||||||
|
metrics surface in Prometheus via node-exporter's normal scrape.
|
||||||
|
|
||||||
|
Apply / re-apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability patch daemonset \
|
||||||
|
kube-prometheus-stack-prometheus-node-exporter \
|
||||||
|
--type=strategic \
|
||||||
|
--patch-file=apps/observability/patches/node-exporter-textfile-collector.yaml
|
||||||
|
kubectl -n observability rollout status daemonset \
|
||||||
|
kube-prometheus-stack-prometheus-node-exporter
|
||||||
|
```
|
||||||
|
|
||||||
|
The kube-prometheus-stack chart is not currently tracked in ArgoCD;
|
||||||
|
if it moves under GitOps, fold these values into the chart values as
|
||||||
|
`prometheus-node-exporter.extraArgs` + `.extraHostVolumeMounts`
|
||||||
|
instead of maintaining this patch.
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
# Strategic-merge patch enabling node-exporter's textfile collector on
|
||||||
|
# the kube-prometheus-stack node-exporter DaemonSet (DEV-494).
|
||||||
|
#
|
||||||
|
# The chart is Helm-managed (release `kube-prometheus-stack` in
|
||||||
|
# namespace `observability`, chart kube-prometheus-stack-86.2.2 /
|
||||||
|
# prometheus-node-exporter-4.55.0) and is NOT currently tracked in
|
||||||
|
# ArgoCD, so a direct DaemonSet patch is the pragmatic wiring path.
|
||||||
|
# The change:
|
||||||
|
#
|
||||||
|
# 1. adds the `--collector.textfile.directory=/host/textfile_collector`
|
||||||
|
# arg to the node-exporter container, and
|
||||||
|
# 2. mounts the host directory `/var/lib/node_exporter/textfile_collector`
|
||||||
|
# read-only at `/host/textfile_collector` (type DirectoryOrCreate so
|
||||||
|
# kubelet creates the dir on nodes where it does not yet exist).
|
||||||
|
#
|
||||||
|
# Apply with:
|
||||||
|
# kubectl -n observability patch daemonset \
|
||||||
|
# kube-prometheus-stack-prometheus-node-exporter \
|
||||||
|
# --type=strategic \
|
||||||
|
# --patch-file=apps/observability/patches/node-exporter-textfile-collector.yaml
|
||||||
|
#
|
||||||
|
# If the Helm release is ever `helm upgrade`d without folding these
|
||||||
|
# values into the chart values, this patch will be reverted — re-apply
|
||||||
|
# it after the upgrade (or move it into a repo-owned values file).
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: node-exporter
|
||||||
|
args:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/host/root
|
||||||
|
- --path.udev.data=/host/root/run/udev/data
|
||||||
|
- --web.listen-address=[$(HOST_IP)]:9100
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run/containerd/.+|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
|
||||||
|
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs|erofs)$
|
||||||
|
- --collector.textfile.directory=/host/textfile_collector
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /host/proc
|
||||||
|
name: proc
|
||||||
|
readOnly: true
|
||||||
|
- mountPath: /host/sys
|
||||||
|
name: sys
|
||||||
|
readOnly: true
|
||||||
|
- mountPath: /host/root
|
||||||
|
mountPropagation: HostToContainer
|
||||||
|
name: root
|
||||||
|
readOnly: true
|
||||||
|
- mountPath: /host/textfile_collector
|
||||||
|
name: textfile-collector
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- hostPath:
|
||||||
|
path: /proc
|
||||||
|
type: ""
|
||||||
|
name: proc
|
||||||
|
- hostPath:
|
||||||
|
path: /sys
|
||||||
|
type: ""
|
||||||
|
name: sys
|
||||||
|
- hostPath:
|
||||||
|
path: /
|
||||||
|
type: ""
|
||||||
|
name: root
|
||||||
|
- hostPath:
|
||||||
|
path: /var/lib/node_exporter/textfile_collector
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
name: textfile-collector
|
||||||
Loading…
Add table
Reference in a new issue