Skip to content

Coroot's hcloud volumes can still strand on an autoscaler node #3287

Closed

Description

@devantler

🤖 Generated by the Agentic Engineer

Evidence

Measured live on prod 2026-08-21. Every hcloud PVC in the cluster and the node its pod runs on:

PVC Pod Node
observability/data-coroot-clickhouse-keeper-0 coroot-clickhouse-keeper-0 autoscale-cx43-6406b46e4820452e ← ephemeral
openbao/data-openbao-0, audit-openbao-0 openbao-0 autoscale-cx43-… ← handled by #3286
the other 13 hcloud PVCs — prod-worker-{1,2,3}

hcloud volumes attach to exactly one node and are not force-detached when that node departs, so a volume on an autoscaler node is stranded the moment the autoscaler reclaims it.

Why the existing guards do not cover this

The cluster already makes a deliberate split, and block storage falls through it:

Storage Guard Strength
Longhorn replicas restrict-storage-to-baseline-workers.yaml hard (allowScheduling: false + eviction)
General pods prefer-baseline-nodes.yaml soft — deliberately, so burst still works (FinOps)
hcloud block volumes none — falls through to the soft bias only

The soft bias is the right default for stateless workloads and is documented as such ("a hard nodeAffinity would defeat burst"). It is measurably insufficient for single-attach block storage: openbao-0 carried that weight-100 preference and was on the autoscaler node anyway.

Note the failure mode is worse here than for Longhorn, which is hard-guarded: Longhorn replicates across nodes, an hcloud volume does not.

Impact

