Skip to content

Network services: load balance across the locations an application runs in - #411

Draft
scotwells wants to merge 21 commits into
mainfrom
proto/network-service
Draft

Network services: load balance across the locations an application runs in#411
scotwells wants to merge 21 commits into
mainfrom
proto/network-service

Conversation

@scotwells

@scotwells scotwells commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Prototype for Network Services. Draft: this is a working prototype to prove the design, not a merge candidate.

What a consumer can do

Declare a NetworkService naming the members of their application and point an HTTPProxy at it. The edge then serves each request from members in the nearest location and moves on when one fails.

apiVersion: networking.datumapis.com/v1alpha
kind: NetworkService
metadata:
  name: storefront
spec:
  networkInterfaceClaims:
    selector:
      matchLabels:
        compute.datumapis.com/workload-name: storefront
  ports:
    - name: http
      port: 8080
rules:
  - backends:
      - networkService:
          name: storefront
          port: http

Membership tracks whatever is running. A consumer writes no addresses, no per-location weights and no failover order, and adding capacity or a location needs no edit to either resource.

What is here

  1. NetworkService, selecting network interface claims by label, with per-location member and health counts in its status.
  2. A controller resolving that selector into membership, refusing a selector that reaches two networks.
  3. A fourth HTTPProxy backend form alongside endpoint, connector and instance, emitting endpoints that carry each member's location.
  4. The serving location stamped onto claims in the cell that serves them.
  5. An end-to-end scenario that runs against the two-cluster environment.

What the end-to-end run proves

A real request returns 200 through the edge from a member on its allocated address. Envoy receives each member in the locality its claim names, and a member whose data plane is not programmed is carried but marked unhealthy and serves nothing.

What it does not prove yet

The environment has one cell, so ranking across locations cannot be exercised end to end. Multi-location rollup is covered by controller tests only. Proving it for real needs a second cell.

Two known gaps, both recorded in the enhancement: no location carries coordinates yet, so the ranking that makes "nearest" mean anything cannot be computed; and claims are not published to the consumer's project, so membership currently resolves where the claims already are.

Notable limits

A service spans one network. Past 100 members the proxy serves the first shard and reports that it is doing so rather than silently truncating. Health is judged on request outcomes, so a member is ejected after failing rather than before.

Related

A NetworkService is a named set of endpoints spanning every location a
consumer runs in. Members are selected by label against network interface
claims, and a proxy names the service as its backend. Each edge ranks the
service's locations by distance from itself and serves from the best one,
so consumers configure no routing.

This lands the consumer-facing type only. Controllers, webhooks, and RBAC
follow separately.

Key changes:
- Add api/v1alpha/networkservice_types.go with spec.networkInterfaceClaims
  (label selector), spec.ports, and spec.trafficDistribution
- Report per-location member, healthy, and serving counts in status, with
  a summary rollup backing the print columns
- Define Ready, MembersResolved, and EndpointsReachable conditions plus
  the reasons that explain a service not serving
- Constrain the selector to set matchLabels or matchExpressions, so an
  empty selector cannot make every claim in the namespace a member
- Enforce unique port names and port numbers within a service
- Keep protocol and trafficDistribution.strategy single-valued enums so
  later values are additive
- Regenerate CRDs, deepcopy, and API reference docs
A NetworkService names a label selector and the platform has to turn it
into the endpoint set behind it. The membership controller resolves the
selector against the network interface claims in the service's own
namespace, rolls the matches up per location, and reports what it found
so an over-broad or stale selector is visible instead of silent.

Membership is not the same question as health. A matched claim is a
member; it is healthy only once it reports Programmed, so a location
scaling up shows members it is not yet serving from rather than looking
broken. A location leaves rotation when none of its members are healthy,
which is what moves traffic down each edge's ranking.

One network per service cannot be expressed in CEL, because it is a fact
about the set of matched claims rather than about the service. The
controller detects it and refuses to narrow the membership silently.

Key changes:
- Add NetworkServiceReconciler, watching claims and re-enqueuing every
  service whose selector reaches the claim that changed
- Report status.locations, status.summary, and the MembersResolved,
  EndpointsReachable and Ready conditions
- Count a claim carrying no location label as a member and name the
  discrepancy on MembersResolved rather than dropping it
- Register the controller with the manager and add its RBAC
A rule can now name a NetworkService and one of its ports as its backend,
alongside the endpoint, connector, and instance forms. The controller
resolves the service's membership and publishes it as EndpointSlices, so a
proxy follows instances appearing, disappearing, and moving between
locations without an edit.

Membership is resolved by evaluating the service's claim selector directly
rather than reading a status a sibling controller has not written yet. Each
member's address is taken from the claim's external addresses first, falling
back to its interface addresses: backhaul from the edge crosses the public
internet, so an externally reachable address is what the edge needs.

