Skip to content
Published on

Move to Ambient and EnvoyFilter Goes Silently Ignored — The Gap Istio 1.30's TrafficExtension Closes, and What It Doesn't

Share
Authors

Introduction — What Doesn't Come Along When You Strip Out the Sidecar

Ambient mode's pitch is always about resources. Strip out the per-pod Envoy sidecar, split the work between one ztunnel per node and waypoints placed only where you need them — the architecture itself, and how it compares to sidecars, is already covered in Istio Ambient vs Sidecar comparison, so I won't repeat it here.

What I want to cover is the side those posts don't usually get into. The place teams that actually attempt the migration get stuck is, more often than not, not CPU or memory but extensibility. That EnvoyFilter almost everyone kept around in the sidecar era — the one bolting on an extra header, wiring in some oddball auth scheme, or flipping on some obscure Envoy filter — does not come along when you move to ambient.

And that isn't my speculation. It's written directly into Istio's own official docs.

EnvoyFilter Gets Silently Ignored on Waypoints

The Migrating policies from sidecars to ambient doc, new with Istio 1.30, says this:

EnvoyFilter resources are not supported on waypoints. If you have EnvoyFilter resources that configure sidecar proxy behavior, they will be silently ignored after migration and must be handled before proceeding.

The key phrase is "silently ignored." No error, no crashing pod. The filter you set up simply stops doing anything. I won't spell out what that means if it happened to be an auth-bypass-prevention filter.

The same doc lays out three ways to handle it, and the last one is the honest one.

  • If the filter is adding custom Envoy behavior, check whether WasmPlugin can reproduce the same behavior.
  • If it's no longer needed, delete it.
  • If there's no ambient-compatible alternative, this is a migration blocker. Don't proceed until the dependency is resolved.

That's a sentence you don't see much in CNCF conference slides. It means "ambient is GA" and "your EnvoyFilter is a migration blocker" can both be true at the same time.

Why it ended up this way makes sense once you look at how EnvoyFilter was designed. The comment on the EnvoyFilter API definition introduces itself with the warning that this feature "must be used with care, as incorrect configurations could potentially destabilize the entire mesh," and that "some aspects of this API are deeply tied to the internal implementation in Istio's networking subsystem as well as Envoy's XDS API." It's an API that reaches directly into the Envoy config istiod generates, so it simply can't be ported somewhere the data plane isn't Envoy (ztunnel is a separate proxy written in Rust) or somewhere the generation path is entirely different (waypoints). EnvoyFilter was never a portable abstraction to begin with — it was a patch against one specific implementation.

Istio 1.30's Answer — TrafficExtension

Istio 1.30.0 shipped on May 18, 2026, and it brought along an API aimed at this exact gap. Introducing the TrafficExtension API, written by Docusign's Liam White, lays out the background.

Until then, WasmPlugin was the only officially supported extension API Istio had. If you wanted Lua, EnvoyFilter was your only route — and as we just saw, that gets ignored on waypoints. Which meant Lua wasn't an option on ambient at all.

TrafficExtension bundles Wasm and Lua into a single API, and attaches to sidecars, gateways, and waypoints with the same syntax. The actual proto definition makes the shape clear.

message TrafficExtension {
  enum ExecutionPhase {
    UNSPECIFIED = 0;
    AUTHN = 1;
    AUTHZ = 2;
    STATS = 3;
  }
  istio.type.v1beta1.WorkloadSelector selector = 1;
  repeated istio.type.v1beta1.PolicyTargetReference targetRefs = 2;
  ExecutionPhase phase = 3;
  google.protobuf.Int32Value priority = 4;
  repeated TrafficSelector match = 5;
  oneof filter_config {
    WasmConfig wasm = 6;
    LuaConfig lua = 7;
  }
}

The oneof matters — a single resource carries either Wasm or Lua, never both.

Ordering is handled by phase and priority. phase pins a filter to a known point in the filter chain (AUTHN/AUTHZ/STATS, or near the router if unset), and within the same phase, priority runs in descending order — higher values run first. The tiebreaker noted in the proto comment is especially useful in practice: when priority is absent or tied, order is determined deterministically by creationTimestamp, then name, then namespace. So "unset" doesn't mean random — it means creation order, which also means reapplying a resource can change the order.

selector Also Gets Silently Ignored on Waypoints

