OKEP-6800: EgressIP Node Selector¶
- Issue: #6800
Problem Statement¶
Cluster administrators managing multi-tenant or compliance-sensitive
environments cannot control which specific pool of egress-assignable nodes
hosts a particular EgressIP. Today, all nodes labeled with
k8s.ovn.org/egress-assignable form a single, flat pool shared by every
EgressIP object, forcing operators into coarse workarounds (dedicated clusters,
complex label choreography, or manual IP subnet partitioning) to satisfy
topology, regulatory, or zone-based placement requirements.
Goals¶
- Allow EgressIP objects to specify a node selector that restricts which
nodes are eligible to host that specific EgressIP. This exposes the
currently hardcoded
k8s.ovn.org/egress-assignablelabel as a configurable, per-objectegressNodeSelectorfield with a CRD default that preserves the existing label-based behavior. - Maintain backwards compatibility for the EgressIP assignment path:
EgressIP objects that omit the field automatically receive a default
selector matching
k8s.ovn.org/egress-assignable, so assignment behavior is unchanged. - Update the health-check probe target set so it is driven by the union
of
egressNodeSelectormatches across all EgressIP objects, rather than unconditionally probing all nodes with thek8s.ovn.org/egress-assignablelabel (see Health-Check Membership Reconciliation). - Reuse existing Kubernetes label selector semantics
(
metav1.LabelSelector) for consistency with broader Kubernetes ecosystem patterns. - Support failover: if a node matching the selector becomes unavailable or unreachable, the EgressIP is reassigned to another node that matches the selector (not any arbitrary node).
- Ensure the feature works on both bare-metal and cloud environments (CloudPrivateIPConfig flow).
Non-Goals¶
- Supporting node affinity with
requiredDuringScheduling/preferredDuringSchedulingsemantics. This OKEP implements hard requirement semantics only (matching nodes must satisfy the selector). This includes topology-aware scheduling (e.g., preferring nodes in the same zone as the majority of selected pods) — operators can already achieve zone-aware placement by usingegressNodeSelectorwith per-zone labels. - Changing the EgressIP datapath or SNAT behavior. This is purely a control-plane assignment optimization.
- Modifying the health-check/reachability probing mechanism itself (GRPC/connection-based probe logic). The underlying probe implementation is unchanged; only the set of nodes targeted by probes changes (see Goals).
- Deprecating or removing the
k8s.ovn.org/egress-assignablelabel. The label remains the default selector value: EgressIP objects that omitegressNodeSelectorreceive a CRD default that matches this label, preserving current behavior. Users who prefer the label-based workflow can continue using it indefinitely. - Per-IP node selectors within a single EgressIP object is a non-goal. The
egressNodeSelectorapplies to all IPs in the object. Splitting IPs into separate EgressIP objects with different selectors is not equivalent: it sacrifices the anti-co-location guarantee (IPs from separate objects can land on the same node), and if both objects select the same namespace/pods only one is active while the other remains passive. Adding true per-IP selectors would require one of: (a) a newegressIPConfigslist that pairs each IP with its own selector (duplicating IPs betweenegressIPsand the new field), (b) convertingegressIPsfrom[]stringto a list of structs (breaking API change), or (c) makingegressNodeSelectoritself a struct that maps individual IPs to selectors — which adds unnecessary complexity to a field that should remain a simpleLabelSelector. None of these are justified without a concrete use case.
Introduction¶
Background¶
OVN-Kubernetes provides the EgressIP feature to assign stable source IPs
to egress traffic from selected pods/namespaces. The cluster administrator
labels nodes with k8s.ovn.org/egress-assignable to designate them as
candidates for hosting EgressIPs. The egressIPClusterController in
ovnkube-cluster-manager then assigns each requested IP to one of the
assignable, ready, and reachable nodes, balancing allocations across the
pool.
Problem¶
In production environments, the flat node pool model is insufficient for operators who need per-EgressIP control over node placement. The use cases below describe the specific scenarios where this gap causes pain.
User-Stories/Use-Cases¶
Story 1: Zone-Based / Compliance-Driven Placement¶
As a cluster administrator operating nodes in multiple operational zones (e.g., DMZ vs internal, audited vs general), I want to ensure EgressIPs are only assigned to nodes in the appropriate zone, so that traffic placement respects network topology and compliance boundaries without requiring a separate cluster. Multiple nodes may share the same subnet and all pass the existing subnet-membership check, but only a subset has the right connectivity, firewall rules, or audit status.
Example 1: Nodes in the DMZ are labeled network-zone=dmz. The EgressIP
for internet-facing pods specifies egressNodeSelector: {matchLabels:
{network-zone: dmz}}, ensuring IPs are only hosted on nodes with
external connectivity.
Example 2: As a security officer, I want EgressIPs for regulated
workloads to stay within an audited boundary. Nodes in the PCI-DSS
segment are labeled compliance-zone=pci. The EgressIP for regulated
workloads specifies egressNodeSelector: {matchLabels: {compliance-zone: pci}},
ensuring egress traffic stays within the audited segment.
Story 2: Multi-Tenant Egress Node Pools¶
As a platform operator running a multi-tenant cluster, I want each tenant's EgressIP to be assigned only to nodes dedicated to that tenant's egress traffic, so that tenants cannot exhaust each other's egress capacity and external firewalls can be configured per-tenant by using disjoint EgressIP objects that select tenant namespaces and disjoint node selectors for each tenant on those objects.
Example: A cluster has 6 worker nodes split between two tenants —
staging and production. Nodes 1–3 are labeled egress-pool=staging,
nodes 4–6 are labeled egress-pool=production. Each tenant's
namespaces are labeled accordingly (tenant: staging and
tenant: production).
Staging EgressIP:
spec:
egressIPs: ["10.0.1.10"]
namespaceSelector:
matchLabels:
tenant: staging
egressNodeSelector:
matchLabels:
egress-pool: staging
Production EgressIP:
spec:
egressIPs: ["10.0.2.10", "10.0.2.11"]
namespaceSelector:
matchLabels:
tenant: production
egressNodeSelector:
matchLabels:
egress-pool: production
Staging IPs land only on nodes 1–3, production IPs land only on nodes 4–6. Neither tenant can consume the other's egress node capacity.
Story 3: Controlled Maintenance Drain¶
As a cluster administrator preparing to put egress nodes into
maintenance across different maintenance windows, I want to exclude
specific nodes from a particular EgressIP's candidate pool in a single
operation, so that IPs move directly to the remaining
eligible nodes without intermediate hops. Today, removing the
egress-assignable label from node A causes its IPs to move to node B —
but if node B is also scheduled for maintenance, those IPs hop again
from B to C. I need a way to express "these IPs should only live on
nodes C and D" so that draining A and B happens in one step.
Story 4: Infrastructure-Level IP-to-VM Mapping (eg. OpenStack UPI)¶
On platforms without the cloud-network-config-controller (CNCC) — such
as OpenStack deployed via Baremetal UPI (platform=None) — the
infrastructure team manually pre-configures which EgressIPs are allowed
on each VM's port using OpenStack
allowed-address-pairs.
OVN-Kubernetes is unaware of these static mappings. It assigns EgressIPs
based on the node's primary interface subnet, the egress-assignable
label, and node readiness/reachability. When all worker nodes share the
same subnet, OVN-Kubernetes treats them as interchangeable — but the
infrastructure only allows specific IPs on specific VMs.
On cloud IPI platforms (AWS, OpenStack IPI, etc.), CNCC dynamically configures the cloud to accept whatever IP OVN-Kubernetes assigns via CloudPrivateIPConfig. But on UPI platforms without CNCC, the allowed-address-pairs are static: the cloud anti-spoofing mechanism drops traffic sourced from IPs not in the VM's port configuration, so an IP assigned to the wrong VM is silently black-holed.
Example: An OpenStack UPI cluster has 3 worker nodes (VMs) on subnet 192.168.0.0/24. All 3 are marked egress-assignable. The operator needs 6 EgressIPs, 2 per node, pre-configured in each VM's allowed-address-pairs:
OVN-Kubernetes Cluster (OpenStack UPI, no CNCC)
All workers labeled: k8s.ovn.org/egress-assignable: ""
worker-0 worker-1 worker-2
┌───────────┐ ┌───────────┐ ┌───────────┐
EgressIP │ .212 │ │ .213 ← ❌ │ │ .215 ← ❌ │
assigned │ .218 ← ❌ │ │ .214 │ │ .219 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─
│ OpenStack Infrastructure │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ VM w0 │ │ VM w1 │ │ VM w2 │
│ .10 │ │ .11 │ │ .12 │
│ AAP: │ │ AAP: │ │ AAP: │
│ .212,.213 │ │ .214,.215 │ │ .218,.219 │
└───────────┘ └───────────┘ └───────────┘
AAP = allowed-address-pairs (pre-configured by infra team)
Problem: .218 (belongs on w2) lands on worker-0 → ❌ black-holed
.213 (belongs on w0) lands on worker-1 → ❌ black-holed
.215 (belongs on w1) lands on worker-2 → ❌ black-holed
Each EgressIP is pre-configured in exactly one VM's
allowed-address-pairs. All 3 workers share the same subnet
(192.168.0.0/24), so OVN-Kubernetes' primary interface subnet check
passes for every node. Without CNCC, there is no
cloud.network.openshift.io/egress-ipconfig annotation — capacity is
treated as unlimited. The IP-to-VM mapping is completely invisible to
OVN-Kubernetes.
Without egressNodeSelector: The egress-assignable label is a single
global flag shared by all EgressIP objects — it cannot express
per-object affinity. All 3 workers look identical to OVN-Kubernetes.
The controller freely distributes the 6 IPs across them without
respecting the static IP-to-VM mapping.There is no way to
express "these IPs on this node, those IPs on that node" with a single
boolean label.
With egressNodeSelector: Each worker gets a pool label (e.g.,
eip-pool: w0, eip-pool: w1, eip-pool: w2). Each EgressIP object
targets its corresponding worker:
egressNodeSelector: {matchLabels: {eip-pool: "w0"}} for .212/.213,
{eip-pool: "w1"} for .214/.215, and {eip-pool: "w2"} for
.218/.219. Every IP lands only on the VM where the infrastructure has
pre-configured it.
Ecosystem Precedent¶
-
EgressService (OVN-Kubernetes): Already has a
nodeSelectorfield (EgressServiceSpec.NodeSelector) that limits which nodes can host the service's egress traffic. This OKEP proposes the same pattern for EgressIP. -
CiliumEgressGatewayPolicy (Cilium): Uses
egressGateway.nodeSelectorto designate which node acts as the egress gateway for a policy. The pattern of pairing a node selector with an egress IP is well-established in the CNI ecosystem. -
Kubernetes Scheduling: The
nodeSelector/nodeAffinitypattern is the standard Kubernetes mechanism for constraining workloads to specific nodes.
Proposed Solution¶
Add an egressNodeSelector field to EgressIPSpec with a CRD default
that matches the existing k8s.ovn.org/egress-assignable label. This
makes the currently hardcoded node selection mechanism visible and
configurable on each EgressIP object:
- Field omitted: The CRD default is applied automatically by the API
server — the selector becomes
{matchExpressions: [{key: k8s.ovn.org/egress-assignable, operator: Exists}]}, preserving today's behavior without any code-level fallback. - Field set by the user: The user's value is used as-is. For example,
{matchLabels: {egress-pool: tenant-a}}limits assignment to nodes in that pool. An empty selector ({}) matches all nodes, which also means the health-check probe set expands to every node in the cluster — a cost to consider at scale and not a recommended setting for admins.
Because the API server applies the default on reads (including for existing objects stored before the schema change), the controller sees a populated selector on every EgressIP object and always uses it — there is no branching between "selector set" and "legacy fallback" paths.
One caveat of this approach is that users now have two ways to achieve
the same outcome: they can either label nodes with
k8s.ovn.org/egress-assignable (relying on the default selector) or set
a custom egressNodeSelector on each EgressIP object. Both are valid
workflows and can coexist in the same cluster.
API Details¶
CRD Change¶
// EgressIPSpec is a desired state description of EgressIP.
type EgressIPSpec struct {
// EgressIPs is the list of egress IP addresses requested. Can be IPv4 and/or IPv6.
// This field is mandatory.
// +listType=atomic
EgressIPs []string `json:"egressIPs"`
// NamespaceSelector applies the egress IP only to the namespace(s) whose label
// matches this definition. This field is mandatory.
NamespaceSelector metav1.LabelSelector `json:"namespaceSelector"`
// PodSelector applies the egress IP only to the pods whose label
// matches this definition. This field is optional, and in case it is not set:
// results in the egress IP being applied to all pods in the namespace(s)
// matched by the NamespaceSelector.
// +optional
PodSelector metav1.LabelSelector `json:"podSelector,omitempty"`
// EgressNodeSelector limits the pool of nodes that can host this
// EgressIP. Only nodes whose labels match this selector are eligible
// for assignment.
// This field is optional. When not specified by the user, the CRD
// default is applied: {matchExpressions: [{key:
// k8s.ovn.org/egress-assignable, operator: Exists}]}.
// An empty selector ({}) matches all nodes — use with caution as it
// expands the eligible pool and health-check set to the entire cluster.
// This field restricts only where the IPs in this EgressIP object are
// placed; it does not reserve the matched nodes for exclusive use.
// Other EgressIP objects can still consume capacity on the same nodes.
// +kubebuilder:default={"matchExpressions":[{"key":"k8s.ovn.org/egress-assignable","operator":"Exists"}]}
// +optional
EgressNodeSelector *metav1.LabelSelector `json:"egressNodeSelector,omitempty"`
}
EgressNodeSelector is a pointer (*metav1.LabelSelector) rather than a value type for two reasons.
First, encoding/json's omitempty tag has no effect on structs — a zero-value struct is never
omitted. Without a pointer, typed Go clients would always serialize this field as
egressNodeSelector: {}, preventing the CRD default from ever being applied (the field is never
absent in the submitted object). A nil pointer is correctly omitted by omitempty, allowing the
CRD default to be injected at admission. Second, metav1.LabelSelector has no required fields,
so {} is a valid user choice meaning "match all nodes" per
Kubernetes API conventions
— a pointer is the only way to distinguish nil (unset, apply default) from {} (explicitly
match all nodes).
Example YAML¶
apiVersion: k8s.ovn.org/v1
kind: EgressIP
metadata:
name: egressip-tenant-a
spec:
egressIPs:
- 192.168.50.10
- 192.168.50.11
namespaceSelector:
matchLabels:
tenant: a
egressNodeSelector:
matchLabels:
egress-pool: tenant-a
This ensures that 192.168.50.10 and 192.168.50.11 are only assigned
to nodes with the egress-pool=tenant-a label. Because the user
explicitly set egressNodeSelector, the CRD default
(k8s.ovn.org/egress-assignable) is overridden — the label is not
required on these nodes.
Backwards Compatibility of API¶
The field is optional with a CRD default. When a user omits
egressNodeSelector, the API server injects the default value
({matchExpressions: [{key: k8s.ovn.org/egress-assignable, operator: Exists}]})
at admission time and on reads from storage. This means:
- Existing objects (created before the schema change): the default is applied transparently on read, so the controller sees the selector without any object migration.
- New objects without the field: the default is applied automatically — no client changes needed.
- New objects with a custom selector: the user's value is used as-is.
Mutability¶
The egressNodeSelector field is mutable. Updating it triggers re-evaluation
of current assignments: IPs assigned to nodes that no longer match the
new selector are reassigned to nodes that do. This is consistent with
how the existing egressIPs, namespaceSelector, and podSelector
fields are all mutable and trigger reconciliation on update.
Validation¶
No additional CEL validation rules are needed beyond what
metav1.LabelSelector already provides. The field follows standard
Kubernetes label selector semantics including matchLabels and
matchExpressions.
Implementation Details¶
Component: ovnkube-cluster-manager (egressIPClusterController)¶
The primary change is in the assignEgressIPs function in
go-controller/pkg/clustermanager/egressip_controller.go.
Current behavior (simplified):
1. getSortedEgressData() returns all nodes where
isEgressAssignable && isReady && isReachable.
2. assignEgressIPs() iterates these nodes, checking subnet membership
and capacity, and assigns the first available node.
New behavior:
1. When evaluating an EgressIP, the controller compiles egressNodeSelector
into a label selector and builds the candidate pool from all nodes whose
labels match the selector and that are infrastructure-ready, ready, and
reachable. In production, CRD defaulting ensures egressNodeSelector is
always populated; unit tests must set the field explicitly since they bypass
the API server.
2. The hardcoded isEgressAssignable boolean (which matched the
k8s.ovn.org/egress-assignable label) is fully removed and replaced by two
separate concerns: per-EgressIP selector matching (via egressNodeSelector)
and a node infrastructure-readiness check (host CIDRs parseable). For
objects that kept the default selector, behavior is identical; for objects
with a custom selector, only the custom labels matter.
3. If the candidate pool is empty, the controller emits a NoMatchingNodeFound
event and logs the selector for diagnostics.
4. Proceed with the existing assignment logic (subnet, capacity,
anti-co-location) on the candidate pool.
Reconciliation trigger on node label changes:
The existing egressIPClusterControllerEventHandler in
go-controller/pkg/clustermanager/egressip_event_handler.go already
watches node label changes. When a node's labels change, we must
re-evaluate all EgressIP assignments that have an egressNodeSelector to
determine if:
- A previously ineligible node is now eligible (attempt assignment of
any currently unassigned EgressIPs — valid existing assignments are
not moved).
- A previously eligible node is no longer eligible (trigger reassignment
of its EgressIPs to other eligible nodes).
This is handled by extending the existing node update handler to compile all
EgressIP selectors and check whether the updated node matches any of them,
replacing the old hardcoded k8s.ovn.org/egress-assignable check. The
health-check path uses the same mechanism to probe only nodes that match at
least one EgressIP's selector.
Reconciliation trigger on egressNodeSelector spec change:
When the EgressIP object's egressNodeSelector field is updated, the existing
EgressIP watch handler (reconcileEgressIP) fires because the spec
changed. The reconciliation logic re-runs assignEgressIPs with the new
selector, invalidating any current assignments to nodes that no longer
match and reassigning those IPs to nodes that do.
Validation in ensureAllocatorEgressIPAssignments:
The ensureAllocatorEgressIPAssignments function (called during sync)
validates that current assignments are still valid. The existing
isEgressAssignable check
in the validation path must be replaced (not supplemented) with a
selector match against the EgressIP's egressNodeSelector. If the old
label-only check is retained alongside the selector, custom selectors
that target unlabeled nodes would have their assignments invalidated on
every sync — causing an infinite reassign loop. The readiness,
reachability, subnet, and network checks remain unchanged.
Node reboot / unavailability:
No change to existing behavior. When a node becomes unreachable (reboot, network partition), the existing reachability checker marks it unreachable and triggers reassignment to another eligible node. The egressNodeSelector filter is applied during reassignment — the IP moves to another node that is both reachable AND matches the selector. If no such node exists, the IP remains unassigned until one becomes available. This is identical to how EgressIPs behave today when no reachable egress-assignable node exists.
Multiple EgressIP objects with the same egressNodeSelector:
This is fully supported. Multiple EgressIP objects can specify the same (or overlapping) egressNodeSelectors. The existing load-balancing logic (sort by allocation count) distributes IPs across the shared node pool. Each EgressIP object's IPs are independently anti-co-located (no two IPs from the same object on one node), but IPs from different objects can share a node — this is existing behavior and is unchanged.
Component: ovnkube-controller¶
No changes required. The ovnkube-controller reads the EgressIP status (assigned node + IP) and programs OVN logical router policies and NAT rules accordingly. The datapath is unchanged — only the assignment decision in cluster-manager is affected.
Component: ovnkube-node¶
No changes required. ovnkube-node handles the local plumbing (adding the EgressIP to an interface, configuring ARP/NDP) based on what the cluster-manager assigns. The node doesn't need to know why it was selected.
Gateway Modes (lgw/sgw)¶
This feature does not touch the gateway datapath. It only affects the control-plane assignment decision. Both local gateway and shared gateway modes are unaffected — the EgressIP datapath flows remain the same regardless of how the node was selected.
Cloud Environment (CloudPrivateIPConfig)¶
On cloud platforms, EgressIP assignment creates CloudPrivateIPConfig
objects. The node selector filtering happens before the
CloudPrivateIPConfig is created, so the cloud workflow is unchanged
— it just receives a different (filtered) set of candidate nodes.
Interaction with EgressIP MultiNIC (Secondary Host Networks)¶
The existing secondary host network filtering in assignEgressIPs
(which restricts nodes to those hosting the IP's network via MultiNIC)
is applied after the egressNodeSelector filter. The intersection of both
filters determines the final candidate set.
User Defined Networks (UDN)¶
EgressIP with egressNodeSelector works identically across all network types — the default cluster network and User Defined Networks. The egressNodeSelector is evaluated purely at the control-plane level during node assignment in the cluster-manager. The per-network ovnkube-controllers that program the OVN logical router policies and NAT rules for EgressIP on each network are unaffected — they read the assigned node from the EgressIP status and program flows regardless of how that node was chosen. No per-network or per-topology changes are needed. This applies equally whether the cluster uses BGP-advertised EgressIPs or not.
When Dynamic UDN Node Allocation
is enabled, EgressIP assignment is one of the activity triggers that
causes a UDN to be rendered on a node. If egressNodeSelector directs
an EgressIP to a node where the UDN is not yet rendered, dynamic UDN
will start rendering the network on that node. Conversely, if an
EgressIP is reassigned away from a node (due to a selector change or
node relabel), and that node has no remaining pods or EgressIP
assignments for the UDN, dynamic UDN will tear down the network after
the configured grace period (--udn-deletion-grace-period, default
120s). Administrators should be aware of this interaction when
combining egressNodeSelector with dynamic UDN — directing EgressIPs
to nodes that don't already run the UDN incurs a network rendering
delay before the datapath is ready.
Health-Check Membership Reconciliation¶
Today, checkEgressNodesReachabilityIterate probes every node where
isEgressAssignable && isReady. With selectors, the probe target set is the
union of all egressNodeSelector matches across all EgressIP objects. On each
health-check tick (~5 s), all EgressIP selectors are compiled fresh and each
node's labels are checked against them. Only nodes matching at least one
EgressIP's selector are probed. This avoids maintaining a cached probe set
that would need invalidation on every EgressIP CRUD event and node label
change.
For EgressIP objects using the default selector (matching
k8s.ovn.org/egress-assignable), the probe set is identical to today's
behavior.
Startup ordering: When a new EgressIP is created with a custom
selector matching nodes that are not already in the probe set, those
nodes have no reachability status yet. The assignment logic requires
isReachable, so the first assignment may be delayed by up to one probe
interval while the prober catches up. This is a one-time delay per
newly targeted node.
Behavioral change from today: Labeling a node as egress-assignable
no longer unconditionally enables health-check probes on that node.
Probes are driven by actual EgressIP selector matches. This scopes
health-check traffic to nodes genuinely in use, but means that
pre-labeling nodes "just in case" no longer warms up their reachability
status in advance.
Testing Details¶
Unit Tests¶
egressip_controller_test.go:- Test assignment with egressNodeSelector matching a subset of nodes.
- Test assignment when no nodes match the egressNodeSelector (expect unassigned status and warning event).
- Test that a matching node losing the label triggers reassignment.
- Test that a new node gaining a matching label triggers assignment of previously unassigned EgressIPs (valid assignments do not move).
- Test that omitting egressNodeSelector results in the CRD default
(matching
k8s.ovn.org/egress-assignable) being applied — only labeled nodes are eligible, identical to current behavior. - Test egressNodeSelector with
matchExpressions(NotIn, Exists, DoesNotExist). - Test that updating egressNodeSelector in a way that still matches the currently assigned node does not trigger reassignment (no unnecessary churn).
- Test interaction with cloud provider path (CloudPrivateIPConfig created for filtered node only).
- Test interaction with secondary host network filtering (both filters applied).
E2E Tests¶
- Create EgressIP with egressNodeSelector, verify assignment only to matching nodes.
- Remove matching label from assigned node, verify IP migrates to another matching node.
- Add matching label to a new node, verify load balancing considers it.
- Verify egress traffic uses the correct source IP when egressNodeSelector restricts the assignment.
- Verify that multiple EgressIP objects with different egressNodeSelectors correctly partition across different node pools.
- Update the EgressIP's egressNodeSelector to match a different set of nodes, verify IPs are reassigned to the new matching nodes.
- Update a node's labels so it no longer matches the egressNodeSelector while simultaneously updating the EgressIP's egressNodeSelector to match different nodes — verify correct convergence without IP duplication or loss.
Cross-Feature Interaction Tests¶
- EgressIP with egressNodeSelector + dual-stack / IPv6: verify that egressNodeSelector filtering works correctly for IPv4, IPv6, and dual-stack EgressIP assignments. The selector logic is IP-family agnostic, but E2E coverage should confirm no regressions.
- EgressIP with egressNodeSelector + UDN: verify EgressIP works correctly for pods on User Defined Networks when egressNodeSelector is specified. Since the egressNodeSelector only affects control-plane assignment and not the datapath, the same behavior applies to all network types (default cluster network and User Defined Networks alike).
- EgressIP with egressNodeSelector + Dynamic UDN: verify that assigning an
EgressIP to a node where the UDN is not yet rendered triggers network
rendering and that the datapath becomes functional after rendering completes.
Run EgressIP E2E tests with
--enable-dynamic-udn-allocationenabled.
Documentation Details¶
- Update
docs/features/cluster-egress-controls/egress-ip.mdwith: - New
egressNodeSelectorfield documentation. - Example YAML showing usage.
- Explanation that omitting the field defaults to matching
k8s.ovn.org/egress-assignable, and custom selectors override it. - Troubleshooting section for when no nodes match the selector.
- Best practices section covering:
- Avoid frequent changes to
egressNodeSelectoror node labels in production; there is a race window during label changes where the cached node state may be stale, potentially causing brief EgressIP reassignment churn. Plan node label schemes upfront and perform label changes during a maintenance window. - An empty selector (
{}) matches all nodes, expanding the eligible pool and health-check probe set to the entire cluster — use with caution at scale.
- Avoid frequent changes to
- Update the EgressIP API reference in
docs/api-reference/egress-ip-api-spec.md. - Update
mkdocs.ymlto include this OKEP under Enhancement Proposals.
Performance and Scale¶
Assignment Overhead¶
The egressNodeSelector filtering adds one metav1.LabelSelectorAsSelector()
conversion per assignEgressIPs call (cached per reconciliation, not per
node) and one selector.Matches() call per candidate node. At 500
candidate nodes, this is 500 label set comparisons per EgressIP
reconciliation — negligible compared to the existing node iteration and
network lookups.
Watch Overhead¶
No new watches are added. The existing node watch already triggers EgressIP reconciliation on label changes. The only additional work is iterating EgressIP objects to check which ones have egressNodeSelectors affected by the label change. With N EgressIP objects and M node changes, this adds O(N) selector evaluations per node update.
At scale (1000 EgressIP objects, 500 nodes), a single node label change
triggers at most 1000 selector evaluations — each is a simple map
lookup (for matchLabels) and is cheap relative to the existing node
iteration and network lookups in the assignment path.
OVN DB Impact¶
Zero additional OVN DB objects. This feature operates entirely in the cluster-manager's assignment logic before any OVN DB mutations occur.
Memory¶
The egressNodeSelector is stored as part of the EgressIP spec (already in the
informer cache). No additional caching is needed. The compiled
labels.Selector is created per-reconciliation and garbage collected
immediately.
Risks, Known Limitations and Mitigations¶
Risk: Overly restrictive selectors leading to unassigned EgressIPs¶
If the egressNodeSelector is too restrictive (matches zero nodes, or all matching nodes are unreachable), the EgressIP remains unassigned. Additionally, because the controller never co-locates two IPs from the same EgressIP object on the same node, an egressNodeSelector that matches fewer nodes than the number of requested IPs will leave the excess IPs unassigned. For example, an EgressIP with 3 IPs and an egressNodeSelector matching only 2 nodes will assign 2 IPs (one per node) and leave the 3rd unassigned — even if both nodes are healthy.
Mitigation: Emit distinct Kubernetes events on the EgressIP object:
one for "no nodes match the egressNodeSelector" (selector
misconfiguration) and another for "matching nodes exist but are all
unavailable" (transient issue). Since getSortedEgressData() pre-filters
unreachable/not-ready nodes, the implementation should check the
unfiltered cache against the egressNodeSelector to distinguish between these
two cases.
Risk: Label race during node label changes¶
If an operator changes node labels while the controller is mid-assignment, there's a window where the cached node labels are stale.
Mitigation: The existing mutex-protected assignment logic serializes
access. Label changes trigger re-evaluation via the node event handler,
which will correct any stale assignments in the next reconciliation cycle.
This is the same pattern used for the k8s.ovn.org/egress-assignable
label today.
Risk: Increased reconciliation frequency with many EgressIPs¶
If many EgressIP objects have egressNodeSelectors referencing the same labels, a single label change on one node could trigger re-evaluation of many EgressIP objects.
Mitigation: The re-evaluation only checks selector matches (cheap operation). Actual reassignment only occurs if the current assignment is invalidated. In the common case (label change doesn't affect existing assignments), the reconciliation short-circuits.
Limitation: Per-object node selector, not per-IP¶
All IPs within a single EgressIP object share the same egressNodeSelector. If different IPs need different node pools, separate EgressIP objects must be created (see Non-Goals for rationale). This is consistent with the existing per-object semantics for namespace/pod selectors.
OVN-Kubernetes Version Skew¶
This feature targets the next minor release (v1.5.0). The expected upgrade order is: CRD schema first, then controller rollout. This is the standard OVN-Kubernetes upgrade sequence (Helm/manifests apply CRDs before controller pods restart).
During a rolling upgrade:
- Old cluster-manager, new CRD: The old cluster-manager does not
understand the
egressNodeSelectorfield and continues to use the hardcodedk8s.ovn.org/egress-assignablelabel check. Because the CRD default foregressNodeSelectormatches that same label, the effective behavior is unchanged for existing objects. However, if a user creates an EgressIP with a customegressNodeSelectorduring this window, the old cluster-manager will ignore the selector and assign the EgressIP to any labeled node — potentially outside the requested pool. The custom selector is enforced once the new cluster-manager is running, which triggers a reconciliation and corrects any mis-assignments. Operators should avoid creating EgressIP objects with custom selectors until the upgrade is complete. - New cluster-manager, old CRD (abnormal order): This is a general
concern for any new CRD field, not specific to
egressNodeSelector. If the controller is rolled before the CRD schema is updated, the new field has nodefaultand existing objects will not have it populated. As with any new field, the controller should handle a nil value defensively. The standard OVN-Kubernetes upgrade sequence (CRDs first, then controller) avoids this scenario. - New cluster-manager, new CRD (normal post-upgrade): The controller
reads
egressNodeSelectorfrom every object (always present due to CRD defaulting) and uses it as the sole candidate filter. Objects with the default selector behave identically to the old label-based path. Objects with custom selectors are handled by the new code path.
Backwards Compatibility¶
- The
egressNodeSelectorfield has a CRD default that matchesk8s.ovn.org/egress-assignable. Existing EgressIP objects that omit the field automatically receive this default on read, so their behavior is unchanged. - No datapath changes. Existing E2E tests continue to pass without modification.
- The CRD schema change is additive (new optional field with a default) and does not require a new API version.
- Existing E2E tests that create EgressIP objects without
egressNodeSelectorvalidate that the current behavior is preserved.
Upgrade Behavior¶
CRD defaulting handles the transition transparently:
- The updated CRD schema adds the
egressNodeSelectorfield with adefaultof{matchExpressions: [{key: k8s.ovn.org/egress-assignable, operator: Exists}]}. - Existing EgressIP objects stored without the field automatically receive the default on read — no object migration is required.
- The new controller replaces the hardcoded
isEgressAssignablecheck with a selector evaluation. Because every object now has a selector (either user-set or the default), the single code path covers both existing and new EgressIPs. - Users who prefer the label-based workflow need not change anything.
Users who want per-object selectors set
egressNodeSelectorexplicitly, overriding the default.
Alternatives¶
Alternative 1: Use a separate CRD (EgressIPPool) to define node pools¶
Instead of adding an egressNodeSelector to EgressIP directly, create a new
EgressIPPool CRD that defines a pool of nodes (via a node selector) and
a set of IP addresses. EgressIP objects would reference a pool instead of
specifying an egressNodeSelector inline.
Pros: - Separates concerns: pool definition vs. IP-to-pod binding. - Allows reuse of the same pool across multiple EgressIP objects without repeating the selector. - Could support IP capacity management at the pool level.
Cons:
- Introduces a new CRD, adding API surface area and operational
complexity.
- Requires two objects to achieve what one object can do with an inline
field.
- Increases coupling — deleting a pool could orphan EgressIP objects.
- The EgressService CRD already established the inline nodeSelector
pattern in OVN-Kubernetes. Deviating from this creates inconsistency.
Decision: Rejected. The inline egressNodeSelector is simpler, consistent with EgressService, consistent with Cilium's approach, and sufficient for the use cases. A pool abstraction can be layered on top in the future if demand materializes.
Alternative 2: Extend the k8s.ovn.org/egress-assignable label to be per-EgressIP¶
Instead of a selector on the EgressIP object, use per-EgressIP labels on
nodes: k8s.ovn.org/egress-assignable-<egressip-name>="".
Pros: - No CRD change needed. - Simple to understand: label the node for the specific EgressIP.
Cons: - Violates the principle that the CRD declaratively expresses intent. The user must coordinate labels across two objects (EgressIP + nodes). - Doesn't scale — with hundreds of EgressIP objects, nodes accumulate hundreds of labels. - Label names are limited to 63 characters for the key, and EgressIP names can vary, making this error-prone. - No ecosystem precedent for this pattern.
Decision: Rejected. The inline egressNodeSelector is declarative, scalable, and follows Kubernetes conventions.
Alternative 3: AND semantics (egressNodeSelector intersected with egress-assignable)¶
Require both the k8s.ovn.org/egress-assignable label AND the
egressNodeSelector to match for a node to be eligible.
Pros: - Simple to implement — filter by label first, then by selector. - The label acts as a cluster-admin veto (only node-edit privilege holders can opt a node into egress duty).
Cons: - Operators must forever maintain both the label and the selector, doubling configuration burden. - The selector alone already provides the same RBAC-like veto: only someone who can label nodes can make them eligible. Adding the label as a hard prerequisite is redundant. - Users who want custom selectors still need to label nodes, defeating the purpose of per-object selectors.
Decision: Rejected. CRD defaulting achieves backwards compatibility without an intersection — the default selector matches the label, and custom selectors fully replace it.
Alternative 4: "If set, use selector; else fall back to label" (no defaulting)¶
Use a pointer field (*metav1.LabelSelector) with omitempty. When
nil, the controller falls back to the hardcoded
k8s.ovn.org/egress-assignable label check. When set (even to {}),
the selector takes full ownership and the label is ignored. This
enables a phased deprecation of the label.
Pros:
- Provides a path to eventually deprecate the
k8s.ovn.org/egress-assignable label entirely.
- The nil vs empty distinction gives fine-grained control: nil = legacy,
{} = all nodes, {matchLabels: ...} = custom pool.
Cons: - Requires branching logic in the controller ("is nil? use label; else use selector"), increasing code complexity. - Requires a multi-phase migration plan (introduce field, deprecate label, remove label) spanning several releases. - Breaking the label is risky for a heavily used feature (60%+ of OpenShift clusters use EgressIP) and requires coordination with external components like CNCC. - Adding a required field later (to finish the deprecation) is itself a breaking API change.
Decision: Rejected in favor of CRD defaulting, which avoids the entire deprecation and migration story while providing the same per-object selector functionality.
References¶
- PoC Implementation (PR #6791) — proof-of-concept implementation
- EgressIP types.go — current EgressIP CRD definition
- EgressService types.go — EgressService with existing
nodeSelectorpattern - egressip_controller.go — cluster-manager EgressIP assignment logic
- Cilium Egress Gateway Policy — Cilium's
egressGateway.nodeSelectorapproach - OpenShift EgressIP documentation — current EgressIP documentation
- Kubernetes Label Selectors — standard label selector semantics