Key changes:
- Add networkService to HTTPProxyRuleBackend, referencing a service by name
  and a port by name so the reference survives a port change, and extend the
  struct-level CEL to keep it exclusive with endpoint, connector, and
  instance
- Set each endpoint's zone from the claim's location label, which is what
  Envoy Gateway turns into a locality, and its ready condition from whether
  the claim reports Programmed
- Shard members across EndpointSlices at the 100-endpoint limit, labelling
  every shard with the service name so the set stays discoverable as one
  service's endpoints
- Report NetworkServiceBackendNotFound when the service or port is missing,
  and NetworkServiceMembersUnreferenced when members were published that a
  single backendRef cannot reach
- Watch NetworkService and NetworkInterfaceClaim so membership changes
  reprogram the proxies that consume them
The NetworkService type, its membership reconciler, and the networkService
HTTPProxy backend had only unit tests. This runs them against the real
two-cluster environment and holds what it found.

The scenario selects three network interface claims across two locations,
reports two of them programmed, and requires the resulting endpoints to
reach the running proxy: one Envoy locality per location, the members'
real IPAM addresses inside them, the unprogrammed member carried but out
of rotation. A real request then has to come back 200 from a member, and
the unprogrammed member has to have taken none of it.

Key changes:
- Add test/e2e-edge/networkservice-endpoints, covering the data-plane
  path, a selector spanning two networks (MultipleNetworks), and an
  unprogrammed claim counting as a member but not as healthy
- Add a host-network stand-in for a member so the edge talks to a real
  server at the address IPAM allocated, since this environment runs no
  compute
- Run the scenario in the default e2e set
A NetworkService resolves its members by reading
networking.datumapis.com/location off the NetworkInterfaceClaim, but
nothing ever wrote that key onto a claim: only the projected
NetworkInterface carried it. Every member was therefore unlocated —
counted in the summary, absent from status.locations, and emitted to
Envoy with no zone, leaving one empty locality and nothing for
nearest-location routing to rank.

The cell already knows the location it serves, so the claim reconciler
records it on the claim it fulfills, next to the labels compute puts
there. Both travel the same path to any published copy.

Key changes:
- Label the claim from the resolved cell location before binding, with
  an additive merge patch so other writers' labels survive and a claim
  already naming the location is left alone
- Drop the hand-applied labels from the networkservice-endpoints e2e
  scenario and assert the operator's instead; the cell serves one
  location, so the scenario now proves one locality reaches Envoy and
  the multi-location rollup stays with the envtest suite
NetworkService had no IAM definition, so it could not be granted to a
user the way every other networking type can. Add the ProtectedResource
that registers the type and its permissions with the IAM system, and
surface those permissions through the existing networking roles.

The type is namespaced, consumer-authored and a child of a Project, so
it takes the same CRUD permission set as the other consumer-authored
networking types. Status is written by the controller under its own
RBAC, so no status permission is exposed.

Key changes:
- Add config/iam/protected-resources/networkservices.yaml declaring
  networkservices under networking.datumapis.com with a Project parent
- Grant read to the Network Viewer role and write to the Network Admin
  role, matching how network interface claims are handled
A NetworkService resolved its members from NetworkInterfaceClaims, which
are never published to a consumer's project and whose Programmed
condition has no writer anywhere: NSO seeds it Unknown and only the
interface ever carries the data plane's verdict. A service therefore
selected objects a consumer cannot see and judged health from a field
nothing sets. Interfaces are projected, already carry the location, and
carry the conditions, so membership now selects them.

Two things a label selector cannot say stay in the controller. A
retained interface outlives its claim with every label intact, so only
one in the Bound phase is a member; healthy is the interface reporting
Programmed. Compute's keys reach the interface by an allow-list at bind,
so a consumer can still select a whole workload without a key they
control landing on one the platform reads.

Key changes:
- Rename spec.networkInterfaceClaims to spec.networkInterfaces and the
  wrapper type with it; NoMatchingClaims becomes NoMatchingInterfaces
- Resolve membership through one helper both the service controller and
  the proxy's endpoint synthesis use, so the two cannot diverge on which
  interfaces count; endpoints read spec.addresses and
  spec.externalAddresses, external first as before
- Carry the claim's compute.datumapis.com keys and the cell's location
  onto the interface it binds, replacing the propagated set rather than
  merging it, and carry the same keys onto the published copy
- Fix writeProjection, which merged labels and so kept a held-by or
  location key forever after its source dropped it; a stale label
  defeats the retired-capacity check
- Drop the location stamp on the claim, which nothing reads now
Every NetworkService member in staging is unhealthy and no service can go
Ready. Health read the interface's Programmed condition, whose only writer
is the VPC fabric's BGP advertisement controller, gated on a fabric
attachment. Staging has none, so Programmed is permanently Unknown and
every member with it.

