Skip to content

Split View: ambient로 옮기면 EnvoyFilter는 조용히 무시된다 — Istio 1.30 TrafficExtension이 메운 구멍과 남은 구멍

|

ambient로 옮기면 EnvoyFilter는 조용히 무시된다 — Istio 1.30 TrafficExtension이 메운 구멍과 남은 구멍

들어가며 — 사이드카를 걷어낼 때 따라오지 않는 것

ambient 모드의 판매 문구는 늘 리소스입니다. 파드마다 붙던 Envoy 사이드카를 걷어내고, 노드마다 하나씩 도는 ztunnel과 필요한 곳에만 두는 waypoint로 나눈다 — 이 구조 자체와 사이드카와의 비교는 Istio ambient와 사이드카 비교 편에서 이미 다뤘으니 반복하지 않겠습니다.

여기서 다룰 것은 그 글들이 잘 다루지 않는 쪽입니다. 실제로 이전을 시도한 팀들이 막히는 지점은 대체로 CPU나 메모리가 아니라 확장성입니다. 사이드카 시절에 누구나 하나쯤 갖고 있던 그 EnvoyFilter — 헤더를 하나 더 붙이거나, 특이한 인증을 끼워 넣거나, Envoy의 잘 알려지지 않은 필터를 켜 두던 그 리소스 — 는 ambient로 넘어갈 때 따라오지 않습니다.

그리고 이건 제 추측이 아니라, Istio 공식 문서가 직접 적어 둔 내용입니다.

EnvoyFilter는 waypoint에서 조용히 무시된다

Istio 1.30과 함께 새로 들어온 사이드카에서 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" — 조용히 무시된다는 것입니다. 에러가 나지 않고, 파드가 죽지도 않습니다. 그냥 당신이 걸어 둔 필터가 더 이상 동작하지 않을 뿐입니다. 그게 인증 우회 방지 필터였다면 어떻게 되는지는 굳이 설명하지 않겠습니다.

같은 문서는 대처법을 세 가지로 제시하고, 마지막이 정직합니다.

  • 필터가 Envoy 커스텀 기능을 더하는 거라면, WasmPlugin으로 같은 동작을 낼 수 있는지 검토하라.
  • 더 이상 필요 없다면 지워라.
  • ambient 호환 대안이 없다면, 이건 마이그레이션 블로커다. 의존성이 해소될 때까지 진행하지 마라.

CNCF 발표 자료에서는 잘 안 나오는 문장입니다. "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 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에서 무시됩니다. 즉 ambient에서 Lua는 아예 선택지가 아니었습니다.

TrafficExtension은 Wasm과 Lua를 하나의 API로 묶고, 사이드카·게이트웨이·waypoint 모두에 같은 문법으로 붙입니다. 실제 프로토 정의를 보면 구조가 명확합니다.

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는 필터 체인의 알려진 지점(AUTHN/AUTHZ/STATS, 미설정이면 라우터 근처)에 꽂고, 같은 phase 안에서는 priority가 내림차순으로 — 값이 클수록 먼저 — 실행됩니다. 프로토 주석에 적힌 타이브레이커가 특히 실무에 유용합니다. priority가 없거나 같으면 순서는 creationTimestamp, 그다음 이름과 네임스페이스로 결정론적으로 정해집니다. 즉 "안 정해주면 랜덤"이 아니라 "안 정해주면 만든 시각순"입니다 — 재적용하면 순서가 바뀔 수 있다는 뜻이기도 합니다.

waypoint에서는 selector도 조용히 무시된다

여기서 두 번째 함정이 나옵니다. 그리고 첫 번째와 정확히 같은 방식으로 아픕니다.

TrafficExtension은 대상을 고르는 방법이 둘입니다. 사이드카는 라벨 기반 selector로, waypoint는 targetRefs로 잡습니다. waypoint는 워크로드에 라벨로 대응되는 물건이 아니기 때문입니다. 프로토 주석은 이 점을 대문자로 못 박아 뒀습니다.

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

또 "ignored"입니다. 사이드카에서 잘 돌던 selector 기반 확장을 그대로 waypoint에 들고 가면, 에러 없이 그냥 적용되지 않습니다. 이 패턴은 ambient 이전 전반에 반복됩니다 — 같은 마이그레이션 문서는 L7 규칙을 쓰거나 action: CUSTOM/AUDIT를 쓰는 AuthorizationPolicy, 그리고 RequestAuthenticationWasmPlugin도 전부 targetRefs로 바꿔 달아야 한다고 적고 있습니다.

targetRefs가 받아 주는 종류도 프로토에 명시돼 있습니다 — 같은 네임스페이스의 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 vs Wasm — 공식 문서의 메모리 표를 그대로 믿으면 안 되는 이유

이제 Lua가 1급 시민이 됐으니, 당연히 "그래서 뭘 쓰지"가 따라옵니다. 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입니다. Envoy 내부 메모리 통계가 아니라 컨테이너 전체 RSS입니다. 컨테이너 메모리 한도는 15.6 GiB로, 압박이 전혀 없는 상태입니다.
  • 로직은 코인 플립입니다. 요청 헤더 전체의 결정론적 해시로 200/500을 반환하는 수준의 작업입니다. 실제 확장이 하는 일과는 거리가 있습니다.
  • Envoy는 v1.33에 고정돼 있습니다.

