Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 55ea98c035 docs: answer the direct-routing question from source instead of a test
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 first draft ended on an open question and proposed a two-VM sandbox to
answer it. That was unnecessary: the question is answerable from the Cilium
source and the agent flag reference, and the answer is negative.

installDirectRoute takes its next-hop as a parameter, and nodeUpdate supplies
GetNodeIP(false), which prefers NodeInternalIP. NodeInternalIP comes from the
Kubernetes Node object; --ipv4-node looks like an override but initFromK8s()
runs after initFromConfig() and overwrites it. --direct-routing-device selects
a device, not an address. The same value feeds the VXLAN endpoint in tunnel
mode.

So cluster-internal traffic is welded to the kubelet InternalIP, and the routing
mode does not matter — tunnel sources the VXLAN from it, native uses it as the
next-hop. That deletes the one disruptive, non-rolling step the first draft
contained. Native routing buys nothing for this problem.

Since cluster-internal cannot be moved, the separation has to come from moving
management services off the node IP, and those do not share a mechanism, so each
is checked separately: etcd moves via cluster.etcd.advertisedSubnets, the
apiserver endpoint is already a VIP whose Layer2VIPConfig carries a link field,
the Talos API turned out to answer on every node address, and kubelet cannot
move because it is the node IP.

The result is four VLANs, but the boundary does not fall where the class names
suggest — kubelet stays with cluster-internal. Stated plainly rather than filed
under management and hoped over.

Recommends leaving the node IP where it is and relabelling VLAN 40 as the
cluster VLAN, rather than moving every node's identity to a new VLAN. Same end
state, and the address the cluster is administered through never changes.

Also records that the Ceph pattern is the only mechanism that fully detaches a
workload from the node IP, and that it is too expensive to generalise to
east-west traffic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:49:19 +02:00
claude-botandClaude Opus 5 6a50ab1daa docs: propose separating the four traffic classes onto their own VLANs
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
Issue #576 framed this as "give node management its own NIC, and Ceph public
its own VLAN". That is two of four classes, and one of them turned out to be
solved already — Ceph client I/O riding the pod network is correct, since that
traffic is cluster-internal.

The classes are Ceph replication (done, VLAN 49), cluster-internal,
cluster-external and management. Three of the four share mgmt0, which was never
a decision: until the bare-metal conversion there was one NIC.

Each class gets a VLAN; which interface a VLAN lands on stays a separate and
changeable decision. That ordering means the separation survives recabling and
does not need one port per class.

Three of the four are controllable with documented levers. Management is the
InternalIP and does not move — everything else moves away from it, which is
also the direction that never changes the address the cluster is administered
through. Ingress is CiliumBGPNodeConfigOverride's per-peer localAddress, on a
BGP session that has been running for months. Egress follows the output device
under eBPF masquerading.

Cluster-internal is the one that does not resolve. In tunnel mode VXLAN sources
from the node IP with no documented alternative. Native routing is the way out
and its prerequisite is already met — BGP has been advertising the pod CIDRs to
the UDM all along — but whether nodePort.directRoutingDevice moves the next-hop
address or only the interface is undocumented across the three Cilium pages
checked.

So the proposal ends on a question rather than a plan, and the next step is a
two-interface Talos sandbox, not a cable. If the answer is unfavourable the
design changes, not the wiring.

Records what was measured rather than assumed: the BGP session state, that
Cilium's direct-routing device follows the InternalIP (adding a second address
elsewhere left it on mgmt0), that the override CRD exposes localAddress, and
where Ceph's two address families actually sit.

