feat(ceph): add the toolbox, move image GC off Ceph's warn line, alert on health #500

Merged
k.renner merged 1 commits from fix-ceph-toolbox-and-gc-thresholds into master 2026-08-18 19:27:31 +00:00
Member

Addresses #434 — Gap 2, the parked alerting follow-up, and a threshold bug found while checking the issue's status.

Gap 1 was already closed

PR #432 landed and worked: ceph_health_status returns from Prometheus today, against "zero ceph_* series for all 79 days" when the issue was filed. Nothing to do.

Gap 2 — the toolbox

Still no rook-ceph-tools; ceph status, ceph health detail, ceph osd df tree, ceph osd perf, ceph device ls are all unreachable during an incident. Adds the upstream manifest from rook v1.20.4, matching the running operator image docker.io/rook/ceph:v1.20.4, taken verbatim with three deliberate changes:

  • image pinned to the cluster's own quay.io/ceph/ceph:v19.2.5 (not upstream's :v20) with the same $imagepolicy marker as cephcluster.yaml, so the client cannot drift ahead of the mons it talks to
  • resource requests/limits, since it is a long-lived idle pod
  • nothing else, so future upstream diffs stay readable

Prerequisites verified present, not assumed: rook-ceph-default SA, rook-ceph-mon-endpoints ConfigMap, and ceph-secret + ceph-username in the rook-ceph-mon secret. Namespace PSA is privileged, and the namespace CNP restricts ingress only (no egress rules), so it can reach the hostNetwork mons.

⚠️ It holds the client.admin keyring — a full-privilege credential, not the read-only path the issue wished for. Rook ships no lesser-privileged toolbox. Ingress is gated by the namespace default-deny-ingress CNP.

The threshold bug

The cluster was in HEALTH_WARN / MON_DISK_LOW when I checked. The cause is not Ceph growth:

node % free
k8s-1 48.34%
k8s-2 (mon b) 29.81%
k8s-3 36.08%

Mons are on hostPath, so this is node /var. Ceph's mon_data_avail_warn is ≤30% free. imageGCHighThresholdPercent was 70 — i.e. GC first fires at exactly 30% free.

The thresholds had already been lowered once from the kubelet defaults for precisely this reason; the comment says 70/60 "keeps /var safely under 30%-free", but 70% used is 30% free. It sits on the line rather than below it, so the cache fills to the warn point every time and MON_DISK_LOW trips before kubelet reclaims anything. It cleared to 31.64% on its own once GC ran — which is the oscillation, not a refutation.

60/50 fires at 40% free and reclaims to 50%, leaving 10 points of margin.

I checked that the lower threshold will actually bite rather than just trigger earlier and stall:

  • containerd is ~29 GB of k8s-2's 60 GB /var — the dominant local consumer
  • 179 images cached, 60 in use — roughly two-thirds reclaimable, some dating to May
  • (the ~309 GB talosctl attributes to /var/lib/kubelet is mounted Ceph PVCs counted through mount points, not local disk)

Alerting

The issue parked this until ceph_* series existed. They do now.

  • CephHealthWarn — == 1 for 6h, deliberately long. Ceph warns for self-clearing things (a slow BlueStore op during the Sunday restore sweep, a mon briefly under its disk threshold); paging on those teaches people to ignore the alert. A warning that does not clear is the signal.
  • CephHealthError — == 2 for 5m.
  • CephMonDiskLow — watches /var free directly rather than via Ceph, because this fires for a full image cache, not Ceph growth. Naming that keeps the next person from debugging the wrong subsystem.

Verification

  • kubectl kustomize builds clean for rook-ceph-cluster and monitoring-rules.
  • kubectl apply --dry-run=server accepts the Deployment and the PrometheusRule.
  • CephMonDiskLow's expression runs against live Prometheus — currently 0 nodes below 30%, the correct present-state answer.
  • cluster.yaml parses.

Read before merging