여기에 숫자 자체를 검산해 보면 한 가지가 더 걸립니다. 문서의 요약 항목은 Lua 메모리가 Wasm보다 "~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가 워커 스레드마다 Wasm VM을 두기 때문이고, Lua가 평평한 것도 같은 이유의 뒷면입니다. 워커를 16개 쓰는 프로덕션 게이트웨이라면 이 차이는 실제로 나타납니다. 다만 "Wasm은 파드당 290 MiB를 먹는다"를 당신의 용량 산정에 그대로 옮겨 적으면 안 됩니다. 그 숫자는 Go로 짠 코인 플립을 5 RPS로 돌린 컨테이너의 RSS입니다. 당신의 언어, 당신의 부하, 당신의 모듈에서 다시 재야 하는 값입니다.

Lua에는 fail-open 밖에 없다 — 프로토파일이 증명한다

문서의 비교표에서 메모리보다 훨씬 중요한 줄은 따로 있습니다. 실패 정책입니다.

문서는 Wasm의 실패 정책이 "configurable — fail-closed by default"인 반면 Lua는 "fail-open only — no configuration option"이라고 적었습니다. 이건 문서의 주장일 뿐일까요? 프로토 정의를 보면 구조적으로 확인됩니다.

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이고, 프로토 주석은 FAIL_OPEN에 대해 "this flag is not recommended for the authentication or the authorization plugins"라고 경고합니다. 이 경고를 그대로 뒤집어 읽으면 결론이 나옵니다 — 인증·인가 로직을 Lua로 짜면 안 됩니다. Lua는 fail-open만 가능하고, fail-open 인증은 스크립트가 터지는 순간 인증이 통과되는 걸 뜻하니까요. 새 API가 Lua를 1급으로 올려 줬다는 사실이 "이제 Lua로 인증을 짜도 된다"는 뜻은 아닙니다.

격리 특성도 같은 방향입니다. 문서에 따르면 Wasm은 플러그인마다 VM 샌드박스가 있어 크래시가 플러그인 안에 갇히지만, Lua는 인프로세스로 돌기 때문에 크래시가 워커 스레드를 죽일 수 있습니다. 메모리 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은 어떻게 되나

TrafficExtensionWasmPlugin을 대체한다고 하면 가장 먼저 나오는 걱정이 이겁니다. 답은 온건합니다. 1.30에는 강제 마이그레이션이 없고, 기존 WasmPlugin 리소스는 그대로 동작합니다.

작동 방식이 재밌습니다. istiod가 내부적으로 모든 WasmPluginTrafficExtension으로 변환한 다음 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 타입은 하위 호환을 위해 프로토에 의도적으로 남겨 둔 상태입니다. 정리하면, 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 확장성은 여전히 Alpha이고, VirtualServiceAlpha입니다. 이 글의 주인공인 TrafficExtension 자체도 소개 글이 명시하듯 alpha이며, waypoint에 Wasm/Lua를 붙이는 문서 페이지에도 alpha 배너가 붙어 있습니다. 다시 말해 EnvoyFilter 구멍을 메우러 온 그 도구가, 아직 안정화되지 않은 도구입니다.

그리고 VirtualService가 Alpha라는 점은 별도로 아픕니다. 마이그레이션 문서는 HTTPRoute로 옮기기를 강력히 권하면서, 같은 워크로드에 VirtualServiceHTTPRoute를 섞는 건 지원되지 않고 정의되지 않은 동작으로 이어진다고 경고합니다. DestinationRule의 트래픽 정책(커넥션 풀, 이상치 감지, TLS)은 waypoint에서 그대로 지원되지만, HTTPRoute는 subset이 아니라 Service를 backendRefs로 쓰기 때문에 버전별 트래픽 분할을 하려면 버전마다 Service를 따로 만들어야 합니다. 사이드카 시절 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의 진짜 비용은 리소스 표에 안 나옵니다. 사이드카를 걷어낸다는 건 Envoy를 걷어낸다는 뜻이고, Envoy를 걷어낸다는 건 Envoy에 직접 손대던 모든 것을 걷어낸다는 뜻입니다. EnvoyFilter는 이식 가능한 추상이 아니라 특정 구현에 대한 패치였고, 그래서 따라오지 못합니다. Istio 문서가 "silently ignored"와 "migration blocker"라는 단어를 직접 쓴 건 오히려 정직한 편입니다.

Istio 1.30의 TrafficExtension은 그 구멍을 겨냥한 진지한 시도입니다. Wasm과 Lua를 한 API로 묶고, waypoint에도 같은 문법으로 붙고, 기존 WasmPlugin을 내부 변환으로 흡수합니다. 다만 그게 EnvoyFilter의 대체재는 아닙니다 — EnvoyFilter가 주던 "Envoy 설정 아무거나 건드리기"는 돌아오지 않았고, 돌아올 계획도 없습니다. 당신이 얻은 건 Wasm과 Lua라는 두 개의 잘 정의된 확장점이지, 임의의 필터 체인 조작이 아닙니다. 당신의 EnvoyFilter가 하던 일이 그 둘로 표현되면 이전이 되고, 안 되면 안 됩니다. 그게 전부입니다.

그러니 벤치마크 표가 아니라 당신의 재고 목록에서 시작하십시오. kubectl get envoyfilter -A의 출력이 당신의 ambient 이전 난이도를 거의 다 말해 줍니다.

참고 자료

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

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