Also states what the split does not buy. Management shares a 10 Gb link and is
not contended; nothing observed is slow because of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:34:41 +02:00
k.renner d1e072146f Merge pull request 'docs(runbook): rewrite the cold rebuild for bare metal' (#661) from runbook-cold-rebuild-baremetal into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#661
2026-08-27 08:11:24 +00:00
@@ -0,0 +1,194 @@
# Proposal — separate the four traffic classes onto their own VLANs
Status: draft. The question this started with is **answered** — from the Cilium
source and flag reference, not from a test — and the answer changes the shape
of the proposal rather than confirming it.
Supersedes the framing in issue #576, which described this as "give node
management its own NIC, and Ceph public its own VLAN". That was two of four
classes and one of them turned out already solved.
## The four classes
| class | what it is | where it goes today |
|---|---|---|
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
Three of four share one interface. That is not a design decision — until the
bare-metal conversion there was one NIC to work with.
Ceph client I/O living on the pod network is correct rather than a leftover:
that traffic *is* cluster-internal. Ceph is done.
## Principle
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
separate, changeable decision.**
Defining classes by VLAN means the separation survives recabling, port failures
and capacity changes, and it does not require one port per class. Defining them
by interface would freeze a capacity decision into the architecture.
## The finding that shapes everything else
The original open question was whether `nodePort.directRoutingDevice` moves the
next-hop of Cilium's direct routes, or only selects an interface. It is
answerable from the source, and the answer is: **only the interface.**
`installDirectRoute` takes the next-hop as a parameter:
```go
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
skipUnreachable bool) (routeSpec *netlink.Route, err error)
```
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
cluster there is no path by which Cilium's node IP differs from the kubelet's
`InternalIP`.
`--direct-routing-device` is documented as "Device name used to connect nodes in
direct routing mode" — a device, not an address.
**So cluster-internal traffic is welded to the node IP, and the node IP is the
kubelet InternalIP.** Two consequences:
- The class cannot be moved off whatever VLAN the node IP lives on.
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
from the node IP; native mode uses it as the direct-route next-hop. Native
routing buys nothing here, so the disruptive, non-rolling step this proposal
previously contained is simply gone.
## Consequence: draw the line the other way
If cluster-internal cannot leave the node IP, then separation has to come from
moving *management services* off it — and each one has to be checked
individually, because they do not share a mechanism.
| management service | can it leave the node IP? | lever |
|---|---|---|
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
| kubelet | **no** | it *is* the node IP, by definition |
So four VLANs are reachable, but the boundary does not fall where the class
names suggest: **kubelet stays with cluster-internal.** That is the honest
result, and it is worth stating plainly rather than filing kubelet under
management and hoping.
The remaining classes keep the levers established earlier:
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
For eBGP the session source is the next-hop, so pinning the session pins
where return traffic arrives. The session to the UDM has been running for
months.
- **cluster-external egress** — eBPF masquerading follows the output device, so
this is a default-route decision plus `enable-masquerade-to-route-source`
(today `false`).
- **Ceph replication** — Multus macvlan on VLAN 49, done.
## The general escape hatch
Ceph is separated because its OSDs got a second interface and stopped using the
pod network for replication. That is the *only* mechanism that fully detaches a
workload from the node IP, and it generalises: anything that must not ride the
node IP gets its own `NetworkAttachmentDefinition`.
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
change. Worth it for Ceph replication. Not worth it for east-west traffic in
general.
## Recommended mapping
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
and Talos API. Cleanest match to the class names, but it changes every node's
identity, which is the one address the whole cluster is administered through.
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
Identical end state, and no node ever changes its InternalIP.
Under B:
| VLAN | carries |
|---|---|
| 49 | Ceph replication |
| 40 (relabelled *cluster*) | pod east-west, kubelet |
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
| new *external* | LoadBalancer ingress, egress default route |
The property the user asked for — management carries only management traffic —
holds under B. The label moves; the property is what matters.
## Migration shape
Every step is rolling and reversible. The routing-mode change is gone.
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
Still unused; this only proves the L2 works.
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
waiting for a healthy quorum between each.
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
`k8s.renner.uno` follows.
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
Reversible by reverting the override; failure is visible immediately in
`cilium-dbg shell -- bgp/peers`.
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
Step 3 is the one that deserves care — etcd is the only step where a mistake
costs quorum rather than connectivity.
## What this does not buy
Management currently shares a 10 Gb link and is not contended. Nothing observed
today is slow because of this. The case for the split is that traffic classes
with different failure modes and security postures should not be
indistinguishable — not that something is broken.
## Sources and measurements
Source, Cilium v1.20.1 (the running version):
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
a next-hop that is not directly reachable.
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
Kubernetes Node.
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
to connect nodes in direct routing mode".
Talos:
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
defaults to it. kube-apiserver and Talos API listen addresses are not
covered there.
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
`10.2.40.60` with `link: mgmt0`.
Measured on the cluster:
- `apid` answered on a node address that is not the InternalIP, so the Talos
API is not bound to one address.
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
InternalIP had changed since the last session, which is why nothing here
names an address as fixed.
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
unset and no direct-routing device configured.
Not verified: whether the kube-apiserver's `--advertise-address` follows the
node IP independently of the VIP. It does not affect the recommendation, since
clients reach the API through the VIP either way.