Health now reads HolderAvailable: whatever holds the interface reporting
itself available to serve. The holder writes it, the networking API never
does and never learns what a holder is.

Programmed keeps its meaning and its seam. It is the fabric-realization
signal, and it is still what would say an address is actually carried.
It no longer gates service health, so a service can now report healthy
while its members' addresses are unroutable.

Key changes:
- add the HolderAvailable condition and its reason vocabulary to
  NetworkInterface, in the default condition set and seeded Unknown by the
  claim reconciler alongside Prepared and Programmed
- read it from isServiceMemberHealthy, which both the service controller
  and the HTTPProxy endpoint builder already go through, so location
  health and EndpointSlice ready/serving follow one signal
- cover the flip in envtest, and cover the condition reaching the
  consumer's project copy across both projection hops
A retained interface outlives its holder by design: the claim is named
after the slot, so the next instance filling that slot rebinds the same
interface and returns to the same addresses. Nothing cleared
HolderAvailable when the holder let go, and seedCondition leaves an
existing condition alone, so the rebind inherited the previous holder's
True.

The new holder was therefore counted healthy from the instant it bound,
and the edge sent it a service's traffic before it had said anything.

markInterfaceAvailable is the one place an interface becomes unheld, so
both Retain paths are closed by resetting there.

Key changes:
- reset HolderAvailable to Unknown/HolderReleased when a retained
  interface is unbound, so the next holder starts silent
- state the rule on the condition: NSO only ever writes Unknown, seeding
  it and taking it back; True and False are the holder's alone
- cover the release-and-rebind window, which no test reached
A holder watches the claim it owns, not the interface behind it, so a
reset written on a path with no claim event reaches nothing and the
holder sits on Unknown until an unrelated cause wakes it. Both callers
are claim releases, so the reset converges today, and nothing in this
file says why that is load-bearing.
A networkService backend is always reached over plaintext HTTP: the
synthesized EndpointSlice hardcodes an http appProtocol, and the
BackendTLSPolicy the Gateway controller writes is keyed on an https one.
Setting tls.hostname on such a backend therefore did nothing at all, and
the consumer had no way to learn that.

Reject the combination at admission instead, alongside the CEL rule that
already governs which backend kinds may be combined.

Key changes:
- Add a CEL rule on HTTPProxyRuleBackend refusing tls with networkService
- Say so in the field's own documentation
- Pin both the rejection and the still-valid endpoint+tls case against a
  real apiserver in test/crd
A NetworkService that resolves no members still gets an EndpointSlice,
carrying no endpoints. That reads like litter, but the rule's backendRef
names it: the Gateway controller resolves an EndpointSlice backendRef by
getting the upstream object, and its caller abandons the entire route
loop on the first error. Withholding the slice would therefore stop every
other HTTPProxy on the same Gateway from being reprogrammed for as long
as the service had no members, rather than degrading just that rule.

Leave the behaviour alone and make it expensive to remove by accident.

Key changes:
- Record the reason, and the fan-out caveat that would invalidate it, at
  the branch that writes the empty slice
- Pin that an empty slice still resolves into a downstream Service, and
  that a withheld one surfaces as NotFound out of the route loop
- Strengthen the httpproxy-side case to assert the backendRef and port
  the empty slice has to keep
The manager and the cell mount karmada-hub-kubeconfig with optional set,
so kubelet gives them an empty directory when the secret is not there yet
and they crashloop on a missing file until the next volume sync swaps in
the real contents. It clears itself after a couple of restarts, which is
exactly the kind of thing that flakes in CI.

Ordering is not the lever: karmada-hub-secret already runs ahead of
link-clusters and the crashloop happens anyway, because the empty mount
is established long before either. Neither process can federate without
the file, so it was never optional. Drop the flag and let the pods wait
in ContainerCreating for the mount, the way the downstream and IPAM
kubeconfigs in the same overlay already do; wait-ready still gates on the
result.

Key changes:
- Drop optional from both karmada-hub-kubeconfig volumes in config/e2e
- Say in the up sequence why the secret lands where it does
The condition was set to Unknown on every reconcile and never to anything
else. Nothing reported request outcomes, so it could not become True or
False, and MembersUnreachable was defined and read but never written.

A condition that cannot change states implies a capability the platform
does not have. Reachability is judged by the edge, which already ejects
members that stop answering, and reporting that to the control plane per
request would scale with traffic rather than with topology.

Key changes:
- Remove EndpointsReachable and MembersUnreachable
- Ready now summarizes membership and serving locations only
An edge cannot reach a VPC pod today. galactic-cni publishes a per-pod
EndpointSlice carrying the pod's address and the SRv6 SID that galactic-vrf
turns into an egress route, but it writes it in the cell and nothing carries
it anywhere else. A member holds a ULA that is unroutable from an edge, so a
request against one times out on connect.