cluster.yaml is a bootstrap patch — merging changes nothing on running nodes. The live kubelet value needs talosctl patch machineconfig. Same trap the reviewer caught on #495; flagging it rather than letting it be discovered later.

Not done

The issue's other follow-up — auditing whether operator-managed ServiceMonitors besides Rook's are being dropped by the release: kube-prometheus-stack selector — is untouched and still open. It deserves its own pass rather than being bundled here.

Addresses #434 — Gap 2, the parked alerting follow-up, and a threshold bug found while checking the issue's status. ## Gap 1 was already closed PR #432 landed and worked: `ceph_health_status` returns from Prometheus today, against "zero `ceph_*` series for all 79 days" when the issue was filed. Nothing to do. ## Gap 2 — the toolbox Still no `rook-ceph-tools`; `ceph status`, `ceph health detail`, `ceph osd df tree`, `ceph osd perf`, `ceph device ls` are all unreachable during an incident. Adds the upstream manifest from **rook v1.20.4**, matching the running operator image `docker.io/rook/ceph:v1.20.4`, taken verbatim with three deliberate changes: - image pinned to the cluster's own `quay.io/ceph/ceph:v19.2.5` (not upstream's `:v20`) with the same `$imagepolicy` marker as `cephcluster.yaml`, so the client cannot drift ahead of the mons it talks to - resource requests/limits, since it is a long-lived idle pod - nothing else, so future upstream diffs stay readable Prerequisites verified present, not assumed: `rook-ceph-default` SA, `rook-ceph-mon-endpoints` ConfigMap, and `ceph-secret` + `ceph-username` in the `rook-ceph-mon` secret. Namespace PSA is `privileged`, and the namespace CNP restricts ingress only (no egress rules), so it can reach the hostNetwork mons. ⚠️ It holds the **client.admin** keyring — a full-privilege credential, not the read-only path the issue wished for. Rook ships no lesser-privileged toolbox. Ingress is gated by the namespace `default-deny-ingress` CNP. ## The threshold bug The cluster was in `HEALTH_WARN / MON_DISK_LOW` when I checked. The cause is not Ceph growth: | node | % free | |---|---| | k8s-1 | 48.34% | | **k8s-2 (mon b)** | **29.81%** | | k8s-3 | 36.08% | Mons are on `hostPath`, so this is node `/var`. Ceph's `mon_data_avail_warn` is **≤30% free**. `imageGCHighThresholdPercent` was **70** — i.e. GC first fires at *exactly* 30% free. The thresholds had already been lowered once from the kubelet defaults for precisely this reason; the comment says 70/60 "keeps /var safely under 30%-free", but 70% used **is** 30% free. It sits on the line rather than below it, so the cache fills to the warn point every time and MON_DISK_LOW trips before kubelet reclaims anything. It cleared to 31.64% on its own once GC ran — which is the oscillation, not a refutation. **60/50** fires at 40% free and reclaims to 50%, leaving 10 points of margin. I checked that the lower threshold will actually bite rather than just trigger earlier and stall: - containerd is **~29 GB of k8s-2's 60 GB** `/var` — the dominant local consumer - **179 images cached, 60 in use** — roughly two-thirds reclaimable, some dating to May - (the ~309 GB `talosctl` attributes to `/var/lib/kubelet` is mounted Ceph PVCs counted through mount points, not local disk) ## Alerting The issue parked this until `ceph_*` series existed. They do now. - `CephHealthWarn` — `== 1` **for 6h**, deliberately long. Ceph warns for self-clearing things (a slow BlueStore op during the Sunday restore sweep, a mon briefly under its disk threshold); paging on those teaches people to ignore the alert. A warning that does *not* clear is the signal. - `CephHealthError` — `== 2` for 5m. - `CephMonDiskLow` — watches `/var` free directly rather than via Ceph, because this fires for a full image cache, not Ceph growth. Naming that keeps the next person from debugging the wrong subsystem. ## Verification - `kubectl kustomize` builds clean for `rook-ceph-cluster` and `monitoring-rules`. - `kubectl apply --dry-run=server` accepts the Deployment and the PrometheusRule. - `CephMonDiskLow`'s expression runs against live Prometheus — currently 0 nodes below 30%, the correct present-state answer. - `cluster.yaml` parses. ## Read before merging **`cluster.yaml` is a bootstrap patch — merging changes nothing on running nodes.** The live kubelet value needs `talosctl patch machineconfig`. Same trap the reviewer caught on #495; flagging it rather than letting it be discovered later. ## Not done The issue's other follow-up — auditing whether operator-managed ServiceMonitors *besides* Rook's are being dropped by the `release: kube-prometheus-stack` selector — is untouched and still open. It deserves its own pass rather than being bundled here.
claude-bot added 1 commit 2026-08-18 19:07:14 +00:00
feat(ceph): add the toolbox, move image GC off Ceph's warn line, alert
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
16360aacf9
Addresses #434.

