Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 f0701370db docs: describe the traffic-class split as built (#576)
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
Replaces the proposal, which was wrong before it was written down. It presented A, B and C as the option space and concluded pod-to-pod was blocked; policy routing was available the whole time and is what moved pod egress and then pod-to-pod. The file recorded a blind spot and gave it the authority of a checked-in document.

Three of four classes are separated now, so this describes the built design rather than proposing one, and the PROPOSAL- name goes with it: which link each class uses, the rule order and why 980/981 match source and destination, where each table comes from, how the bird mesh renders itself from the API without repeating an address, and why the node's own CIDR is originated rather than read back from the kernel.

Management is the remaining class. Its levers are listed, along with the two approaches the owner rejected — with the caveat that the same reasoning which produced that list also called pod-to-pod impossible, so it is a record of decisions and not an analysis to trust.

Failure behaviour is stated because it is what makes the design safe to roll one node at a time: an empty table 48 falls through to main and pod traffic returns to VLAN 40, unseparated rather than broken.

bootstrap.sh's reference follows the rename; its point was always that the QEMU/Hetzner sites use two segments, not the homelab's split.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:18:57 +02:00
k.renner 9131f73d6c Merge pull request 'feat(talos): send pod-to-pod over pod0 via table 48 (#576)' (#855) from feat/podpod-rules into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#855
2026-09-16 09:05:22 +00:00
claude-botandClaude Opus 5 7e19ed1b59 feat(talos): send pod-to-pod over pod0 via table 48 (#576)
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 last step of the traffic-class split. The bird mesh already fills table 48 with one route per peer pod CIDR, next hop the peer's pod0 address; these two rules are what make the kernel look there.

Source AND destination are matched. Source alone would also catch the node's own traffic to a pod, which would then leave with a pod0 source — an address CiliumNode does not list, so Cilium would classify it as world rather than host and policy would drop it. Destination alone would catch traffic from anywhere.

Priority 980/981 sits ahead of the 990/991 pair that sends the cluster and local networks to main, so pod-to-pod wins for that traffic and everything else is unchanged. Traffic to a pod on the same node falls through: table 48 holds only the peers' CIDRs, the lookup misses, and 990/991 send it to main and cilium_host.

Verified on k8s-1 with --mode=try, 200 requests per family from a pod on k8s-1 to a pod on k8s-2: pod0 gained 6957 transmit packets, matching 400 requests, while pod0 receive moved by 45 — the peer is unpatched and still answers over mgmt0, the same asymmetry the earlier measurement showed. Both rules were installed as expected, one per family.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:04:05 +02:00
k.renner 41556cbb16 Merge pull request 'fix(bird): originate the node's own pod CIDR instead of learning it' (#854) from fix/bird-static into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#854
2026-09-16 09:00:04 +00:00
claude-botandClaude Opus 5 377270375f fix(bird): originate the node's own pod CIDR instead of learning it
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
IPv6 populated table 48 on all three nodes; IPv4 was empty. The daemon log gives the reason: KRT: Received route 10.244.0.0/24 with strange next-hop 10.244.0.195. Cilium's IPv4 pod route points at a next hop inside the prefix it describes, which BIRD's kernel protocol refuses. The IPv6 route is a device route with no next hop, so it passed — the asymmetry, not a filter, is why one family worked.

Reading the route back made BIRD's view depend on the shape Cilium happens to install. The prefix is already in the Node object, so a static protocol originates it directly: blackhole, because the route exists only to be advertised, and the sole kernel protocol exporting from these tables takes BGP routes.

That removes the kernel-learning tables, the pipes and the own-CIDR filters entirely — with them the dependence on learn all, on ifname, and on the maximum-length exclusion.

Verified: the templates render OWN_POD4 10.244.0.0/24 and OWN_POD6 fd10:244::/64 from the live k8s-1 object, and bird -p parses the result inside the image with exit 0.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:57:28 +02:00
k.renner df9019d90e Merge pull request 'fix(bird): reference the image by digest, the dated tag resolves stale' (#853) from fix/bird-digest into master
Reviewed-on: k.renner/homelab#853
2026-09-16 08:54:37 +00:00
claude-botandClaude Opus 5 c1ccf228b7 fix(bird): reference the image by digest, the dated tag resolves stale
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 DaemonSet came up with Init:Error, kubectl: not found. The build had pushed sha256:3bc85006 for bird:2026-09-16, but every pod resolved that tag to sha256:75e19214 — the first build of the same day, from before kubectl was added.

imagePullPolicy: Always does not help: it re-pulls, but the tag itself resolves to the stale digest. Deleting the pods changed nothing; three fresh pods on three nodes all resolved to the old digest. A one-off pod pulling the digest directly ran kubectl 1.33.1 and BIRD 2.17.2, so the registry holds the right image and only tag resolution is stale.

The dated tag cannot carry a same-day rebuild, and this image was rebuilt three times today. Referencing the digest makes the deployment deterministic. The cost is that the reference has to be updated whenever the image is rebuilt; the alternative, tagging by commit, does not work for a manifest living in the same commit.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:53:55 +02:00
k.renner 1cd26b74ae Merge pull request 'feat(bird): iBGP mesh over pod0, routes into table 48 (#576)' (#852) from feat/bird-mesh2 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#852
2026-09-16 08:50:26 +00:00
claude-botandClaude Opus 5 3a9ea5eefe feat(bird): iBGP mesh over pod0, routes into table 48 (#576)
ci/woodpecker/push/build-ci-images Pipeline was successful
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
Nothing reads table 48 yet. The mesh fills it so the routing rules land on a table that is already correct, and so a mistake here shows as an empty table rather than as broken pod traffic.

No address is written down twice. Each node publishes its pod0 addresses as Talos nodeAnnotations, beside the VLANConfig that assigns them; the init container reads the node list and renders from it. bird.conf holds no address at all, and the pod CIDRs are not restated either — the own-CIDR filter keys on the outgoing interface plus a maximum-length exclusion, which separates the node's pod route from cilium_host's own host address without naming a prefix.

Rendering is go-template-file, not string building: kubectl renders the two templates, a field selector drops the node itself server-side, and in-cluster config removes the token and CA plumbing. kubectl comes from the Alpine package, so there is no version literal to drift out of sync and no download to trust; it only ever reads core/v1 Nodes. jq and curl are gone.

The init container ends with bird -p, so BIRD's own parser gates startup: a node whose annotation is missing renders as <no value> and fails there rather than crashlooping the daemon or forming a mesh with a hole.

learn all rather than learn, since plain learn skips proto kernel. direct is explicit because iBGP defaults to multihop, which would make the gateway recursive. strict bind keeps the listener off mgmt0 and ingress0.

Verified against the live cluster with all three nodes annotated via --mode=try: the templates rendered the expected defines and four peer protocols, and bird -p parsed the result inside the image with exit 0 — which also confirms net.len and the include ordering. kubectl kustomize builds.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:43:42 +02:00
k.renner b914b030cc Merge pull request 'feat(bird): add curl and jq for the peer-list init container' (#849) from feat/bird-image-api into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#849
2026-09-16 08:10:50 +00:00
claude-botandClaude Opus 5 bb3987ce1c feat(bird): add curl and jq for the peer-list init container
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The per-node peer list is rendered from the Kubernetes API rather than written into the repo a second time, so the init container needs an HTTP client that can verify against the API server's CA bundle. busybox wget cannot, and the query is not going to run unverified. Using the same image for the init container and the daemon keeps one lifecycle instead of borrowing a CI image at runtime.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:10:09 +02:00
k.renner 480b2b11e4 Merge pull request 'feat(bird): image for the pod-network BGP mesh (1/3) (#576)' (#846) from feat/bird-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#846
2026-09-16 07:50:27 +00:00
claude-botandClaude Opus 5 3302e2c5cd fix(bird): pin the base image by digest and self-test the binary
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Every other Alpine image under ci/images/ pins alpine:3.21 by index digest, and alpine-jq states the reason in the file: the resolved version drifts between restarts of an otherwise pinned image. The first revision used a bare alpine:3.22 tag. 3.22 is still right — bird2 first appears in 3.22/community — so the tag stays and the index digest is added.

heating-can runs --help checks before publishing so a wrong path fails the build rather than the first pod; bird documents --version, so it gets the same guarantee.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:38:46 +02:00
claude-botandClaude Opus 5 3792485c2f feat(bird): image for the pod-network BGP mesh (1/3) (#576)
ci/woodpecker/push/build-ci-images Pipeline was successful
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
Pod east-west follows the peer's Kubernetes InternalIP on mgmt0, and Cilium offers no way to point it elsewhere — established by test in #838, and directRoutingDevice (#837) does not do it. Policy routing does: a rule on the pod source prefixes into a table whose routes carry pod0 next-hops, proven on k8s-1 by counting 4647 packets onto pod0.

Those routes have to be generated, not written down: PodCIDRs are Kubernetes-assigned and do not survive a rebuild in place. BGP between the nodes over pod0 generates them, with the session source as the next-hop.

BIRD rather than FRR. FRR's docs describe 'set table' only as setting a BGP table identifier, never a kernel table; binding bgpd to an address implies --no_kernel and would suppress the route installation the design exists for; and a gatewayless IPv6 device route has no next-hop for its route-maps to match, so the own-CIDR filter could not be uniform. BIRD documents 'kernel table', 'strict bind' without touching the kernel protocol, and an 'ifname' filter attribute that discriminates the local route from the learned ones in both families.

No maintained standalone BIRD image exists: pierky/bird self-declares as lab-only and amd64-only, calico/bird is a Calico-internal artifact expecting confd, slipmesh/bird is five weeks old, srl-labs and ibhde are lab-scoped or on BIRD 1.6. A custom controller would have needed an image of its own anyway, so the build is common to both routes.

Image only. The DaemonSet is 2/3 and the routing rules 3/3, so the table fills before anything reads it.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:29:17 +02:00
k.renner 1a1e095f76 Merge pull request 'feat(talos): route pod egress over ingress0 (#576)' (#842) from feat/pod-egress-vlan47 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#842
2026-09-16 05:40:40 +00:00
claude-botandClaude Opus 5 11df090913 feat(talos): route pod egress over ingress0 (#576)
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 default route cannot simply move to ingress0: the node IP, the API VIP and apid answer on mgmt0, so a reply leaving over ingress0 would carry an mgmt0 source onto VLAN 47. Policy routing splits the two instead — pod source prefixes look up table 47, everything the node sends itself keeps main and mgmt0.

Table 47 holds only the two default routes, so destination rules at 990/991 send the cluster and local networks back to main first; without them a pod's packet to a service matches the source rule, finds only the default, and leaves for the UDM. That was measured on k8s-1, where in-cluster DNS stopped resolving until the rules were added.

The v6 gateway is the UDM's configured ULA on the segment, the address BGP already peers with, rather than the RA-learned link-local. accept_ra_defrtr stays 0 on ingress0 so the RA default does not land in main.

Verified on k8s-1 with --mode=try: a pod egressed as 2003:d6:973f:5605:5a47:caff:fe74:7027, the ingress0 GUA from the VLAN 47 prefix, while in-cluster DNS and the Talos and Kubernetes APIs kept working.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 07:35:14 +02:00
security-bot cfb1406201 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 21:06:23 +00:00
security-bot 4190c634ec chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 20:21:56 +00:00
17 changed files with 476 additions and 112 deletions
+26
View File
@@ -376,3 +376,29 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/bird
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/bird/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/bird:$TAG \
--destination gitea.renner.uno/gitea_admin/bird:latest
+22
View File
@@ -0,0 +1,22 @@
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
#
# 3.22 rather than the 3.21 the other images here pin: bird2 first appears in
# 3.22/community.
#
# Config is read from /etc/bird/bird.conf, not the package default
# /etc/bird.conf, so the mount cannot shadow what the package ships.
#
# kubectl renders the init container's per-node config: go-template-file for
# the templating, a field selector for excluding the node itself, in-cluster
# config for the auth. From the distro, so there is no version literal to drift
# and no download to trust. It is only ever used to read core/v1 Nodes.
RUN apk add --no-cache bird2 kubectl
# Fails the build rather than the first pod if a binary or its path is wrong.
RUN /usr/sbin/bird --version \
&& kubectl version --client > /dev/null
ENTRYPOINT ["/usr/sbin/bird", "-f", "-c", "/etc/bird/bird.conf"]
+117
View File
@@ -0,0 +1,117 @@
# Traffic classes on the k8s nodes
Issue #576. Three of the four classes are separated; management is not.
## Where each class runs
| class | link | mechanism |
|---|---|---|
| Ceph replication | `ceph-san`, VLAN 49 | the whole link is handed to the OSD pod (Multus `host-device`) |
| world→pod | `ingress0`, VLAN 47 | BGP session sourced there, so the advertised next hop is that address |
| pod→world | `ingress0`, VLAN 47 | routing rule on the pod source prefix → table 47 |
| pod↔pod | `pod0`, VLAN 48 | routing rule on pod source **and** destination → table 48 |
| management | `mgmt0`, VLAN 40 | not started |
Hard constraint, set by the owner: inter-node and pod traffic never traverses
the UDM. Everything above stays on the 10G switch.
## Why policy routing rather than moving the node IP
Cilium reaches a peer's pods at that peer's Kubernetes InternalIP, in native
and tunnel mode alike. `--ipv4-node` is overwritten from the Node object, and
`nodePort.directRoutingDevice` selects the BPF NodePort device, not a next hop.
Both were established by test (#837, #838), not by reading.
So the InternalIP decides where pod traffic goes — unless the lookup happens in
a different table. The rules do that, and nothing about the node's identity
moves. The routing decision runs before Cilium's masquerade hook, so a rule
matching the pod source sees the pod address, and the masquerade then rewrites
it to the egress link's.
## Rule order
Lower priority runs first.
| prio | match | table |
|---|---|---|
| 980 | src pod, dst pod | 48 |
| 981 | src pod, dst pod (v4) | 48 |
| 990 | dst `fd00::/8` | main |
| 991 | dst `10.0.0.0/8` | main |
| 1000 | src pod | 47 |
| 1001 | src pod (v4) | 47 |
980/981 match source **and** destination. Source alone would also catch the
node's own traffic to a pod, which would then leave with a `pod0` source — an
address `CiliumNode.spec.addresses` does not list, so Cilium classifies it as
`world` rather than `host`.
990/991 are Cilium's `ipv4/ipv6NativeRoutingCIDR`, and they are what keeps
cluster-internal traffic out of table 47. Without them a pod's packet to a
service matches the source rule, finds only a default route, and leaves for the
UDM — measured, in-cluster DNS stopped resolving.
Traffic to a pod on the same node falls through on its own: table 48 holds only
the peers' CIDRs, so the lookup misses and 990/991 send it to main.
## Where each table comes from
- **47** — two default routes declared per node in `talos/patches/k8s-*.yaml`.
The IPv6 gateway is the UDM's configured address on the segment, not the
RA-learned link-local; `accept_ra_defrtr` stays `0` on `ingress0` so the RA
default does not land in main.
- **48** — filled by the `bird` DaemonSet: an iBGP mesh over `pod0`, one route
per peer pod CIDR with the peer's `pod0` address as next hop.
## The bird mesh
`kubernetes/infrastructure/bird/`. Each node advertises only its own pod CIDR
and installs what it learns into table 48. Cilium's routes in main are
untouched, which is why the two coexist.
No address is written into this repo twice. Each node publishes its `pod0`
addresses as `nodeAnnotations`, beside the `VLANConfig` that assigns them; the
pod CIDRs come from the Node objects. An init container renders both from the
API with `kubectl -o go-template-file`, excludes the node itself with a field
selector, and ends with `bird -p` so BIRD's own parser gates startup.
The node's own CIDR is **originated**, not read back from the kernel. Reading
it back made BIRD depend on the shape Cilium installs, and the IPv4 route is
rejected outright: its next hop sits inside the prefix it describes, which KRT
refuses with `strange next-hop`. The IPv6 route is a device route and passed,
so the failure was silent on one family only.
## Failure behaviour
Benign at every layer. An empty or absent table 48 makes 980/981 fall through
to main, and pod traffic returns to VLAN 40 — unseparated, not broken. The same
holds if the DaemonSet cannot start, which is the case during a cold rebuild
until Gitea serves its image.
## Management, the remaining class
Not started. The levers differ per service and none of them is the node IP:
| service | can leave the node IP? | lever |
|---|---|---|
| etcd | yes | `cluster.etcd.advertisedSubnets`; needs a reboot per node |
| kube-apiserver endpoint | yes | it is a VIP — `Layer2VIPConfig.link` |
| Talos API | n/a | `apid` answers on every node address; a firewall question |
| kubelet | no | it *is* the node IP |
Two approaches were rejected by the owner and are not to be proposed again:
moving the node IP and etcd to `pod0` (#838), and relabelling VLAN 40 as the
cluster VLAN while moving etcd and the API VIP to a new management VLAN.
Replacing the CNI (#839, Antrea) was shelved.
Note that the reasoning which produced that list also concluded pod↔pod was
blocked, which policy routing disproved. Treat it as a record of decisions, not
as an exhaustive analysis.
## Sources
Cilium v1.20.1 `pkg/datapath/linux/node.go`, `pkg/node/types/node.go`,
`pkg/node/sync/local_node_sync.go`; BIRD 2.17 user manual (kernel protocol,
`learn all`, `strict bind`, `direct`); Talos v1.13 `RoutingRuleConfig` and
`machine.nodeAnnotations`. PRs #831, #833, #835, #836, #837, #842, #852, #854,
#855; closed unmerged: #830, #838, #839.
@@ -1,103 +0,0 @@
# Proposal — separate the four traffic classes onto their own VLANs
Status: open, issue #576. Two of the four classes are separated. Pod east-west
cannot leave the node IP under Cilium. Both ways of moving it within Cilium
(options A and B below) are rejected by the owner, and replacing the CNI
(option C) is shelved.
## Target
Set by the owner on 2026-09-08:
| class | link |
|---|---|
| Ceph replication | X710 p1, VLAN 49 |
| pod-pod | X710 trunk, a VLAN of its own |
| world-pod | X710 trunk, a VLAN of its own |
| node management | 1 Gb onboard NIC |
Hard constraint: inter-node and pod traffic never traverses the UDM. It stays
on the 10G switch.
## Where the classes are
Measured 2026-09-15.
| class | today | state |
|---|---|---|
| Ceph replication | `ceph-san`, VLAN 49, handed whole to the OSD pod (Multus `host-device`) | done |
| world→pod | BGP from each node's `ingress0` (VLAN 47) to `fd02:47::1` / `10.2.47.1`; LB VIPs and PodCIDRs are advertised with that next-hop | done (#835, #836) |
| pod→world | default routes via `mgmt0`: v4 metric 1024 against `ingress0`'s 2048, v6 from SLAAC on `mgmt0`; `devices` unset | open |
| pod-pod | native routing, `<PodCIDR> via <peer InternalIP> dev mgmt0`, VLAN 40 | blocked, see below |
| management | `mgmt0`, VLAN 40 | not started |
`ingress0` (VLAN 47) and `pod0` (VLAN 48) exist on all three nodes (#831,
#833). `pod0` holds addresses and carries no traffic.
## Why pod-pod is blocked
Cilium's route to a peer's pods uses the peer's Kubernetes InternalIP as the
next-hop, in native mode, and as the VXLAN endpoint in tunnel mode:
- `pkg/datapath/linux/node.go`: `installDirectRoute(…, nodeIP net.IP, …)` is
fed from `newNode.GetNodeIP(false)`.
- `pkg/node/types/node.go`: `GetNodeIP` prefers `NodeInternalIP`.
- `pkg/node/sync/local_node_sync.go`: `initFromK8s()` runs after
`initFromConfig()` and overwrites `--ipv4-node` / `--ipv6-node` from the
Node object.
- `--direct-routing-device` selects a device for the BPF NodePort path, not a
next-hop.
Tested on the cluster, none of these moved the next-hop:
| attempt | result |
|---|---|
| `ipv4-node` / `ipv6-node` via `CiliumNodeConfig` | agent logged both flags, still published its `mgmt0` address |
| `nodePort.directRoutingDevice: pod0` (#837) | agent logged `direct-routing-device=pod0`, still installed routes via `mgmt0` |
| tunnel and native mode, v4 and v6 | endpoints and routes keyed to the peer's InternalIP |
The InternalIP comes from `machine.kubelet.nodeIP.validSubnets` in
`talos/patches/site-homelab.yaml`, currently `10.2.40.0/24` and `fd40::/64`.
Pod-pod traffic therefore sits on whichever VLAN holds the InternalIP. The
cluster has been in native routing mode since #837. `directRoutingDevice: pod0`
has no effect on pod-pod routing.
## Options for pod-pod
| | option | status |
|---|---|---|
| A | move the node IP and etcd to `pod0` | **rejected** by the owner; #838 closed 2026-09-08 |
| B | keep the node IP on VLAN 40, relabel VLAN 40 as the cluster VLAN, move etcd and the API VIP to a new management VLAN | **rejected** by the owner, repeatedly. Not to be proposed again |
| C | replace Cilium with a CNI that picks the transport interface independently of the node IP (Antrea `transportInterface`) | **shelved** 2026-09-10; #839 closed. Needs a cold rebuild, rewriting the CiliumNetworkPolicy objects, and Antrea's BGP is alpha |
A per-workload Multus attachment detaches a single workload from the node IP,
the way Ceph replication is detached. It does not cover east-west traffic in
general.
## Levers per management service
These hold regardless of which option is chosen:
| service | can leave the node IP? | lever |
|---|---|---|
| etcd | yes | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it); needs a reboot per node |
| kube-apiserver endpoint | yes | VIP, `Layer2VIPConfig` with a `link:` field |
| Talos API | n/a | `apid` answers on every node address; a client and firewall choice |
| kubelet | no | it is the node IP |
## Remaining, independent of pod-pod
- **pod→world egress**: default route and masquerade source onto `ingress0`,
via route metrics, `devices` and `enable-masquerade-to-route-source`. This
moves the nodes' own egress (image pulls, NTP) to VLAN 47 too.
- **BGP rollback path**: the UDM still accepts sessions from VLAN 40; to be
removed once ingress on VLAN 47 is settled.
## Sources
- Cilium v1.20.1 source files listed above; `cilium-agent` flag reference for
`--direct-routing-device`.
- Talos multihoming guide: `cluster.etcd.advertisedSubnets`, `listenSubnets`.
- PRs #831, #833, #835, #836, #837, #838, #839 and the comments on #576.
- Live state 2026-09-15: `kubectl get nodes -o wide`, `cilium-config`,
`CiliumBGPNodeConfig` status, node routing tables on k8s-1 and k8s-2.
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.2.1 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.2.2 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+1 -1
View File
@@ -87,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.2.1 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.2.2 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
+2 -2
View File
@@ -610,8 +610,8 @@ if [[ "$PHASE" == "hcloudtest" ]]; then
# Constraints on the choice: cpx11/cpx21 are US-only and cax* are ARM, which
# would not match the amd64 schematic this cluster installs. That leaves the
# cx*/cpx*2 families in the German locations.
# Today's traffic-class split, not the target one in
# PROPOSAL-traffic-class-separation.md: the node address carries management,
# Two segments only, unlike the homelab's split in
# NETWORK-traffic-classes.md: here the node address carries management,
# cluster-internal and external alike, and Ceph replication has its own.
#
# The order is load-bearing: it decides which kernel interface each segment
+67
View File
@@ -0,0 +1,67 @@
# Identical on every node and free of addresses. defines.conf and peers.conf
# are rendered per node by the init container; the first is included above the
# templates that reference it, the second below them.
include "/etc/bird/defines.conf";
router id from "pod0";
log stderr all;
protocol device { scan time 10; }
# This node's own pod CIDR, originated rather than read back out of the kernel.
# Reading it back made BIRD's view depend on the shape of Cilium's route, and
# the IPv4 one is rejected outright: its next hop sits inside the prefix it
# describes, which KRT refuses with "strange next-hop". The prefix comes from
# the Node object either way, so there is nothing to learn.
#
# blackhole because this route is only ever advertised. Nothing installs it:
# the only kernel protocol exporting from these tables takes BGP routes.
protocol static own4 {
ipv4 { table master4; };
route OWN_POD4 blackhole;
}
protocol static own6 {
ipv6 { table master6; };
route OWN_POD6 blackhole;
}
# Only BGP-learned routes reach table 48 — not the static above, and not
# anything else that may end up in the master tables.
filter to_t48 {
if source != RTS_BGP then reject;
accept;
}
protocol kernel k48v4 {
kernel table 48;
metric 0;
ipv4 { table master4; import none; export filter to_t48; };
}
protocol kernel k48v6 {
kernel table 48;
metric 0;
ipv6 { table master6; import none; export filter to_t48; };
}
# iBGP over pod0. 64514 keeps clear of Cilium's 64513, which peers with the UDM
# from ingress0 and is unrelated.
#
# direct is explicit because iBGP defaults to multihop, which makes the gateway
# recursive — BIRD would try to resolve the peer in the pod RIB. strict bind
# keeps the listener off mgmt0 and ingress0.
template bgp pod4 {
local as 64514;
source address MY4;
direct;
strict bind;
ipv4 { next hop self; import all; export all; };
}
template bgp pod6 {
local as 64514;
source address MY6;
direct;
strict bind;
ipv6 { next hop self; import all; export all; };
}
include "/etc/bird/peers.conf";
@@ -0,0 +1,115 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: bird
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bird
rules:
# The peer list is read from the nodes' pod0 annotations, so no address is
# written into this repo twice.
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: bird
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: bird
subjects:
- kind: ServiceAccount
name: bird
namespace: kube-system
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: bird
namespace: kube-system
labels:
tier: node
app: bird
spec:
selector:
matchLabels:
app: bird
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
tier: node
app: bird
spec:
# Carries pod east-west once the routing rules land. Without it table 48
# stays empty and the rules fall through to main, i.e. back to VLAN 40.
priorityClassName: system-node-critical
# Writes host routes and peers on host addresses.
hostNetwork: true
serviceAccountName: bird
# Every node here is a control plane node, so this is not optional.
tolerations:
- operator: Exists
effect: NoSchedule
initContainers:
- name: render-config
# By digest, not by the dated tag: the tag is rebuilt within the day
# it names, and the cluster's image path keeps resolving it to the
# first build of that day — imagePullPolicy: Always does not help,
# because the tag itself resolves stale. Update this when the image
# is rebuilt.
image: gitea.renner.uno/gitea_admin/bird@sha256:3bc8500613eeb45b2d8858b5445198bae3990ecf64f6d891aa94797f5a09dc1d
command: ["/bin/sh", "-c"]
args:
- |
set -eu
cp /config/bird.conf /etc/bird/bird.conf
kubectl get node "$NODE_NAME" \
-o go-template-file=/config/defines.tmpl > /etc/bird/defines.conf
kubectl get nodes --field-selector "metadata.name!=$NODE_NAME" \
-o go-template-file=/config/peers.tmpl > /etc/bird/peers.conf
# BIRD's own parser is the check: a node whose annotation is
# missing renders as <no value> and fails here rather than
# crashlooping the daemon or forming a mesh with a hole.
bird -p -c /etc/bird/bird.conf
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: config
mountPath: /config
- name: etc-bird
mountPath: /etc/bird
containers:
- name: bird
image: gitea.renner.uno/gitea_admin/bird@sha256:3bc8500613eeb45b2d8858b5445198bae3990ecf64f6d891aa94797f5a09dc1d
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "200m"
memory: "128Mi"
securityContext:
capabilities:
drop: ["ALL"]
# NET_ADMIN writes the routes; NET_BIND_SERVICE binds 179.
add: ["NET_ADMIN", "NET_BIND_SERVICE"]
volumeMounts:
- name: etc-bird
mountPath: /etc/bird
volumes:
- name: config
configMap:
name: bird-config
- name: etc-bird
emptyDir: {}
@@ -0,0 +1,7 @@
define MY6 = {{ index .metadata.annotations "net.renner.uno/pod0-v6" }};
define MY4 = {{ index .metadata.annotations "net.renner.uno/pod0-v4" }};
{{/* podCIDRs follows the cluster's podSubnets order, v4 first. A flip puts an
IPv6 prefix into an ipv4 protocol, which fails the parse in the init
container rather than passing silently. */}}
define OWN_POD4 = {{ index .spec.podCIDRs 0 }};
define OWN_POD6 = {{ index .spec.podCIDRs 1 }};
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- daemonset.yaml
configMapGenerator:
# Hashed name, so editing any of these rolls the DaemonSet.
- name: bird-config
namespace: kube-system
files:
- bird.conf
- defines.tmpl
- peers.tmpl
@@ -0,0 +1,7 @@
{{- range $i, $n := .items -}}
{{- $v6 := index $n.metadata.annotations "net.renner.uno/pod0-v6" -}}
{{- $v4 := index $n.metadata.annotations "net.renner.uno/pod0-v4" -}}
# {{ $n.metadata.name }}
protocol bgp peer{{ $i }}_v6 from pod6 { neighbor {{ $v6 }} as 64514; }
protocol bgp peer{{ $i }}_v4 from pod4 { neighbor {{ $v4 }} as 64514; }
{{ end -}}
@@ -7,6 +7,7 @@ resources:
- cluster-settings/
- cilium/
- multus/
- bird/
- cert-manager/
- snapshot-controller/
- metrics-server/
+17 -3
View File
@@ -52,6 +52,12 @@ machine:
# this label instead of listing hostnames, so which boxes carry storage is
# stated once, here, by the machine that knows.
storage.renner.uno/ceph-osd: "true"
# Publishes this node's pod0 addresses, declared below in its VLANConfig, so
# the bird DaemonSet reads its peers from the node list rather than from a
# copy. Annotations, not labels: a label value cannot contain a colon.
nodeAnnotations:
net.renner.uno/pod0-v6: fd02:48::11
net.renner.uno/pod0-v4: 10.2.48.11
udev:
# Renames every port this node uses to its role at boot, matched by
# burned-in MAC — kernel names derive from PCI topology and shift when
@@ -182,8 +188,14 @@ addresses:
# network's clients selectively, so the client goes rather than the record.
# DHCP is off on VLAN 47 for the same reason.
#
# The metric loses to mgmt0's default 1024 deliberately: this pass only adds
# the segment. The default route moves here in the change that flips it.
# Both defaults live in table 47, not in main, so only the pod-source rules in
# site-homelab.yaml reach them. mgmt0 keeps main's default and with it every
# reply the node sends from its own addresses.
#
# The v6 gateway is the UDM's configured ULA on this segment, the same address
# BGP peers with. The RA-learned link-local would be the alternative and is not
# a value to write down. accept_ra_defrtr stays 0 for that reason: the RA
# default would land in main, where it does not belong.
apiVersion: v1alpha1
kind: VLANConfig
name: ingress0
@@ -193,8 +205,10 @@ up: true
addresses:
- address: 10.2.47.11/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
metric: 2048
table: "47"
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication and nothing else. The udev rule in the machine
+7 -1
View File
@@ -33,6 +33,10 @@ machine:
topology.kubernetes.io/zone: saturn
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
# pod0 addresses for the bird peer list — see k8s-1.yaml.
nodeAnnotations:
net.renner.uno/pod0-v6: fd02:48::12
net.renner.uno/pod0-v4: 10.2.48.12
udev:
# Renames every port to its role at boot — see k8s-1.yaml.
rules:
@@ -129,8 +133,10 @@ up: true
addresses:
- address: 10.2.47.12/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
metric: 2048
table: "47"
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication only, not routed off its own segment.
+7 -1
View File
@@ -29,6 +29,10 @@ machine:
topology.kubernetes.io/zone: neptune
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
# pod0 addresses for the bird peer list — see k8s-1.yaml.
nodeAnnotations:
net.renner.uno/pod0-v6: fd02:48::13
net.renner.uno/pod0-v4: 10.2.48.13
udev:
# Renames every port to its role at boot — see k8s-1.yaml.
rules:
@@ -116,8 +120,10 @@ up: true
addresses:
- address: 10.2.47.13/24
routes:
- gateway: fd02:47::1
table: "47"
- gateway: 10.2.47.1
metric: 2048
table: "47"
---
# Dedicated Ceph cluster network — see k8s-1.yaml. The node holds NO address
# here on purpose; the link is handed whole to the OSD pod (host-device).
@@ -20,3 +20,70 @@ machine:
validSubnets:
- 10.2.40.0/24
- fd40::/64
---
# Pod egress leaves over ingress0. Matching on the pod source prefix is what
# keeps the node's own traffic out of it: the node IP, the API VIP and apid all
# answer on mgmt0, and a reply that left over ingress0 would carry an mgmt0
# source onto VLAN 47.
#
# The routing decision runs before Cilium's masquerade hook, so the rule sees
# the pod address and the masquerade then rewrites it to ingress0's.
#
# Table 47 holds nothing but the two default routes, so the destination rules
# below have to run first: without them a pod's packet to a service or to a pod
# on another node also matches the source rule, finds only the default, and
# leaves for the UDM. Measured — in-cluster DNS stopped resolving.
#
# They send everything inside the cluster and the local networks back to main.
# The prefixes are Cilium's ipv4/ipv6NativeRoutingCIDR, which already mark what
# is not foreign traffic.
#
# Priorities are below main (32766) and clear of the rule Cilium installs at 9.
# The two families are separate rule tables, so the priorities need not match;
# the document name is the priority and must be unique.
# Pod to pod between nodes goes over pod0, via table 48, which the bird mesh
# fills with one route per peer pod CIDR and the peer's pod0 address as the
# next hop. Ahead of 990/991 so it wins for that traffic, and matching source
# AND destination so nothing else lands there: the node's own traffic to a pod
# would otherwise leave with a pod0 source, which CiliumNode does not list, so
# Cilium would classify it as world rather than host.
#
# Traffic to a pod on this node falls through — table 48 holds only the peers'
# CIDRs, so the lookup misses and 990/991 send it to main and cilium_host.
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "980"
src: fd10:244::/48
dst: fd10:244::/48
table: "48"
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "981"
src: 10.244.0.0/16
dst: 10.244.0.0/16
table: "48"
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "990"
dst: fd00::/8
table: main
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "991"
dst: 10.0.0.0/8
table: main
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1000"
src: fd10:244::/48
table: "47"
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1001"
src: 10.244.0.0/16
table: "47"