This adds the cell-to-hub hop. Every slice galactic labels with
galactic.datum.net/tenant-id is copied to the federation hub, where the
propagation policy already selecting EndpointSlices carries it to every
gateway cluster with no Karmada change. The copy is transport, not
interpretation: address type, endpoints, conditions, ports and both galactic
annotations cross unread. Nothing here parses the SID, because galactic-vrf
refuses a slice this operator has normalised and would say nothing about why.

Key changes:
- Add VPCEndpointSliceWriteBackReconciler, modelled on the interface
  write-back: same hub client, same project routing, same periodic sweep
- Name a copy after the location that published it, never after the pod. A
  cell is also a gateway cluster, so propagation returns the copy to the
  namespace the original sits in; sharing the name would hand galactic's own
  object to federation and resurrect it after the pod goes
- Drop the pod owner reference, which names nothing on the hub
- Publish on observation only. A failed read, a slice carrying no endpoint,
  and another location's copies all leave what is already up alone
What allocates a network's identity on the fabric runs centrally and has
no multi-cluster runtime, so it could not watch a Network where one
lives. It was driven by NetworkContexts instead, which meant it could
never tell a network that had been deleted from one that is required
nowhere right now, and it withdrew the identity in both cases.

The Network now joins the set already mirrored onto the hub, so that
component reads the network itself.

Key changes:
- Add Network to the replicator's default resource set, beside the
  NetworkContext and Subnet it already carries
- Skip the downstream-to-upstream status sync, as a Network's status is
  written by NSO's own controllers
- Propagate no labels: the copy is read on the hub and deliberately goes
  no further, where a context carries a location label only so a
  per-location policy can select it onto a cell
- Install the Network CRD outside the project control plane, since the
  hub now stores a copy
Envoy programmed a NetworkService's members correctly but could not reach
any of them: it egresses in the default network namespace, where no route
to the tenant ULA exists, so every connection timed out without ever
establishing.

Two defects kept the socket bind from working.

The extension server only recorded a VRF binding for instance backends,
which name a galactic EndpointSlice directly. A networkService backend
names no slice, so it was skipped entirely. Its tenant is now joined by
member address instead: the addresses the backend forwards to, looked up
against the tenant label galactic puts on the slice owning each address.
Those slices reach an edge either from the local CNI or as federated
copies, so a member resolves wherever it runs.

The VRF device name was a placeholder that matched nothing galactic
creates. It is now byte-identical to intf.GenerateInterfaceNameVRF, and
keyed on the VPC alone rather than on vpc-plus-attachment, because
galactic shares one device across every attachment of a VPC on a node.
The instance path had the same wrong name, so it can never have bound a
real device either.

Key changes:
- Join a networkService backend to its tenant by member address, over a
  single pass of the cluster's EndpointSlices
- Canonicalise addresses on both sides so the join survives two
  components spelling one IPv6 address differently
- Leave the binding unresolved when members span more than one VPC; a
  socket bind is a property of the whole cluster, and either choice
  would black-hole the other VPC's members
- Skip the bind rather than emit a name no device answers to
- Register discovery/v1 in the server test scheme, which the production
  scheme already carries through client-go
An edge reached a member at its external address when it had one, on the
reasoning that backhaul crosses the public internet. It does not: an edge
reaches a member over the fabric, and the address it dials is the tenant
one. Dialling an external address leaves the fabric and crosses the public
internet to reach an origin meant to stay private.

Key changes:
- Publish only the addresses a member holds inside its network
- Assert an external address is never published
A NetworkService with no serving members answered slowly and generically.
Envoy's default 50% panic threshold meant that below half healthy it
ignored health entirely and forwarded to members whose holder had
explicitly said they were not serving, burning the full 10s connect
timeout on each attempt before returning a raw 503.

Panic mode exists because active health checking can be wrong at scale.
NetworkService health is not probed but declared — compute writes
HolderAvailable from the instance's own state — so zero healthy is ground
truth, not a measurement artifact. Turning the threshold off makes Envoy
fail immediately and report no healthy upstream, which is a condition
specific enough to brand; with panic mode on it reports a connect failure
or timeout instead, indistinguishable from a broken backend.

Key changes:
- Attach a BackendTrafficPolicy with panicThreshold 0 to routes carrying a
  networkService backend, and remove it when the backend changes kind
- Serve an offline page for responses flagged as having no healthy
  upstream, ahead of the existing generic mapper, which stays the fallback
- Source the offline body the same way as the generic one: optional mount,
  embedded default, and a missing body falls through to the generic page
  rather than erroring, so the failOpen:false hook is never stalled
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant