aether-proxy¶
A custom Envoy build for the Aether data plane, structured after
envoyproxy/examples/filter-cc
— the upstream downstream-build template published alongside Envoy's WORKSPACE
removal (envoy#42890 / envoy#47217).
This is a separate Bazel module from the root
aetherrepo. The root//.bazelignorelistsproxy, sobazel build //.../ Gazelle in the repo root never descend here. It pins its own Bazel version (.bazelversion = 8.7.0), its own module graph (MODULE.bazel), and its own registries (.bazelrc). The two workspaces share only the git repo. Seedocs/proposals/010.
Build¶
All commands run from inside proxy/ (so the right Bazel version and module
graph are used):
cd proxy
# Build the custom Envoy binary (multi-hour C++ build; use a warm cache / CI).
bazel build //:envoy
# Build + load the custom aether-proxy image into the local Docker daemon.
# --config=release bakes the optimized Envoy (plain builds are fastbuild/dev).
# The Makefile `load-proxy-image` target does this for you.
bazel build --config=release //:image
bazel run --config=release //:load # quay.io/aethermesh/proxy:latest
# Image smoke test (container-structure-test).
bazel test //:image_test
You almost certainly cannot build this locally. A cold Envoy build fetches tens of GB of module archives (BoringSSL, V8, ICU, gRPC, the LLVM 22.1.8 distributions, …) and needs hundreds of GB of output base even with the compile offloaded to RBE — analysis still fetches everything. What is cheap locally, and worth running before pushing, is module resolution:
# No unresolved modules; envoy/envoy_api at the pinned version. bazel mod graph --depth=1 # The single assertion that override_repo took: this MUST print our # local_repository at bazel/build_config, not Envoy's default_envoy_build_config. bazel mod show_repo --base_module=envoy @envoy_build_config # Selected versions match the registry pin, with no MVS surprises. By module # name: quiche is not a direct dependency any more, so `@quiche` does not resolve. bazel mod explain quiche protobuf abseil-cppEverything from
bazel build --nobuild //:envoyonward is validated by.github/workflows/proxy.yml, the PR check, which builds both arches on BuildBuddy RBE. The release path is a separate workflow,.github/workflows/proxy-release.yml: on a push tomaintouchingproxy/**it builds and pushes each arch by digest, assembles the multi-arch manifest tagged with the commit SHA, and opens the chart pin PR that movescharts/aether/values.yaml'sproxy.imageonto it (#703/#727). Never push a proxy image or edit that pin by hand.
MODULE.bazel.lockis committed as a dev convenience. CI runs with the default--lockfile_mode=update, noterror: theenvoy_toolchains_extension(arch_alias→ctx.os.arch) andtoolchains_llvm'sllvmextension are host-arch dependent, and the two CI legs drive from different arches.
The image is distroless/cc base + the custom //:envoy binary at
/usr/local/bin/envoy. Plain bazel build //:image produces a fastbuild
(unoptimized) binary — always pass --config=release (CI and the Makefile
targets do) to bake the production binary.
The released binary is not fully stripped, on purpose. --strip=always
removes DWARF but keeps .symtab (~22 MB, ~944k symbols), which is what lets
Pyroscope put names on the proxy fleet's native frames — the profiler itself
symbolizes nothing native (aether #651). //integration:symtab_test guards it,
and //integration:build_id_test guards the content-derived GNU build-ID the
symbol upload is keyed by (#653).
aether_statsis a compiled-in C++ extension (proposal 012), built into//:envoyviaAETHER_EXTENSIONSinBUILD.bazel. It records source→destination request metrics fromStreamInfoat stream completion. The earlier Rust dynamic-module approach was dropped — no Rust toolchain or dynamic-module wiring is needed.
Layout¶
| Path | Purpose |
|---|---|
MODULE.bazel |
module graph; pins envoy / envoy_api (see "Envoy version bumps") |
.bazelrc |
build config; pins the envoy bazel-registry commit; clang/RBE/release configs |
.bazelversion |
8.7.0 (independent of the root repo) |
bazel/build_config/ |
the compiled-in Envoy extension set, as a tiny local module (envoy_build_config) |
bazel/platforms/BUILD.bazel |
BuildBuddy RBE exec platforms (amd64 / arm64), derived from Envoy's |
bazel/get_workspace_status |
--workspace_status_command stamping script |
bazel/image_metadata.sh |
emits the image's OCI provenance labels (aether commit + the Envoy pin) |
BUILD.bazel |
custom envoy_cc_binary + oci_image/oci_push/oci_load + image_test |
integration/ |
build_id_test, symtab_test, container-structure-test config |
source/extensions/filters/http/aether_stats/ |
native C++ aether_stats filter (compiled into //:envoy) |
Customizing¶
- Compiled extensions: add your
envoy_cc_libraryconfig target toAETHER_EXTENSIONSinBUILD.bazel. It is a dep of the binary, not an entry inbazel/build_config/extensions_build_config.bzl— that dict is only for extensions that live in@envoy. - Dropping upstream extensions: add a substring to
_DROPPEDinbazel/build_config/extensions_build_config.bzl. It filters Envoy's own default dict, so an Envoy bump picks up new upstream extensions for free. Today we dropwasmanddynamic_module. - Source patches: the exception, not the mechanism —
@envoyis a registry module, so a patch needs asingle_version_override(patches = [...])inMODULE.bazel(thebazel/patches/directory). Prefer upstreaming; a carried patch is an upstream PR that has not reached a pin yet, and it goes away with the next pin bump that contains it. A carried patch's own Envoy tests run from//bazel/patches:carried_patch_tests, since//...does not reach@envoytests.
Carried today¶
Applied in this order (single_version_override in MODULE.bazel, which also
says what each one changes in detail). scripts/bump-envoy-pin.sh reports, for a
new pin, which of them the new Envoy already contains.
Patch (bazel/patches/envoy-…) |
What it fixes | Upstream |
|---|---|---|
47740-quic-hostname-check-explicit-san |
The QUIC client defers its hostname check to explicit SAN matchers (aether#957); behind envoy.reloadable_features.quic_hostname_check_deferred_to_explicit_san_match. |
envoyproxy/envoy#47740, open |
aether1050-hotrestart-nonblocking-forward |
The hot-restart main-thread deadlock (aether#1050): parent UDP/QUIC forwarding to the child no longer blocks the parent's main thread, and the child keeps servicing forwarded packets while it waits, with a bound, for the parent's replies. Counts what it forwards in server.hot_restart_udp_forwarding_{datagrams,retries,dropped}. A signal interrupting a send or a wait on the hot-restart domain socket (EINTR, which the socket timeouts make non-restartable) is retried instead of aborting the child on a RELEASE_ASSERT. |
envoyproxy/envoy#47834, open (the patch is that pull request's diff) |
aether1054-http3-drain-idle-connections |
Idle HTTP/3 connections drain themselves inside the drain window (aether#1054), behind envoy.reloadable_features.http3_drain_idle_connections, beside upstream's own proactive drain (envoyproxy/envoy#47991), which arms nothing at the chart's 10s drain time. |
not proposed |
aether1054c-paused-udp-listener-no-read |
A paused hot-restart child UDP listener no longer reads the parent's socket when the QUIC listener injects a read to process forwarded handshakes (aether#1054). | envoyproxy/envoy#47868, merged on Envoy main after v1.40.0 (a later pin drops it) |
aether1074-quic-persistent-info-lifetime |
HTTP/3 pools keep the per-cluster persistent QUIC info alive after a CDS removal, instead of their draining connections reading its freed clock (aether#1074). | envoyproxy/envoy#47893, open |
aether1126-forwarded-udp-worker-index |
A hot-restart child with fewer workers than its parent no longer indexes past its worker table when the parent forwards a UDP/QUIC datagram tagged with one of its extra workers: the index is mapped onto index % concurrency (aether#1126). |
not reported |
No longer carried, because the pinned release contains them:
envoyproxy/envoy#47743 (UDP hot-restart forwarding keyed by listener address +
network namespace, aether#967) and envoyproxy/envoy#47776
(execInNetworkNamespace restores the calling thread's netns via
/proc/thread-self/ns/net, aether#1022; the carried patch also fell back to
/proc/self/task/<tid>/ns/net on kernels older than 3.17, upstream does not),
both dropped at the 189f198 snapshot (aether#980); and envoyproxy/envoy#48206
(a draining hot-restart parent answers packets for its own recently closed QUIC
connections from its time-wait list instead of forwarding them to the child,
aether#1054; behind the same
envoy.reloadable_features.quic_hot_restart_time_wait_before_forward), dropped
at the 1.40.0.envoy release, which contains it as 69ff216f86.
Dropped without an upstream merge: upstream Envoy PR 48319, which kept the
hot-restart child's inherited UDP listeners paused until the parent had exited
(behind envoy.reloadable_features.hot_restart_wait_for_parent_exit). It had
replaced the first half of the earlier aether1054 patch, and was closed upstream
after an A/B soak showed no measurable benefit. The child now completes its
drains, unpausing those listeners, at Terminate, as unpatched Envoy does. An 8 h
soak of exactly this configuration (the 48319 guard off) on the reference cluster
showed 0 misses near handoffs and 0 QUIC stateless resets. The aether1054 patch
stays the HTTP/3 drain only; its old hot-restart half is not restored.
Which Envoy is this? (envoy_server_version, and the image labels)¶
What envoy_server_version actually reports¶
It reports the aether commit the proxy image was built from. It says nothing about Envoy. Do not try to match it against the pin — it will never agree, and the disagreement is not a bug (aether #837).
You do not have to take that on inference. The pinned-Envoy target — today
//agent/test/envoy_validate:envoy_bin, via //bazel/proxy_pin; #841 factors it out,
so bazel query for it if the label has moved — is the Envoy binary extracted
from the published aether-proxy image at the digest
charts/aether/values.yaml pins. You can ask it directly, locally, with no
cluster:
$ bazel build //agent/test/envoy_validate:envoy_bin
$ "$(bazel info output_base)/$(bazel cquery --output=files //agent/test/envoy_validate:envoy_bin)" --version
envoy version: cff8beb87eb685a44e00cf70c1a534695039da1b/1.40.0-dev/Clean/RELEASE/BoringSSL
cff8beb87eb685a44e00cf70c1a534695039da1b is an aether commit — it is that
image's own tag: in values.yaml. The Envoy part of the string is just
1.40.0-dev, with no Envoy revision in it anywhere. So for that image the gauge
is 0xcff8be; a proxy reporting something else is simply an older image.
The chain that produces it, end to end:
.bazelrcsets--workspace_status_command="bash bazel/get_workspace_status", and that script runsgit rev-parse HEAD.proxy/is a nested workspace inside the aether git repository, soBUILD_SCM_REVISIONis the aether commit. Envoy is a bzlmod dependency here; nothing ever asks its git for a revision.envoy_cc_binarydefaults tostamp = 1(Envoy'sbazel/envoy_binary.bzl), which iscc_binary's "always stamp, even under--nostamp". So the//source/common/version:version_linkstamplinkstamp compiles that real sha in even though no build here passes--stamp. (Verified: with a plaincc_binary,--nostampyields the redactedBUILD_SCM_REVISIONof"0"; withstamp = 1it yields the workspace-status value.)VersionInfo::revision()returns it, and Envoy'sserver.ccdoesatoull(revision().substr(0, 6), 16)to set theserver.versiongauge.
So the gauge is the first six hex digits of the aether commit. The reading that opened #837 decodes cleanly that way:
envoy_server_version = 3121738 = 0x2FA24A -> aether commit 2fa24a8
"test: drop duplicate integration targets ... (#772) (#788)"
That is a genuinely useful fact — it tells you which aether tree cut the proxy —
but it is only 24 bits of it, and it is not the Envoy revision. 189f19, the
first six digits of the Envoy commit in a dev-snapshot pin such as
1.40.0-dev.20261009.189f198.envoy, is what you would be looking for and it is
nowhere in the process.
Where the Envoy revision actually is: the image labels¶
//:image_metadata (see bazel/image_metadata.sh) parses the pin out of the
two files that define it and puts it on the image as both OCI labels (image
config) and annotations (manifest):
| key | value |
|---|---|
org.opencontainers.image.source |
https://github.com/bpalermo/aether — overrides the GoogleContainerTools/distroless value inherited from the base, which used to be the image's only annotation |
org.opencontainers.image.revision |
the aether commit; the same sha the gauge reports the first six digits of |
dev.aethermesh.envoy.module-version |
e.g. 1.40.0.envoy (a release) or 1.40.0-dev.20261009.189f198.envoy (a main snapshot) |
dev.aethermesh.envoy.revision |
e.g. 189f198 — the upstream Envoy commit; only on a -dev snapshot pin, whose version carries it. A release pin (1.40.0.envoy) has no such label: the version names the release tag (v1.40.0) instead |
dev.aethermesh.envoy.bazel-registry |
the envoyproxy/bazel-registry commit; the other half of the pin (see "Envoy version bumps") |
To read them off a published image, without pulling it:
crane config quay.io/aethermesh/proxy@sha256:… | jq .config.Labels
crane manifest quay.io/aethermesh/proxy@sha256:… | jq .annotations
None of this costs reproducibility: the labels are fixed strings, created
stays unset (1970-01-01T00:00:00Z) and the history entries stay bazel bu….
The metadata genrule is tagged no-cache on purpose — BUILD_SCM_REVISION is a
volatile workspace-status key, which Bazel deliberately does not invalidate
on, so a cacheable action would re-stamp a stale commit.
Envoy version bumps¶
The module version and the registry commit are ONE pin and must move
together. The envoy bazel-registry
(envoyproxy/bazel-registry)
publishes main dev snapshots and, since its first tagged release (v0.0.1,
2026-10-13), Envoy releases. Until then it kept exactly one version per module
and deleted the previous version directory on every bump; it now keeps
published versions side by side (at tag v0.0.3, modules/envoy holds both
1.40.0-dev.20261009.189f198.envoy and 1.40.0.envoy) and its CI enforces that
a published module is immutable. Pin the commit anyway: the .envoy-suffixed
closure a version resolves against is only fixed by the commit, and an unpinned
(or mismatched) registry commit drifts with every sibling bump.
Worse, registry HEAD is frequently not self-consistent: sibling modules get
bumped before modules/envoy/<version>/MODULE.bazel is updated to match, so at
HEAD the envoy module can reference .envoy-suffixed versions that no longer
exist. Envoy's own .bazelrc pins a commit that predates the publication of its
own snapshot, so it cannot be copied either.
Check modules/envoy/metadata.json first. It lists every envoy version the
registry currently publishes, and one API call answers the whole question:
gh api repos/envoyproxy/bazel-registry/contents/modules/envoy/metadata.json \
--jq '.content' | base64 -d | jq .versions
If the only version listed is the one already pinned, there is nothing to bump to
and the loop below will confirm it the slow way. The real gate on a bump is a new
envoy snapshot, not a newer registry commit.
Otherwise the bump recipe is a resolvability loop — find the newest registry
commit at which the whole .envoy-suffixed closure resolves:
git clone https://github.com/envoyproxy/bazel-registry.git /tmp/br
# For each recent commit, newest first:
# 1. envoy_ver=$(git -C /tmp/br ls-tree -d --name-only <commit> modules/envoy/)
# 2. BFS `bazel_dep(name=..., version=...)` from
# modules/envoy/$envoy_ver/MODULE.bazel and modules/envoy_api/...
# 3. every dep whose version ends in `.envoy` must have a
# modules/<name>/<version>/MODULE.bazel in that tree
# Take the newest commit with zero misses.
Two traps when checking a candidate commit:
--registryaccumulates; it does not override. Adding--registry=…/<candidate>on the command line appends a registry behind the two.bazelrcalready pins, so the pinned commit still answers every lookup and the probe passes no matter how broken the candidate is. To test a candidate you must edit the--registry=line in.bazelrc, and pass--lockfile_mode=offsoMODULE.bazel.lockdoes not answer from cache.- A missing version directory is only fatal if nothing requests a higher
one. Bazel selects the MVS maximum, so
foo@1.3.0.envoyresolves fine if some other module in the closure asks forfoo@1.3.3.envoyand that directory exists. The step-3 rule above is the conservative approximation; if it rejects a commit you otherwise want, re-check against the selected version rather than the requested one.
If the loop finds no commit newer than the current pin, that is the normal
steady state between snapshots, not a failed bump: the registry bumps
siblings ahead of modules/envoy/<version>, so after a snapshot publishes it
was (while the registry kept one version per module) usually resolvable for
only a handful of commits. For a release, prefer the commit that added it (the
release tag's commit) over a later HEAD. Do not "fix" the misses with single_version_override — forcing a
sibling past what the envoy module was published against buys an unvalidated
combination that only a multi-hour CI build can disprove. Wait for the next
snapshot.
Then, in one commit:
MODULE.bazel: setenvoyandenvoy_apito that snapshot version..bazelrc: set--registry=https://raw.githubusercontent.com/envoyproxy/bazel-registry/<that commit>.- Update every other
.envoy-suffixedbazel_depinMODULE.bazel(proxy-wasm-cpp-host,rules_rust,toolchains_llvm,protobuf) to the versions that commit carries — they must match what theenvoymodule requests or MVS will fail. Move the plain BCR ones theenvoymodule also requests (rules_python,abseil-cpp,rules_cc, …) to the version it asks for too, soMODULE.bazelstates what is selected. - Re-diff
.bazelrcagainst the two upstream sources named at the top of that file (the filter-cc template and Envoy's own.bazelrcat the new pin). - Run the local
bazel modchecks above, then push and let CI build both arches.
When a stable release publishes a 1.NN.0.envoy module, move to it. The pin is
1.40.0.envoy at registry tag v0.0.3 (a1264b53, the commit that added it);
it replaced the 1.40.0-dev.20261009.189f198.envoy snapshot. A release pin needs
the matching go-control-plane/envoy bindings (v1.NN.0) on main first:
scripts/check-envoy-api-parity.sh accepts bindings one minor behind only for a
-dev pin.
Nothing in the root workspace needs re-pinning alongside it any more. The root
MODULE.bazel used to carry @envoy_binary_linux_*, a stock Envoy release asset
that //agent/test/envoy_validate ran envoy --mode validate with, and that pin had to
move in step. #709 replaced it: //bazel/proxy_pin now lifts
/usr/local/bin/envoy out of the aether-proxy image at the digest
charts/aether/values.yaml pins, so the validate gate follows the chart pin — and
therefore this module — with no second version to keep in sync.
The aether_stats C++ extension builds against this same Envoy tree, so there
is no separate SDK version to keep in sync.
Status¶
Custom Envoy + aether_stats C++ extension building on CI (proposals 010 /
012), on bzlmod against the Envoy 1.40.0.envoy release (main dev snapshots
from #697 until the 1.40.0 release). The Envoy compile
runs on BuildBuddy RBE — amd64 driven from an x64 runner, arm64 driven from a
native ubuntu-24.04-arm runner (@clang_platform and @llvm_toolchain derive
their exec constraints from the driver host's arch, so an x64 driver cannot
drive the arm64 pool).