Skip to content
Published on

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

공유하기
Authors

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

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 이전 난이도를 거의 다 말해 줍니다.

참고 자료