Gap 2 — no admin path. There is still no rook-ceph-tools deployment, so
ceph status, ceph health detail, ceph osd df tree, ceph osd perf and ceph
device ls are all unreachable during an incident: mons, OSDs and mgr
mount no admin keyring and the operator's /etc/ceph is empty. Adds the
upstream toolbox from rook v1.20.4 (matching the running operator image)
with the image pinned to the cluster's own quay.io/ceph/ceph:v19.2.5 and
carrying the same imagepolicy marker as cephcluster.yaml, so the client
cannot drift ahead of the mons. Prerequisites verified present: the
rook-ceph-default ServiceAccount, the rook-ceph-mon-endpoints ConfigMap,
and ceph-secret/ceph-username in the rook-ceph-mon secret.

Image GC. The thresholds were already lowered once from the kubelet
defaults, to 70/60, explicitly to stay under Ceph's mon_data_avail_warn.
But that line is <=30% free and a high of 70 fires at exactly 30% free —
on the threshold, not below it. So the cache fills to precisely the point
where the mon warns, and MON_DISK_LOW trips before kubelet reclaims
anything. Observed on k8s-2: 29.81% free and HEALTH_WARN MON_DISK_LOW,
clearing to 31.64% once GC ran. 60/50 fires at 40% free and reclaims to
50%, leaving 10 points of margin.

There is plenty to reclaim, so the lower threshold will bite: containerd
holds ~29 GB of k8s-2's 60 GB /var and caches 179 images against 60 in
use, some dating to May. (The ~309 GB talosctl attributes to
/var/lib/kubelet is mounted Ceph PVCs counted through mount points, not
local disk.)

Alerting, the follow-up #434 parked until ceph_* series existed — they do
now, via #432. CephHealthWarn deliberately waits 6h: Ceph warns for
self-clearing conditions and paging on those teaches people to ignore it,
so what is worth waking for is a warning that does not clear.
CephHealthError fires at 5m. CephMonDiskLow watches /var free directly
rather than going through Ceph, because this fires for a full image cache
rather than for Ceph growth, and naming that keeps the next person from
debugging the wrong subsystem.

NOTE: cluster.yaml is a bootstrap patch. Merging changes nothing on
running nodes — the live kubelet value needs talosctl patch machineconfig.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 16360aacf94f33d686301aa3f9504a3b80590c62.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Three independent, well-scoped changes: (1) adds a rook-ceph-tools Deployment sourced from upstream Rook v1.20.4 with a pinned Ceph image and resource limits, (2) lowers Talos kubelet image-GC thresholds from 70/60 to 60/50 so GC fires before crossing Ceph's mon_data_avail_warn (30% free) line, and (3) adds a PrometheusRule with CephHealthWarn, CephHealthError, and CephMonDiskLow alerts. The GC-threshold arithmetic checks out (a 70% "high" threshold triggers GC exactly at the 30%-free warn line, so 60% gives real margin), the new PrometheusRule follows the existing repo conventions (release: kube-prometheus-stack label, alert/severity/for style matches prometheusrule-cilium.yaml), and the toolbox manifest's quay.io/ceph/ceph:v19.2.5 image plus {"$imagepolicy": "flux-system:ceph"} marker matches cephcluster.yaml and the ceph ImagePolicy in kubernetes/infrastructure/image-automation/ceph.yaml. No blocking issues found.