Here comes the second trap, and it stings in exactly the same way as the first.

TrafficExtension has two ways to pick a target. Sidecars use the label-based selector; waypoints use targetRefs. That's because a waypoint isn't something a workload maps to via labels. The proto comment nails this down in no uncertain terms.

NOTE: Waypoint proxies are required to use this field for policies to apply; selector policies will be ignored.

"Ignored" again. Carry a selector-based extension that worked fine on sidecars straight over to a waypoint, and it just doesn't apply — no error. This pattern repeats throughout the ambient migration story: the same migration doc also says AuthorizationPolicy resources that use L7 rules or action: CUSTOM/AUDIT, along with RequestAuthentication and WasmPlugin, all need to be switched over to targetRefs.

What targetRefs accepts is also spelled out in the proto — Gateway (group gateway.networking.k8s.io) in the same namespace, GatewayClass in the root namespace, Service in the same namespace (waypoint-only), and ServiceEntry in the same namespace. Up to 16 entries max, and selector and targetRefs can't be used together.

The minimal form of attaching Wasm to a waypoint on ambient looks like this.

apiVersion: extensions.istio.io/v1alpha1
kind: TrafficExtension
metadata:
  name: basic-auth-gateway
spec:
  targetRefs:
    - kind: Gateway
      group: gateway.networking.k8s.io
      name: bookinfo-gateway
  phase: AUTHN
  wasm:
    url: oci://ghcr.io/istio-ecosystem/wasm-extensions/basic_auth:1.12.0
    pluginConfig:
      basic_auth_rules:
        - prefix: "/productpage"
          request_methods: ["GET", "POST"]
          credentials: ["ok:test"]

Lua vs. Wasm — Why You Shouldn't Take the Official Doc's Memory Table at Face Value

Now that Lua is a first-class citizen, the obvious next question is "so which one do I use?" Istio's extensibility concepts doc answers with a memory table. Lua sits at roughly 20–26 MiB regardless of concurrency, while Wasm ranges from about 110 MiB at low concurrency up to about 290 MiB at high concurrency.

Concurrency (--concurrency)Lua (MiB)Wasm (MiB)
119.79117.7
223.07132.5
422.63152.0
823.97190.9
1625.66291.8

Looking at the numbers alone, the conclusion seems obvious. But open the original benchmark repo the doc links to, and you'll find the measurement conditions — and they shake the conclusion considerably. Worth noting: the author of this benchmark is the same person who wrote the TrafficExtension API introduction post — meaning this is author-measured.

The repo's README and docker-compose.yml reveal the conditions:

  • The Wasm module was compiled from Go. Go 1.24.4, targeting WASI, built in c-shared mode. This is decisive. A Wasm module built from Go carries the entire Go runtime and GC into every sandbox instance. The same logic written in Rust or C++ would produce very different numbers. The doc's table just says "Wasm," but what's actually being measured is "Wasm compiled from Go."
  • Load is 5 RPS. The load generator is a container running a curl loop with sleep 0.2, and the README states 5 RPS explicitly. In other words, this table is not memory under active traffic — it is effectively resident memory at near-idle.
  • The measurement tool is docker stats. That's whole-container RSS, not Envoy's internal memory stats. The container memory limit is 15.6 GiB, so there's no memory pressure at all.
  • The logic is a coin flip. It's work at the level of returning 200/500 based on a deterministic hash of the full request headers — a far cry from what a real extension does.
  • Envoy is pinned to v1.33.

Double-checking the numbers themselves turns up one more thing. The doc's summary bullet claims Lua memory is "~10x lower" than Wasm, but dividing the table's own values directly gives 5.9x at concurrency 1 and 11.4x at concurrency 16. The "10x" framing only holds at high concurrency.

And the "roughly 110 MiB to roughly 290 MiB" range in the doc's body text actually mixes two different runtimes. The original repo benchmarked two Wasm runtimes side by side, V8 and WAMR — the ~110 figure is WAMR's concurrency-1 value (110.6), and the ~290 figure is V8's concurrency-16 value (291.8). The doc's own table, meanwhile, only includes V8, whose concurrency-1 value is 117.7. Not a major error, but the table and the body text aren't actually describing the same data.

None of this means the numbers are useless. The direction is clearly right — Wasm memory scales close to linearly with concurrency because Envoy keeps a Wasm VM per worker thread, and Lua staying flat is the flip side of the same reason. On a production gateway running 16 workers, this difference shows up for real. But you shouldn't lift "Wasm eats 290 MiB per pod" straight into your own capacity planning. That number is the RSS of a container running a Go-written coin flip at 5 RPS. It's a value you need to re-measure with your own language, your own load, and your own module.

Lua Has Only fail-open — The Proto File Proves It

There's a line in the doc's comparison table that matters far more than memory: the failure policy.

The doc states Wasm's failure policy is "configurable — fail-closed by default," while Lua is "fail-open only — no configuration option." Is that just the doc's own claim? Looking at the proto definitions, it's confirmed structurally.

message WasmConfig {
  string url = 1;
  string sha256 = 2;
  PullPolicy image_pull_policy = 3;
  string image_pull_secret = 4;
  string verification_key = 5;
  google.protobuf.Struct plugin_config = 6;
  string plugin_name = 7;
  FailStrategy fail_strategy = 8;
  VmConfig vm_config = 9;
  PluginType type = 10;
}

message LuaConfig {
  string inline_code = 1;
}

LuaConfig has exactly one field, inline_code. There's no fail_strategy field at all. When the doc says "no configuration option," that isn't rhetoric — it's exactly what the API surface shows.

On the Wasm side, FailStrategy defaults to FAIL_CLOSE = 0, and the proto comment warns that FAIL_OPEN "is not recommended for the authentication or the authorization plugins." Flip that warning around and the conclusion writes itself — you should not implement authentication or authorization logic in Lua. Lua can only fail open, and fail-open auth means the moment the script blows up, authentication passes through. The fact that the new API elevated Lua to first-class status doesn't mean "you can now write auth in Lua."

The isolation properties point the same direction. Per the doc, Wasm gets a per-plugin VM sandbox, so a crash stays contained to the plugin, while Lua runs in-process, so a crash can take down the worker thread. That's the price of the 20 MiB.

One more thing — the Wasm supply-chain story. WasmConfig has a verification_key field, but the comment in wasm.proto says this field "will not be implemented until the detailed design is established. For the future use, just keep this field in proto and hide from documentation." No code in the Istio source actually references this field. So when you pull Wasm from an OCI registry, the integrity mechanism you actually have is sha256 digest pinning, not signature verification. The sha256 side is properly implemented — reference an image by tag and populate this field, and the checksum of what you pull gets verified against it. If you're running external Wasm in production, pin by digest, not by tag.

What Happens to Existing WasmPlugin Resources

The first worry once you hear TrafficExtension replaces WasmPlugin is exactly this one. The answer is mild. There's no forced migration in 1.30, and existing WasmPlugin resources keep working as-is.

How it works under the hood is the interesting part. istiod internally converts every WasmPlugin into a TrafficExtension before generating the Envoy config. Open the conversion code and it's right there.

const syntheticMarker = "~istio-translated-wasmplugin"

func translateWasmPlugin(cfg config.Config) *config.Config {
	wp, ok := cfg.Spec.(*extensions.WasmPlugin)
	if !ok {
		return nil
	}
	targetRefs := wp.TargetRefs
	if wp.TargetRef != nil && len(targetRefs) == 0 {
		targetRefs = []*typeapi.PolicyTargetReference{wp.TargetRef}
	}
	te := &extensions.TrafficExtension{
		Selector:   wp.Selector,
		TargetRefs: targetRefs,
		Phase:      extensions.TrafficExtension_ExecutionPhase(wp.Phase),
		Priority:   wp.Priority,
		Match:      convertTrafficSelectors(wp.Match),
		FilterConfig: &extensions.TrafficExtension_Wasm{
			Wasm: &extensions.WasmConfig{ /* ...field copies... */ },
		},
	}
	// name gets the syntheticMarker suffix appended to mark it as a synthetic resource
	return &config.Config{ /* ... */ }
}

One thing worth knowing when you're debugging: the synthesized resource's name has ~istio-translated-wasmplugin appended to the original name. If you dump istiod's internal config and see a name like that, it's not a ghost you never created — it's the converted output of your own WasmPlugin.

For what it's worth, WasmPlugin's phase enum and its nested TrafficSelector type are intentionally kept in the proto for backward compatibility. Bottom line: WasmPlugin is now, in effect, a thin legacy surface over TrafficExtension.

