Mesh Workload Requirements¶
What a Kubernetes workload needs to participate in the Aether mesh, and what it needs to be rolled with zero dropped requests. Validated end-to-end on the reference cluster (2026-06-10): three consecutive rolling restarts of three services under ~250 rps with 0 failed requests across every stream.
Joining the mesh¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-svc
spec:
replicas: 4
minReadySeconds: 10 # see "Hitless rolling restarts"
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata:
labels:
app: my-svc
aether.io/managed: "true" # CNI manages this pod
spec:
serviceAccountName: my-svc # SERVICE NAME = service account name
containers:
- name: app
readinessProbe: { httpGet: { path: /healthz, port: 8080 } }
lifecycle:
preStop: { sleep: { seconds: 10 } } # see "Hitless rolling restarts"
- Service identity: the registry service name is the pod's
ServiceAccount name. Pods sharing a ServiceAccount are endpoints of one
service. The SPIFFE ID is
spiffe://<trust-domain>/ns/<ns>/sa/<sa>. aether.io/managed: "true"label opts the pod into mesh management.- Pods in control-plane/mesh-internal namespaces are always ignored.
Annotations (optional)¶
| Annotation | Default | Meaning |
|---|---|---|
endpoint.aether.io/port |
8080 |
The pod's declared port: served, health-checked, and what <name>:18082 reaches when it is raw TCP. Not what a port-less authority means (that is port 80, served only when declared): a client names the port it wants |
endpoint.aether.io/ports |
<port> |
Every port the app serves, comma-separated, each optionally suffixed =h1, =h2 or =tcp (e.g. 8080,9090=h2,9000=tcp). A port with no suffix takes endpoint.aether.io/protocol |
endpoint.aether.io/protocol |
http |
The default L4 class for ports with no suffix. http or tcp; a raw-TCP service rides the transparent-capture TCP floor as an mTLS passthrough |
endpoint.aether.io/weight |
1024 |
Load-balancing weight |
endpoint.aether.io/health-path |
/ |
Path the node-local agent health-checks (delegated liveness) |
endpoint.aether.io/health-check-mode |
eds |
eds: node-local agent vets the endpoint once and publishes health over EDS (endpoints enter clients pre-warmed). active: every client proxy probes the endpoint itself |
metadata.endpoint.aether.io/<key> |
— | Free-form endpoint metadata (subset keys) |
endpoint.aether.io/uds-socket |
— | Deliver to a Unix socket instead of a TCP port (see "Serving on a Unix domain socket"); overrides an EndpointPolicy on the service |
config.aether.io/upstreams |
— | Comma-separated services this pod calls (see "Declaring upstreams") |
aether.io/identity-gate |
— | "false" opts this pod out of the egress identity gate (see "Identity before the first request") |
Identity before the first request¶
Every connection a mesh pod makes is mTLS with the pod's own SVID as the
client certificate — and SPIRE issues that SVID a few seconds after the pod is
created (its registration entry has to be created and synced to the node's
SPIRE agent: ~7.5 s measured on the reference cluster). An app that sends in that window
used to get 503 UF (connection_timeout): the source proxy had no certificate
to present yet (#1053).
So the controller's pod-mutating webhook injects an init container,
aether-identity-ready, first in line in every mesh pod it admits. It asks
the SPIRE Workload API (a csi.spiffe.io volume mounted into that init
container only — your app containers are not given the socket) for the pod's
X.509 SVID and exits 0 the moment SPIRE has issued it. Your containers start
after that, so the app's first request already has an identity. Cost: pod
start waits for the SVID (what the first request would have waited for anyway,
or failed on); nothing afterwards.
- Default ON, chart-wide knob
controller.webhook.identityGate.enabled. Never injected whenspire.enabled=false. - Opt one pod out with the annotation
aether.io/identity-gate: "false"(its first requests may then fail until the SVID lands). - Not injected into
hostNetworkpods or pods in mesh-ignored namespaces. - A pod stuck in
Init:0/Nwithaether-identity-readyrunning means SPIRE has not issued the pod's SVID: its log says so every 10 s and names the socket and the last error. Usual causes: no registration entry matches the pod (e.g. aClusterSPIFFEIDwhosepodSelectormisses it, or an entry keyed on ak8s:container-nameselector — the init container has its own name), spire-agent down on the node, or the SPIFFE CSI driver missing. It fails closed on purpose: a pod without an identity cannot talk to the mesh anyway. See docs/runbook.md, "Pod held in Init by aether-identity-ready".
Serving on a Unix domain socket¶
An app that serves on a Unix socket instead of a TCP port joins the mesh with
endpoint.aether.io/uds-socket: <volume>/<socket-file>. The node proxy then
delivers inbound requests to that socket. Nothing changes for callers: the pod
is still reached at its pod IP over mTLS and is indistinguishable from a
TCP-serving pod.
The socket lives in an inline csi.aether.io volume: a small per-pod
tmpfs the mesh's own CSI node plugin mounts for the pod (proposal 039). It is
the only supported carrier. Since chart 2.0.0 an emptyDir is not (see
"Upgrading from an emptyDir socket" below).
metadata:
labels:
aether.io/managed: "true"
annotations:
endpoint.aether.io/port: "8080"
endpoint.aether.io/uds-socket: "uds/app.sock"
spec:
securityContext:
fsGroup: 65532 # REQUIRED: owns the socket directory
containers:
- name: app
volumeMounts:
- name: uds
mountPath: /run/app # the app binds /run/app/app.sock
volumes:
- name: uds
csi:
driver: csi.aether.io # the ONLY socket carrier
Requirements:
- An inline
csi: {driver: csi.aether.io}volume, exactly one per pod. The plugin mounts a tmpfs at/run/aether/uds/<pod-uid>on the node (nosuid,nodev,noexec,nosymfollow, 1 MiB by default) and binds it onto the volume's mount path. The proxy dials the socket there, and sees nothing else of the pod. Two such volumes, or an annotation naming any other volume, are refused. securityContext.fsGroupis required. The tmpfs isroot:<fsGroup>, mode2770: the kubelet adds the fsGroup to every container of the pod, which is how a nonroot app creates its socket. A pod without one is denied at admission (and the plugin would refuse it with aFailedMountevent anyway). There is no world-writable fallback.- The annotation is
<volume>/<socket-file>— the volume name from the pod spec (not its mount path) and a file directly inside it. Anything else (extra path segments,.., absolute paths) is rejected. - The socket file name has a 54-byte budget. The host path
/run/aether/uds/<36-byte pod UID>/<file>must fit anAF_UNIXaddress (107 bytes). The volume name is not part of the path and costs nothing. A value over budget is denied at admission; one that slips past is refused by the agent (not sent to Envoy, which would reject the cluster). - Ports are still required and still meaningful.
endpoint.aether.io/port(andendpoint.aether.io/portsfor multi-port) name the service ports clients dial and drive inbound demux and endpoint registration; the annotation only changes what the proxy dials on delivery. A pod declares one socket and every declared port is delivered to it — multiplexing protocols on the one socket is the app's affair (normal for gRPC). - The app creates the socket, ideally
0600–0660. The proxy runs as root and is never blocked by the mode; the directory's setgid bit makes the socket group<fsGroup>, so0660lets the pod's other containers in and keeps everyone else out. - The node needs the plugin. It ships with the chart and is on by default
(
udsCsi.enabled). A pod scheduled onto a node where it is not running waits inContainerCreatingwithFailedMount: driver name csi.aether.io not founduntil it is. Talos needs nothing extra.
Declaring it once per service instead (EndpointPolicy)¶
The same delivery can be declared for a whole service with an EndpointPolicy
CR, so a platform owner can own it separately from the Deployment:
apiVersion: config.aether.io/v1
kind: EndpointPolicy
metadata:
name: echo-uds
namespace: team-a
spec:
targetRef:
kind: Service # core group, same namespace, Service only
name: echo
udsSocket: s/app.sock # same <volume>/<file> value as the annotation
The admission webhook rejects a malformed value — including a socket file over
the 54-byte budget — at kubectl apply, instead of leaving the agent to count
a resolve failure and fall back.
- The pod annotation wins. A pod carrying
endpoint.aether.io/uds-socketuses its own value; the policy is the service-level default for the pods that do not. - The pod spec still has to match. The CR cannot see the workload, so a
policy naming a volume the pods do not mount as
csi.aether.iois not rejected at apply time. The agent refuses it per pod, counted with a reason (below). - One policy per service. A second policy targeting the same Service is ignored (the lexicographically smallest policy name wins) and the conflict is logged by the agent.
- The CRD ships in the
crdschart, and the agent only reads it whenudsCsi.enabledis true.
When delivery cannot happen¶
Admission catches what it can see on the pod: the controller's pod webhook
denies a pod whose endpoint.aether.io/uds-socket names an emptyDir (or
any volume that is not its csi.aether.io volume), a socket file over budget,
a csi.aether.io volume without fsGroup, or two of them. The denial names
the fix.
Everything else — a policy-declared socket, a pod admitted while the webhook
was down — the agent refuses at resolution and counts:
aether.agent.uds.resolve_failures{reason} (Prometheus
aether_agent_uds_resolve_failures_total, every reason exported at zero), plus
one log line per pod per reason naming the pod. Reasons: not_csi (the named
volume is declared, but not as csi.aether.io — an old emptyDir),
volume_not_declared, bad_file, path_too_long, multiple_csi_volumes,
no_uid, bad_request, and disabled (udsCsi.enabled: false).
A refused pod keeps TCP delivery. Failure semantics then match an app that has not bound its port: a UDS-only app has nothing listening on TCP, so the delegated-liveness probe fails and the endpoint stays unpromoted; no traffic is sent to it. The same holds until the socket file exists and accepts.
Upgrading from an emptyDir socket¶
Chart 2.0.0 removed the emptyDir carrier with no compatibility window. Every
UDS workload must switch its socket volume before (or in the same rollout
as) the chart upgrade:
emptyDir: {}→csi: {driver: csi.aether.io}on the volume the annotation (or policy) names; drop anysubPathon its mount.- Add
securityContext.fsGroup(any gid your app runs with; distroless nonroot is65532). - Check the socket file name fits 54 bytes (it almost certainly does; the old budget was ~16 for volume and file together).
A workload left on an emptyDir is not deleted: new pods are denied at
admission, and running pods fall back to TCP and stay unpromoted, counted as
resolve_failures{reason="not_csi"}.
Calling other services¶
With transparent capture + mesh DNS (both on by default), apps dial the
destination by name on a port it declares —
http://<service>.<namespace>.<meshDomain>:<port> (mesh DNS) or the generated
Kubernetes Service <service>.<namespace>.svc.cluster.local:<port> — and the
CNI-programmed capture listener routes it. Apps that prefer zero interception
assumptions can instead use the mesh's HTTP proxy port: http://127.0.0.1:18081
with the mesh FQDN and port in the Host header, or as an HTTP proxy
(HTTP_PROXY; see "Using the proxy port" in the getting-started guide). Either way every hop is mTLS between
workload identities; the callee sees the caller's SPIFFE ID in
x-forwarded-client-cert. Between proxies an HTTP request rides HTTP/3 over
QUIC (HTTP/2 for a weighted GAMMA split or a destination behind the east/west
waypoint); your application speaks what it always did, to its own proxy. That
needs two DNS SANs on every workload SVID and UDP 18008 open beside TCP
18008: see the getting-started guide, "HTTP/3 between proxies".
A service has a name and ports; a request names both.
<service>.<namespace>.<mesh-domain> (default domain aether.internal,
agent --mesh-domain / chart meshDomain; proposal 020) is the mesh form.
A captured dial and the HTTP proxy port (since chart 3.0.3) also honor the
Kubernetes names of the generated Service
(<service>.<namespace>.svc.cluster.local, <service>.<namespace>), and a
name without a namespace (Host: my-svc:8080) is my-svc in the caller's own
namespace, as the cluster's DNS search makes it. A name that is not a mesh
service is not the mesh's: on the proxy port it answers 404, and a captured
dial passes through to the address that was dialled.
The authority's port is the port (proposal 043, chart 3.0.0). It is not
stripped and it has no default but HTTP's own: <name>:<port> reaches that
port, which the pods must declare (endpoint.aether.io/port,
endpoint.aether.io/ports), and a name without a port is port 80, served only
when the pods declare 80. Nothing means "the port the pods declare": a request
for a port the service does not serve as HTTP is refused 421 with an
x-aether-error that says why. See the runbook, "Chart 3.0.0". The SPIFFE trust domain is resolved from each component's own SVID and
matches the mesh domain by design, so addressing and identity share one
domain.
How a spelling resolves (proposal 037)¶
Every way of addressing a mesh service resolves to a (port, protocol) pair.
The URL scheme types the address, which is why a portless HTTP URL is not
ambiguous and raw TCP needs a port:
| Client dials | Reaches |
|---|---|
http://<svc>.<ns>.<domain>/ |
port 80, if the service declares it as HTTP; else 421 |
https://<svc>.<ns>.<domain>/ |
port 443, as the service declares it |
<svc>.<ns>.<domain>:18081 |
the mesh's HTTP proxy port: the port in Host decides. Not a port of the service |
<svc>.<ns>.<domain>:18082 |
the port in endpoint.aether.io/port when it is raw TCP (the explicit L4 spelling, kept) |
<svc>.<ns>.<domain>:18082/udp |
the port in endpoint.aether.io/port, as plaintext UDP (the same L4 spelling; needs a UDPRoute) |
<svc>.<ns>.<domain>:<p> |
port p, in whatever class p declares |
18081 and 18082 are reserved. A pod cannot declare either in
endpoint.aether.io/port or endpoint.aether.io/ports, with any protocol:
they are the mesh's own ports and a port of no service. The pod is denied at
admission, and a Host that names one is always refused
(421 port-not-declared). See the runbook, "BREAKING: a pod cannot declare
18081 or 18082".
HTTP demuxes on the authority header, which carries its own port as a
string — so http://<svc>/ is unambiguous without one. Raw TCP has no
authority; its only demux key is the 5-tuple. That asymmetry is intrinsic to
the protocols, not to this mesh, and it is why TCP gets a well-known port of
its own (18082) rather than the bare name changing meaning depending on
which protocols a service's ports happen to be.
:18082 does not require redirect-all. The scoped capture rule diverts
it alongside :18081 — on both transports — and the generated mesh Service
exposes it. A dial to a service's own application port (<svc>:9000) is
captured only under redirect-all, which is the managed-pod default and TCP-only
— the same property per-port HTTP already has. UDP is captured on :18082
only; there is no any-port UDP capture.
A port nobody registered is refused rather than silently forwarded: the
generated mesh Service exposes only its known ports, and kube-proxy REJECTs the
rest, so the caller gets ECONNREFUSED instead of a hang.
Traffic shaping (canary weights, header routing, timeouts, gRPC method routing, L4 splits/SNI) is standard Gateway API routes parented to the Service (GAMMA) — see the getting-started guide §10.
Declaring upstreams¶
The mesh distributes a service's clusters/endpoints/routes only to nodes that need them (demand-scoped distribution, proposal 004). Declare what a pod calls:
metadata:
annotations:
config.aether.io/upstreams: "svc-payments,svc-ledger,svc-audit"
- Declared upstreams are warm before first use — the node's proxy carries
them the moment the pod lands. Declare everything latency- or
correctness-critical. The list is also reviewable architecture
documentation, exactly like
minReadySeconds/preStopabove. - Undeclared upstreams still work (cold path): the first request pauses
~one node-local xDS round-trip while the cluster is fetched on demand
(ODCDS), then stays warm while used (1h idle TTL). Cold-path calls use the
same FQDN authority as everything else. Requests to nonexistent services
under the mesh domain fail after the on-demand timeout (
onDemandClusterTimeout, 2s —agent/internal/xds/proxy/httpfilter.go); anything outside the domain 404s immediately at the route table. - Every miss increments
aether.agent.upstreams.miss(and is logged with the service name) — the signal to promote an undeclared dependency to the annotation. - A pod's own service is always in scope; it never needs declaring.
Use keepalive (or HTTP/2) connections to the outbound listener. The mesh pools upstream mTLS connections per downstream connection (this is what keeps one pod's certificate from ever being reused for another pod's traffic). A long-lived client connection — an HTTP/1.1 keepalive connection or an HTTP/2/gRPC channel, whose multiplexed streams all share one upstream — reuses its mTLS connection across requests. Connection-per-request clients pay a fresh mTLS handshake per request and each abandoned upstream lingers until the 30s idle timeout reclaims it: it works, but it is the expensive traffic shape.
Subset routing and locality¶
Requests choose which endpoints of a service they may land on via headers; the mesh prefers closer endpoints automatically.
Pinning (always available)¶
| Header | Meaning |
|---|---|
x-aether-ip: 10.42.1.11 |
route to exactly that endpoint |
x-aether-pod: my-svc-7f9c4-xv2qp |
route to exactly that pod |
Pin-or-fail: if the target is gone (drained, ejected, never existed) the request gets a 503 — it never silently lands on a different pod.
Provider-defined subsets¶
Endpoints publish routing dimensions via metadata annotations:
metadata:
annotations:
metadata.endpoint.aether.io/version: "v2"
Consumers select with x-aether-subset-<key> (here
x-aether-subset-version: v2). The vocabulary travels via the control
plane — consumers declare nothing; any key published by an in-scope service
is routable from every pod on the node. Selection is strict (NO_FALLBACK):
asking for a subset that has no endpoints fails rather than spilling onto
the rest of the service. Keys must be lowercase DNS-label shaped
([a-z0-9-]); ip, pod, cluster, namespace are reserved.
Multiple subset headers intersect: a request carrying
x-aether-subset-version: v2 and x-aether-subset-shard: s1 routes only to
endpoints matching both, or fails. Up to 4 keys per service combine; beyond
that, extra keys select individually only. Pin headers are exclusive:
x-aether-ip/x-aether-pod identify a single endpoint by design and never
combine — mixing a pin with subset headers matches no selector and falls
back to normal balancing.
Requests without subset headers are balanced across all healthy endpoints, unchanged. Note: a cold (ODCDS) first request to an undeclared upstream routes before that service's vocabulary lands (~ms); declare upstreams whose subset routing is correctness-critical.
Locality-aware failover¶
Endpoints carry their node's topology.kubernetes.io/region/zone. Each
node's proxy routes to same-zone endpoints first (EDS priority 0), spilling
to same-region (1) and then anywhere (2) only as closer endpoints become
unhealthy or drain — a zonal roll automatically shifts traffic to the
region and back. Nodes without topology labels express no preference.
Hitless rolling restarts¶
The mesh handles most of the work automatically — endpoints are marked draining the instant pod deletion is requested (before SIGTERM), new endpoints enter clients pre-vetted, and client routes retry connection-level failures on another endpoint. Two workload-side settings close the remaining windows; without them rolls outrun the mesh and drop requests:
minReadySeconds: 10— Kubernetes considers a new pod Ready seconds before the mesh has vetted and propagated its endpoint (~5–10s: local health-check pass → liveness promotion → registrar → every client's EDS).minReadySecondspaces the roll so the previous endpoint is only retired after the replacement is mesh-routable.preStop: { sleep: { seconds: 10 } }(native sleep action, k8s ≥ 1.30 — no shell needed in the image) — delays SIGTERM so the app keeps serving through the mesh's two-phase drain. The sleep sizes the in-flight completion window: at deletion-requested the endpoint goes DRAINING (no new requests after ~1s), and the mesh closes client connection pools 1s before SIGTERM — established requests havesleep − 1sto finish, and the pools close while idle, ahead of the app's exit.
Measured under full load (2026-06-12): sleep 10 (9s window) → 0 failed
requests per roll; sleep 3 (2s window, the supported minimum) → ~1 blip
per pod for requests still in flight when the window ends. Use ≥ 10 for
zero-loss rolls; longer if requests can run longer than ~9s (the window is
capped 2s short of terminationGracePeriodSeconds).
Also keep maxUnavailable: 0 (the mesh never has fewer vetted endpoints than
replicas) and a real readinessProbe (the agent gates endpoint promotion on
the app actually answering).
What the mesh retries for you¶
Client routes retry on a different endpoint (2 retries, 25–250ms backoff):
| What happened | Retried? |
|---|---|
The caller's proxy could not send the request: connect-failure, refused-stream, reset-before-request |
yes, every method |
The destination's proxy could not reach your application and answered 503: connection refused or timed out, no healthy host |
yes, every method |
Your application answered 503 itself (the standard "try another endpoint" signal) |
yes, every method |
The destination's proxy had sent the request to your application, and the application closed or reset the connection before answering (503) |
only GET, HEAD, OPTIONS, TRACE, PUT, DELETE |
Your HTTP/2 application refused the stream (RST_STREAM with REFUSED_STREAM: not processed, by the protocol) |
yes, every method |
| Any other application error (other 5xx), a timeout, a gRPC status your application returned | no |
A gRPC call follows the same rows. The destination's proxy reports its own
failures to a gRPC caller as UNAVAILABLE, and the mesh retries those it
could not deliver and not those your application had received.
The fourth row is the one to design for. A request your application has received may have been run, so the mesh replays it only when the method is idempotent by definition (RFC 9110). What that means for you:
- A
POSTorPATCHyour application received is never run again by the mesh. If the application exits, crashes or closes the connection with one in flight, the caller gets a503and decides for itself. A caller that retries needs the endpoint to be safe to retry (an idempotency key, or a natural one). - A
GET,HEAD,OPTIONS,TRACE,PUTorDELETEmay be run twice, on two different pods: once by the pod that failed to answer, once by the one the retry reached. Keep them idempotent, as HTTP requires. AGETwith a side effect will see that side effect repeated. - Keep your server's keep-alive (idle) timeout long, or unset. The node
proxy reuses its connections to your application (an idle HTTP/1.1
connection for up to an hour, an idle HTTP/2 one for 30 seconds). A server
that closes an idle connection at the instant the proxy sends a request on
it looks exactly like a server that died with the request in hand, so a
POSTthat loses that race is answered503and not replayed. Idempotent methods are retried and never notice. - An application that answers
503asks for another endpoint, for every method,POSTincluded. Answer503only for a request you did not run. - A gRPC
UNAVAILABLEyour application returns is yours: the mesh does not retry it.
The destination's proxy tells the caller's which row applies in the response
header x-aether-outcome. It is removed before a response reaches a client
application, anything your application puts under that name is overwritten,
and a request header of that name is removed before it reaches you. One header of Envoy's own still
applies: a response that carries x-envoy-ratelimited is never retried.
While the mesh itself is being upgraded across the release that introduced
this, a caller on an upgraded node does not retry anything a destination on a
not-yet-upgraded node answers (docs/runbook.md, "Upgrading across #1641").
Do not roll workloads during that upgrade.
Termination sequence (what actually happens)¶
kubectl delete pod / rollout step
└─ apiserver sets deletionTimestamp (pod still Running)
└─ agent marks endpoint DRAINING (~1s to every client's EDS:
new requests stop arriving; established connections keep going)
└─ 1s before SIGTERM: agent re-marks UNHEALTHY — clients close their
now-idle pools ahead of the app's exit (drain phase 2)
└─ kubelet runs preStop sleep, then SIGTERM
└─ app finishes any post-SIGTERM work through the grace period
└─ containers exit; CNI DEL fires
└─ endpoint removed from the registry; local xDS torn down;
netns pin released after the drain tail (60s, detached)
Force deletes (--grace-period=0) skip the draining phase; clients then rely
on retries and health checking, so brief errors are possible — avoid force
deletes for serving workloads.