🟡 Suggestions

  • kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml:64-65 — CephMonDiskLow's expression compares against Ceph's mon_data_avail_warn (documented/described as "≤30% free") using a strict < 30. At exactly 30.00% free Ceph would already be warning but this alert would not yet have crossed its own threshold. In practice float noise on node_filesystem_*_bytes makes this edge case very unlikely to matter, but <= 30 would match the semantics described in the alert's own comment more precisely.
  • kubernetes/infrastructure/rook-ceph-cluster/toolbox.yaml:126 — dnsPolicy: ClusterFirstWithHostNet is normally paired with hostNetwork: true; this pod does not set hostNetwork, so it runs on the regular pod network with ClusterFirstWithHostNet DNS policy (which still resolves cluster DNS correctly, just via a slightly different code path than plain ClusterFirst). Since the PR states the toolbox is "taken verbatim" from upstream, this is presumably copied as-is from rook/rook's example manifest rather than a change introduced here — worth a one-line note in the header comment if it was a conscious decision to keep it despite not using host networking, otherwise harmless.
  • I could not reach the network to diff this file against the actual upstream https://raw.githubusercontent.com/rook/rook/v1.20.4/deploy/examples/toolbox.yaml to independently confirm the "verbatim + three deliberate changes" claim; the script body and structure read as a faithful copy of the well-known Rook toolbox template, so this is a low-risk gap, not a specific concern.

🟢 Nits

None.

Tests

No automated tests exist for this repo's Kubernetes/Talos manifests (it's a GitOps homelab config repo, not an application with a test suite), and none of the sibling PrometheusRule/manifest additions carry tests either — consistent with repo norms. The PR body documents concrete manual verification instead (kubectl kustomize build, kubectl apply --dry-run=server, live Prometheus query for CephMonDiskLow, cluster.yaml parse), which is the right level of rigor for this kind of change and mirrors how other infra PRs in this repo are validated. I was not able to independently re-run these checks (no kubectl/cluster access in this environment), but the manifests are internally consistent with the rest of the checkout: the kustomization files reference the new resources in correct alphabetical order, the flux-system:ceph image-policy marker matches the existing ImagePolicy, and the referenced rook-ceph-mon secret keys / rook-ceph-mon-endpoints ConfigMap names match how cephcluster.yaml and the rest of the rook-ceph-cluster kustomization refer to them.

One thing worth flagging explicitly (the PR body already calls this out prominently under "Read before merging"): kubernetes/talos/patches/cluster.yaml is a bootstrap patch, so merging this PR alone does not change the live kubelet imageGCHighThresholdPercent/imageGCLowThresholdPercent on the running nodes — a talosctl patch machineconfig (or equivalent) is required separately. That's a deployment-process note rather than a code defect, but it's the kind of thing that's easy to lose track of after merge.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `16360aacf94f33d686301aa3f9504a3b80590c62`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Three independent, well-scoped changes: (1) adds a `rook-ceph-tools` Deployment sourced from upstream Rook v1.20.4 with a pinned Ceph image and resource limits, (2) lowers Talos kubelet image-GC thresholds from 70/60 to 60/50 so GC fires before crossing Ceph's `mon_data_avail_warn` (30% free) line, and (3) adds a `PrometheusRule` with `CephHealthWarn`, `CephHealthError`, and `CephMonDiskLow` alerts. The GC-threshold arithmetic checks out (a 70% "high" threshold triggers GC exactly at the 30%-free warn line, so 60% gives real margin), the new PrometheusRule follows the existing repo conventions (`release: kube-prometheus-stack` label, alert/severity/for style matches `prometheusrule-cilium.yaml`), and the toolbox manifest's `quay.io/ceph/ceph:v19.2.5` image plus `{"$imagepolicy": "flux-system:ceph"}` marker matches `cephcluster.yaml` and the `ceph` `ImagePolicy` in `kubernetes/infrastructure/image-automation/ceph.yaml`. No blocking issues found. ## 🟡 Suggestions - `kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml:64-65` — `CephMonDiskLow`'s expression compares against Ceph's `mon_data_avail_warn` (documented/described as "≤30% free") using a strict `< 30`. At exactly 30.00% free Ceph would already be warning but this alert would not yet have crossed its own threshold. In practice float noise on `node_filesystem_*_bytes` makes this edge case very unlikely to matter, but `<= 30` would match the semantics described in the alert's own comment more precisely. - `kubernetes/infrastructure/rook-ceph-cluster/toolbox.yaml:126` — `dnsPolicy: ClusterFirstWithHostNet` is normally paired with `hostNetwork: true`; this pod does not set `hostNetwork`, so it runs on the regular pod network with `ClusterFirstWithHostNet` DNS policy (which still resolves cluster DNS correctly, just via a slightly different code path than plain `ClusterFirst`). Since the PR states the toolbox is "taken verbatim" from upstream, this is presumably copied as-is from `rook/rook`'s example manifest rather than a change introduced here — worth a one-line note in the header comment if it was a conscious decision to keep it despite not using host networking, otherwise harmless. - I could not reach the network to diff this file against the actual upstream `https://raw.githubusercontent.com/rook/rook/v1.20.4/deploy/examples/toolbox.yaml` to independently confirm the "verbatim + three deliberate changes" claim; the script body and structure read as a faithful copy of the well-known Rook toolbox template, so this is a low-risk gap, not a specific concern. ## 🟢 Nits None. ## Tests No automated tests exist for this repo's Kubernetes/Talos manifests (it's a GitOps homelab config repo, not an application with a test suite), and none of the sibling PrometheusRule/manifest additions carry tests either — consistent with repo norms. The PR body documents concrete manual verification instead (`kubectl kustomize` build, `kubectl apply --dry-run=server`, live Prometheus query for `CephMonDiskLow`, `cluster.yaml` parse), which is the right level of rigor for this kind of change and mirrors how other infra PRs in this repo are validated. I was not able to independently re-run these checks (no `kubectl`/cluster access in this environment), but the manifests are internally consistent with the rest of the checkout: the kustomization files reference the new resources in correct alphabetical order, the `flux-system:ceph` image-policy marker matches the existing `ImagePolicy`, and the referenced `rook-ceph-mon` secret keys / `rook-ceph-mon-endpoints` ConfigMap names match how `cephcluster.yaml` and the rest of the `rook-ceph-cluster` kustomization refer to them. One thing worth flagging explicitly (the PR body already calls this out prominently under "Read before merging"): `kubernetes/talos/patches/cluster.yaml` is a bootstrap patch, so merging this PR alone does not change the live kubelet `imageGCHighThresholdPercent`/`imageGCLowThresholdPercent` on the running nodes — a `talosctl patch machineconfig` (or equivalent) is required separately. That's a deployment-process note rather than a code defect, but it's the kind of thing that's easy to lose track of after merge. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=16360aacf94f33d686301aa3f9504a3b80590c62 -->
Author
Member

Correcting a number in the PR body, and adding the safety analysis behind the GC change.

The "roughly two-thirds reclaimable" figure is misleading. That was two-thirds by image count, and the unused images are the small ones. By size, measured on k8s-2:

unique cached images 9.93 GB
of which in use 7.92 GB
unused / reclaimable 2.01 GB (~20%, not ~66%)

Also worth separating: talosctl usage attributes 29 GB to /var/lib/containerd, but only 9.93 GB of that is image content. The remaining ~19 GB is unpacked overlayfs snapshots. Image GC removes an image and its snapshots, so the on-disk reclaim is roughly 2.9x the compressed figure — about 6 GB, not 2 GB.

Does 60/50 still work? Yes, because the trigger moves earlier rather than the cache getting bigger. GC fires at 60% used (36 GB of the 60 GB disk). By then a week of image-automation churn has accumulated its ~6 GB of unused layers, so the sweep lands near 30 GB — the 50% low mark. The node then oscillates roughly 40–50% free instead of 30–40%, which keeps it clear of Ceph's 30% line.

The margin is thinner than "two-thirds reclaimable" implied. If unused layers ever accumulate to less than ~6 GB between sweeps, kubelet will get below the 60% high threshold but not down to 50%, and will log failed to garbage collect required amount of images each cycle. That is noisy, not dangerous, and it still achieves the goal of keeping Ceph quiet.

Security of the toolbox — checked, not assumed:

  • system:serviceaccount:monitoring:ci-diagnostics cannot create pods/exec in rook-ceph and cannot get secrets there. CI agents cannot use the toolbox to reach Ceph admin.
  • The toolbox's own SA, rook-ceph-default, holds no meaningful Kubernetes permissions — only selfsubject* review APIs. Its capability is entirely the mounted keyring.
  • No privilege escalation: the rook-ceph-mon secret already exists in the namespace, so anyone who can exec in rook-ceph could already read it and build a keyring by hand. The toolbox makes that convenient, not newly possible.

Applying the kubelet change should be staged. talosctl patch machineconfig restarts kubelet on the target node, and the first sweep afterwards deletes most of the unused cache. Do one node, confirm it settles above 40% free and that pods still start, then the next — rather than all three at once.

Correcting a number in the PR body, and adding the safety analysis behind the GC change. **The "roughly two-thirds reclaimable" figure is misleading.** That was two-thirds *by image count*, and the unused images are the small ones. By size, measured on k8s-2: | | | |---|---| | unique cached images | **9.93 GB** | | of which in use | **7.92 GB** | | unused / reclaimable | **2.01 GB** (~20%, not ~66%) | Also worth separating: `talosctl usage` attributes **29 GB** to `/var/lib/containerd`, but only 9.93 GB of that is image content. The remaining ~19 GB is unpacked overlayfs snapshots. Image GC removes an image *and* its snapshots, so the on-disk reclaim is roughly 2.9x the compressed figure — about **6 GB**, not 2 GB. **Does 60/50 still work?** Yes, because the trigger moves earlier rather than the cache getting bigger. GC fires at 60% used (36 GB of the 60 GB disk). By then a week of image-automation churn has accumulated its ~6 GB of unused layers, so the sweep lands near 30 GB — the 50% low mark. The node then oscillates roughly 40–50% free instead of 30–40%, which keeps it clear of Ceph's 30% line. The margin is thinner than "two-thirds reclaimable" implied. If unused layers ever accumulate to less than ~6 GB between sweeps, kubelet will get below the 60% high threshold but not down to 50%, and will log `failed to garbage collect required amount of images` each cycle. That is noisy, not dangerous, and it still achieves the goal of keeping Ceph quiet. **Security of the toolbox — checked, not assumed:** - `system:serviceaccount:monitoring:ci-diagnostics` **cannot** `create pods/exec` in `rook-ceph` and **cannot** `get secrets` there. CI agents cannot use the toolbox to reach Ceph admin. - The toolbox's own SA, `rook-ceph-default`, holds no meaningful Kubernetes permissions — only `selfsubject*` review APIs. Its capability is entirely the mounted keyring. - No privilege escalation: the `rook-ceph-mon` secret already exists in the namespace, so anyone who can exec in `rook-ceph` could already read it and build a keyring by hand. The toolbox makes that convenient, not newly possible. **Applying the kubelet change should be staged.** `talosctl patch machineconfig` restarts kubelet on the target node, and the first sweep afterwards deletes most of the unused cache. Do one node, confirm it settles above 40% free and that pods still start, then the next — rather than all three at once.
k.renner merged commit 2337aa16e7 into master 2026-08-18 19:27:31 +00:00
Sign in to join this conversation.