So Is Ambient Production-Ready? Reading the Maturity Table As-Is

The phrase "ambient is GA" is too blunt an instrument on its own. Istio tracks per-feature maturity in features.yaml, and reading that directly is a lot more honest. As of July 2026, the ambient area looks like this.

FeatureMaturity
ztunnel: CoreStable
Waypoints: CoreStable
Waypoints: Gateway API Stable Channel (HTTPRoute, GRPCRoute)Stable
Waypoints: DestinationRuleStable
AuthorizationPolicyStable
PeerAuthenticationStable
Waypoints: Cross-namespace usageBeta
RequestAuthenticationBeta
DNS ProxyingBeta
Dual Stack, IPv6Beta
Multi-network multiclusterBeta
Waypoints: Gateway API Experimental Channel (TLSRoute, TCPRoute)Alpha
Waypoints: VirtualServiceAlpha
Waypoints: WebAssembly extensibility (WasmPlugin)Alpha
Baggage based telemetryAlpha

Here's how to read it. The core is genuinely stable — ztunnel and the waypoint core itself, mTLS, and authorization policy are all Stable. That's the honest scope of "ambient is GA," and if all you need is L4 mTLS and L4 authorization, most services can take that claim at face value.

The problem is the layer above it. Wasm extensibility on waypoints is still Alpha, and so is VirtualService. TrafficExtension, the star of this post, is itself explicitly alpha per its introduction post, and the doc page for attaching Wasm/Lua to waypoints also carries an alpha banner. In other words, the tool that showed up to close the EnvoyFilter gap is itself a tool that hasn't stabilized yet.

And VirtualService being Alpha stings for a separate reason. The migration doc strongly recommends moving to HTTPRoute, and warns that mixing VirtualService and HTTPRoute on the same workload is unsupported and leads to undefined behavior. DestinationRule traffic policies (connection pooling, outlier detection, TLS) are still supported as-is on waypoints, but HTTPRoute uses a Service as its backendRefs rather than a subset, so version-based traffic splitting requires a separate Service per version. For teams running subset-based canaries in the sidecar era, that's not a trivial rewrite.

When You Shouldn't Move Yet

Putting it together, here's the decision framework.

Ambient migration is reasonably smooth when

  • L4 mTLS and L4 authorization cover everything you need. You end up depending only on what's Stable in the maturity table.
  • Only a small number of services need L7, so you can deploy waypoints selectively.
  • You have no EnvoyFilter resources, or the ones you have can be deleted.

When it's still right to wait

  • Wasm/Lua extensions sit on the critical request path. That surface is still Alpha.
  • You're heavily dependent on subset-based VirtualService canaries. Compute the cost of the HTTPRoute rewrite and Service split first.
  • You handle authentication or authorization via a proxy extension and are considering moving it to Lua. The fact that fail-open is your only option rules this out.
  • You have even one EnvoyFilter with no alternative. In Istio's own doc's words, that's a blocker.

If you're going to migrate, the order is clear. Start with kubectl get envoyfilter -A to take inventory. Things that get silently ignored need to be counted before migration, not after.

Closing

Ambient's real cost doesn't show up on a resource table. Stripping out the sidecar means stripping out Envoy, and stripping out Envoy means stripping out everything that touched Envoy directly. EnvoyFilter was never a portable abstraction — it was a patch against one specific implementation, which is exactly why it doesn't come along. Istio's docs using the words "silently ignored" and "migration blocker" outright is, if anything, the honest move.

Istio 1.30's TrafficExtension is a serious attempt at closing that gap. It bundles Wasm and Lua into one API, attaches to waypoints with the same syntax, and absorbs existing WasmPlugin resources via internal conversion. But it isn't a replacement for EnvoyFilter — the "touch whatever piece of the Envoy config you want" power EnvoyFilter granted hasn't come back, and there's no plan for it to. What you've gained are two well-defined extension points, Wasm and Lua, not arbitrary filter-chain manipulation. If what your EnvoyFilter was doing can be expressed through those two, the migration works. If it can't, it doesn't. That's the whole story.

So start from your own inventory, not a benchmark table. The output of kubectl get envoyfilter -A tells you nearly everything about how hard your ambient migration will be.

References