Compare commits

..
Author SHA1 Message Date
issue-fix-bot 781b849adb fix: [alert] KubeStatefulSetReplicasMismatch (openbao) (#645)
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
2026-08-26 22:45:57 +00:00
k.renner 71d6bd3724 Merge pull request 'feat(openbao): run three raft replicas so a node drain is not an outage' (#646) from openbao-ha-3-replicas into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#646
2026-08-26 20:51:03 +00:00
claude-botandClaude Opus 5 0809823511 feat(openbao): run three raft replicas so a node drain is not an outage
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
OpenBao ran a single replica. Every node drain took the whole secret store
down with it: today the k8s-1 Talos upgrade sealed it, the ClusterSecretStore
went InvalidProviderConfig, and it stayed that way for two hours because
nothing was watching.

Three, not two. Raft needs a quorum and the quorum of two is two, so a
two-replica cluster tolerates no failures — it would stop the moment one pod
is sealed or rescheduled, which is worse than the single replica it replaced.
Three tolerates one.

What this buys: a drain seals the replica on that node, and raft elects a new
leader from the remaining standbys within seconds. The service does not go
down.

What it does not buy, stated plainly because it is easy to assume otherwise: a
sealed server can neither serve requests nor act as a standby, so that replica
stays out of the cluster until someone unseals it. Scaling removes the outage,
not the manual unseal. For rolling node maintenance the consequence matters —
a Talos upgrade drains all three nodes in turn, so unseal each replica as it
comes back, or the third drain takes the last unsealed one and quorum is lost
anyway.

retry_join is what makes a scaled replica join the existing cluster rather
than come up isolated and uninitialized; without it each new pod would need a
manual `bao operator raft join`. All three peers are listed on every node —
the stanza is a list of possible leaders, a node ignores its own entry, and
listing all three keeps the config identical across replicas regardless of
which pod currently leads. Addressed through the headless service so each
entry resolves to one specific pod.

The snapshot job moves from the plain service to openbao-active. With one
replica the plain service could only ever hit the one pod; with three it
selects any of them, and a raft snapshot has to be taken against the leader.

No affinity was added: the chart already applies a required podAntiAffinity on
kubernetes.io/hostname, so the replicas spread across the three nodes on their
own — verified against the running StatefulSet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:49:41 +02:00
k.renner 96acff3456 Merge pull request 'chore(talos): drop the node addresses on the Ceph SAN' (#643) from ceph-san-cleanup into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#643
2026-08-26 20:18:18 +00:00
claude-botandClaude Opus 5 b5a1818896 chore(talos): drop the node addresses on the Ceph SAN
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The nodes held 10.99.0.1-.3/29 on enp2s0f1np1 only because reaching that link
required CephCluster.spec.network.provider: host, which is not per-daemon and
put every Ceph daemon on host networking. Rook now attaches the link to the
OSD pods through a Multus macvlan, and a macvlan parent only needs to be up —
it does not need an address of its own for its children to work.

By the rule this segment is built on — Ceph-internal traffic and nothing else,
no router, no other participants — the node has no business holding an address
there either. This is stricter than before, not looser.

The link keeps up: true and mtu: 9000. Both still matter: the parent must be
up for the macvlan children to exist, and the MTU is what the children
inherit.

Not in this commit, deliberately: widening the attachment's whereabouts pool
from .4-.6 to the full /29. That must not land before these addresses are
actually gone from the running nodes, or whereabouts could hand .1 to an OSD
while a node still holds it. Follow-up once these configs are applied and
verified.

Also records the one-time step that the manifests cannot express. Removing
addressRanges did not clear public_network and cluster_network from Ceph's
config database — the same trap the bluestore comment in the same file
describes. The first OSD of the phase A rollout came up on the pod network,
still had public_network 10.2.40.0/24 in the config database, found no
interface in it and crash-looped on "Failed to pick public address". Both keys
were removed by hand. A rebuild from scratch will not hit this because the
config database starts empty; another provider change on a live cluster will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:17:15 +02:00
k.renner 1a94adb4c4 Merge pull request 'feat(rook-ceph): attach the cluster network via Multus (phase B of two)' (#642) from ceph-provider-phase-b into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#642
2026-08-26 20:08:31 +00:00
k.renner e9f3175b5b Merge pull request 'refactor(rook-ceph): drop the host network provider (phase A of two)' (#640) from ceph-provider-phase-a into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#640
2026-08-26 19:54:07 +00:00
claude-botandClaude Opus 5 a775b675d4 feat(rook-ceph): attach the cluster network via Multus (phase B of two)
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
Puts the OSDs back on the VLAN 49 link that phase A gave up, without putting
every Ceph daemon on host networking to get there.

The cluster network is an OSD concern. Per the Ceph documentation it carries
OSD heartbeat, replication and recovery, and "Ceph Monitors always operate on
the public network". Reaching it through addressRanges required
provider: host, which is not per-daemon — the mons went onto host networking
as a side effect, making a mon's address the node's DHCP lease.

That failure is documented rather than hypothetical. When a lease moved, the
mon could not bind ("Cannot assign requested address") and crash-looped. Rook
detected it and began a failover, but with three mons on three nodes each
holding the mon host ports the replacement could not be scheduled anywhere —
the canary sat Pending on "didn't have free ports". Two of three nodes hit it
during the Talos v1.12.11 upgrade; both needed hand intervention.

Selecting only `cluster` leaves `public` on the pod network, so mons, MDS,
MGR and the CSI pods keep Rook-managed identities and hold no host ports.
That removes the failure mode rather than working around it.

Isolation is not weakened. Rook's Multus prerequisites about hosts routing to
the Multus network apply to a Multus *public* network; selecting only
`cluster` means they do not apply here. VLAN 49 stays Ceph-internal traffic
and nothing else, with no router and no other participants.

MUST NOT be applied before phase A. Verified rather than assumed — kubectl
diff against the current cluster refuses it:

  spec.network.provider: Invalid value: "multus": network provider must be
  disabled (reverted to empty string) before a new provider is enabled

Second of the two rolling OSD restarts, bounded by the same two settings: one
OSD at a time, only once PGs are clean.

Follow-up, not in this commit: once this settles, the node addresses on VLAN
49 (10.99.0.1-.3) have no remaining purpose and should come out of the Talos
configs, after which the attachment's pool can take the whole /29 instead of
the three addresses it reserves today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:52:38 +02:00
claude-botandClaude Opus 5 a6dd1f4be2 refactor(rook-ceph): drop the host network provider (phase A of two)
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
Intermediate state, not a destination. san-network-attachment.yaml is already
in the cluster and stays unused until phase B sets provider: multus.

The detour is forced by Rook's own CRD validation, not chosen:

  rule:    self == '' || oldSelf == '' || self == oldSelf
  message: network provider must be disabled (reverted to empty string)
           before a new provider is enabled

host -> multus is rejected outright. It has to pass through empty, and an
empty provider means the default CNI — so addressRanges, which only takes
effect under provider: host, goes with it.

What this costs while it is in place: there is no cluster network. OSD
heartbeat, replication and recovery move onto the pod network and share it
with everything else instead of the dedicated X710 SFP+ link. Slower, and
every OSD restart pays that price in backfill. Not a correctness problem —
the OSDs can still reach one another throughout, which is the property that
matters, and the one a separate subnet would have broken.

The mons do not move here. Rook leaves existing mons on the network they were
created with; they change only on failover, which is deliberate and separate.

Bounded by the two settings that landed ahead of this: one OSD restarts at a
time, and only once PGs are clean again. Expect three sequential OSD restarts
with full recovery between them, and the same again in phase B.

kubectl diff against the live cluster shows provider host -> "" and the
generation bump. The removal of addressRanges does not appear there, because
this resource carries no managedFields and client-side diff cannot see a
field deletion. It is inert either way — Rook does not read addressRanges
without provider: host — but worth confirming after the apply rather than
assuming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:51:08 +02:00
k.renner 5d923c84e0 Merge pull request 'fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs' (#639) from ceph-osd-update-safety into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#639
2026-08-26 19:44:29 +00:00
claude-botandClaude Opus 5 c88ee0e9a0 fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Rook's defaults are wrong for a cluster this size, and nothing here had
overridden them:

  storage.osdMaxUpdatesInParallel   default 20
  upgradeOSDRequiresHealthyPGs      default false

Twenty parallel OSD restarts is a small fraction of a large cluster. Here it
is all three, at once, with no gate on PG recovery in between. Every PG goes
inactive and every pool — RBD volumes and both CephFS mounts — becomes
unreachable for the duration. Not a degraded cluster, a stopped one.

With three OSDs at size 3 there is no spare copy to absorb the second
restart, so the health gate between one OSD returning and the next going down
is the only thing separating a rolling change from an outage.

This binds any change that rewrites the OSD deployment spec, not only Ceph
version upgrades. The immediate reason is the pending network provider
switch, which is exactly such a change — but the exposure predates it and
would have applied to any of them.

Lands inert: `kubectl diff` against the live cluster shows the two added
fields and the generation bump, nothing else. No OSD deployment spec changes,
so nothing restarts.

Deliberately separate from the provider switch rather than bundled with it,
so the bound is in place and observed before the operation it exists to
bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:43:19 +02:00
k.renner b0a473d038 Merge pull request 'feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network' (#638) from ceph-san-nad into master
Reviewed-on: k.renner/homelab#638
2026-08-26 19:41:39 +00:00
claude-botandClaude Opus 5 585ea36c9f fix(rook-ceph): keep the SAN pool on-link with the node addresses
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
The pool was 10.99.1.0/24, a separate subnet on the same segment, chosen to
avoid carving up a /29 with only three addresses left. That choice would have
broken the migration it exists for.

OSDs move one at a time, so the cluster runs mixed: an OSD already on the
attachment has to reach an OSD still using its node's host address on
10.99.0.x. There is no router on this segment — by design, it carries
Ceph-internal traffic and nothing else — so a pool outside the /29 is
unreachable from the un-migrated side. The two OSDs would mark each other
down, during precisely the window that is supposed to be the safe one.

Moves the pool into 10.99.0.0/29 with range_start/range_end reserving .4-.6,
leaving .1-.3 to the nodes. That is one address per OSD with nothing spare,
which is sufficient rather than merely tolerable: osdMaxUpdatesInParallel
bounds the migration to one OSD at a time, so allocations grow 1, 2, 3 and
never overlap. Headroom returns after the switch is confirmed and the node
addresses come out of the Talos configs — then the pool takes the whole /29.

Verified: the embedded CNI JSON parses with the intended range and bounds;
the NAD passes a server-side dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:37:41 +02:00
claude-botandClaude Opus 5 296f79469e feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network
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
Step 2 of moving Ceph's cluster network off host networking. Inert on its
own: a NetworkAttachmentDefinition does nothing until a pod references it,
and nothing will until cephcluster.yaml names it under
network.selectors.cluster.

Why this exists at all: `addressRanges` only takes effect under
`provider: host`, so carrying replication over the dedicated 10G link meant
setting a provider that is not per-daemon. It puts mons on host networking
too, which makes a mon's address the node's DHCP lease — and when that lease
moves the mon cannot bind. Three mons on three nodes then each hold the mon
host ports, so Rook cannot schedule the replacement it just decided to create
and the failover stalls. Both happened during the v1.12.11 upgrade, on two of
three nodes.

None of that serves the goal. Per the Ceph documentation the cluster network
carries OSD heartbeat, replication and recovery only — "Ceph Monitors always
operate on the public network". Attaching it here gives the OSDs the link and
leaves every other daemon on the pod network.

Its own subnet rather than the existing /29. The nodes hold 10.99.0.1-.3 for
as long as Ceph still uses host networking, which leaves exactly three
addresses for three OSDs — no headroom during the one phase where things
actually move, and a leaked allocation from a hard node loss would mean an
OSD without an address. OSDs never need to reach the node addresses, since
the cluster network is OSD-to-OSD, so the pool sits in its own range on the
same segment instead. A /24 for three addresses costs nothing: nothing else
can ever live on this segment, and it takes address count off the table when
OSDs are added.

After the provider switch is confirmed, the node addresses on VLAN 49 have no
remaining purpose and can come out of the Talos configs.

No gateway and no routes, because there is no router on this segment and
there must not be one. That also means no `sbr` chaining, unlike the v30iot
attachments — there is no DHCP-supplied default route to confine here.

whereabouts for IPAM: dhcp needs a server that must not exist on the SAN, and
host-local keeps state per node with no cluster-wide coordination, so two
nodes would hand out the same address on shared L2.

MTU 9000 matches the link. macvlan inherits it, but stating it means a change
to the parent cannot silently halve replication throughput with nothing in
the tree recording the expectation.

Verified: whereabouts from step 1 is running on all three nodes with its
binary installed next to macvlan; the NAD passes a server-side dry run; the
embedded CNI JSON parses and carries the intended master, mtu, ipam type and
range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:33:02 +02:00
k.renner f1f2c6ac6b Merge pull request 'feat(multus): add whereabouts IPAM for segments without a DHCP server' (#637) from whereabouts-ipam into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#637
2026-08-26 19:28:54 +00:00
10 changed files with 259 additions and 31 deletions
+5 -1
View File
@@ -51,7 +51,11 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
```
**Manual unseal is required after every pod restart** (node drain, upgrade,
crash): `kubectl -n openbao exec -it openbao-0 -- bao operator unseal`.
crash), **per replica** — the server runs three raft peers (see
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
voter nor a standby, so it stays out of the cluster until unsealed:
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
all three. Three peers mean one sealed replica is survivable; two are not.
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
Secrets keep their last value (they are real k8s Secrets — pods keep running).
+10 -4
View File
@@ -149,8 +149,8 @@ Secrets, and every secret-consuming app stays Pending. This step is **manual**
(the unseal key lives in `pass`, off-cluster by design).
```sh
# 1. Wait for the OpenBao pod to exist (sealed, 0/1 Ready is expected).
kubectl -n openbao get pod openbao-0
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
kubectl -n openbao get pods
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
@@ -158,8 +158,12 @@ kubectl -n openbao get pod openbao-0
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
# 3. Unseal with the key from pass (threshold 1).
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
# A sealed server is neither a raft voter nor a standby, so unsealing only
# openbao-0 leaves the three-peer cluster one node short of quorum.
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
```
Verify the store recovers before moving on:
@@ -170,7 +174,9 @@ kubectl -n <ns> get externalsecret # want: SecretSynced
```
> **Manual unseal is required after every OpenBao pod restart**, not just
> during rebuilds. Details + rationale: `MIGRATION-sops-openbao.md`.
> during rebuilds, and for *each* replica that restarts — a rolling node
> drain seals all three in turn. Details + rationale:
> `MIGRATION-sops-openbao.md`.
## Step 5 — apps: let the rest reconcile
@@ -42,12 +42,28 @@ spec:
# the agent-injector mutating webhook is unused surface.
enabled: false
server:
# Single-node raft (not standalone/file) so we get `bao operator raft
# snapshot` as a consistent backup primitive. TLS terminates at the
# Traefik ingress; in-cluster traffic is plain HTTP.
# Raft (not standalone/file) for two reasons: `bao operator raft
# snapshot` as a consistent backup primitive, and real HA across three
# nodes. TLS terminates at the Traefik ingress; in-cluster traffic is
# plain HTTP.
ha:
enabled: true
replicas: 1
# Three, not two. Raft needs a quorum, and the quorum of two is two —
# a two-replica cluster tolerates no failures at all and would stop
# the moment one pod is sealed or rescheduled, which is worse than
# the single replica it replaced. Three tolerates one.
#
# What this buys, and what it does not: a node drain seals the replica
# on it, and raft elects a new leader from the remaining standbys
# within seconds, so the service does not go down. It does NOT remove
# the manual unseal — a sealed server can neither serve requests nor
# act as standby, so that replica stays out until someone unseals it.
#
# The consequence for rolling node maintenance is worth stating: a
# Talos upgrade drains all three nodes in turn, sealing each replica
# as it goes. Unseal each one as it comes back, or the third drain
# takes the last unsealed replica with it and quorum is lost anyway.
replicas: 3
raft:
enabled: true
setNodeId: true
@@ -65,8 +81,32 @@ spec:
cluster_address = "[::]:8201"
}
# retry_join is what makes a replica join the existing raft
# cluster instead of coming up as an isolated, uninitialized
# node. Without it, scaling the StatefulSet produces pods that
# never become peers and would each need a manual
# `bao operator raft join`.
#
# All three peers are listed on every node, including the node
# itself. That is deliberate: the stanza is a list of possible
# leaders, a node ignores its own entry, and listing all three
# means the set is identical on every replica and stays correct
# regardless of which pod currently leads.
#
# Addressed through the headless service, so each entry resolves
# to one specific pod rather than load-balancing across them.
storage "raft" {
path = "/openbao/data"
retry_join {
leader_api_addr = "http://openbao-0.openbao-internal:8200"
}
retry_join {
leader_api_addr = "http://openbao-1.openbao-internal:8200"
}
retry_join {
leader_api_addr = "http://openbao-2.openbao-internal:8200"
}
}
service_registration "kubernetes" {}
@@ -53,7 +53,13 @@ spec:
command: ["/bin/sh", "-ec"]
args:
- |
export BAO_ADDR=http://openbao.openbao.svc.cluster.local:8200
# openbao-active, not openbao: with more than one replica
# the plain service selects any pod, and a raft snapshot has
# to be taken against the leader. Standbys forward ordinary
# requests, but this is not one to leave to chance — the
# active service selects the leader by label, so the
# snapshot is always taken where it is consistent.
export BAO_ADDR=http://openbao-active.openbao.svc.cluster.local:8200
BAO_TOKEN=$(bao write -field=token auth/kubernetes/login \
role=openbao-snapshot \
jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)")
@@ -8,6 +8,17 @@ spec:
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
allowUnsupported: false
dataDirHostPath: /var/lib/rook
# Do not start restarting OSDs while PGs are still recovering. The default
# is false, so Rook would proceed regardless — and paired with the old
# osdMaxUpdatesInParallel default that means it can take the next OSD down
# while the previous one's data is still being caught up.
#
# With three OSDs and size 3 there is no spare copy to fall back on: the
# gate between one OSD returning and the next going down is the only thing
# that keeps a rolling change from becoming an outage. See
# storage.osdMaxUpdatesInParallel below, which bounds the same operation
# from the other side.
upgradeOSDRequiresHealthyPGs: true
mon:
count: 3
allowMultiplePerNode: false
@@ -147,19 +158,65 @@ spec:
ssl: false
monitoring:
enabled: true
# Dedicated Ceph cluster network on VLAN 49 / 10.99.0.0/29, carried by an
# X710 SFP+ port PCI-passed-through to each Talos K8s VM (see
# talos/patches/k8s-{1,2,3}.yaml and opentofu/vm_15{0,1,2}_k8s_*.tf).
# `addressRanges` requires `provider: host` — Rook v1.18 explicitly notes
# the default (CNI) provider ignores addressRanges. Public range matches
# the existing v40srv subnet that the controlplane VMs already live on.
# Dedicated Ceph cluster network on VLAN 49, carried by the second X710
# SFP+ port (enp2s0f1np1) on each node, attached to the OSDs through Multus.
#
# WHY NOT provider: host, WHICH ALSO REACHES THAT LINK
#
# Because it reaches it for every daemon, not just the ones that need it.
# The cluster network is an OSD concern: per the Ceph documentation it
# carries OSD heartbeat, replication and recovery, and "Ceph Monitors always
# operate on the public network". `addressRanges` is only honoured under
# provider: host, so using it put the mons on host networking as a side
# effect — making a mon's address the node's DHCP lease.
#
# That is not theoretical. When a lease moved, the mon could no longer bind
# ("Cannot assign requested address") and crash-looped. Rook detected it and
# started a failover, but with three mons on three nodes each holding the
# mon host ports, the replacement could not be scheduled anywhere — the
# canary sat Pending on "didn't have free ports". Two of three nodes hit
# this during the Talos v1.12.11 upgrade and both needed hand intervention.
#
# Only `cluster` is selected here. With `public` left unset Rook puts client
# traffic on the pod network, so mons, MDS, MGR and the CSI pods keep
# Rook-managed identities and hold no host ports — which is what removes
# that failure mode rather than working around it.
#
# ISOLATION IS PRESERVED
#
# Rook's Multus prerequisites about hosts routing to the Multus network
# apply to a Multus *public* network. Selecting only `cluster` means they do
# not apply, so VLAN 49 stays what it is: Ceph-internal traffic and nothing
# else, no router, no other participants. After this settles, the node
# addresses on that segment (10.99.0.1-.3) have no remaining purpose and
# should come out of the Talos configs — at which point the attachment's
# pool can take the whole /29.
#
# Reached from provider: "" rather than directly from host, which Rook's CRD
# rejects. See the phase A commit.
#
# ONE-TIME STEP THAT THIS FILE CANNOT EXPRESS, recorded because it is not
# reproducible from the manifests alone. Removing addressRanges here does
# not clear public_network and cluster_network from Ceph's own config
# database — the same trap the bluestore comment above describes: `ceph
# config` retains a value that is only deleted from this file. During the
# phase A rollout the first OSD came up on the pod network, still had
# public_network 10.2.40.0/24 in the config database, found no interface in
# it and crash-looped on "Failed to pick public address". Both keys were
# removed with `ceph config rm global public_network` and the same for
# cluster_network; Rook then wrote cluster_network afresh from the
# attachment below. Anyone rebuilding this cluster from scratch will not hit
# it — the config database starts empty — but anyone repeating a provider
# change on a live cluster will.
#
# Bounded by upgradeOSDRequiresHealthyPGs and storage.osdMaxUpdatesInParallel
# above: one OSD restarts at a time and only once PGs are clean again.
#
# The attachment itself is san-network-attachment.yaml.
network:
provider: host
addressRanges:
public:
- "10.2.40.0/24"
cluster:
- "10.99.0.0/29"
provider: multus
selectors:
cluster: rook-ceph/ceph-san
# Nodes are listed explicitly rather than useAllNodes, so that a node can be
# taken out of the storage fleet by removing it here. Under useAllNodes the
# operator re-provisions an OSD on any node whose device still carries a
@@ -193,6 +250,15 @@ spec:
# 2326E6E8E67D Crucial — Talos EPHEMERAL (/var)
# S437NF0M501176 PM983 — this OSD
storage:
# One OSD restarted at a time. The default is 20, which on a three-OSD
# cluster means all of them at once — every PG inactive, every pool
# unreachable, for as long as the restart takes. The default suits a
# cluster large enough that 20 OSDs is a small fraction of it; here it is
# the whole thing.
#
# This binds any change that rewrites the OSD deployment spec, not just
# Ceph version upgrades — a network provider change is one such.
osdMaxUpdatesInParallel: 1
useAllNodes: false
useAllDevices: false
nodes:
@@ -1,6 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# Attachment for the VLAN 49 cluster network. Inert until cephcluster.yaml
# names it under network.selectors.cluster — creating it changes nothing on
# its own.
- san-network-attachment.yaml
- cephcluster.yaml
- toolbox.yaml
- storageclass.yaml
@@ -0,0 +1,82 @@
---
# The Ceph cluster network as a Multus attachment, so OSDs reach VLAN 49
# without every Ceph daemon being put on host networking to get there.
#
# WHAT THIS REPLACES, AND WHY
#
# `addressRanges` only takes effect under `provider: host` — Rook ignores it
# for the default CNI provider. So carrying replication over the dedicated
# 10G link meant setting provider: host, which is not per-daemon: it puts
# mons on host networking too. A mon's address is then the node's DHCP lease,
# and when that lease moves the mon cannot bind and fails. Worse, three mons
# on three nodes each hold the mon host ports, so Rook cannot schedule a
# replacement anywhere and the failover it starts cannot finish.
#
# None of that has anything to do with the goal. Per the Ceph documentation
# the cluster network carries OSD heartbeat, replication and recovery only —
# "Ceph Monitors always operate on the public network". Attaching it here
# gives the OSDs the link and leaves everything else on the pod network,
# where Rook manages identity itself.
#
# ISOLATION
#
# VLAN 49 carries Ceph-internal traffic and nothing else. Two consequences
# are deliberate:
#
# No gateway and no routes. There is no router on this segment and there
# must not be one. whereabouts adds neither unless asked, so nothing here
# can leak onto or off the SAN. This also means no `sbr` chaining is
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
# route to confine, because there is no DHCP and no default route.
#
# The pool stays inside 10.99.0.0/29, on-link with the node addresses, and
# this is load-bearing during the migration rather than a detail. OSDs move
# one at a time, so the cluster runs mixed for a while: an OSD already on
# this attachment has to reach an OSD still using the node's host address.
# There is no router on this segment, so anything outside the /29 would be
# unreachable from the un-migrated side and the two would mark each other
# down — during precisely the window that is supposed to be safe.
#
# range_start/range_end reserve .4-.6 and leave .1-.3 to the nodes. That is
# exactly one address per OSD with nothing spare, which is sufficient
# because osdMaxUpdatesInParallel bounds the migration to one OSD at a
# time: allocations grow 1, 2, 3 and never overlap. Once the switch is
# confirmed and the node addresses come out of the Talos configs, the pool
# can take the whole /29 and the spare returns.
#
# IPAM
#
# whereabouts, because neither alternative works on this segment: dhcp needs
# a server that must not exist here, and host-local keeps its state per node
# with no cluster-wide coordination, so two nodes would hand out the same
# address on shared L2. See infrastructure/multus/whereabouts-daemonset.yaml.
#
# MTU 9000 matches the link. macvlan children inherit the master's MTU, but
# it is stated so that a change to the parent does not silently halve
# replication throughput without anything in the tree recording the
# expectation.
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: ceph-san
namespace: rook-ceph
spec:
# `master` is an interface name. enp2s0f1np1 is the second X710 SFP+ port,
# a busPath-derived predictable name (net.ifnames=1 via the Image Factory
# schematic) that is identical on k8s-1/2/3 and cannot shuffle across
# reboots.
config: |
{
"cniVersion": "0.3.1",
"name": "ceph-san",
"type": "macvlan",
"master": "enp2s0f1np1",
"mode": "bridge",
"mtu": 9000,
"ipam": {
"type": "whereabouts",
"range": "10.99.0.0/29",
"range_start": "10.99.0.4",
"range_end": "10.99.0.6"
}
}
+12 -5
View File
@@ -117,13 +117,20 @@ parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. Used by Rook for OSD<->OSD replication via
# CephCluster.spec.network.addressRanges.cluster. Not routed off its own
# segment, and deliberately has no default route.
# untagged on the wire. Carries OSD<->OSD replication and nothing else.
#
# The node deliberately holds NO address here. Rook attaches this link to the
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
# does not need an address of its own for its children to work.
#
# It used to carry 10.99.0.1/29, because reaching this link required
# CephCluster.spec.network.provider: host, and that put every Ceph daemon on
# host networking, mons included. Nothing needs it now, and by the rule this
# segment is built on — Ceph-internal traffic and nothing else — the node has
# no business on it either.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.1/29
+9 -2
View File
@@ -89,10 +89,17 @@ up: true
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. OSD<->OSD replication only. Not routed off its own
# segment and deliberately has no default route.
#
# The node deliberately holds NO address here. Rook attaches this link to the
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
# does not need an address of its own for its children to work.
#
# It used to carry 10.99.0.2/29, because reaching this link required
# CephCluster.spec.network.provider: host, which put every Ceph daemon on
# host networking, mons included. Nothing needs it now.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.2/29
+8 -2
View File
@@ -73,10 +73,16 @@ parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network — see k8s-1.yaml.
# The node deliberately holds NO address here. Rook attaches this link to the
# OSD pods through a Multus macvlan (infrastructure/rook-ceph-cluster/
# san-network-attachment.yaml), and a macvlan parent only has to be up — it
# does not need an address of its own for its children to work.
#
# It used to carry 10.99.0.3/29, because reaching this link required
# CephCluster.spec.network.provider: host, which put every Ceph daemon on
# host networking, mons included. Nothing needs it now.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.3/29