The realised cost of one stranded hcloud volume is on record (#2363): on 2026-07-01 data-openbao-0 stayed attached to a departed node for ~9h and the prod merge queue was wedged for the duration. Coroot is observability rather than delivery-path, so the blast radius is smaller — but "we lose monitoring while the cluster is unhealthy" is a bad time to lose monitoring.

Expected behaviour

No hcloud-backed pod is schedulable onto a node the autoscaler may reclaim.

Smallest useful first step

Extend the same required node affinity #3286 applies to OpenBao to the Coroot StatefulSets (coroot-clickhouse-keeper, coroot-clickhouse-shard, coroot-coroot, coroot-prometheus).

Worth deciding along the way

Per-workload patches do not generalise — the next chart that requests an hcloud PVC is exposed again with nothing to catch it. A durable alternative is a Kyverno policy that adds the required affinity to any pod mounting a PVC whose storageClassName is hcloud, mirroring restrict-storage-to-baseline-workers for the other storage class. That needs an admission-time apiCall to resolve the PVC's class, so it is worth designing rather than assuming.

Acceptance criteria

  • No pod mounting an hcloud PVC can schedule onto a node labelled ksail.io/autoscaled.
  • The guard is verified by a negative control against the live node set, not by inspection.
  • The general FinOps burst behaviour for non-hcloud workloads is unchanged.

Rough size

S–M. Per-workload patches are S; the generalised policy is M.

Part of #2363.

Activity

  1. added theissue type on Aug 21, 2026
  2. moved this to 📥 Backlog in 🌊 Project Boardon Aug 21, 2026
  3. devantler commented on Aug 21, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Adding a fail-closed requirement to this issue's scope, from CodeRabbit's review of #3286.

    The affinity #3286 ships uses a negative discriminator — ksail.io/autoscaled DoesNotExist — which fails open for a node that carries no label at all. A positive match (In on a baseline-pool label) would fail closed. That is the better shape and this issue should deliver it.

    It could not be done in #3286, and the reason constrains the design here:

    • The only in-repo node-labelling surface is talos/workers/label-nodes.yaml, a single shared file applied to every Talos worker. Autoscaler nodes boot from that same config — verified live: autoscale-cx43-… and all three prod-worker-* nodes all carry its node.longhorn.io/create-default-disk=true. A permanent-worker=true added there would land on the autoscaler node too, so the positive match would match everything — worse than the negative one.
    • ksail.io/autoscaled is stamped by KSail at node creation (ksail#5113), so its inverse cannot be minted from this repo.
    • Node affinity matches label values and supports no wildcard, so the autoscale-* name discriminator the sibling Longhorn policies use is not expressible in an affinity — documented verbatim in prefer-baseline-nodes.yaml.

    So a fail-closed guard needs a real label source, and there are two routes:

    1. KSail stamps a positive baseline-pool label (the inverse of ksail.io/autoscaled), which any affinity can then match with In.
    2. A Kyverno mutate on Node CRs matching names: ["prod-worker-*"] applies the label — the same name-based mechanism restrict-storage-to-baseline-workers.yaml already uses for exactly this reason.

    Route 2 is self-contained here; route 1 is cleaner but crosses a repo boundary. Worth deciding explicitly, because it also fixes the unlabelled-legacy-node hole that #3178 tracks — the same root gap, reached from a different direction.

    Added acceptance criteria

    • The guard uses a positive node-label match, so a node carrying no labels is excluded rather than admitted.
    • The label source is declarative and demonstrably applies to the baseline workers only — verified against the live node set, not by inspection of the config that sets it.
  4. self-assigned this
    on Sep 13, 2026
  5. moved this from 📥 Backlog to 🏃🏻‍♂️ In Progress in 🌊 Project Boardon Sep 13, 2026
  6. devantler commented on Sep 13, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Live re-measurement 2026-09-13: the negative guards are inert on EVERY current autoscaler node

    Read-only against prod, 2026-09-13 ~04:30Z.

    1. No node carries ksail.io/autoscaled — including both autoscaler nodes.

    node created ksail.io/autoscaled node.longhorn.io/create-default-disk
    autoscale-cx43-59ee3c84… 2026-08-26 absent true
    autoscale-cx43-55e35623… 2026-09-08 absent true
    prod-worker-{1,2,3} 2026-06-16 absent true

    Apart from the hostname, the label sets on the autoscaler nodes and the baseline workers are identical. These are not legacy nodes: both were created long after ksail#5113 shipped. KSail's GenerateAutoscalerWorkerConfig stamps the marker and strips the Longhorn disk label, so a node carrying the disk label and no marker did not boot from that stripped config. Why is a separate question; I have not chased it here.

    The consequence is what matters for this issue. Every guard that keys on ksail.io/autoscaled DoesNotExist currently admits every node:

    2. hcloud volumes are attached to autoscaler nodes right now. 9 VolumeAttachments point at the two autoscaler nodes, including:

    • observability/coroot-clickhouse-shard-0-1 → autoscale-cx43-59ee3c84… (running)
    • three openbao/vault-snapshot-* CronJob pods (Completed) → autoscale-cx43-55e35623…, so the snapshot job is also an hcloud consumer this issue's scope did not list.

    So this issue's premise stands and is broader than filed. It also confirms the added criterion: only a positive match fails closed.

    Plan (claimed; branch claude/baseline-worker-node-label-3287)

    Route 2 from the comment above, split so no scheduling changes until the label source is proven live:

    1. This slice — label source only. A Kyverno policy labels core Nodes named prod-worker-* with a positive baseline-worker label (admission, plus a retrofit rule for existing nodes). The mechanism is the same name-based match restrict-storage-to-baseline-workers already uses, and [Node,*,*] is already dropped from Kyverno's resource filters. Nothing reads the label yet, so merging cannot move a pod. After merge, verify against the live node set that exactly the three baseline workers carry it.
    2. Next slice — switch the guards. Replace DoesNotExist with a positive In on that label for every hcloud consumer (OpenBao, the Coroot StatefulSets, vault-snapshot). Add the expression inside each existing nodeSelectorTerm, because terms are ORed and a separate term would fail open.
  7. added a commit that references this issue on Sep 13, 2026
  8. devantler commented on Sep 13, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Slice 1 is live and verified: #3758 merged 05:28:32Z

    Read-only against prod right after the merge-queue deploy:

    node platform.devantler.tech/baseline-worker
    prod-worker-1, prod-worker-2, prod-worker-3 true
    autoscale-cx43-55e35623…, autoscale-cx43-59ee3c84… absent
    prod-control-plane-{2,4,5} absent
    • label-baseline-workers and label-existing-baseline-workers are both Ready=True.
    • Both Kyverno controller grants exist.
    • infrastructure-controllers and infrastructure are Ready on the latest revision.

    The label source is therefore proven against the live node set, as this issue's added acceptance criterion requires. The existing nodes were labelled by the retrofit policy, and a node that registers later is labelled at admission.

    Slice 2 (next): switch the hcloud guards to the positive label

    Nothing requires the label yet. The remaining work is to make every consumer of hcloud block storage require it, replacing the inert ksail.io/autoscaled DoesNotExist check:

    • OpenBao: openbao/patches/keep-off-autoscale-nodes.yaml, the required affinity.
    • Coroot: the coroot-clickhouse-keeper, coroot-clickhouse-shard, coroot-coroot and coroot-prometheus StatefulSets, via the Coroot CR.
    • Vault snapshots: the vault-snapshot CronJob and vault-snapshot-init Job, which mount the vault-snapshots hcloud PVC.

    Add platform.devantler.tech/baseline-worker In ["true"] inside each existing nodeSelectorTerm. Terms are ORed, so a separate term would fail open.

    At merge, coroot-clickhouse-shard-0-1 was still running on autoscale-cx43-59ee3c84…. A required affinity does not move a running pod, so the switch needs a planned reschedule of that pod, and a negative control against the live node set.

    The unlabelled-marker root cause is tracked separately in devantler-tech/ksail#7013.

  9. added a commit that references this issue on Sep 13, 2026
  10. devantler commented on Sep 13, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Slice 2 in review: #3761

    Every hcloud consumer in the infrastructure layers now requires platform.devantler.tech/baseline-worker In ["true"] in each required nodeSelectorTerm:

    Workload Before After
    OpenBao server ksail.io/autoscaled DoesNotExist (inert) positive label
    Coroot server, Prometheus, ClickHouse, Keeper no node rule positive label, merged beside the base podAntiAffinity
    vault-snapshot CronJob, vault-snapshot-init Job no node rule positive label

    Proof (static): scripts/tests/test-hcloud-baseline-worker-affinity.sh renders both hetzner overlays. It finds Coroot's hcloud components by walking the CR, so a component that gains hcloud storage later fails until it is guarded. On main it fails all 7 consumers; on the branch it passes all 7. Three ablations each failed for the right reason: an extra unlabelled OR term on the CronJob (1 of 2), the Keeper affinity removed, and OpenBao's value set to "false".

    Capacity, read-only 2026-09-13 ~06:05Z: memory requests on prod-worker-{1,2,3} are 70% / 70% / 82%. coroot-clickhouse-shard-0-1 (512Mi request) is the only Coroot pod on an autoscaler node, and its anti-affinity peer is on prod-worker-3, so it lands on worker 1 or 2.

    Still open after this slice:

    • FleetDM Redis (apps layer): master and replica StatefulSets use storageClass: hcloud with no node rule. It needs its own hetzner apps overlay patch, so it is the next slice.
    • SPIRE carries hcloud storage values but is disabled, so it creates no volume today.
    • Live negative control (AC 2): after deploy, confirm every Coroot, OpenBao and vault-snapshot pod runs on prod-worker-*, and that the rendered StatefulSets carry the rule.
  11. 10 remaining items

  12. added a commit that references this issue on Sep 13, 2026
  13. moved this from 🫴 Ready to 🏃🏻‍♂️ In Progress in 🌊 Project Boardon Sep 13, 2026
  14. devantler commented on Sep 13, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    OpenBao slice is live: #3780 merged and verified in production

    #3780 merged at 22:11:40Z at its evaluated head 17f45c4c. In its merge-queue run (34785385939), 🚀 Deploy to Prod succeeded at 22:11:07Z and the heal job was skipped. Every check below is a read-only production read taken after that.

    • Release. The openbao/openbao HelmRelease is Ready (Helm upgrade succeeded … openbao.v40).
    • The effect is live. The openbao StatefulSet's required node affinity is now kubernetes.io/hostname In [prod-worker-1, prod-worker-2, prod-worker-3], replacing ksail.io/autoscaled DoesNotExist. A node label change can no longer place OpenBao, or its hcloud volumes, on an autoscaler node.
    • No regression. All three servers are Ready on the static workers: openbao-0 on worker-2, openbao-1 on worker-3 and openbao-2 on worker-1. That is the same placement as before, so nothing was rescheduled.

    This issue stays open for the Coroot clickhouse-keeper volume it also tracks.

  15. added a commit that references this issue on Sep 14, 2026
  16. devantler commented on Sep 14, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Remaining work, re-measured 2026-09-14 (read-only)

    1. OpenBao's running Pods do not carry the #3780 guard yet. The StatefulSet template requires kubernetes.io/hostname In [prod-worker-1..3]. The update strategy is OnDelete, and currentRevision still differs from updateRevision. All three running Pods (openbao-0 created 2026-08-26) still carry the earlier ksail.io/autoscaled DoesNotExist rule, which matches every node today. OpenBao is on the static workers by placement, not by the guard. The guard takes effect only when those Pods are replaced. Replacing them is a deliberate OpenBao rolling step and is not part of this issue's code work.

    2. The keeper volume this issue still mentioned is covered. coroot-clickhouse-keeper requires platform.devantler.tech/baseline-worker In ["true"], and all three keepers run on prod-worker-{1,2,3}. Every csi.hetzner.cloud VolumeAttachment is on a prod-worker-* node (3 / 6 / 5), and none is on an autoscaler node.

    3. What is left is the general guard. The first acceptance criterion says no Pod mounting an hcloud claim can schedule on an autoscaler node. The per-workload rules meet that only for the consumers that exist today and are known by name. #3794 adds the admission-time guard for any future consumer.

  17. moved this from 🏃🏻‍♂️ In Progress to 👀 In Review in 🌊 Project Boardon Sep 14, 2026
  18. moved this from 👀 In Review to 🚀 Ready to Merge in 🌊 Project Boardon Sep 14, 2026
  19. devantler commented on Sep 14, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    The general guard is live and verified: #3794 merged and deployed

    #3794 merged at 05:28:02Z at its evaluated head c81b7e81. Its merge-queue CI run, including the production deploy, succeeded. Read-only checks against production at about 06:00Z:

    1. No pod mounting an hcloud claim can schedule onto an autoscaler node. The require-baseline-workers-for-hcloud-volumes ClusterPolicy is Ready. Its require-baseline-worker-affinity rule runs in Enforce on Pod CREATE, so a future consumer without the baseline-worker affinity is refused at admission rather than scheduled.

    2. Negative control against the live node set. The background scan fails exactly 2 Pods. Both are completed vault-snapshot Pods from 2026-09-12 and 2026-09-13 that ran on an autoscaler node before the snapshot CronJob gained its required affinity. Today's 03:30Z snapshot ran on a baseline worker, and the CronJob template now requires platform.devantler.tech/baseline-worker In [true]. Results: 54 pass, 2 fail, 0 error, 0 claim-lookup failures. The 8 PolicyViolation events since the deploy are all background-scan reports on those 2 Pods. None is an admission refusal.

    3. Burst for other workloads is unchanged. 456 results are skip (the Pod mounts no hcloud claim). A vault-config Job Pod was scheduled onto an autoscaler node at 05:56Z and is reported skip, so non-hcloud Pods still burst onto autoscaler nodes.

    All three acceptance criteria hold, so I am closing this issue. The running OpenBao Pods still carry the older placement rule until they are replaced. That is an OpenBao rolling step, not code work here, and they already run on baseline workers.

  20. closed this as completedby moving to ✅ Done in 🌊 Project Boardon Sep 14, 2026
  21. moved this from 🚀 Ready to Merge to ✅ Done in 🌊 Project Boardon Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

Priority

None yet

Effort

None yet

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions