Skip to content
Published on

迁移到 ambient,EnvoyFilter 会被静默忽略 — Istio 1.30 TrafficExtension 补上的缺口,以及仍未补上的缺口

分享
Authors

引言 — 拆掉 sidecar 之后,不会跟着走的东西

ambient 模式的卖点,说来说去都是资源。把每个 Pod 上挂的 Envoy sidecar 拆掉,换成每个节点跑一个的 ztunnel,再加上只在需要的地方放置的 waypoint — 这个架构本身,以及它和 sidecar 的对比,已经在Istio ambient 与 sidecar 对比一文里讲过了,这里不再重复。

这里要讲的,是那些文章通常不太涉及的一面。真正尝试迁移的团队卡住的地方,往往不是 CPU 或内存,而是可扩展性。sidecar 时代几乎人人都攒过一个的那种 EnvoyFilter — 多加一个请求头、塞进某种奇特的认证方式、或者打开某个鲜为人知的 Envoy filter 的那种资源 — 迁到 ambient 时并不会跟过来。

而这不是我的猜测,是 Istio 官方文档白纸黑字写的。

EnvoyFilter 在 waypoint 上会被静默忽略

Istio 1.30 新增的从 sidecar 向 ambient 迁移策略文档里,是这么说的。

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.

关键在「silently ignored」— 静默忽略。不会报错,Pod 也不会崩溃,只是你原本挂上的那个 filter 不再起作用了。如果那恰好是一个防止认证绕过的 filter,会发生什么,这里就不多说了。

同一份文档给出了三种应对方式,最后一种最诚实。

  • 如果 filter 加的是 Envoy 的自定义功能,检查 WasmPlugin 能不能实现同样的行为。
  • 如果已经不再需要,删掉它。
  • 如果没有 ambient 兼容的替代方案,这就是一个迁移阻塞项。 在依赖解决之前不要继续推进。

这是 CNCF 大会 PPT 里不太常见的一句话。它意味着「ambient 已经 GA」和「你的 EnvoyFilter 是个迁移阻塞项」可以同时成立。

为什么会这样,看一眼 EnvoyFilter 的设计就能理解。EnvoyFilter API 定义里的注释是这样介绍自己的 — 这个功能「must be used with care, as incorrect configurations could potentially destabilize the entire mesh」,而且「some aspects of this API are deeply tied to the internal implementation in Istio's networking subsystem as well as Envoy's XDS API」。这是一个直接深入 istiod 生成的 Envoy 配置内部去改的 API,所以它没办法原样搬到数据面不是 Envoy 的地方(ztunnel 是一个用 Rust 写的独立代理),也没办法搬到生成路径完全不同的地方(waypoint)。EnvoyFilter 从一开始就不是一个可移植的抽象,而是针对某个具体实现的补丁。

Istio 1.30 给出的答案 — TrafficExtension

Istio 1.30.0 于 2026 年 5 月 18 日发布,随之而来的正是一个瞄准这个缺口的 API。Docusign 的 Liam White 撰写的TrafficExtension API 介绍一文交代了背景。

在此之前,Istio 官方支持的扩展 API 只有 WasmPlugin 一个。想用 Lua,就只能经由 EnvoyFilter — 而正如前面所见,它在 waypoint 上会被忽略。也就是说,Lua 在 ambient 上根本不是一个选项。

TrafficExtension 把 Wasm 和 Lua 合并到一个 API 里,并以相同的语法挂到 sidecar、网关、waypoint 上。看一眼实际的 proto 定义,结构就很清楚了。

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;
  }
}

oneof 这一点很关键 — 一个资源里只能装 Wasm 或者 Lua 二者之一,不能同时装。

顺序由 phasepriority 负责。phase 把 filter 钉在 filter 链上的一个已知位置(AUTHN/AUTHZ/STATS,不设置则靠近 router),同一个 phase 内,priority 按降序执行 — 值越大越先执行。proto 注释里写的这个 tie-breaker 在实践中特别有用:如果 priority 缺失或相同,顺序就由 creationTimestamp 决定性地确定,其次是名称和命名空间。也就是说「不设置」不等于「随机」,而是「按创建时间」— 这也意味着重新应用资源可能会改变顺序。

waypoint 上 selector 同样会被静默忽略

第二个陷阱来了,而且疼法和第一个一模一样。

TrafficExtension 选取目标的方式有两种。sidecar 用基于标签的 selector,waypoint 用 targetRefs。这是因为 waypoint 不是那种能通过标签映射到工作负载的东西。proto 注释把这一点大写字母钉死了。

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

又是「ignored」。把一个在 sidecar 上运作良好的基于 selector 的扩展原样搬到 waypoint,它就是不生效,也不报错。这个模式在整个 ambient 迁移过程中反复出现 — 同一份迁移文档还提到,使用 L7 规则或 action: CUSTOM/AUDITAuthorizationPolicy,以及 RequestAuthenticationWasmPlugin,全部都得改用 targetRefs

targetRefs 能接受的类型也在 proto 里写明了 — 同命名空间的 Gateway(group 为 gateway.networking.k8s.io)、根命名空间的 GatewayClass、同命名空间的 Service(仅限 waypoint)、同命名空间的 ServiceEntry。最多 16 个,selectortargetRefs 不能同时使用。

在 ambient 上给 waypoint 挂 Wasm 的最小形式是这样的。

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 对比 Wasm — 为什么不能照单全收官方文档的内存对比表

既然 Lua 已经成了一等公民,接下来自然要问「那到底该用哪个」。Istio 的可扩展性概念文档用一张内存对比表来回答这个问题。Lua 大约用 20~26 MiB,与并发度无关;Wasm 在低并发下约 110 MiB,高并发下能到约 290 MiB。

并发度(--concurrency)Lua (MiB)Wasm (MiB)
119.79117.7
223.07132.5
422.63152.0
823.97190.9
1625.66291.8

只看数字,结论似乎很明显。但打开文档链接的原始基准测试仓库,测量条件就浮出水面了,而这些条件相当程度地动摇了这个结论。顺便一提,这份基准测试的作者,正是那篇 TrafficExtension API 介绍文章的作者本人 — 也就是说这是作者自测

仓库的 READMEdocker-compose.yml 披露的条件是这样的:

  • Wasm 模块是用 Go 编译的。 用 Go 1.24.4,以 WASI 为目标,c-shared 构建模式编译。这一点是决定性的。用 Go 编译出来的 Wasm,会把整个 Go 运行时和 GC 一并带进每一个沙箱实例。同样的逻辑如果用 Rust 或 C++ 写,数字会完全不同。文档表格里只写了「Wasm」,但实际测的是「用 Go 编译出来的 Wasm」。
  • 负载是 5 RPS。 负载生成器是一个跑 curl 循环、循环里带 sleep 0.2 的容器,README 也明确写着 5 RPS。也就是说,这张表测的不是承接流量时的内存,而是近乎空闲状态下的常驻内存
  • 测量工具是 docker stats 测的是整个容器的 RSS,不是 Envoy 内部的内存统计。容器内存上限是 15.6 GiB,完全没有内存压力。
  • 逻辑是抛硬币级别的。 对整个请求头做一次确定性哈希,然后返回 200 或 500,跟真实扩展做的事相去甚远。
  • Envoy 版本固定在 v1.33。

把数字本身拿来验算一遍,又发现一处问题。文档摘要里说 Lua 的内存比 Wasm「低约 10 倍(~10x lower)」,但直接用表里的数值相除,并发度 1 时是 5.9 倍,16 时是 11.4 倍。「10 倍」这个说法只在高并发时才成立。

而且文档正文里「大约 110 MiB 到大约 290 MiB」这个区间,其实混用了两种不同的运行时。原始仓库并排测了 V8 和 WAMR 两个 Wasm 运行时,约 110 是 WAMR 在并发度 1 时的值(110.6),约 290 是 V8 在并发度 16 时的值(291.8)。而文档自己的表格只收录了 V8,其并发度 1 的值是 117.7。这不算大错,但表格和正文说的并不是同一份数据。

这不是说这些数字毫无用处。方向明显是对的 — Wasm 内存随并发度接近线性增长,是因为 Envoy 给每个 worker 线程都配了一个 Wasm VM;Lua 之所以平坦,也是同一个原因的另一面。在一个跑 16 个 worker 的生产网关上,这个差异是真实存在的。但你不该把「Wasm 每个 Pod 吃 290 MiB」原样抄进自己的容量规划里。那个数字,是一个用 Go 写的抛硬币逻辑、在 5 RPS 下跑出来的容器 RSS。它是需要你用自己的语言、自己的负载、自己的模块重新测一遍的数值。

Lua 只有 fail-open 一条路 — proto 定义能证明

文档对比表里,比内存重要得多的是另一行:失败策略。

文档写道,Wasm 的失败策略是「configurable — fail-closed by default」,而 Lua 是「fail-open only — no configuration option」。这只是文档自己的说法吗?看一眼 proto 定义,结构上就能得到印证。

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 只有 inline_code 这一个字段,压根就没有 fail_strategy 字段。文档说「没有配置选项」,不是修辞,就是 API 表面本身如此。

Wasm 这边的 FailStrategy 默认值是 FAIL_CLOSE = 0,proto 注释对 FAIL_OPEN 给出的警告是「this flag is not recommended for the authentication or the authorization plugins」。把这个警告反过来读,结论就出来了 — 不应该用 Lua 来写认证、授权逻辑。 Lua 只能 fail-open,而 fail-open 式的认证意味着脚本一崩溃,认证就直接放行了。新 API 把 Lua 提升为一等公民,并不代表「现在可以用 Lua 写认证了」。

隔离特性也指向同一个方向。按文档所说,Wasm 每个插件都有独立的 VM 沙箱,崩溃会被限制在插件内部;而 Lua 是进程内运行,一崩溃可能连带拖垮 worker 线程。这是省下那 20 MiB 内存的代价。

还有一点 — Wasm 的供应链问题。WasmConfig 里有个 verification_key 字段,但wasm.proto 的注释写着「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」。实际上 Istio 源码里没有任何代码引用这个字段。也就是说,从 OCI 镜像仓库拉取 Wasm 时,你实际能用的完整性手段是 sha256 摘要固定,而不是签名验证。sha256 这一侧倒是实现得妥妥当当 — 用标签引用镜像的同时填上这个字段,拉下来的内容就会被拿去核对校验和。如果你要在生产环境用外部 Wasm,请用摘要而不是标签来固定版本。

现有的 WasmPlugin 会怎样

一听说 TrafficExtension 要取代 WasmPlugin,最先冒出来的担心就是这个。答案很温和。1.30 没有强制迁移,现有的 WasmPlugin 资源照常运作。

运作方式挺有意思。istiod 内部会先把所有 WasmPlugin 转换成 TrafficExtension,再生成 Envoy 配置。打开转换代码就能看到这一点。

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{ /* ...复制各字段... */ },
		},
	}
	// 名称后缀加上 syntheticMarker,标记这是一个合成资源
	return &config.Config{ /* ... */ }
}

调试时有件事值得知道。合成出来的资源,名字是在原名后面加上 ~istio-translated-wasmplugin。如果你导出 istiod 内部配置时看到这样的名字,那不是你从没创建过的幽灵资源,而是你自己的 WasmPlugin 被转换后的结果。

顺带一提,WasmPluginphase 枚举和嵌套的 TrafficSelector 类型,是为了向后兼容而特意保留在 proto 里的。归纳一下,WasmPlugin 现在实质上已经变成了 TrafficExtension 的一层薄薄的历史遗留接口。

那么 ambient 到底能不能上生产 — 照实读成熟度表

「ambient 已经 GA」这句话本身太笼统了。Istio 把每个功能的成熟度记录在 features.yaml 里,照实读它要诚实得多。截至 2026 年 7 月,ambient 领域是这样的。

功能成熟度
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

读法是这样的。核心部分是真的稳定 — ztunnel 和 waypoint 本体、mTLS、授权策略都是 Stable。这就是「ambient 已经 GA」这句话诚实的边界,如果你只需要 L4 mTLS 和 L4 授权,对大多数服务而言,这句话可以照单全收。

问题出在上面那一层。waypoint 上的 Wasm 扩展性仍然是AlphaVirtualService 也是Alpha。本文的主角 TrafficExtension 本身,正如其介绍文章明确说明的那样也是 alpha,在 waypoint 上挂 Wasm/Lua 的文档页面上同样挂着 alpha 的横幅。换句话说,那个赶来补 EnvoyFilter 缺口的工具本身,还是一个尚未稳定下来的工具。

VirtualService 是 Alpha 这件事,还有另一层痛处。迁移文档强烈建议迁到 HTTPRoute,并警告在同一个工作负载上混用 VirtualServiceHTTPRoute 是不受支持的,会导致未定义行为。DestinationRule 的流量策略(连接池、异常检测、TLS)在 waypoint 上照样支持,但 HTTPRoute 用的是 Service 而不是 subset 作为 backendRefs,所以要做按版本的流量拆分,就得给每个版本单独建一个 Service。对于 sidecar 时代靠 subset 做金丝雀发布的团队来说,这不是一件轻松的返工。

什么时候不该迁移

归纳起来,判断标准是这样的。

ambient 迁移比较顺利的情况

  • 需求全部就是 L4 mTLS 和 L4 授权,最终只依赖成熟度表里 Stable 的那些。
  • 需要 L7 的服务只是少数,可以只在这些地方有选择地放置 waypoint。
  • 没有 EnvoyFilter,或者有也是可以删掉的那种。

仍然应该再等等的情况

  • Wasm/Lua 扩展处在请求路径的核心位置。这个能力面目前还是 Alpha。
  • 深度依赖基于 subset 的 VirtualService 金丝雀发布。得先算清楚 HTTPRoute 返工和拆分 Service 的成本。
  • 用代理扩展来处理认证、授权,而且正打算迁到 Lua。只能 fail-open 这个事实,直接排除了这个选项。
  • 哪怕只有一个没有替代方案的 EnvoyFilter。用 Istio 文档自己的说法,那就是一个阻塞项。

如果决定要迁移,顺序很清楚。先用 kubectl get envoyfilter -A 摸清家底。会被静默忽略的东西,应该在迁移之前数清楚,而不是迁移之后。

结语

ambient 真正的成本,不会体现在资源对比表上。拆掉 sidecar 意味着拆掉 Envoy,拆掉 Envoy 意味着拆掉所有直接动过 Envoy 的东西。EnvoyFilter 从来就不是一个可移植的抽象,而是针对某个具体实现的补丁,所以它跟不过来。Istio 文档直接用「silently ignored」和「migration blocker」这样的字眼,反倒算是诚实的做法。

Istio 1.30 的 TrafficExtension,是对这个缺口一次认真的尝试。它把 Wasm 和 Lua 合并到一个 API 里,以同样的语法挂到 waypoint 上,并通过内部转换吸收了现有的 WasmPlugin。但它并不是 EnvoyFilter 的替代品 — EnvoyFilter 曾经给的那种「随便动 Envoy 配置的哪个角落」的能力,并没有回来,也没有计划让它回来。你得到的,是 Wasm 和 Lua 这两个定义明确的扩展点,而不是任意的 filter 链操作。如果你的 EnvoyFilter 做的事,能用这两者表达出来,迁移就成立;表达不出来,就不成立。仅此而已。

所以,别从基准测试表开始,从你自己的家底开始。kubectl get envoyfilter -A 的输出,几乎已经说明了你的 ambient 迁移会有多难。

参考资料