Configuration Reference¶
A structured reference for configuring Aether: the aether Helm chart values,
the CRDs, the CLI flags of each binary, and the labels/annotations
workloads use. Values and defaults are sourced from
charts/aether/values.yaml,
common/constants/, and each binary's Cobra flags — those
files are authoritative if anything here drifts.
Configuration has two layers:
- Deploy-time system config —
aetherchart values, set once and inherited by every component (agent, proxy, registrar, controller, edge). - Runtime proxy observability — the namespaced
MeshConfigCR, which overrides only the proxy data plane's access-log / tracing / stats policy without a redeploy.
1. Chart values (charts/aether)¶
Top-level¶
| Key | Default | Purpose |
|---|---|---|
nameOverride / fullnameOverride |
"" |
Override the chart name / fully-qualified resource name. Neither changes the node proxy's objects, which are always named aether-proxy: see "One release per cluster" below. |
namespace.create |
false |
Whether the chart renders the Namespace, with the three privileged pod-security labels (enforce/audit/warn) and helm.sh/resource-policy: keep. false (the default since chart 2.4.21, #1403): Helm (--create-namespace) or you create it, and on a cluster that enforces Pod Security admission you label it before installing, or the agent, proxy, mesh-dns and uds-csi pods are refused; see Getting started. true works only when the release is stored in a different namespace (helm -n <other> --set namespace.name=...); for the release's own namespace the render fails, naming both ways out, because Helm writes the release record into that namespace before it creates anything the chart renders. A namespace the release already owns (any install made while this defaulted to true) is rendered whatever this says, so an upgrade never deletes it. The first upgrade of a release that an older chart installed with false is refused until kubectl annotate namespace <ns> helm.sh/resource-policy=keep has been run once (runbook.md, "Chart 2.4.21"). |
namespace.name |
"" |
Namespace all resources deploy into (defaults to the release namespace). |
clusterName |
none: required | This cluster's name in the mesh, passed to the agent, the registrar and the edge control plane as --cluster-name. The agent stamps it on every endpoint it registers and deregisters only endpoints that carry it; with the etcd backend it is a segment of every key the registrar writes. Since chart 2.6.0 the chart has no default: a render without it (or with the empty string) fails with a message that names the value, before Helm touches the cluster. Choose it once and keep it, unique among the clusters that share a registry and without a /. A release that never set it runs with the default of charts before 2.6.0 and must pass that same name: see the runbook, "Chart 2.6.0". |
controlCluster |
"" |
Cross-cluster config authority (026 EM3). Set to a cluster name → only that cluster's registrar exports GAMMA config and everyone imports only from it. Empty = federated (any peer, highest-version wins). |
debug |
true |
Verbose logging on all components (--debug). |
meshDomain |
aether.internal |
DNS-style domain services are addressed under (<service>.<meshDomain>); also the ODCDS catch-all suffix. |
One release per cluster¶
Install the aether chart once per cluster (#1540). The agent, the node
proxy, mesh-dns and uds-csi own state that has one owner per node: /run/aether
and its sockets, the CNI binary and its entry in the node's conflist, the
data-plane ports of the host network namespace, the hot-restart shared memory
and the csi.aether.io driver. Two releases targeting the same nodes cannot
both work, whatever their objects are called.
Most object names follow the release (<release>-aether-agent, or
<release>-agent when the release name contains aether: aether-agent for
a release named aether). These do not:
| Object | Name | Scope |
|---|---|---|
node proxy DaemonSet, its ServiceAccount |
aether-proxy |
the release's namespace |
node proxy ConfigMap |
aether-proxy-config |
the release's namespace |
CSIDriver (udsCsi.enabled) |
csi.aether.io |
cluster |
MeshConfig seed (meshConfig.createDefault) |
default |
its namespace |
edge Namespace, GatewayClass, EdgeConfig (edge.enabled) |
aether-ingress, aether, aether-edge-defaults (edge.namespace, edge.gatewayClassName, edge.config.name) |
cluster, cluster, the edge namespace |
The proxy's name is a constant on purpose and is not going to follow the
release: renaming a DaemonSet is a delete and a create, which would replace
every node's proxy pod at once with no hot-restart handoff, and the name is
part of the external-harness contract (test/harnesscontract). Address the
proxy as daemonset/aether-proxy in the release's namespace, whatever the
release is called.
Since chart 2.4.28 the agent DaemonSet's own metadata carries the annotation
aether.io/harness-contract-version: the version of that contract the chart
was packaged with. No value sets it. It identifies the contract, not the build,
it is absent from a render of the source tree, and it is never on a pod
template, so it changes without replacing a pod. docs/runbook.md, "The
external-harness contract", has the command that reads it.
Since chart 2.4.27 a second release is refused at render time, where the chart
can see it: when the aether-proxy DaemonSet in the release's namespace, or the
csi.aether.io CSIDriver, already exists and names another Helm release
(Helm's meta.helm.sh/release-name / release-namespace annotations, or the
app.kubernetes.io/instance label on an object that has lost them), the render
fails and says so. The label names a release, not the namespace it is stored
in, so a first install is also refused when one of the two objects names a
release of the same name and has no release-namespace annotation: it may be
another release called the same. An upgrade of the owner whose objects lost
their annotations is not refused. Both objects are looked up whatever the new
release enables. The check does not see a second release in another
namespace when the first one runs with udsCsi.enabled=false (there is no
CSIDriver, and the first release's proxy DaemonSet is in the other namespace),
nor a first release that runs neither the proxy nor uds-csi, and it sees nothing in a render without a cluster
(helm template, a client-side --dry-run, a GitOps tool that renders with
helm template): there, one release per cluster is yours to keep.
Resources — removing a default request or limit¶
Every container's resources value (agent, agent.meshDnsDaemon, cniInstall, udsCsi, proxy, proxy.supervisor, proxy.authzSidecar, edge, registrar, controller) has defaults in values.yaml, and Helm merges maps: an override is laid over the defaults key by key, so an empty map removes nothing. --set-json 'controller.resources={}', --set-json 'controller.resources.limits={}' and a values file with resources: {} or limits: {} all render the unchanged defaults. To remove one quantity, set that quantity to null or to the empty string. Both work on every container since chart 2.4.14 (#1355: before it, an empty string rendered cpu: "" on the agent, proxy, authz sidecar, edge, registrar and controller, which the apiserver rejects). Removing a limit: --set controller.resources.limits.cpu=null, --set controller.resources.limits.cpu=, or limits: {cpu: null} / limits: {cpu: ""} in a values file. Removing a request is the same on the other map: --set controller.resources.requests.cpu=null, --set controller.resources.requests.cpu=, or requests: {cpu: null} / requests: {cpu: ""}. A map left with no entries is not rendered, and a whole map can be dropped at once with --set controller.resources.limits=null (or requests=null, or requests: ~ in a values file). Removing limits.memory also removes GOMEMLIMIT where the chart derives it. Removing limits.cpu from the registrar or the controller leaves their GOMAXPROCS (resourceFieldRef: limits.cpu) following the node's core count, because the kubelet substitutes node allocatable for a missing limit; their defaults keep the limit for that reason. Every spelling above was rendered with helm template (Helm 3.18) against chart 2.4.14; none was applied to a cluster. Do not combine them with --reuse-values: read the release's values back (helm get values -o yaml) and pass them with -f.
otel — system-wide telemetry¶
Enable once; every component inherits it. The proxy may override its own access-log/tracing policy via the MeshConfig CR.
| Key | Default | Purpose |
|---|---|---|
otel.enabled |
false |
Enable the OTel MeterProvider + push telemetry everywhere. |
otel.endpoint |
"" |
OTLP gRPC collector host:port (insecure). Empty disables OTLP export from every component, and the Envoy stats sink of the node proxy and the edge proxy. A deploy-time value: the chart passes it as --otlp-endpoint to the agent, the proxy supervisor, mesh-dns, the registrar, the controller and the edge control plane, and renders it into the two Envoy bootstraps; it is never read from a runtime ConfigMap. uds-csi and cni-install are not passed it. The CNI plugin takes no endpoint: it exports no telemetry of its own (#1166/#1185) and forwards its timings to the agent, which exports them (see cniInstall below). |
otel.logs |
false |
Export component logs over OTLP (also tee'd to stderr). |
otel.traceSampleRate |
0.1 |
Head-sampling ratio (0.0–1.0); bounds exported spans only. |
otel.traceExport |
false |
Export spans over OTLP (needs a collector traces pipeline). |
Who names a component. Each Go component (agent, registrar, controller, edge
control plane, mesh-dns, proxy supervisor, prober) sets its own service.name
(aether-agent, aether-mesh-dns, …) and its own service.version (the build's),
and takes every other resource attribute from OTEL_RESOURCE_ATTRIBUTES, which the
charts use for the pod's k8s.* attributes and, on the registrar, the controller
and both containers of the edge, for service.instance.id (the pod name; chart
2.5.3, #1560). Prometheus stores that one as instance, and it is what keeps
their replicas' series apart: do not drop it in your pipeline
(observability/metric-labels.md; the
runbook's "Chart 2.5.3" for the upgrade). A service.name inside
OTEL_RESOURCE_ATTRIBUTES is ignored by these components (#1562): dashboards and
alerts select on the component's name. To rename one on purpose, set
OTEL_SERVICE_NAME on its container. A service.version inside
OTEL_RESOURCE_ATTRIBUTES is ignored too (#1575), and nothing overrides it: the
version says which binary produced the telemetry, and only the binary knows. All
of them build that resource with common/telemetry/serviceresource. Envoy is not a
Go component. The edge proxy's stats resource takes service.name from
OTEL_RESOURCE_ATTRIBUTES, which is how it gets aether-edge-proxy. The node
proxy's is named aether-proxy in its bootstrap (chart 2.4.26, #1561; it had no
name before, so its series had no job in a backend that makes job from
service.name), not in the container's environment:
the supervisor runs in that container too and reads the same variables, so an
OTEL_SERVICE_NAME there would rename the supervisor's metrics, and it does
not rename Envoy's.
spire — system-wide mTLS¶
enabled is the mesh-wide mTLS switch; the rest is per-component plumbing.
| Key | Default | Purpose |
|---|---|---|
spire.enabled |
true |
Mesh-wide mTLS switch: the SPIRE wiring of the agent, the proxy, the registrar and the edge, and the controller's identity gate. false means mesh mTLS is off (cleartext between proxies), not that SPIRE is absent: the webhook certificate is a separate switch, controller.webhook.spire, and controller.webhook.spire=true with spire.enabled=false is supported (#1412). The controller is then the release's only SPIRE client: it gets --spire-enabled, its csi.spiffe.io volume and --identity-gate=false. |
spire.workloadSocketPath |
/run/secrets/workload-spiffe-uds/socket |
Workload API socket (the csi.spiffe.io mount). |
spire.brokerSocket.hostPath |
/run/spire/agent/sockets/csi.spiffe.io/broker |
SPIRE agent SPIFFE Broker Endpoint socket directory on the node — where the spiffe/spire chart puts it with spire-agent.sockets.broker.mountOnHost=true. The agent brokers an X.509-SVID for every pod on its node through it (proposal 036). |
spire.brokerSocket.mountPath |
/run/spire/broker-sockets |
Where that directory is mounted in the agent container. |
spire.brokerSocket.socketName |
broker.sock |
Broker socket filename. |
spire.waitWarnAfter |
2m |
How long a component may wait for its first SVID before the waiting log line escalates from INFO to WARN (--spire-wait-warn-after on agent, registrar, controller and edge). On the agent it is also the dwell before the spire-svid readiness check reports NotReady — which arms the node taint, so it must not fire on a brief SPIRE hiccup (#740). The Deployments take no dwell: they go NotReady the instant they are known to lack an identity, which just removes one replica from one endpoint set. |
Workload SVID DNS SANs (mesh-wide, required by east-west QUIC). East-west QUIC
(038 Phase 4b) is unconditional — there is no value or flag for it — so every
workload SVID must carry the DNS SANs <sa>.<ns>.<meshDomain> and
*.<sa>.<ns>.<meshDomain> (dnsNameTemplates on the workloads' ClusterSPIFFEID).
Envoy's QUIC client checks the SNI <port>.<sa>.<ns>.<meshDomain> against them after
the SPIFFE pin (aether#957); the requirement stands until envoyproxy/envoy#47740 is in
a plain proxy pin (today's pin carries it as a patch, #972). This is SPIRE
configuration, not an aether chart value; see runbook.md
§ East-west QUIC for the spiffe/spire snippet, the rollout order and the budget.
meshConfig — proxy MeshConfig seeding¶
| Key | Default | Purpose |
|---|---|---|
meshConfig.createDefault |
true |
Seed the singleton MeshConfig (default) once, when none is live: on the first install, and on an upgrade that finds it absent. Never overwritten on upgrade or rollback (operators own it via kubectl). Since chart 2.4.24 the seed is a Helm hook (helm.sh/hook: pre-install,pre-upgrade), so it is in no revision's manifest and helm rollback to the revision that seeded it (normally a release's first) works (#1471); helm install --no-hooks therefore seeds nothing. The hook never deletes a MeshConfig (helm.sh/hook-delete-policy: never, a value Helm does not act on, in place of its default before-hook-creation): one created while an upgrade is running is not deleted, and that upgrade can fail on "default" already exists; run it again (with Helm 4's server-side apply the upgrade goes through and the seed is applied to that object: the runbook has what that changes). It is still a release object on the one revision that also creates the namespace it goes into (namespace.create=true). The edge's own MeshConfig hangs off the same switch; in an edge namespace the chart creates (edge.namespaceCreate=true) it is, since chart 2.4.25, an object of the release on every revision, with an empty spec (#1514). See runbook.md, "Chart 2.4.24" and "Chart 2.4.25", for releases installed by an older chart. With a tool that renders with helm template, set false and keep the MeshConfig with your own manifests. |
meshConfig.proxy |
{} |
The spec.proxy seeded into that CR (protojson field names). Empty = proxy inherits everything from system config. |
agent¶
| Key | Default | Purpose |
|---|---|---|
agent.gamma |
true |
GAMMA east-west L7 routing (018): watch HTTPRoutes and GRPCRoutes parented to a Service (plus ReferenceGrants and HTTPFilter attachments) and apply them on both the explicit outbound path and the transparent-capture path. MESH-HTTP Core conformance-green. Safe without the Gateway API CRDs (CRD-detected, degrades with a warning); false is a kill switch. |
agent.cniConflistReassert |
true |
Keep aether chained in the node's active CNI conflist (#645): the agent watches /etc/cni/net.d (read-write mount) and re-appends the aether-cni entry whenever a competing writer strips it — kube-flannel cp -fs its ConfigMap template over 10-flannel.conflist on every flannel pod recreation, which a Talos bootstrap-manifest re-sync triggers, silently unmeshing every pod started afterwards. Never creates a conflist of its own; false is a kill switch. |
agent.updateStrategy.surge |
false |
How the agent DaemonSet rolls (proposal 041). false: delete-then-create (maxSurge: 0, maxUnavailable: 1); the node's proxy has no ADS stream for the whole pod replacement + startup. true: maxSurge: 1, maxUnavailable: 0; the new agent starts as a standby (it finds /run/aether/agent.lock held), builds its whole first snapshot but binds no node socket and writes no node file, reports Ready, and takes the node over the instant the old agent exits. The gap becomes the reconnect backoff plus a storage diff (~0.1-0.4 s). Needs room on every node for a second agent.resources request during the overlap. Roll out in two steps: upgrade to 2.3.0 with false first, then set true (runbook explains why). In both modes the agent pod declares no ports: and probes over a pod-local socket (/agent-ready). See runbook.md § Agent surge roll (proposal 041). |
agent.importConfig |
false |
Cross-cluster config import (026): poll the registrar for peer-exported GAMMA projections and materialize them (merged with local; local wins). Pairs with registrar.registryBackend=etcd. |
agent.eastWestWaypoint |
false |
East/west waypoint (019): dial cross-cluster endpoints at their node's routable IP + the fixed tunnel port 18009 instead of the (unroutable) pod IP; this node's host-network proxy SNI-forwards inbound tunnel traffic to local pods. Intra-cluster stays direct pod-to-pod. Needs cross-cluster endpoint visibility (shared or replicated etcd) + a shared SPIRE trust domain. |
agent.serviceCIDRs |
[] |
The cluster's Service range (proposal 044): the IPv4 CIDRs ClusterIPs are allocated from, e.g. ["10.96.0.0/12"]. Empty = the agent discovers it from the networking.k8s.io/v1 ServiceCIDR objects (Kubernetes >= 1.33, the agent's ClusterRole reads them). When set it wins and they are not watched; set it on a cluster older than 1.33. An entry that is not a CIDR fails the render. IPv6 entries are accepted and ignored for now. Where the multicluster.x-k8s.io ServiceImport CRD is installed, a /32 for each IPv4 ClusterSetIP (spec.ips of a ClusterSetIP-type import, aether's own imports excepted) outside the range joins it, with or without this override: the MCS API lets an implementation allocate ClusterSetIPs outside the ServiceCIDRs. The agent's ClusterRole reads serviceimports; installing the CRD later needs an agent restart. With no range known the agent logs a warning and reports aether.agent.service_range.known = 0. Phase 0 reads it for measurement only: the capture listener counts connections to the range under cap_passthrough_svc, on the same path. |
agent.captureRedirectAllDefault |
true |
Redirect-all as the DEFAULT for managed pods (022 Step 4); opt out per-pod with capture.aether.io/redirect-all="false". false = per-pod opt-in via the same annotation set to "true". (Transparent capture itself and the passthrough chain are unconditional since proposal 031.) |
agent.meshDns |
true |
Per-pod mesh DNS (018). Gates BOTH halves: the agent's in-process resolver (which writes the record snapshot) and the separate aether-mesh-dns DaemonSet that serves pods from it. |
agent.meshDnsUpstream |
[] |
Upstream resolver(s) for non-mesh queries, passed to the mesh-dns daemon (--mesh-dns-upstream), not the agent. Empty = the daemon's own resolv.conf (kube-dns). |
agent.meshDnsDaemon.image.* |
repo+digest placeholders | The slim mesh-dns image, pinned separately from the agent's (#583) — the DaemonSet ships only the /mesh-dns binary. Rendered when agent.meshDns is true. |
agent.meshDnsDaemon.resources.{requests,limits} |
requests cpu 50m / mem 32Mi; limits mem 64Mi, no CPU limit |
No CPU limit by design (#1253, chart 2.4.8; was 25m request / 100m limit). The daemon is every managed pod's resolver, so its tail latency is every pod's DNS latency: at 25m/100m it averaged ~24.5m yet was CFS-throttled in 8.7 % of 100 ms periods, because its CPU comes in bursts and a throttled period parks every query in flight until the next one. The 50m request is its scheduling reservation and its CFS weight against the proxy under contention. QoS was and stays Burstable. Set limits.cpu to put a cap back (and see goMaxProcs); an empty limits.cpu renders no limit. No GOMEMLIMIT is set for this container. How to measure throttling: runbook.md § mesh-dns CPU throttling. |
agent.meshDnsDaemon.goMaxProcs |
2 |
Rendered as the mesh-dns container's GOMAXPROCS env var (#1253). The Go runtime derives it from the CPU limit with a floor of 2 — so 2 is what the old 100m limit gave — and without a limit it would scale to the node's core count (and the GC's background workers with it). 0 (or empty) omits the env var and lets the Go runtime choose. |
agent.meshDnsDaemon.forwardPoolSize |
null |
Pooled, already-connected UDP sockets per upstream on the forward path (--forward-pool-size; #674 — dialling per query was 20.97% of this daemon's CPU). Unset emits no flag and takes the built-in default of 8; 0 disables pooling and restores dial-per-query. An escape hatch, not a tuning knob: reuse trades per-query source-port randomisation for the socket's lifetime, which is safe only because the socket is connected (the kernel drops any datagram not from the upstream's exact address). |
agent.meshDnsDaemon.lameDuckMax |
10s |
Ceiling on the post-SIGTERM window in which the resolver stops reporting ready but keeps answering (--lame-duck-max; #729). It closes as soon as it observes a different instance answering on the same address, so the ceiling only bites when no successor appears (scale-down, node drain, failed surge). The DaemonSet's terminationGracePeriodSeconds is derived from this (+5s). "0s" restores the pre-#729 close-on-SIGTERM behaviour, which dropped one queued datagram on every roll. |
agent.meshDnsDaemon.debug |
false |
TRACE logging for this daemon only (#684). The top-level debug deliberately no longer reaches mesh-dns — it is the one component on every managed pod's :53 path, and everything it logs is also fanned out to the OTLP log exporter. |
agent.eastWestQuicIdleTimeout |
8s |
Idle timeout of every east-west QUIC (quic:) twin's upstream pool (--east-west-quic-idle-timeout, #1054); h1/h2 pools keep 30 s. An idle source h3 connection gets no GOAWAY when the destination proxy hot-restarts, so it must close before the parent exits or its next packet draws a stateless reset. The chart refuses to render unless eastWestQuicIdleTimeout + 5s < proxy.hotRestart.parentShutdownTime (and it must be > 0); lowering the parent-shutdown time needs this lowered with it. Every twin also carries a fixed QUIC transport idle_network_timeout of 8 s and a 1 s keep-alive PING while a request is open (#1087: a dead destination fails an in-flight request in at most about 10 s instead of hanging to the 15 s route timeout; 8 s, not less, because of the #1093 worker stalls). The transport idle also closes a twin connection with no open stream after 8 s, so values of this flag above 8 s no longer lengthen reuse. Cost: a pair idle between this and 30 s pays one extra QUIC handshake. |
agent.image.* |
repo+digest placeholders, pullPolicy: Always |
Digest-pinned image; mirror by overriding repository alone. |
agent.resources.{requests,limits} |
requests cpu 200m / mem 96Mi; limits mem 128Mi, no CPU limit |
No CPU limit by design (#1116): snapshot builds are 5–25 ms CPU bursts on the ODCDS/EDS path, and a 200m CFS quota turned them into 100 ms steps and could throttle a goroutine while it held the snapshot-cache mutex (#1105/#1114). The pod is therefore QoS Burstable, not Guaranteed. To get Guaranteed, set limits.cpu equal to requests.cpu (raise both to at least 1000m) and requests.memory equal to limits.memory. Memory was 64Mi/64Mi before chart 2.4.2; the 2026-10-04 soak OOMKilled agents at that limit (Go runtime 44–61 MiB, plus the /agent-ready exec probe processes, ~3 MiB RSS each, charged to the same cgroup). GOMEMLIMIT is rendered at 90 % of limits.memory (115.2 MiB at the default) and omitted when no memory limit is set. A surge roll needs a second 200m/96Mi request per node. See runbook.md § The node agent is OOMKilled. |
agent.goMaxProcs |
2 |
Rendered as the agent container's GOMAXPROCS env var (#1116). Since Go 1.25 the runtime derives GOMAXPROCS from the cgroup CPU limit, so without a limit it would scale to the node's core count. 0 (or empty) omits the env var and lets the Go runtime choose, e.g. when you set an explicit CPU limit. |
The agent reaches the registry only through the registrar (gRPC) — it never talks to an external registry backend and carries no backend credentials.
proxy — per-node Envoy DaemonSet¶
| Key | Default | Purpose |
|---|---|---|
proxy.enabled |
true |
Deploy the per-node Envoy. Disable to run only the agent. |
proxy.image.repository |
quay.io/aethermesh/proxy |
External image built by the //proxy workspace, digest-pinned by the proxy release's bump-chart PR. Every pin reader accepts exactly this repository (image_reference("proxy")). |
proxy.image.tag |
(commit SHA) | The publishing commit. |
proxy.image.digest |
(index digest) | Multi-arch index digest of that commit's image; the aether.image helper prefers it over tag. |
proxy.logLevel |
info |
Envoy log level. |
proxy.jsonLogs |
true |
Envoy application logs as one JSON object per line. |
proxy.hotRestart.baseId |
0 |
Envoy hot-restart tunables (mechanism is not optional; see proposal 001). |
proxy.hotRestart.drainTime |
10s |
Graceful connection-close window for the draining epoch. A Go duration (10s, 1m30s, 1.5s): the supervisor's --drain-time accepts nothing else, and since chart 2.6.2 the render refuses anything else. proxy.terminationGracePeriodSeconds must be greater than this + 15 s. |
proxy.hotRestart.parentShutdownTime |
15s |
When the previous epoch is terminated (must exceed drainTime). Also the supervisor's admin re-verify budget: the epoch-identity probe re-confirms on a fresh connection every parentShutdownTime/3 (floor 2s, ceiling 15s), so a cross-pod takeover is diagnosed while the draining parent still lives. Below ~6s the floor takes over and the supervisor logs the lost margin at startup; raising it also delays successor-pod readiness by the same amount. |
proxy.hotRestart.handoffDeadline / adminUnresponsiveDeadline |
0 |
Supervisor watchdogs (0 = built-in defaults). |
proxy.hotRestart.shmHostPath |
/run/aether/shm |
Shared-memory hostPath for cross-pod hot restart. |
proxy.hotRestart.drainStrategy |
gradual |
Envoy --drain-strategy for the hot-restart parent and every other drain (LDS listener removal, the pod-termination drain): gradual or immediate. immediate is an opt-in only: on the reference cluster it made the #1054 stateless resets worse (#1068); the carried patches (#1064/#1066/#1069) fix the exit window under gradual. See runbook.md § Source h3 requests die on a stateless reset at a destination's roll (#1054). |
proxy.hotRestart.skipParentStats |
false |
Pass Envoy --skip-hot-restart-parent-stats (#1050). An off-by-default emergency switch: the hot-restart main-thread deadlock it worked around is fixed by the carried patch #1060. Turning it on costs the parent's gauges and its last ≤5 s of counter deltas in the child. |
proxy.hotRestart.hotRestartOnConcurrencyChange |
false |
Supervisor --hot-restart-on-concurrency-change (#1136). Off: a roll that changes the Envoy worker count (proxy.concurrency) drains the predecessor and starts a fresh Envoy instead of hot-restarting. On: hot-restart anyway, which resets about half of the predecessor's live HTTP/3 connections on every node. An operator override, not a tuning knob. |
proxy.concurrency |
0 |
Envoy worker thread count (#1093). 0 passes no flag, so Envoy picks its own default: the smallest of the node's online CPUs, the CPUs the container's affinity mask allows, and the container's cgroup CPU limit (proxy.resources.limits.cpu) rounded down to a whole CPU, never less than 1. That is one worker per core only when the container has no CPU limit and no restricted mask, as with the chart's defaults; with a 1500m limit it is one worker, with 2 or 2500m two. N > 0 passes --concurrency N to every Envoy epoch through the supervisor, and Envoy then runs exactly N whatever the limit or the mask. Exists because on a 4-core node a hot-restart handoff runs two Envoys (8 workers + 2 main threads on 4 cores). Changing it is a drain + fresh start, not a hot restart (#1136): a hot restart between different worker counts re-steers the parent's QUIC connections by the child's count (the reuse-port steering program belongs to the whole socket group) and resets about half of the live HTTP/3 connections on each node. So when the new pod's supervisor finds a live predecessor whose /server_info reports another concurrency, it logs one WARN naming both counts, drains the predecessor (POST /drain_listeners?graceful, then hotRestart.drainTime), stops it (/quitquitquit) and starts its own Envoy fresh at epoch 0; aether_supervisor_handoff_mode_total{mode="fresh_after_drain"} counts it. The cost, per node in turn, is the drain window plus a gap with no listeners while the fresh Envoy initializes (on kind: about 2 s, bridged by SYN retransmits, 0 failed requests; on a busy node expect the successor's init time, 3–15 s on the reference cluster). Every restart after that rollout is same-to-same and hot again. With 0 the supervisor has no flag to compare, so it computes the count the way Envoy does, from its own container's CPUs, affinity and cgroup limit (#1442; until then it assumed one worker per online CPU, so a proxy with a CPU limit or a restricted mask took the drain + fresh start on every roll). A rollout that changes proxy.resources.limits.cpu across a whole-CPU boundary while proxy.concurrency is 0 therefore changes the worker count, and is a drain + fresh start like any other count change. Set an explicit count whenever you set a CPU limit. Envoy reads only its container's own cgroup: a CPU limit on the pod as a whole, with none on the proxy container, leaves one worker per core on a throttled pod (#1455; runbook.md § Sizing nodes for a proxy hot restart has the measurement). proxy.hotRestart.hotRestartOnConcurrencyChange forces the old hot restart. A negative or non-integer value fails the render. Do not change it on a live mesh until both prerequisites are deployed: the #1126 successor crash is fixed by the carried Envoy patch envoy-aether1126-forwarded-udp-worker-index.patch in proxy images built from it or later, and the #1127 supervisor fix is the other prerequisite; see runbook.md § Sizing nodes for a proxy hot restart. |
proxy.terminationGracePeriodSeconds |
180 |
The proxy pod's terminationGracePeriodSeconds, also passed to the supervisor as --termination-grace. The deleted pod keeps its Envoy alive as the successor's hot-restart parent, so it is deliberately generous. Where no successor can appear (node shutdown, kubectl delete daemonset, a replacement stuck Pending) the supervisor drains Envoy itself at terminationGrace − (hotRestart.drainTime + 15s) instead of being SIGKILLed (#771). Must be greater than hotRestart.drainTime + 15 s (at least 26 at the default drainTime); since chart 2.6.2 the render fails otherwise, naming both values and the minimum, because at or below it the supervisor applies no bound at all and a pod terminated while it replaces a predecessor (a proxy.concurrency change) is SIGKILLed with the node's listeners closed. proxy.shutdownDrainImmediately does not lower the minimum. Keep it well above: the minimum leaves a deleted pod one second to wait for its successor. |
proxy.overload.enabled |
true |
Envoy overload-manager graceful-degradation ladder. |
proxy.overload.maxHeapSizeBytes |
402653184 (384Mi) |
Keep at ~75% of resources.limits.memory. |
proxy.resources.{requests,limits} |
cpu 500m, mem 512Mi |
No CPU limit on purpose. The CPU request buys the proxy cgroup weight on a contended node, not capacity; a hot-restart handoff needs about a core of node headroom on top of it. See runbook.md § Sizing nodes for a proxy hot restart. |
proxy.supervisor.resources.{requests,limits} |
requests cpu 100m / mem 32Mi; limits mem 64Mi, no CPU limit |
Resources of the install-supervisor init container (#1344, chart 2.4.13; it rendered no resources before). It runs on every proxy pod start and copies the supervisor and the proxy-ready prober onto the pod's shared volume: about 50 ms of CPU and a 14 MB peak RSS, measured on a workstation build. The request is cniInstall's; it gives the container a CFS weight on a busy node and puts it in request accounting, and reserves nothing extra (the proxy container's 500m / 512Mi is the pod's effective request). No CPU limit by design (a quota is what slowed cni-install, #1335). To add a cap set limits.cpu; an empty value renders no limit rather than an invalid quantity. |
proxy.authzSidecar — external authorization (proposal 027)¶
| Key | Default | Purpose |
|---|---|---|
proxy.authzSidecar.enabled |
false |
Add a node-local authz gRPC sidecar (UDS) + a DISABLED ext_authz filter entry; zero effect until an HTTPFilter (extAuthz) opts a route/service in. |
proxy.authzSidecar.opa.enabled |
false |
Built-in OPA preset (opt-in). |
proxy.authzSidecar.opa.image |
openpolicyagent/opa:1.21.1-envoy-static@sha256:b4a8bbe8…344b (the multi-arch index of that tag) |
OPA image. Pinned by digest since chart 2.4.22 (#1401); it was the tag alone. The upgrade that crosses 2.4.22 rolls the proxy DaemonSet once where the preset is on and this value is the default (runbook.md, "Chart 2.4.22"). A reference you set (a mirror) is used as written. Moving the pin: runbook.md, "Refreshing third-party image pins". |
proxy.authzSidecar.opa.policy |
"" |
Rego policy (ConfigMap-mounted); required when opa.enabled. The sidecar watches it (opa run --watch, chart 2.4.19, #1383): a changed policy is loaded by each node's sidecar when that node's kubelet delivers the ConfigMap update (27–64 s observed on one node; not a bound), with no pod restart and no staging. The chart does not validate it: check it before you change it. See Changing the OPA policy below. |
proxy.authzSidecar.image.{repository,tag,args} |
"" / [] |
Bring-your-own authz container (serves envoy.service.auth.v3.Authorization on unix:///run/aether/authz/authz.sock). |
proxy.authzSidecar.timeout |
200ms |
Per-check gRPC timeout. |
proxy.authzSidecar.failureMode |
DENY |
DENY (fail-closed, 403 when unreachable) or ALLOW (fail-open). |
proxy.authzSidecar.resources |
10m / 32Mi requests, 128Mi memory limit |
Sidecar resources (OPA preset and bring-your-own). No CPU limit on purpose: it is on the request path, and throttling becomes ext_authz timeouts — 403s under DENY. |
proxy.authzSidecar.startupProbe.enabled |
true |
Render the sidecar's startupProbe. The kubelet starts the proxy container only after it passes (#1275). Off: the sidecar counts as started as soon as its process runs. |
proxy.authzSidecar.startupProbe.{periodSeconds,timeoutSeconds,failureThreshold} |
1 / 1 / 120 |
Timings of the default probe, which execs the staged proxy-ready --unix-socket=/run/aether/authz/authz.sock inside the sidecar container: it passes once a connect(2) to the authz socket succeeds. Works for the OPA preset and for a bring-your-own image, since both must serve on that socket. |
proxy.authzSidecar.startupProbe.override |
{} |
A full Kubernetes probe, used verbatim instead of the default socket check. The pod is hostNetwork, so a port probe answers for whichever pod on the node holds the port. |
proxy.authzSidecar.{livenessProbe,readinessProbe} |
{} |
Optional probes, rendered verbatim. A native sidecar's readinessProbe counts toward pod Ready (and so gates a proxy roll); a failed livenessProbe restarts only the sidecar, and checks fail per failureMode while it is down. |
The sidecar is a native sidecar (chart 2.4.9, #1275). authz is an init container
with restartPolicy: Always, placed after install-supervisor (which stages the probe
binary) and before the proxy container. The kubelet does not start proxy until the
sidecar's startupProbe passes, so a new Envoy never takes the node's listeners with no
authz behind it. On deletion the kubelet stops the sidecar only after proxy has exited,
so an Envoy that is still serving (waiting for a successor, or draining) keeps its authz.
It used to be a second regular container, started after proxy. When its image had to be
pulled, the new Envoy served for 7–13 s with no authz, and every check failed (a 403 under
DENY). See runbook.md § The ext_authz sidecar across a proxy roll.
Native sidecars need Kubernetes >= 1.29 (on by default from 1.29, GA in 1.33). With
the sidecar enabled the chart refuses to render for an older cluster. An older apiserver
drops restartPolicy from an init container, and the sidecar would then be an init
container that never exits, with every proxy pod stuck in Init. helm template with no
cluster uses Helm's built-in Kubernetes version, so pass --kube-version there. With the
sidecar disabled nothing changes and no minimum applies.
Changing the OPA policy (chart 2.4.19, #1383). The preset runs
opa run --server --watch … /policy/policy.rego. When opa.policy changes, helm upgrade
changes only the ConfigMap <release>-opa-policy; the proxy pod template does not follow
the policy, so no pod is replaced and Envoy is not hot-restarted. Each node's kubelet
delivers the new file on its own schedule: its sync period plus the delay of its ConfigMap
cache. On one kind node that was 27–64 s after helm upgrade returned, over eight
changes; it is an observation on one node, not a bound. OPA then swaps the policy without
leaving a check unanswered: 8,607 requests sent through a reload were all answered, with
no ext_authz error.
- It is unstaged and asynchronous. A DaemonSet roll applied a policy one node at a
time and stopped at the first pod that did not become Ready. A watched ConfigMap is
picked up by every node independently, in no order, with no canary node and nothing to
pause, and nothing reports when the last node has it. Do not assume the fleet has
converged after a minute: confirm it per proxy pod (
runbook.md§ Changing the OPA policy, "Confirming every node has the new policy"). - Validate before you update. The chart adds no admission check and no pre-upgrade
hook; a policy is the responsibility of whoever changes it. With the image the chart
runs (
proxy.authzSidecar.opa.image):
OPA_IMAGE="$(helm get values aether -n aether-system -a -o json | jq -r .proxy.authzSidecar.opa.image)"
# parses and compiles (exit 1 and the error otherwise)
docker run --rm -v "$PWD/policy.rego:/policy/policy.rego:ro" "$OPA_IMAGE" \
check /policy/policy.rego
# the decision the sidecar queries is DEFINED for one request you write down
# (request.json: the input the sidecar hands the policy, see below). Prints the
# decision; exit 1 when it is undefined for that request
docker run --rm -v "$PWD/policy.rego:/policy/policy.rego:ro" \
-v "$PWD/request.json:/policy/request.json:ro" "$OPA_IMAGE" \
eval --fail -f pretty -d /policy/policy.rego --input /policy/request.json \
'data.envoy.authz.allow'
# your own unit tests, if you keep *_test.rego files next to it
docker run --rm -v "$PWD:/policy:ro" "$OPA_IMAGE" test /policy
request.json is one request in the shape the sidecar receives from Envoy
(input.attributes.request.http, header names in lower case); use one your policy
should allow:
cat > request.json <<'EOF'
{
"attributes": {
"request": {
"http": {
"method": "GET",
"path": "/",
"host": "my-service.my-namespace.aether.internal:8080",
"scheme": "http",
"protocol": "HTTP/1.1",
"headers": {
":authority": "my-service.my-namespace.aether.internal:8080",
":method": "GET",
":path": "/",
"x-authz": "letmein"
}
}
}
},
"parsed_path": [""],
"parsed_query": {}
}
EOF
What each command proves, and no more:
- opa check: the policy parses and compiles with this OPA version. It passes a policy
that defines no envoy.authz.allow at all.
- opa eval --fail --input: envoy.authz.allow is defined for that one request,
and what it is (true/false). Without --input the policy is evaluated against no
request at all, which rejects a correct policy that has no default allow and says
nothing about real requests. Exit 1 means undefined for this request: either the
policy does not define the decision, or it has no default and this request matches
no rule.
- opa test: whatever your tests assert. Only they cover the policy's behaviour over
the requests you care about; neither command above does.
- A policy that does not parse or compile is not loaded by a running sidecar. It keeps
deciding with the last good policy, does not exit, and logs the error. That last good
policy exists only in the running process: a sidecar that starts with the bad file
exits 1. So the failure is delayed until a sidecar restarts (an OOM kill, a node
reboot) or a proxy pod is replaced, and then that node fails every check per
failureMode, or loses its proxy. runbook.md § Changing the OPA
policy has the measurements, how to see a failed reload, and the recovery.
- The first upgrade to 2.4.19 rolls the proxy DaemonSet once when the OPA preset is on
(the checksum/opa-policy annotation leaves the pod template and the sidecar gains
--watch). With the preset off the proxy pod template is unchanged. Chart 2.4.15–2.4.18
rolled the DaemonSet on every policy change; before 2.4.15 a policy changed at an
unchanged chart version was not applied until the next roll.
OPA 1.21 YAML change (chart 2.4.6, #1222). The preset image moved from OPA 1.20.2
to 1.21.1, which parses YAML under the 1.2 core schema everywhere OPA reads it
(--data, bundles, config files, the yaml.unmarshal builtin): the bare words
yes/no/on/off/y/n are now strings, not booleans (true/false are
unchanged), and a YAML document with unreachable content is rejected. The preset
itself hands OPA no YAML — only command-line flags and the opa.policy Rego file — so
the chart needs no change. If your policy calls yaml.unmarshal, or you run your own
OPA config/bundle/data YAML through a bring-your-own image on 1.21+, write booleans as
true/false (or quote the word if you meant the string). The same release also types
empty literals ({}, []) as empty, so a policy that selects a key out of one, or
compares a non-empty object/array to {}/[], now fails to compile — use
count(x) == 0. A policy that does not compile keeps the sidecar from serving, which
under failureMode: DENY is a 403 on every opted-in route, so check it with
opa check against the new image before upgrading.
Envoy exports no ext_authz statistics until a route actually uses the filter
(its OTLP stats sink only flushes counters that have been used). The prober chart's
authzCanary gives a cluster one such route and asserts an allow and a deny
decision every cycle — see charts/prober/values.yaml.
cniInstall — CNI installer init container¶
| Key | Default | Purpose |
|---|---|---|
cniInstall.image.* |
repo+digest placeholders, pullPolicy: IfNotPresent |
Digest-pinned image. |
cniInstall.resources.{requests,limits} |
requests cpu 100m / mem 32Mi; limits mem 32Mi, no CPU limit |
No CPU limit by design (#1335, chart 2.4.12; was a 100m limit). The init container runs on every agent pod start, and its work is one short CPU burst (Go start-up plus a byte compare of the plugin on the host against the image's) that a 100m quota — 10 ms per 100 ms period — stretched to 500–800 ms on all 40 agent starts measured on the reference cluster, always a multiple of ~100 ms, for at most 50–80 ms of CPU. The request costs the node nothing (the agent container's 200m is the pod's effective request). To restore a cap set limits.cpu; an empty value (--set cniInstall.resources.limits.cpu=) renders no limit rather than an invalid quantity. See docs/runbook.md, "cni-install CPU limit". |
cniInstall.otlpEndpoint and cniInstall.pinOTLPEndpoint were removed in chart
2.4.0 (#1166): the CNI plugin binary exports no telemetry of its own. It reports
its timings and its capture-divert outcome to the agent over the CNI gRPC socket,
and the agent exports aether_cni_operations_total{aether_cni_operation="capture_divert"}
and the span attributes with its own telemetry (otel.*). A values file that still
sets the two keys is accepted and ignored.
udsCsi — the csi.aether.io CSI node plugin (proposal 039)¶
The only carrier for UDS delivery (034) since 039 Phase 2 (chart 2.0.0),
and on by default: udsCsi.enabled gates all of UDS delivery — this
DaemonSet and the CSIDriver, the agent's --uds-csi-root (rendered from
udsCsi.root), the proxy's read-only HostToContainer mount of udsCsi.root
(its only view of workload sockets; the old /var/lib/kubelet/pods mount and
proxy.udsWorkloads are gone), and the agent's EndpointPolicy watch. On a
node without UDS workloads it is an idle 5m/16Mi DaemonSet. A pod declaring
securityContext.fsGroup: <gid> and volumes: [{name: s, csi: {driver: csi.aether.io}}]
gets a per-pod tmpfs at the volume's mountPath, mounted by the plugin at
<root>/<pod-uid> on the host: nosuid,nodev,noexec,nosymfollow, capped at
size, mode=2770,uid=0,gid=<fsGroup>. A pod without an fsGroup is refused
with a FailedMount event naming the fix (and, if mesh-managed, denied earlier by the
controller's pod webhook). Renders the aether-uds-csi DaemonSet
(privileged; Bidirectional propagation on <kubeletRoot>/pods and on root)
and the cluster-scoped CSIDriver (attachRequired: false, podInfoOnMount:
true, volumeLifecycleModes: [Ephemeral], fsGroupPolicy: File). The plugin
serves the kubelet's plugin-registration API itself (no node-driver-registrar
sidecar), makes no API calls (no RBAC, no token) and does not involve SPIRE.
| Key | Default | Purpose |
|---|---|---|
udsCsi.enabled |
true |
UDS delivery on the csi.aether.io carrier: the DaemonSet, the CSIDriver, the agent's --uds-csi-root, the proxy's read-only mount of root, and the agent's EndpointPolicy RBAC/watch. false renders none of them and passes --uds-csi-root=: pods asking for a socket fall back to TCP, counted as aether.agent.uds.resolve_failures{reason="disabled"}. |
udsCsi.image.* |
repo+digest placeholders, pullPolicy: Always |
The slim uds-csi image (quay.io/aethermesh/uds-csi). |
udsCsi.kubeletRoot |
/var/lib/kubelet |
The kubelet's --root-dir — the only place it appears on the CSI path: --kubelet-root, the CSI socket (<kubeletRoot>/plugins/csi.aether.io/csi.sock), the registration socket (<kubeletRoot>/plugins_registry/csi.aether.io-reg.sock) and the pods dir every target path lives under all derive from it, with hostPath == mountPath. Right for kubeadm, kind and Talos (default root); k0s is /var/lib/k0s/kubelet, microk8s /var/snap/microk8s/common/var/lib/kubelet. Must be absolute (the chart refuses otherwise). |
udsCsi.root |
/run/aether/uds |
Host directory holding the per-pod tmpfs mounts (<root>/<pod-uid>). Under /run so a reboot starts it empty (the kubelet republishes). The single source of truth for the carrier path: the plugin's --root, the agent's --uds-csi-root and the proxy's mount all render from it. Sets the socket-file budget: 107 − len("<root>/") − 36 − 1 = 54 bytes at the default, which is what admission checks. |
udsCsi.size |
1Mi |
Size cap of each per-pod tmpfs (bytes or Ki/Mi/Gi, at most 1Gi). Pages are charged to the writing app's memory cgroup. |
udsCsi.inodes |
64 |
Inode cap (nr_inodes) of each per-pod tmpfs: files, sockets and directories, its root directory included (#1107). Without it the kernel default is half the node's RAM pages' worth, far past anything size bounds. At least 8; the chart refuses less. |
udsCsi.debug |
false |
Debug logging for this daemon only; the global debug does not reach it. |
udsCsi.nodeSelector / udsCsi.tolerations |
{} / [] |
Extra scheduling constraints. The aether.io/agent-not-ready toleration is always rendered. |
udsCsi.resources.{requests,limits} |
requests cpu 5m / mem 16Mi; limits mem 32Mi, no CPU limit |
No CPU limit by design (#1321, chart 2.4.10; was a 100m limit). The plugin serves NodePublishVolume/NodeUnpublishVolume, on the start and termination path of every UDS pod: at 5m/100m it averaged under 1m yet was CFS-throttled in 57.6 % of the 100 ms periods it ran in, because its CPU comes in bursts (a publish, and the 30 s exec liveness probe, a second Go process in the same cgroup). The 5m request is unchanged: it is several times the measured average and is reserved on every node. QoS was and stays Burstable. Set limits.cpu to put a cap back (and see goMaxProcs); an empty limits.cpu renders no limit. No GOMEMLIMIT is set for this container. How to measure throttling: runbook.md § uds-csi CPU throttling. |
udsCsi.goMaxProcs |
2 |
Rendered as the uds-csi container's GOMAXPROCS env var (#1321); the exec liveness probe inherits it. The Go runtime derives it from the CPU limit with a floor of 2 — so 2 is what the old 100m limit gave — and without a limit it would scale to the node's core count. 0 (or empty) omits the env var and lets the Go runtime choose. |
registrar¶
| Key | Default | Purpose |
|---|---|---|
registrar.registryBackend |
kubernetes |
Backend (--registry-backend): kubernetes or etcd. |
registrar.replicaCount |
2 |
Always 2 (exercises the multi-replica write-behind topology). |
registrar.nodeSpread |
soft |
How the replicas are spread across nodes (#1434). soft (default): one topology spread constraint over kubernetes.io/hostname, maxSkew: 1, ScheduleAnyway; a preference the scheduler weighs against its other scores, and a single-node cluster schedules as before. required: the same with DoNotSchedule and nodeTaintsPolicy: Honor; it refuses a node only while another usable node holds fewer replicas, so replicas beyond the node count still schedule (two or three on a single node, run on kind), but a replica stays Pending when the only emptier node has no room, and a NotReady or cordoned node is not counted while it is. Needs Kubernetes >= 1.27 (the render fails below). none: no constraint (write none, not off: unquoted that is YAML false, which the chart rejects). From Kubernetes 1.27 the constraint has matchLabelKeys: [pod-template-hash], so a rollout's old replicas are not counted with the new ones. It acts at scheduling time only: replicas already on one node stay until the next rollout. |
registrar.topologySpreadConstraints |
[] |
Non-empty replaces the constraint registrar.nodeSpread renders (not merged with it). Empty = that constraint. |
registrar.enableMCS |
false |
Multi-Cluster Services phase 1 (018 + 006): export ServiceExports and materialize ServiceImports + clusterset VIPs. Requires the etcd backend + the MCS-API CRDs. |
registrar.region |
local |
Region owning this registrar's etcd partition (006); keys are /aether/v1/regions/<region>/clusters/<clusterName>/…. One region = one etcd. |
registrar.etcd.endpoints |
[] |
etcd client endpoints (etcd backend). |
registrar.peerEtcd |
[] |
Cross-region replication (006 Phase 2), one entry per peer region: "<region>=<endpoint>[,<endpoint>...]". The leader registrar mirrors this region's own registry subtree verbatim into each peer's etcd under an origin-heartbeat lease (TTL ~30s): if this region dies, its mirror expires on the peers — whole-region failover cleanup with no peer-side GC. Requires the etcd backend + a non-default region. |
registrar.service.{port,targetPort} |
443 / 8443 |
gRPC service ports. |
registrar.image.* / registrar.resources.* |
placeholders / cpu 100m, mem 64Mi (requests = limits) |
The CPU limit is kept on purpose (#1321): 1.5 % of CFS periods throttled on the 2026-10-06 soak, not on a request path, and requests == limits keeps the pod QoS Guaranteed. GOMAXPROCS is derived from limits.cpu. |
controller¶
| Key | Default | Purpose |
|---|---|---|
controller.replicaCount |
2 |
Reconcilers and the node-taint guard are leader-elected, but every replica serves the admission webhooks, which are failurePolicy: Ignore: with none answering, a pod in an aether.io/managed namespace is admitted unmeshed. One replica measured a 49 s gap on a leader delete. |
controller.nodeSpread |
soft |
How the replicas are spread across nodes; same values and meaning as registrar.nodeSpread. Until chart 2.4.20 this was a preferred term in the default controller.affinity. |
controller.topologySpreadConstraints |
[] |
Non-empty replaces the constraint controller.nodeSpread renders (not merged with it). Empty = that constraint. |
controller.affinity |
{} |
The controller pod's affinity; empty = none. Until chart 2.4.20 empty meant preferred pod anti-affinity away from the registrars' node; that default is gone because it cancelled the registrar's own spread (#1434; docs/runbook.md, "Chart 2.4.20"). Independent of controller.nodeSpread. |
controller.injectPodNdots |
true |
Pod-mutating webhook injects dnsConfig ndots into managed pods so mesh FQDNs resolve absolute-first (musl/Alpine safety). Pairs with mesh DNS. |
controller.namespaceInjection |
true |
Namespace auto-injection: a pod in a namespace labeled aether.io/managed=true is given the pod label automatically (opt out with aether.io/managed=false). |
controller.webhook.spire |
false |
Webhook serving cert source — decoupled from mesh SPIRE. false = Helm self-signed CA and certificate (works out of the box), generated once, valid ten years, and reused by every upgrade (the chart looks the Secret up in the cluster). true = serve with the controller's SPIRE SVID + inject the trust bundle; no Secret is rendered, and the controller's ClusterRole gains get and update (nothing else since chart 2.4.27, #1456: list, watch and patch were dropped; a controller image older than chart 2.4.19's needs list and watch and hangs without them, #1431) on its webhook configurations: the validating one always, the pod-mutating one whenever it renders (namespaceInjection or injectPodNdots; docs/runbook.md, "The pod-mutating webhook's caBundle is empty"). With false, helm template has no cluster to look in and generates a new pair on every render: see docs/runbook.md, "Rendering the chart reproducibly". |
controller.webhook.certRotation |
"" (never rotate) |
Only with spire=false (#1364, chart 2.4.16). Set it to any new value (a date works) to generate a new CA and serving certificate on the next upgrade. The value is stamped on the Secret (aether.io/webhook-cert-rotation), so later upgrades with the same value reuse the new pair; clearing it rotates nothing and leaves the stamp, so restoring the same value later does not rotate again. After the rotating upgrade the webhooks' caBundle holds the new CA and the old one; the controller loads the new certificate from its mounted Secret without a restart, and the next upgrade renders the new CA alone. Not gap-free: the Secret is patched before the webhook configurations, so an upgrade that fails in between leaves the webhooks failing open until it completes (runbook, "Rendering the chart reproducibly"). |
controller.webhook.clusterSpiffeID.create |
true |
When spire=true, create the controller's ClusterSPIFFEID with the webhook Service DNS SANs (it is rendered only when className is set). false says "I register the controller's identity myself" (a ClusterSPIFFEID or a registration entry of your own that gives its SVID the webhook Service's DNS names): nothing is rendered and nothing is checked. |
controller.webhook.clusterSpiffeID.className |
"" |
spire-controller-manager class name; REQUIRED when spire=true and create=true. Left empty in that case, the chart renders no ClusterSPIFFEID; unless something else registers the identity, the apiserver's TLS hostname check of the webhook then fails and the webhooks fail open (failurePolicy: Ignore) with no error (#1457; read from the templates, the runtime effect was not reproduced). Since chart 2.4.27 that combination fails the render on a cluster that serves the spire.spiffe.io/v1alpha1 ClusterSPIFFEID API, naming the two ways out: set this value, or create=false. Where the API is not served, and in a render that is not told what the cluster serves (helm template without --api-versions), it still renders, and the install notes carry a warning. |
controller.webhook.identityGate.enabled |
true |
Egress identity gate (#1053): the pod-mutating webhook injects the aether-identity-ready init container (first in line) into every mesh pod it admits; it holds the app containers until SPIRE has issued the pod's X.509 SVID, so no request leaves before the pod has a client certificate (otherwise 503 UF for the first seconds). Asks the Workload API over a csi.spiffe.io volume mounted into the init container only. The controller is passed --identity-gate=false, and none of the gate's other flags, with spire.enabled=false and when the /mutate webhook it rides does not render (namespaceInjection and injectPodNdots both off, #1432). Opt a pod out with aether.io/identity-gate: "false". |
controller.webhook.identityGate.image.* |
empty = agent.image |
Image running /identity-ready (an extra layer of the agent image, already on every node). Because it is passed to the controller as --identity-gate-image, a new agent image digest also rolls the controller Deployment (#1428; docs/runbook.md, "Which workloads a chart upgrade rolls"). |
controller.webhook.identityGate.pullPolicy |
IfNotPresent |
The agent image is digest-pinned and already pulled by the agent DaemonSet. |
controller.webhook.identityGate.timeout |
"" (wait forever) |
Go duration after which the init container gives up (exit 1; the kubelet retries with backoff). Empty = fail closed: the pod stays in Init until the SVID exists. |
controller.webhook.identityGate.resources |
req cpu 5m mem 16Mi, limit mem 64Mi |
Init container resources; an empty value leaves that entry unset. |
controller.image.* / controller.resources.* |
placeholders / requests cpu 50m, mem 64Mi; limits cpu 100m, mem 64Mi |
The CPU limit is kept on purpose (#1321): 1.0 % of CFS periods throttled on the 2026-10-06 soak, and a throttled period adds under 100 ms to an admission call with a 5 s timeout. GOMAXPROCS is derived from limits.cpu. |
edge — north-south ingress gateway (proposals 003/018/021/028)¶
An unprivileged Deployment (Envoy + agent edge) that dials mesh pods directly
over mTLS and routes external traffic via the Gateway API. Disabled by default.
| Key | Default | Purpose |
|---|---|---|
edge.enabled |
false |
Deploy the edge. |
edge.namespace |
aether-ingress |
The edge runs in its own namespace, isolated from the control plane. |
edge.namespaceCreate |
true |
Let the chart create it (baseline PSA). Since chart 2.4.25 a namespace the chart creates carries aether.io/edge-meshconfig-in-manifest: "true" (with meshConfig.createDefault on), as does the MeshConfig in it, and the edge's MeshConfig is then rendered on every revision instead of seeded once, so helm rollback works to the revision that created the namespace (#1514). The chart sets no field of its spec, so your edits are kept. A namespace an older chart created is left unmarked. See runbook.md, "Chart 2.4.25". |
edge.replicaCount |
2 |
Gateway replicas (standard RollingUpdate + readiness gate; no hot-restart supervisor). |
edge.nodeSpread |
soft |
How the gateway replicas are spread across nodes; same values and meaning as registrar.nodeSpread. With the surge-free default rollout (edge.rollingUpdate) the first new replica is placed without regard to the old one still running, so both live replicas can share a node for the length of the roll. |
edge.topologySpreadConstraints |
[] |
Non-empty replaces the constraint edge.nodeSpread renders (not merged with it). Empty = that constraint. |
edge.rollingUpdate |
{} |
Empty = chart default by replicaCount (#812): ≥ 2 replicas maxSurge: 0, maxUnavailable: 1; exactly 1 maxSurge: 1, maxUnavailable: 0. Set to replace it entirely. |
edge.gatewayClassName |
aether |
The GatewayClass whose Gateways this edge serves (controller gateway.aether.io/edge). Requires the Gateway API CRDs. |
edge.gateway.create |
true |
Chart-manage a Gateway of that class (HTTP + optional HTTPS listeners). |
edge.gateway.tlsSecretName / tlsSecretNamespace |
"" |
The kubernetes.io/tls Secret for the downstream cert; REQUIRED when tls.enabled + gateway.create. |
edge.gateway.address |
"" |
Pin the Gateway's LoadBalancer IP (021 Phase 2, via MetalLB). Empty = auto-assign. |
edge.gateway.hostname |
"" |
Constrain the chart-managed Gateway's listeners (e.g. "*.example.com"). |
edge.gateway.httpRoutes |
[] |
Declaratively managed HTTPRoutes parented to the chart Gateway (the supported replacement for hand-applied manifests). |
edge.tls.enabled |
false |
Downstream TLS: HTTPS listener (certs per Gateway listener via SDS) + HTTP→HTTPS redirect. The edge→pod hop stays mTLS. |
edge.tls.secretNamespaces |
[] |
Namespaces whose kubernetes.io/tls Secrets the edge may read: a Role + RoleBinding in each (they must exist at install), and the edge watches Secrets only there (--secret-namespaces). A certificateRef elsewhere does not resolve. Empty = the edge namespace; edge.gateway.tlsSecretNamespace is always added. Since 3.1.0. |
edge.tls.clusterWideSecrets |
false |
Opt-in to the pre-3.1.0 grant: Secrets in every namespace through the edge's ClusterRole (anyone who can run a pod as the edge's ServiceAccount reads every Secret). For Gateways whose namespaces are not known at install, such as the conformance suite. |
edge.geoip.enabled |
false |
Emit x-geo-* request headers from a MaxMind DB (028). The x-geo-* namespace is always stripped from client requests. |
edge.geoip.headers |
[country] |
Which headers to emit: country, city. |
edge.geoip.database.secretName / fileName |
"" / GeoLite2-City.mmdb |
The bring-your-own mmdb Secret + key. |
edge.xffNumTrustedHops |
0 |
Trusted proxies in front of the edge (feeds HCM client-address + geoip XFF). |
edge.httpPort / httpsPort |
80 / 443 |
Public listener ports (privileged ports via NET_BIND_SERVICE; pod stays unprivileged). |
edge.routeNamespace |
"" |
Namespace the edge watches Gateways/HTTPRoutes in. Empty = its own namespace. |
edge.service.port |
80 |
HTTP port of the shared edge Service. That Service is always ClusterIP: each Gateway gets its own LoadBalancer Service (021 Phase 2), so there is no type value. |
edge.service.httpsPort |
443 |
HTTPS port of the shared Service; rendered only with edge.tls.enabled. |
edge.service.extraPorts |
[] |
Extra Service ports for Gateway TCP/TLS listeners (TCPRoute/TLSRoute), e.g. [{name: postgres, port: 5432, protocol: TCP}]; protocol defaults to TCP. |
edge.drain.preStopSeconds |
10 |
preStop sleep holding off SIGTERM during drain (matches proxy.hotRestart.drainTime). 0 disables. |
edge.drain.terminationGracePeriodSeconds |
30 |
Must exceed preStop + Envoy drain. |
edge.admin.enabled |
false |
Envoy admin endpoint, bound to 127.0.0.1 only and never exposed via a Service (an unauthenticated control surface). Reach it with kubectl port-forward. |
edge.admin.port |
9901 |
Loopback admin port. |
edge.overload.enabled |
true |
Envoy overload manager on the fixed_heap monitor (shrink heap, then stop accepting requests, before the container memory limit OOM-kills Envoy). |
edge.overload.maxHeapSizeBytes |
201326592 (192Mi) |
Keep at ~75% of edge.resources.limits.memory. |
edge.spire.clusterSpiffeID.create |
true |
Create the edge's ClusterSPIFFEID (when spire.enabled) so SPIRE issues the edge pod its SVID. |
edge.spire.clusterSpiffeID.className |
"" |
spire-controller-manager class name; required when create=true (empty = not rendered; manage the ClusterSPIFFEID yourself). |
edge.resources.{requests,limits} |
requests cpu 200m / mem 128Mi; limits mem 256Mi, no CPU limit |
Applied to both containers of the edge pod (agent and envoy). |
edge.goMaxProcs |
2 |
Rendered as the edge agent container's GOMAXPROCS env var (#1335), the same shape as agent.goMaxProcs (#1116). The container has no CPU limit, so the Go runtime would otherwise size itself to the node's core count; it averages under 2m of CPU (4.4m over its busiest 5 minutes on the reference cluster), so 2 is not a constraint. The envoy container is not a Go process. 0 (or empty) omits the env var and lets the Go runtime choose, e.g. when you set an explicit CPU limit. |
edge.concurrency |
2 |
Envoy worker thread count for the edge pod's envoy container (#1344, chart 2.4.13), the same shape as proxy.concurrency: N > 0 passes --concurrency N on the container's command line (the edge runs Envoy directly, with no supervisor), 0 passes no flag and Envoy picks its own default, which is what earlier charts did: the smallest of the node's online CPUs, the CPUs the container's affinity mask allows, and the container's cgroup CPU limit (edge.resources.limits.cpu) rounded down to a whole CPU, never less than 1 — one worker per node core only when the container has no CPU limit and no restricted mask. The default is the count the node proxies run with on the reference cluster; the edge Envoy averages about 12m of CPU there. Raise it for an edge that carries real traffic volume. Changing it is an ordinary rolling update: the edge has no hot restart, so none of proxy.concurrency's caveats apply. A negative or non-integer value fails the render. |
Labelling and annotating a Gateway's LoadBalancer Service¶
Every Gateway of the edge's class gets one generated resource: its own
LoadBalancer Service in the edge namespace (edge.namespace), named
<edge>-gw-<gateway namespace>-<gateway name> (hashed when longer than 63
characters) and labelled aether.io/edge-gateway. The edge pods and their
ServiceAccount belong to the one shared Deployment and serve every Gateway, so
nothing of a single Gateway is put on them.
The Gateway API fields spec.infrastructure.labels and
spec.infrastructure.annotations are copied to that Service. This is how a
Gateway's owner reaches the load-balancer controller without write access to the
edge namespace:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: web
namespace: apps
spec:
gatewayClassName: aether
infrastructure:
labels:
team: payments
annotations:
# whatever your load-balancer controller reads on a Service, for example
service.beta.kubernetes.io/aws-load-balancer-scheme: internal
metallb.universe.tf/address-pool: internal
listeners:
- name: http
port: 80
protocol: HTTP
- Updates and removals follow the Gateway. Changing a value changes it on the Service; removing a key from the Gateway removes it from the Service. No pod is restarted: only the Service's metadata is written.
- Nothing else on the Service is touched. Labels and annotations set by
another controller or by hand stay, whether or not the Gateway sets any. The
edge records the keys it copied in the Service annotation
aether.io/infrastructure-keysand removes only those. If the Gateway sets a key that is already on the Service, the Gateway's value wins and the key is the Gateway's from then on. - Refused keys. A key is not copied when its prefix is
aether.ioor a subdomain of it (the edge's own label decides which Services it garbage-collects, and the record above decides what it removes), when its prefix isgateway.networking.k8s.ioor a subdomain of it (those labels state facts about generated resources that would not be true here), or when it ismetallb.universe.tf/loadBalancerIPs(the address pin is set from the Gateway'sspec.addresses, and the edge removes that annotation whenspec.addressesis empty). A label whose key or value the API server would reject is not copied either. The rest of the Gateway's keys are still applied, and the Gateway staysAccepted=True: the refused keys are named in that condition's message and in the edge log. - What an annotation does is up to the controller that reads it. Some
load-balancer annotations are only read when the Service is created, and some
change the address; the Gateway's
status.addressesfollows whatever the Service is assigned. - A Gateway without
spec.infrastructurelabels or annotations gets the same Service as before, with no extra annotation. - No per-Gateway Service, nothing copied. When the edge runs without
--edge-service-name, or cannot allocate a per-Gateway port, Gateways share the edge's own Service and these fields have no effect.
spec.infrastructure.parametersRef is the third field of that struct; it
attaches an EdgeConfig (next section).
edge.config — the fleet-default EdgeConfig (proposal 029)¶
This block renders one EdgeConfig CR (edge-defaultconfig.yaml) that the
chart's GatewayClass points at through its parametersRef, so every Gateway
of that class inherits it. Override per-Gateway with your own EdgeConfig via
Gateway.spec.infrastructure.parametersRef (override-wins merge). Every field is
optional in the CRD and has a compiled best-practice default — the values below
just surface those defaults for discoverability and tuning.
edge.xffNumTrustedHops (above) also feeds this CR's xffNumTrustedHops; it
lives outside the block because it is a topology fact shared with the geoip
filter.
| Key | Default | Purpose |
|---|---|---|
edge.config.name |
"" |
Name of the generated EdgeConfig; empty means aether-edge-defaults. |
edge.config.useRemoteAddress |
true |
The edge treats the immediate downstream connection address as the client and manages XFF from it. Correct for an internet-facing edge; XFF is forgeable otherwise. |
edge.config.headersWithUnderscoresAction |
HEADERS_WITH_UNDERSCORES_ACTION_REJECT_REQUEST |
Header-smuggling defence. Canonical enum names (buf ENUM_VALUE_PREFIX): …_ALLOW, …_REJECT_REQUEST, …_DROP_HEADER. |
edge.config.streamIdleTimeout |
300s |
Bound on an idle stream (slowloris). |
edge.config.requestTimeout |
300s |
Bound on the whole request; 0s disables. |
edge.config.idleTimeout |
3600s |
Downstream connection idle timeout. |
edge.config.perConnectionBufferLimitBytes |
32768 (32 KiB) |
Listener + edge-cluster buffer cap. |
edge.config.http2.maxConcurrentStreams |
100 |
Downstream HTTP/2 concurrent-stream cap (malicious-client protection). |
edge.config.http2.initialStreamWindowSize |
65536 (64 KiB) |
Downstream HTTP/2 per-stream flow-control window. |
edge.config.http2.initialConnectionWindowSize |
1048576 (1 MiB) |
Downstream HTTP/2 per-connection flow-control window. |
edge.config.http3.enabled |
false |
Add the QUIC/HTTP3 UDP listener on the HTTPS port plus alt-svc advertisement (029 M3). |
2. CRDs (charts/crds)¶
All are config.aether.io/v1, Namespaced, structural-but-permissive
(x-kubernetes-preserve-unknown-fields); authoritative validation is the
controller's protovalidate webhook, not OpenAPI. All carry
helm.sh/resource-policy: keep.
| CRD | Kind (short) | Purpose |
|---|---|---|
meshconfigs.config.aether.io |
MeshConfig (mc) |
Per-namespace proxy observability overrides (access logs, tracing, per-pod stats). A namespace inherits the control-plane namespace's MeshConfig field-by-field unless it sets its own (proposal 015). |
httpfilters.config.aether.io |
HTTPFilter (htf) |
The proxy-extension escape hatch (proposal 025): attach a supported Envoy HTTP filter (ext_authz, RBAC, header-to-metadata) at a chosen scope. |
edgeconfigs.config.aether.io |
EdgeConfig |
Edge Envoy tuning (proposal 029): best-practices hardening defaults, HTTP/3 (QUIC, ALPN h3), timeouts/limits. Attached natively via Gateway API parametersRef on the GatewayClass (fleet default) or per-Gateway (override-wins merge). |
endpointpolicies.config.aether.io |
EndpointPolicy |
Service-scoped UDS delivery (proposal 034 Phase 1b): spec.targetRef (kind=Service, same namespace) + spec.udsSocket (<volume>/<file>) declares socket delivery for every pod of a service; <volume> must be the pods' csi.aether.io volume (039), the file at most 54 bytes. The per-pod endpoint.aether.io/uds-socket annotation wins; one policy per Service (lexicographically smallest name wins). Read by the agent only when udsCsi.enabled (default). |
HTTPFilter scopes (spec.scope, plus the target_refs attachment):
| Scope | Attachment | Applies to |
|---|---|---|
SCOPE_ROUTE (default) |
Gateway API ExtensionRef on an HTTPRoute/GRPCRoute rule | that single route |
| (targetRef) | spec.targetRefs (kind=Service) |
every route of the Service |
SCOPE_CHAIN |
Service targetRef, always-on | the service's capture vhost (one per service; rides the 026 cross-cluster channel) |
SCOPE_INBOUND |
Service targetRef, destination-side | the target service's own pods' inbound listeners (not propagated cross-cluster) |
3. CLI flags¶
Every binary also gets the shared manager flags
(common/manager/flags.go): --debug, --metrics-enabled,
--metrics-bind-address, --otel-enabled, --otlp-endpoint, --logs-enabled,
--trace-sample-rate, --trace-export. (The chart sets these from the values
above.)
agent (node agent — root command)¶
Identity/registrar/SPIRE: --mesh-config (/etc/aether/mesh-config.yaml),
--mesh-domain (aether.internal), --spire-enabled (true), --node-name
(required; doubles as the xDS node identity — the old --proxy-id was retired),
--cluster-name (required),
--registrar-address (aether-registrar.aether-system.svc:443),
--spire-workload-socket, --spire-wait-warn-after (2m).
--spire-wait-warn-after (also on registrar, controller and agent edge;
chart key spire.waitWarnAfter) is how long the wait for this workload's first
SVID stays at INFO before the waiting log line escalates to WARN. Since #740 no
component dies on a missing SPIRE — it retries in the background — so this is a
logging threshold everywhere except the node agent, where it doubles as the
dwell before the spire-svid readiness check reports NotReady. See
runbook.md § The agent is stuck waiting for SPIRE.
Node-agent-specific:
| Flag | Default | Purpose |
|---|---|---|
--mounted-registry-dir |
/host/var/lib/aether/registry |
Local pod-data dir for the CNI plugin. |
--node-lock |
/run/aether/agent.lock |
Node-ownership lock (proposal 041). The agent that owns the node holds an exclusive flock on it for its life. An agent that finds it taken (a surge roll) is a standby: it builds its first snapshot but binds neither xds.sock nor cni.sock and writes no node file (observed upstreams, mesh-DNS snapshot, conflist repair, storage) until the lock is released. It then applies the CNI ADD/DEL served meanwhile and binds. Must be on a host path every agent pod on the node shares. Empty disables the lock. |
--health-socket |
"" |
Serve /healthz and /readyz on this Unix socket for the agent-ready exec probe (041). Must be in the pod's own filesystem (the chart uses /tmp/aether-agent-health.sock in the tmp emptyDir), so the kubelet is never answered by a surge peer on the same hostNetwork node. Empty serves no socket. |
--health-probe-bind-address |
:8082 |
TCP /healthz and /readyz. 0 disables it; the chart passes 0 and probes the socket. Metrics (--metrics-bind-address, :8080) bind only once the agent owns its node. |
--uds-csi-root |
/run/aether/uds |
Host directory under which the csi.aether.io plugin mounts each UDS pod's tmpfs, mounted into the proxy at the identical path; the proxy dials <root>/<pod-uid>/<file> (034/039). Must match the plugin's --root (the chart renders both from udsCsi.root). Empty disables UDS delivery: pods requesting a socket fall back to TCP loopback (resolve_failures{reason="disabled"}). Replaced --kubelet-pods-dir (039 Phase 2). |
--spire-broker-socket |
/run/spire/broker-sockets/broker.sock |
SPIRE agent's SPIFFE Broker Endpoint socket, over which the agent brokers an X.509-SVID for every pod on its node (036). Replaced --spire-admin-socket; requires SPIRE >= 1.15.2 with its experimental broker enabled. |
--gamma |
true |
GAMMA east-west routing (018); default-on kill switch (031). CRD-detected. |
--cni-conflist-reassert |
true |
Re-assert the chained aether-cni entry in the node's active CNI conflist whenever a competing writer strips it (#645). Watches --mounted-cni-net-dir (fsnotify) plus a 60s re-check; only ever appends to an existing, valid conflist that still carries a primary CNI plugin. |
--mounted-cni-net-dir |
/host/etc/cni/net.d |
Host CNI config dir as mounted into the agent (read-write) for the re-assert loop. |
--import-config |
false |
Enable cross-cluster config import (026). |
--control-cluster |
"" |
Trust imported config ONLY from this origin (026 EM3). Empty = federated. |
--east-west-waypoint |
false |
Per-node east/west waypoint for cross-cluster traffic (019); tunnel port is the fixed constant 18009. |
--service-cidrs |
"" |
The cluster's Service range as comma-separated CIDRs (proposal 044); overrides the ServiceCIDR objects, which are then not watched. Empty discovers them. A value that is not a CIDR fails the agent at start; IPv6 entries are ignored. It does not suppress the ClusterSetIPs of MCS ServiceImports: those outside the range are added as /32s either way. Rendered from agent.serviceCIDRs. |
--east-west-quic-pair-fetch-window |
1h |
How long after the agent starts a persisted (source ServiceAccount, destination) QUIC pair with no evidence of use is kept before the pair and its twin are pruned (issues #1033, #1073). Evidence of use is an on-demand fetch of the quic: twin, an on-demand subscription for it, or the proxy re-stating the twin as held on a fresh xDS stream, in this or any earlier agent process (demand_confirmed in the persisted set). A pair with any of it is never pruned by this window. A pruned pair that still carries traffic is re-fetched on its next request (one ODCDS round trip). 0 disables the prune. Not exposed in the chart; the default applies. See runbook.md § East-west QUIC (proposal 038 Phase 4), the "Post-start prune of pairs with no evidence of use" paragraph. |
--east-west-quic-idle-timeout |
8s |
Idle timeout of each quic: twin's upstream connection pool (#1054); h1/h2 keep 30 s. Must be > 0. Keep it at least 5 s below the proxy's --parent-shutdown-time: the agent cannot check that (it never sees the proxy DaemonSet), so the chart does — agent.eastWestQuicIdleTimeout + 5s < proxy.hotRestart.parentShutdownTime or the chart fails to render. The twin's QUIC transport idle (8 s, #1087) closes a connection with no open stream after 8 s anyway, so values above 8 s no longer lengthen reuse. |
--mesh-dns |
false |
Per-pod mesh DNS (018): answer <svc>.<ns>.<mesh-domain> from the generated mesh Services. Upstream forwarding belongs to the mesh-dns daemon, not the agent. |
--mesh-dns-snapshot-path |
/host/var/lib/aether/registry/mesh-dns/records.json |
Host-persistent record table the in-process resolver writes and warm-loads at boot (and the mesh-dns daemon watches). Under the CNI registry hostPath so it survives a rolling restart; empty disables persistence. |
--authz-sidecar |
false |
Node-local ext_authz sidecar entry (027). |
--authz-sidecar-timeout |
200ms |
Per-check gRPC timeout. |
--authz-sidecar-failure-mode-allow |
false |
Fail-open (default: fail-closed). |
The chart's booleans (
agent.gamma,agent.meshDns,agent.captureRedirectAllDefault, …) map to these flags. Transparent capture, the redirect-all passthrough chain, L4 route types, and the startup-taint removal are unconditional since proposal 031 (no flags).
agent edge (subcommand)¶
Inherits the manager + identity flags (but --node-name is relaxed, derived
from POD_NAME). Adds: --edge-http-port (80), --edge-https-port
(443), --edge-tls (false), --gateway-class (aether),
--route-namespace ("" — the default namespace for Gateway TLS Secrets;
watching is cluster-wide), --edge-service-name (""), --geoip-city-db
(""), --geoip-headers ([country]), --xff-num-trusted-hops (0).
The readiness listener is the fixed port 18021 (030 constant), per-Gateway
addressing is unconditional (021 Phase 2), and the empty local store lives at
the fixed pod-local path.
proxy-supervisor (standalone binary — the aether-proxy container's PID 1)¶
The Envoy hot-restart supervisor (proposal 001). It was agent proxy-supervisor
until #772: as a subcommand it made the proxy pod stage and run the whole 65MiB
agent binary — controller-runtime, client-go, go-control-plane, SPIRE, Gateway
API, miekg/dns — to fork a child process. It is now its own binary
(//agent/cmd/proxy-supervisor, about 16MiB; go version -m on the built
binary lists its modules, and //agent/cmd/proxy-supervisor:deps_test holds
what it may link and its size ceiling) in its own image
(quay.io/aethermesh/proxy-supervisor), which also means the proxy
DaemonSet no longer depends on the agent image at all. The agent
proxy-supervisor alias that bridged the split for one release has been removed,
along with the /proxy-ready copy in the agent image that only it read.
Flags (every name and default unchanged by the split — the chart passes them
literally): --envoy-path
(/usr/local/bin/envoy), --config (/etc/envoy/envoy.yaml), --base-id (0),
--drain-time (45s), --parent-shutdown-time (60s), --watch-config
(true), --state-dir (/run/aether/hotrestart), --ready-marker, --envoy-arg
(repeatable), --handoff-deadline/--admin-unresponsive-deadline (0 = defaults),
--termination-grace (0), --shutdown-drain-immediately (false),
--admin-address (127.0.0.1:9901), --install-path,
--install-readiness-path, --otlp-endpoint.
--envoy-arg cannot repeat an Envoy flag the supervisor passes itself: -c /
--config-path (set from --config), --base-id (from --base-id, chart
proxy.hotRestart.baseId), --drain-time-s (from --drain-time, chart
proxy.hotRestart.drainTime), --parent-shutdown-time-s (from
--parent-shutdown-time, chart proxy.hotRestart.parentShutdownTime),
--restart-epoch, --admin-address-path and --mode. Envoy refuses a flag
given twice (Argument already set!), so each of these is a startup error that
names the flag instead of a failure of every fork.
The supervisor checks --envoy-arg once, before the first fork, against what the
pinned Envoy was measured to do. It reads the list the way that Envoy's parser
does (#1443): a flag, then its value in the next item when the flag takes one. It
knows which flags take a value because it carries the pinned Envoy's flag list,
which a test holds against envoy --help of the binary the chart deploys. Two
things follow. The item after a flag that takes a value is always read as that
value and never mistaken for another flag: --service-node -c names a node
-c, and a --log-format of --x=y is a format. And a flag is found
wherever it stands, in any spelling Envoy accepts, including the one-item form
with a space (--envoy-arg="--socket-path @x"), which Envoy does accept.
Besides the flags above it refuses:
- A flag and its value joined by
=or glued together. The pinned Envoy reads a value from the next argument (or after a space in the same one), and not after=or glued to a short flag.--concurrency=2,--service-node=n1,-l=infoand-linfoall answerCouldn't find match for argument. Write--envoy-arg=--concurrency --envoy-arg=2, as the chart does. The error shows the two items for the argument it refused. A flag that takes no value goes alone:--skip-hot-restart-parent-stats=trueis refused too, and the fix is the single item--envoy-arg=--skip-hot-restart-parent-stats(a second itemtruewould be refused by Envoy). - A flag the pinned Envoy does not have (
--log-levle,-x), and an argument that is not a flag where no flag takes it as its value (trueafter--cpuset-threads, a second word after-l info). Envoy answersCouldn't find match for argumentfor both. - A flag that takes a value, standing last (
Missing a value for this argument!). - Flags that break a handoff or stop Envoy from serving. The supervisor does not pass these, so they are not repeats, and Envoy would start:
| Flag | Why it is refused |
|---|---|
--use-dynamic-base-id |
Envoy ignores the fixed --base-id and picks a random one, so no successor finds its shared memory and hot-restart socket. Envoy also refuses the flag at any restart epoch above 0, which is every hot restart. |
--disable-hot-restart |
A successor never contacts its predecessor: the old Envoy is neither drained nor stopped, and no socket is handed over. |
--socket-path |
The two Envoys of a handoff meet on Envoy's default hot-restart socket, an abstract one in the host network namespace. If they disagree on the path the successor loops on connection refused, and a path on a pod's own filesystem is not shared with the next pod. |
--hot-restart-version, --version, -h / --help |
Envoy prints and exits 0 without serving, on every fork. Envoy also reads an h anywhere in a single-dash item as -h (-xh, -lwhatever), so such an item is refused as well. |
-- / --ignore_rest |
Envoy ignores every argument after it, so nothing written behind it is applied (a --concurrency there would never take effect), and nothing behind it is refused when it is wrong. |
- A flag given twice. Envoy refuses any flag given twice except
--stats-tag, in any mix of its spellings (-lwith--log-level). The chart already passes--concurrency,-l/--log-level,--service-cluster,--service-node,--service-zone,--drain-strategyand--skip-hot-restart-parent-stats(--concurrencyforproxy.concurrency,--skip-hot-restart-parent-statsforproxy.hotRestart.skipParentStats), so one more of any of those is a repeat. - A
--concurrencywhose value Envoy cannot use. The value is the next item, whatever it looks like. A missing value, a non-number (x,1.5,0x2), a number above 4294967295 and another flag standing in its place are refused, as Envoy refuses them. Four kinds of value Envoy accepts are refused too: an empty one (Envoy runs its default worker count as if the flag were absent), a negative one (Envoy reads-1as 4294967295 workers), one with a sign or a space (+2,2) and one above 2147483647 (no Envoy starts that many workers).--concurrency 0is accepted: Envoy runs one worker for it, not its default count, and the supervisor counts it as 1 when it compares worker counts at a handoff. (The chart never renders it:proxy.concurrency: 0passes no flag.)
Other hot-restart flags are passed through. --base-id-path only writes the base
id Envoy uses to a file (with the fixed --base-id the file holds that number at
every epoch, and a handoff completes). --skip-hot-restart-on-no-parent only
changes what a child does when its parent is already gone. --socket-mode is not
read while the socket is the abstract default. --cpuset-threads does nothing in
the pinned Envoy.
Envoy accepts an empty item and a lone - and ignores them, so the check does
too.
Limits of the check. It checks how the list parses, not what a value means: a log
level Envoy does not know, a --drain-strategy that is neither gradual nor
immediate, or a non-number for --file-flush-interval-msec still fails at the
fork. And the flag list is the pinned Envoy's. A supervisor paired with another
Envoy build refuses a flag only that build has; the supervisor image and the
proxy image the chart pins are tested together.
--termination-grace is the pod's own terminationGracePeriodSeconds (the chart
passes the same value it sets on the pod spec; 180s as deployed). The supervisor
cannot read it from the API, and it is the only non-arbitrary bound on how long a
SIGTERM may wait for a successor before the kubelet's SIGKILL settles the matter.
The wait budget is terminationGrace − (drainTime + 5s) − 10s, i.e. 155s at the
deployed values; 0 means "unknown", which keeps the wait unbounded (the
pre-#771 behaviour, and what every chart predating the flag gets). A grace period
that leaves no budget (not greater than drainTime + 15s) is unbounded in the same
way, and so is the hold of a termination that arrives while the supervisor replaces
a predecessor it has asked to drain; the chart refuses to render one since 2.6.2
(docs/runbook.md, "Chart 2.6.2").
--shutdown-drain-immediately is the escape hatch for a deployment where no
replacement pod can overlap this one — a DaemonSet without maxSurge, or a
single-instance proxy. Leave it off wherever a surge replacement exists. On
SIGTERM with Envoy still serving at our own epoch, the default is to keep serving
and wait (bounded as above) for the replacement's Envoy to hot-restart ours; that
wait is the entire reason kubectl delete pod — and therefore node drain,
eviction and preemption — costs the node's data plane nothing. Turning it on
trades that for a prompt, still-graceful drain: POST /drain_listeners?graceful,
wait --drain-time, then SIGTERM. It never restores the bare SIGTERM that #795
was filed for. The chart does not set it.
--install-path and --install-readiness-path are the initContainer's staging
mode: the first self-copies this binary to the shared volume as the supervisor,
the second copies the bundled /proxy-ready prober out of the same image. A
requested --install-readiness-path against an image that does not carry one is
a hard failure, so image skew surfaces in the initContainer rather than as a pod
that can never become Ready. Since #772 both binaries ship in the
proxy-supervisor image and are built from one commit, so that skew is no longer
reachable through the supported path; the check stays as a guard.
The supervisor is not its own readiness probe. The pre-#673 --readiness-check
exec-probe mode (re-execing a supervisor binary every 2s per pod spent >=31% of
the supervisor container's CPU on Go package init alone — measured when that
binary was the agent's 67MB one) was deprecated by #673 and has been removed;
the chart execs the standalone proxy-ready binary below.
proxy-ready (standalone binary — bundled in the proxy-supervisor image, not run from it)¶
The aether-proxy pod's exec readiness probe (#673). One flag: --ready-marker
(/var/run/aether-proxy/ready); exit 0 iff that path stats. It is deliberately
stdlib-only (~1.7MB vs the agent's 67MB) — it imports nothing but
common/readymarker, and //agent/cmd/proxy-ready:deps_test fails the build if
that ever changes. It ships as an extra layer in the proxy-supervisor image (no
second pull: the install-supervisor initContainer, which already runs that
image, copies it onto the proxy pod's shared volume at
/opt/aether/proxy-ready). It is not in the agent image: the copy there
served only the removed agent proxy-supervisor alias.
The probe stays an exec probe on the pod-local marker rather than an
httpGet/tcpSocket: the proxy DaemonSet is hostNetwork: true with
maxSurge: 1, so predecessor and successor share the host netns for the whole
handoff and no port-based check is provably pod-local (the reason #582 was closed
for mesh-dns). Envoy's admin endpoint is not an equivalent target either — a
draining hot-restart parent answers LIVE at its old epoch for the entire
--parent-shutdown-time-s window (proposal 001, lesson 6), and the supervisor
deliberately holds readiness while it is still the serving parent.
mesh-dns (standalone daemon — its own binary and image)¶
The aether-mesh-dns DaemonSet (#578, #583). It answers <svc>.<ns>.<mesh-domain>
from the snapshot file the agent writes and forwards everything else upstream, so
agent rolls never gap pod DNS. It does not share the agent's flag set:
--snapshot-path (/host/var/lib/aether/registry/mesh-dns/records.json),
--mesh-domain (aether.internal), --mesh-dns-upstream (repeatable,
host[:port]; empty = /etc/resolv.conf), --ready-marker
(/run/aether/mesh-dns.ready),
--forward-pool-size (8), --lame-duck-max (10s), --otlp-endpoint,
--debug.
It binds UDP+TCP on the host at port 18054, which the CNI DNATs each managed
pod's :53 to.
--lame-duck-max (#729) is the longest this resolver keeps serving after
SIGTERM before closing its SO_REUSEPORT listeners. It stops reporting ready
immediately and closes as soon as it observes a successor answering on the same
address (a TXT identity stamp carrying someone else's value proves the successor
is both in the reuseport group and serving), so the ceiling only applies when no
successor appears — a scale-down, a node drain, or a failed surge. The chart
derives the DaemonSet's terminationGracePeriodSeconds as this value + 5s, so
raising it cannot silently leave the kubelet SIGKILLing mid-window. 0 disables
the window and closes on SIGTERM, which dropped one queued datagram on every
roll before #729. Grade a roll on
aether_mesh_dns_lame_duck_exits_total{reason} — a healthy roll is successor
on every node (see runbook.md § Grading the mesh-DNS lame-duck
handoff across a roll).
The daemon is not its own readiness probe. The pre-#683 --readiness-check
exec-probe mode (re-execing this 16.9MB daemon every 15s per pod spent ~10
core-seconds per 25 minutes fleet-wide, ~3-4% of the container's CPU, on container
exec and Go package init alone) was deprecated by #683 and has been removed; the
chart execs the standalone mesh-dns-ready binary below.
--debug only raises the log level (Info to Trace); it gates no feature and no
data-path behaviour. The mesh-DNS forward path logs nothing per query at any
level — the resolver's only Debug-level call site is the snapshot reload. The
chart nonetheless defaults it off for this daemon (#684) via its own
agent.meshDnsDaemon.debug key: the global debug: true deliberately no longer
reaches mesh-dns, because every record it emits is also fanned out to the OTLP
log exporter and this is the one component on every managed pod's :53 path.
Turn it on for a diagnosis with --set agent.meshDnsDaemon.debug=true.
mesh-dns-ready (standalone binary — bundled in the mesh-dns image)¶
The aether-mesh-dns pod's exec readiness probe (#683). One flag:
--ready-marker (/run/aether/mesh-dns.ready); exit 0 iff that path stats. It
is deliberately stdlib-only (~1.7MB vs the daemon's 16.9MB) — it imports nothing
but common/readymarker, and //agent/cmd/mesh-dns-ready:deps_test fails the
build if that ever changes. It ships as an extra layer in the mesh-dns image, the
image the DaemonSet already runs, so there is no second pull and no chart/image
skew is possible: the prober and the daemon that writes the marker are the same
artifact.
Like proxy-ready, it stays an exec probe on the pod-local marker rather
than an httpGet/tcpSocket: this DaemonSet is hostNetwork: true with
maxSurge: 1, so predecessor and successor share the host netns for the whole
handoff and a port-based check could be answered by the peer pod's SO_REUSEPORT
socket. That is precisely what #582 proposed and why it was closed abandoned.
agent-ready (standalone binary — bundled in the agent image, the agent pod's exec probe)¶
The aether-agent container's liveness and readiness probe (proposal 041). It
dials the agent's --health-socket, sends one GET <path>?verbose, and exits 0
iff the agent answers 200. On failure it prints the response body, which names
the failing check (the kubelet records it in the probe event). Stdlib-only and
without net/http. //agent/cmd/agent-ready:deps_test asserts that the linked
binary's build info lists no module at all: it is exec'd every 2 s on every node.
| Flag | Default | Purpose |
|---|---|---|
--socket |
/tmp/aether-agent-health.sock |
The agent's health socket. |
--path |
/readyz |
/readyz (readiness) or /healthz (liveness). |
--timeout |
1s |
Bound on the whole exchange. |
identity-ready (standalone binary — bundled in the agent image, run as an injected init container)¶
The egress identity gate (#1053): the controller's /mutate webhook injects it as
the aether-identity-ready init container (first in line) into every mesh pod it
admits (#1055, --identity-gate). It asks the SPIRE agent's Workload API — over a
csi.spiffe.io volume mounted into this init container only — for the pod's own
X.509 SVID and exits 0 once SPIRE has issued it, so the app containers never start
before the node proxy has a client certificate for the pod. It ships as an extra
layer (/identity-ready) in the agent image, already on every node; it links gRPC,
protobuf and go-spiffe's generated Workload API client and nothing heavier, and
//agent/cmd/identity-ready:deps_test keeps it that way.
| Flag | Default | Purpose |
|---|---|---|
--spire-workload-socket |
/run/secrets/workload-spiffe-uds/socket |
Workload API socket (the csi.spiffe.io mount). |
--timeout |
0 |
Exit non-zero after this long without an SVID; 0 waits forever (fail closed: the pod stays in Init). |
--retry-interval |
500ms |
Pause between Workload API fetch attempts. |
--attempt-timeout |
15s |
Upper bound on one fetch attempt. |
--log-every |
10s |
How often it logs what it is still waiting for. |
The controller sets --spire-workload-socket and --timeout from its
--identity-gate-* flags. See runbook.md § Pod held in Init by
aether-identity-ready (#1053).
uds-csi (standalone binary — the aether-uds-csi DaemonSet, proposal 039)¶
The csi.aether.io CSI node plugin. Stdlib flag parsing; no Kubernetes client,
no telemetry exporter (//agent/cmd/uds-csi:deps_test,
scripts/check-uds-csi-deps.sh).
| Flag | Default | Purpose |
|---|---|---|
--kubelet-root |
/var/lib/kubelet |
The kubelet's --root-dir. Every target_path must lie under <kubelet-root>/pods/<pod-uid>/, and the socket defaults derive from it. |
--csi-socket |
<kubelet-root>/plugins/csi.aether.io/csi.sock |
CSI Identity + Node endpoint; also the endpoint reported to the kubelet, so it must be the path the kubelet sees. |
--registration-socket |
<kubelet-root>/plugins_registry/csi.aether.io-reg.sock |
The kubelet plugin-registration endpoint (served by this binary, not a sidecar). |
--node-id |
$NODE_NAME |
NodeGetInfo's node ID. |
--root |
/run/aether/uds |
Host directory holding the per-pod tmpfs mounts. |
--size |
1Mi |
Size cap of each per-pod tmpfs. |
--inodes |
64 |
Inode cap (nr_inodes) of each per-pod tmpfs, its root directory included; at least 8. |
--debug |
false |
Debug logging. |
--probe |
false |
Liveness mode: exit 0 iff the CSI socket exists, then exit. |
Both sockets are created at start (stale socket files are removed first; a non-socket at either path is refused) and removed on SIGTERM, the registration socket first so the kubelet deregisters the driver before its endpoint stops answering. A kubelet-reported registration error exits the process non-zero.
registrar¶
--cluster-name (required), --mesh-domain (aether.internal),
--control-cluster (""), --region (""), --registry-backend
(kubernetes), --etcd-endpoints ([localhost:2379]), --peer-etcd
(repeatable, <region>=<endpoint>[,<endpoint>...] — cross-region replication,
006 Phase 2; requires the etcd backend + an explicit --region),
--sync-interval (5s), --enable-mcs
(false), --grpc-address (:8443), --spire-enabled (true),
--spire-workload-socket. The mesh-Service generator is unconditional (031
round 2), and the mTLS peer trust domain is resolved from the registrar's own
SVID (no --spire-trust-domain).
controller¶
--mesh-config-configmap (aether-mesh-config), --spire-enabled (false),
--spire-workload-socket, --webhook-config-name (""),
--mutating-webhook-config-name (""), --mesh-domain (aether.internal —
the pod-mutating webhook derives its injected ndots from the domain's label
count; the old --pod-ndots was retired).
The egress identity gate (#1053/#1055), rendered from
controller.webhook.identityGate.*:
| Flag | Default | Purpose |
|---|---|---|
--identity-gate |
false (the chart sets it) |
Inject the aether-identity-ready init container into mesh-managed pods on /mutate, holding their app containers until SPIRE has issued the pod's SVID. Opt a pod out with aether.io/identity-gate=false. |
--identity-gate-image |
"" |
Image the init container runs /identity-ready from (the agent image). Required with --identity-gate. |
--identity-gate-image-pull-policy |
IfNotPresent |
Always, IfNotPresent or Never. |
--identity-gate-workload-socket |
/run/secrets/workload-spiffe-uds/socket |
Workload API socket path inside the init container; the csi.spiffe.io volume is mounted at its directory. |
--identity-gate-timeout |
0 |
Give up after this long (the init container exits 1 and the kubelet retries it); 0 waits forever (fail closed). |
--identity-gate-cpu-request / --identity-gate-cpu-limit |
5m / "" |
Init container CPU; empty leaves that entry unset. |
--identity-gate-memory-request / --identity-gate-memory-limit |
16Mi / 64Mi |
Init container memory; empty leaves that entry unset. |
cni-install (init container)¶
--cni-bin-dir, --cni-bin-target-dir, --mounted-cni-net-dir,
--capture-redirect-all-default,
--mesh-dns, --host-ip, --debug. The per-pod capture redirect is
unconditional (no --transparent-capture; per-pod capture.aether.io/*
annotations opt out). There is no --otlp-endpoint or --otlp-pin-endpoint: the
plugin exports no telemetry (#1166), the chart has passed neither since 2.4.0,
and the binary rejects both as unknown flags (they were deprecated no-ops for the
releases in between; see docs/runbook.md, "Chart 2.5.8"). (The cni plugin binary itself is configured via CNI-spec stdin,
not flags.)
Netconf keys the plugin reads but cni-install does not write (edit the conflist to
override a default): netns_pin_disabled, netns_pin_dir (/run/aether/netns),
netns_unpin_delay_seconds (0 = 60s), readiness_probe_disabled, and
netns_del_give_up_after_seconds — how long CNI DEL keeps failing back to the runtime
when a reachable agent answers the removal with an error before it degrades to the
agent-unreachable path (unpin on the normal delay, return success, let the ghost sweep
reconcile). 0 = 5m default; negative = give up on the first failure (#796).
prober (standalone chart charts/prober, proposal 013)¶
The synthetic mesh-availability prober: a per-node DaemonSet, mesh-managed
like any other client, that black-box probes the data plane from the client
side and emits its own pass/fail SLI. It exists because a source proxy cannot
report its own outage, so the proxy-emitted aether_stats metric is structurally
blind to the connection-level failures a hot restart can produce. It has its own
chart and its own image (//prober/cmd/prober) and is installed independently of
the aether chart. It does not take the shared manager flags; the list below
is its whole flag set.
| Flag | Default | Purpose |
|---|---|---|
--egress |
127.0.0.1:18081 |
Local mesh egress listener the prober dials (the per-pod listener the CNI plumbs). |
--liveness-path |
/-/-/live |
Proxy local-reply liveness route. The agent programs a direct_response 200 on this exact path, first in the catch-all vhost — no upstream, no app — so a failure is unambiguously the mesh's fault. |
--liveness-authority |
liveness.aether.internal |
Reserved Host authority for the liveness probe; must not be a real service, so the request lands on that catch-all vhost. |
--mesh-domain |
aether.internal |
Mesh authority suffix for the reachability tier. |
--reachability-targets |
[] |
Echo upstreams for the reachability tier, <svc>.<ns>:<port> with a port the service declares: the probe goes to the proxy port with Host: <svc>.<ns>.<meshDomain>:<port>. Without a port the Host is port-less, which is port 80. Empty disables the tier. |
--mesh-dns-targets |
[] |
Namespace-qualified authorities for the mesh_dns tier, <fqdn>:<port> with a port the service declares (no port is appended: without one the probe dials 80; until prober chart 1.1.1 the default was 18081). Unlike the other two tiers these are resolved through the mesh DNS path rather than Host-overridden, so a real mesh-DNS outage becomes an alertable dns_* result (#574). Probed on a no-keep-alive client, so every probe resolves and dials afresh. |
--rate |
5 |
Probes per second, per target. |
--timeout |
2s |
Per-probe timeout. |
--max-concurrent |
16 |
Max in-flight probes per target; a tick that finds the semaphore full records saturated instead of probing. |
--otlp-endpoint |
"" |
OTLP gRPC collector host:port (insecure). Empty disables telemetry — the prober still runs but emits nothing. |
Metrics. Two instruments, both carrying tier (liveness, reachability,
mesh_dns), target (the probed name), result and pod (the prober pod, from the
resource's k8s.pod.name; #1041):
| Metric | Type | Notes |
|---|---|---|
aether_probe_requests_total |
counter | result is one of success, http_error, connection_error, timeout, saturated, plus — mesh_dns tier only — dns_error, dns_nxdomain, dns_timeout. A resolution failure is an independently alertable signal; a post-resolution connect failure stays connection_error. A probe deadline that interrupts the name lookup is dns_timeout; a deadline in any later phase is timeout (#1252). |
aether_probe_request_duration_seconds |
histogram | Explicit seconds-valued buckets (0.001 … 5). They have to be set explicitly: the SDK's default boundaries are tuned for millisecond-valued durations, so against seconds the first bucket is <= 5s and a healthy 2 ms probe is indistinguishable from a timed-out 2 s one (#732). |
Per-node identity is a resource attribute, not a metric label: the chart sets
OTEL_RESOURCE_ATTRIBUTES=k8s.node.name=$(NODE_NAME),… and the prober's resource
builder reads it from the environment, so the series de-collapse per node once
the collector promotes it to node (#210). The prober deliberately sets no
host.name. On a pod without hostNetwork that is the pod name, and a collector that
promotes host.name ahead of k8s.node.name would export node="prober-xxxxx", which
is what happened until #1041. The registrar, the controller and the edge control
plane are on the pod network too and leave host.name out for the same reason
(#1596); the hostNetwork components (agent, mesh-dns, proxy supervisor) keep it. See
observability/metric-labels.md.
Failure log. Every non-success probe prints one bounded
AETHER_PROBE_FAIL {t, tier, target, result, err, elapsed_ms, phase, reused, conn_ms, dns_ms, connect_ms, tls_ms, write_ms, ttfb_ms, dial, remote, local, trace_id, pod, node, n, truncated}
line to stdout: at most 20 per (tier, result) per minute, then one summary line with
the suppressed count (#1040). phase and the *_ms fields come from a per-probe
httptrace trace and say which step of the request the time went to (#1252; -1 =
the phase never started). See runbook.md, "Attributing a prober
failure".
Deployment. The chart renders a DaemonSet + ServiceAccount into a namespace
that must already be mesh-managed — the probe only works if the CNI has plumbed
the pod's egress listener. namespace.create is false and namespace.name
defaults to the release namespace; on the reference cluster that is aether-test. Set
namespace.create=true only for a release stored in a different namespace: for
the release's own namespace the render fails (chart 1.0.6, #1405), for the
reason given under the aether chart's namespace.create. A namespace the
chart rendered carries helm.sh/resource-policy: keep, so turning the value off
again, or helm uninstall, leaves it in place. The first upgrade from a
chart older than 1.0.6 is refused until the namespace has been annotated once
(kubectl annotate namespace <ns> helm.sh/resource-policy=keep): the chart
cannot see whether the older release rendered the namespace, and Helm deletes
an object that leaves the manifest (the same rule as the aether chart;
runbook.md, "Chart 2.4.21"). The pod
carries aether.io/managed: "true", and the chart derives
config.aether.io/upstreams from the union of the reachability and mesh_dns
targets so the agent programs those clusters (proposal 004). It ships no CPU
limit on purpose: the limit is a CFS quota, which quantises probe latency in
~100 ms steps, and at the previous 50m limit that was the dominant term in the
published SLI (#735). The memory limit stays — GOMEMLIMIT is rendered at 90 % of it (chart 1.0.2; it used to be the whole limit).
Resources. Three containers, three values keys (chart 1.0.4):
| Key | Default | Container |
|---|---|---|
resources.{requests,limits} |
requests cpu 100m / mem 32Mi; limits mem 64Mi, no CPU limit |
prober, the DaemonSet's only container (see above for why it has no CPU limit). |
authzCanary.resources.{requests,limits} |
requests cpu 10m / mem 16Mi; limits mem 32Mi, no CPU limit |
curl, the canary client (Deployment authz-canary). Only rendered with authzCanary.enabled. Written into the template before chart 1.0.4 (#1362). |
authzCanary.echo.resources.{requests,limits} |
requests cpu 10m / mem 32Mi; limits mem 64Mi, no CPU limit |
echo, the canary's target (Deployment authz-echo, authzCanary.echo.replicas pods). Only rendered with authzCanary.enabled. Written into the template before chart 1.0.4 (#1362). |
To remove a default request or limit, set it to null or to an empty value
(--set resources.limits.memory=null, --set authzCanary.resources.requests.cpu=,
or limits: {memory: null} in a values file): the key is left out of the pod
spec. Before chart 1.0.4 an emptied quantity on resources rendered as
cpu: "", which the apiserver rejects (#1361). Removing the prober's
limits.memory also removes its GOMEMLIMIT. To check a release's values
against a chart before upgrading, read them back and render (never
--reuse-values):
helm get values prober -n <namespace> -o yaml > values.yaml
helm template prober <chart> -n <namespace> -f values.yaml | grep -n -E '^[[:space:]]+(cpu|memory): (""|null)?$'
No output means no container carries an empty quantity.
Images of the authz canary (only rendered with authzCanary.enabled):
| Key | Default | Notes |
|---|---|---|
authzCanary.image |
curlimages/curl@sha256:58adaa4e…6777 (the multi-arch index of curlimages/curl:8.22.0) |
The client: needs /bin/sh, curl and date. Pinned by digest since chart 1.0.5 (#1374); it was the tag. Nothing moves the pin by itself; scripts/third-party-images.sh outdated says when the registry has moved past it: see runbook.md, "Refreshing third-party image pins". The container has no liveness probe on purpose: a canary that stops shows up as the ext_authz counters no longer increasing. |
authzCanary.echo.image |
gcr.io/k8s-staging-gateway-api/echo-basic@sha256:eb739672…37c3 |
The target. |
Labels (chart 1.0.5). The prober pods carry the DaemonSet's selector labels
(app.kubernetes.io/name, instance, component: prober), part-of,
managed-by and aether.io/managed; helm.sh/chart and
app.kubernetes.io/version are on the DaemonSet object only, so a chart release
no longer rolls the prober by itself (#1372). The canary's objects carry
app.kubernetes.io/component: authz-canary on their own metadata, not
prober (#1373); its pods carry app: authz-canary / app: authz-echo as
before. What the upgrade to 1.0.5 rolls, and how to check it:
runbook.md, "The prober chart".
4. Labels & annotations¶
Defined in common/constants/. Prefixes:
config.aether.io/* = what a pod consumes (client config);
endpoint.aether.io/* = endpoint registration facts (what a pod serves);
capture.aether.io/* = transparent-capture behavior; metadata.endpoint.aether.io/*
= free-form endpoint metadata usable as routing subsets.
Pod / namespace labels¶
| Label | Value | Meaning |
|---|---|---|
aether.io/managed |
"true" |
Opt a pod (or, with controller.namespaceInjection, a namespace) into the mesh. |
aether.io/agent-not-ready |
(taint) | Startup taint keeping pods off a node until the agent's CNI serves. |
Pod annotations (aether.io/*)¶
| Annotation | Value | Meaning |
|---|---|---|
aether.io/identity-gate |
"false" |
Skip the egress identity gate for this pod: no aether-identity-ready init container, so the app may start (and send) before its SVID exists. Any other value, or absent, leaves controller.webhook.identityGate.enabled in charge. |
Endpoint annotations (endpoint.aether.io/*)¶
| 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 a default for HTTP addressing: a client names the port it wants. 18081 and 18082 are refused at admission. |
endpoint.aether.io/ports |
— | Every served port, comma-separated, each optionally typed (8080,9090=h2,9000=tcp,5353=udp). A service is reached on these ports and on no other. |
endpoint.aether.io/weight |
1024 |
Load-balancing weight. |
endpoint.aether.io/health-path |
/ |
Path the agent active-health-checks. |
endpoint.aether.io/health-check-mode |
eds |
eds (agent vets + publishes over EDS) or active (each client proxy probes). |
endpoint.aether.io/protocol |
http |
Wire protocol served: http or tcp. |
endpoint.aether.io/uds-socket |
— | Deliver inbound to a Unix socket (<volume>/<file>) instead of the TCP port (034). <volume> must be the pod's inline csi: {driver: csi.aether.io} volume (039; the pod needs securityContext.fsGroup; an emptyDir is denied at admission), <file> at most 54 bytes. Wins over an EndpointPolicy on the service; needs udsCsi.enabled (default). |
metadata.endpoint.aether.io/<key> |
— | Free-form metadata → selectable routing subset (e.g. …/version=v2). |
Config annotations (config.aether.io/*)¶
| Annotation | Meaning |
|---|---|
config.aether.io/upstreams |
Comma-separated upstream services this pod calls; drives demand-scoped distribution + ODCDS (004). |
Capture annotations (capture.aether.io/*, proposal 022)¶
| Annotation | Meaning |
|---|---|
capture.aether.io/redirect-all |
"true" force redirect-all, "false" opt out, else node default. |
capture.aether.io/exclude-outbound-ports |
Comma-separated outbound ports to carve out of capture (TCP+UDP). |
capture.aether.io/exclude-outbound-ip-ranges |
Comma-separated IPv4 CIDRs to carve out (TCP+UDP). |
Gateway / other¶
| Constant | Value | Meaning |
|---|---|---|
| edge GatewayClass controller | gateway.aether.io/edge |
controllerName of the edge GatewayClass. |
| mesh (GAMMA) controller | gateway.aether.io/mesh |
controllerName for Service-parented route status. |
| Gateway HTTP redirect | gateway.aether.io/http-redirect: "true" |
Opt a Gateway's plain-HTTP listener into HTTP→HTTPS 301. |
| workload SPIFFE ID | aether.io/spiffe-id |
Rejected and ignored (#669). A pod's mesh identity is always spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>, derived from the API server. A pod carrying this annotation is logged at WARN and counted by aether.agent.identity.spiffe_id_override_rejected. |
Always-ignored namespaces¶
Never intercepted regardless of labels (so the control plane + SPIRE never depend
on the mesh): kube-system, aether-system, spire-mgmt, spire-server,
spire-system.
See also¶
getting-started.md— install + workload onboarding.runbook.md— build/test/e2e developer loop.workload-requirements.md— the full workload contract.charts/README.md— chart layout, image mirroring, versioning.../charts/aether/values.yaml— the authoritative values with full inline comments.