--- type: note tags: [istio, envoy, filter-chain, hcm, wasm, lua, envoyfilter] created: 2026-06-07 --- # Envoy는 listener의 network filter와 HCM의 HTTP filter chain으로 요청을 처리하며, Lua·Wasm·external processing으로 재컴파일 없이 확장된다 > [!abstract] 이 문서가 다루는 것 > Envoy의 요청 처리는 **두 겹의 filter chain**이다 — L4(connection) 단의 **network filter chain**과, 그 안의 HTTP connection manager(HCM)가 여는 **L7 HTTP filter chain**. 이 한 장의 그림만 잡으면 두 가지가 동시에 풀린다: ① "L7 정책(JWT·CORS·L7 RBAC·path 라우팅)이 왜 안 먹는가" 같은 진단, ② Lua·Wasm·external processing이라는 세 확장 경로가 사실은 *같은 L7 chain에 필터 하나를 더 끼우는 같은 일*이라는 것. 결론부터: 확장은 별개 메커니즘이 아니라 전부 HCM의 L7 chain 조작이고, Istio에서 그 chain을 직접 건드리는 escape hatch가 `EnvoyFilter`다. 운영 가드레일·우선순위 사다리는 [xDS 5계층과 진단](xds__src-xds-layers-and-diagnosis.html) §07로 위임하고, 이 note는 **멘탈모델**에 집중한다. > > 대상환경 Istio 1.30 / Envoy · 대상독자 L7 정책·확장이 "조용히 무시되는" 이유를 메커니즘으로 알고 싶은 DevOps/SRE · 범위 filter chain 구조와 확장 주입 메커니즘(가드레일은 링크) · 선행개념 [xDS layer 개관](xds__note-xds-api-layers.html), listener/route의 LDS/RDS 관계. --- ## 01. 배경 — 왜 "두 겹"이어야 했나 프록시가 풀어야 하는 근본 긴장은 이거다: 한쪽 끝에는 **raw TCP 바이트**(커널이 주는 건 이게 전부다)가 있고, 다른 쪽 끝에는 **HTTP 의미**(이 요청의 path는 뭐고 JWT는 유효한가, 이 method를 이 principal이 호출해도 되는가)로만 쓸 수 있는 정책이 있다. 바이트 단에서는 "GET /admin"이라는 개념 자체가 없으니 path 기반 RBAC을 적용할 방법이 없다. 그렇다고 모든 커넥션을 HTTP로 파싱하면, 순수 TCP(DB, Redis, mTLS passthrough)까지 깨진다. Envoy의 답은 **두 레이어를 중첩**시키는 것이다. 바깥은 바이트를 다루는 **L4 network filter chain**, 안쪽은 그것을 HTTP 객체로 승격시킨 뒤 도는 **L7 HTTP filter chain**. 그리고 두 레이어를 잇는 경첩이 단 하나의 컴포넌트 — **HCM(`http_connection_manager`)** 이다. HCM이 chain에 놓이면 그 지점부터 L7 세계가 열리고, 없으면 그 커넥션은 영원히 바이트 단에 머문다. 이 구조를 모르면 Istio에서 가장 흔한 함정에 그대로 빠진다: AuthorizationPolicy를 정확히 썼는데 path match가 무시된다, VirtualService 라우팅이 안 먹는다 — 정책 오타가 아니라 **그 포트에 HCM이 안 깔린 것**(L7 chain 자체가 없는 것)이 원인인 경우다. 그래서 이 note는 진단의 토대이기도 하다. ## 02. 핵심 그림 — L4 chain ⊃ HCM ⊃ L7 chain 머릿속에 박을 단 하나의 그림: **HCM은 "또 하나의 network filter"다.** L4와 L7은 별도 시스템이 아니라, L4 chain의 한 슬롯에 HCM이 들어앉으면 거기서부터 L7 chain이 *열리는* 중첩 구조다. 나머지는 전부 여기서 따라 나온다. 커넥션이 listener에 도착하면 바이트는 곧장 HTTP가 되지 않고 먼저 **L4 network filter chain**을 통과한다. network filter는 raw TCP 스트림을 다루는 필터다 — `tls_inspector`(ClientHello에서 SNI/ALPN 추출), `http_connection_manager`(HCM), `tcp_proxy`(L4 그대로 upstream에 전달) 등. L4 chain의 슬롯에 무엇이 들어가느냐가 그 커넥션의 운명을 가른다. - 슬롯에 **HCM**이 들어가면 → 바이트가 request line/header/body로 파싱되고, 그 안에서 **별도의 L7 HTTP filter chain**이 돈다. path·header·JWT·L7 RBAC이 전부 이때 적용된다. - 슬롯에 **`tcp_proxy`**만 있으면 → L7 해석 없이 통과(SNI 기반 passthrough, mTLS, L4 RBAC까지만). 이것이 "포트가 HTTP로 인식 안 되면 라우팅/정책이 안 먹는" 증상의 근본 이유다. ```mermaid flowchart TB CONN["TCP connection on listener"] subgraph L4["L4 network filter chain"] INSPECT["tls_inspector / http_inspector"] PICK["filter_chain_match (SNI/ALPN/port)"] HCM["http_connection_manager"] TCPPROXY["tcp_proxy (non-HTTP path)"] end subgraph L7["L7 HTTP filter chain (inside HCM)"] CORS["cors"] JWT["jwt_authn"] RBAC["rbac (AuthorizationPolicy)"] LUA["lua / wasm / ext_proc"] ROUTER["router (terminal)"] end CONN --> INSPECT --> PICK PICK --> HCM PICK --> TCPPROXY HCM --> CORS --> JWT --> RBAC --> LUA --> ROUTER ``` 이 그림이 xDS와 어떻게 맞물리는지도 한 줄로 잡아두면 진단이 쉬워진다: L4 network filter chain은 [LDS](xds__src-xds-layers-and-diagnosis.html)로 내려오는 listener 설정의 일부이고, HCM 안의 route 결정은 [RDS](xds__note-envoy-routing-chain-debugging.html)가 채운다. 즉 한 listener = "L4 chain + (HCM이면) L7 chain + route table"의 합성이다. ## 03. HCM이라는 경첩 — 바이트→HTTP 추상화 경계 HCM이 왜 그렇게 중요한지는 "그 하나가 무엇을 책임지는가"를 보면 분명해진다. HCM은 Envoy에서 **L4와 L7을 가르는 단일 컴포넌트**이고, 그 책임이 곧 L7 세계의 존재 조건이다. - **프로토콜 파싱/협상**: HTTP/1.1, HTTP/2, (설정 시) HTTP/3를 인식해 바이트를 request/response 객체로 변환. ALPN·`http2_protocol_options`로 결정. - **HTTP filter chain 실행**: 파싱된 요청을 §04의 L7 필터들에 순서대로 통과시킴. 마지막 `router` 필터가 route table을 보고 upstream cluster를 선택. - **route table 연결**: 인라인(`route_config`) 또는 RDS(`rds`)로 받은 route configuration을 참조 — virtual host → route entry → cluster. - **공통 L7 처리**: access logging, request ID 생성/전파(`x-request-id`), tracing span, timeout/retry의 HTTP 의미 적용, header 조작. 여기서 "왜"가 나온다: 이 네 책임이 **전부 HCM 안에서만** 일어나기 때문에, L7 정책(AuthorizationPolicy의 path/method match, JWT 검증, CORS, header 라우팅)은 HCM이 있을 때만 적용된다. Istio가 포트를 HTTP로 식별하지 못하면(이름 없는 포트, `appProtocol`/포트명 prefix 미지정 등) HCM 대신 `tcp_proxy`가 깔리고, 그 순간 L7 정책은 **에러 없이 조용히 무시**된다. 무시인지 거부인지 헷갈리는 이유가 바로 이 silent fallback이다. ```text L4만 (tcp_proxy) : SNI 기반 passthrough, mTLS, L4 AuthorizationPolicy까지 L4 + HCM (L7 filters) : path/header 라우팅, JWT, CORS, L7 RBAC, retry, Lua/Wasm/ext_proc ``` ## 04. L7 chain의 순서와 종결성 — 위치가 곧 정책 의미 HCM 안의 HTTP filter chain은 **순서가 있는 파이프라인**이다. 요청은 위에서 아래로(decoder 방향), 응답은 아래에서 위로(encoder 방향) 흐른다. 각 필터는 `Continue`(다음 필터로)·`StopIteration`(보류, 비동기 처리 후 재개)·직접 응답(예: RBAC deny → 403) 중 하나를 반환한다. 체인의 **마지막은 항상 `router` 필터**다 — 이것이 terminal filter로, route를 평가해 upstream으로 요청을 보낸다. 한번 router를 지나면 요청은 이미 떠난 뒤다. 따라서 **모든 확장 필터는 반드시 `router` 앞에** 위치해야 한다. router 뒤에 끼우면 호출되긴 해도 요청을 바꿀 수 없어 의미가 없다. Istio가 자동으로 까는 대표 L7 필터(요청 방향 순서, 대략): ```text istio.metadata_exchange → cors → fault → (jwt_authn) → (istio_authn) → (rbac: AuthorizationPolicy) → (확장: lua/wasm/ext_proc) → istio.stats → router (terminal) ``` 여기가 이 note의 보안적 핵심이다 — **확장 필터를 어디에 끼우느냐가 곧 정책 의미를 바꾼다.** `INSERT_BEFORE`/`INSERT_AFTER`/`INSERT_FIRST`로 router 또는 rbac 같은 특정 필터 기준 상대 위치를 정하는데, 인증/인가 필터보다 **앞**에 둔 Wasm은 미인증·미허가 요청도 그대로 보게 되고, **뒤**에 두면 통과된 요청만 본다. 즉 같은 코드라도 위치 하나로 보안 우회가 생기거나 막힌다. "router 앞에"만 외우면 안 되고, rbac/jwt_authn 기준 상대 위치를 의식해야 한다. ## 05. 재컴파일 없는 3가지 확장 경로 — 같은 chain, 다른 실행 장소 이제 핵심 통찰로 묶인다: Envoy를 fork·재빌드하지 않고 로직을 주입하는 정식 경로는 셋인데, **모두 HCM의 L7 chain에 새 필터를 하나 추가하는 같은 일**이다. 다른 건 메커니즘이 아니라 *그 필터의 코드가 어디서 실행되는가*뿐이다 — Envoy 프로세스 안인가, sandbox VM인가, 네트워크 건너 별도 서버인가. 이 한 축(실행 장소)으로 트레이드오프 전체가 결정된다. | 경로 | 실행 위치 | Istio 표면 | 적합 / 한계 | |---|---|---|---| | **Lua** | Envoy 프로세스 내(인라인 스크립트) | `EnvoyFilter`의 `envoy.filters.http.lua` | 가벼운 header/body 조작·간단 분기. 무거운 로직·외부 호출엔 부적합(이벤트루프 블로킹 위험) | | **Wasm** | Envoy 내 sandbox VM (proxy-wasm ABI) | **`WasmPlugin`** (정식 API) + ECDS push | 격리된 커스텀 로직, 언어 자유(Rust/Go/C++→wasm). VM 오버헤드·디버깅 난이도 존재 | | **External processing** | **별도 gRPC 서버**(보통 sidecar/외부 서비스) | `EnvoyFilter`의 `ext_proc`(또는 `ext_authz`) | 무겁거나 외부 의존(레거시 auth, DLP)을 프로세스 밖으로. 네트워크 RTT·가용성이 데이터패스에 들어옴 | ```mermaid flowchart LR REQ["HTTP request in HCM chain"] LUA["Lua\n(in-process script)"] WASM["Wasm VM\n(sandbox, ECDS)"] EXT["ext_proc / ext_authz\nexternal gRPC"] UP["router → upstream cluster"] REQ --> LUA --> UP REQ --> WASM --> UP REQ --> EXT -. gRPC call .-> SVC["processing service"] EXT --> UP ``` 선택 기준의 멘탈모델은 "실행 장소" 축에서 바로 읽힌다: **로직이 가볍고 동기면 Lua(in-process), 커스텀·격리·이식성이 필요하면 Wasm(`WasmPlugin`), 외부 시스템/무거운 처리면 external processing**. in-process(Lua/Wasm)는 네트워크 의존이 없는 대신 무거운 로직이 Envoy worker thread를 블로킹하고, ext_proc는 그 부담을 프로세스 밖으로 내보내는 대신 외부 서버의 RTT·가용성이 데이터패스에 들어온다. Wasm에는 추가로 알아야 할 채널 디테일이 하나 있다. Istio 1.30에서 Wasm은 `WasmPlugin` CR로 1급 지원되며, 그 확장 설정은 일반 LDS/RDS가 아니라 **ECDS(Extension Config Discovery Service)** 로 별도 push된다. listener는 "여기에 확장 필터가 들어간다"는 placeholder만 받고, 실제 필터 설정(wasm 바이너리 참조 등)은 ECDS로 따로 내려오는 구조다 — 그래서 `WasmPlugin`이 안 먹을 때 listener만 봐선 안 되고 `istioctl proxy-status`의 `ECDS` 컬럼을 봐야 한다. 확장을 안 쓰면 `NOT SENT`가 정상이다([proxy-status 해석](xds__src-xds-layers-and-diagnosis.html) §05). ## 06. EnvoyFilter — chain에 patch를 직접 삽입하는 메커니즘 `WasmPlugin`/`Telemetry` 같은 1급 API로 표현되지 않는 변경은 `EnvoyFilter`로 직접 chain을 patch한다. 멘탈모델은 단순하다: `EnvoyFilter`는 새 설정을 만드는 게 아니라 **istiod가 이미 만든 Envoy 설정 위에 patch 연산을 얹는다.** 그래서 강력하지만 깨지기 쉽다 — patch 대상이 istiod가 만든 내부 구조이고, 그 구조는 upgrade마다 바뀔 수 있기 때문이다. patch는 4요소로 "어디에 / 무엇을 / 어떻게 / 무슨 값"을 지정한다: ```text applyTo : LISTENER | FILTER_CHAIN | NETWORK_FILTER | HTTP_FILTER | CLUSTER | ROUTE_CONFIGURATION ... match : context(SIDECAR_INBOUND/OUTBOUND/GATEWAY) + listener/filterChain/filter 이름·포트 patch.operation : INSERT_BEFORE | INSERT_AFTER | INSERT_FIRST | MERGE | ADD | REMOVE patch.value : 끼워 넣거나 병합할 raw Envoy config (proto JSON) ``` HTTP filter를 추가하는 전형적 형태는 `applyTo: HTTP_FILTER` + `match`로 HCM 안 `envoy.filters.http.router`를 지목하고 `INSERT_BEFORE`로 새 필터를 그 앞에 넣는 것이다 — §04에서 본 대로 **router 앞 삽입이 거의 모든 확장의 정석 위치**다. 적용 우선순위/순서를 결정하는 요인은 셋이고, 셋 다 "의도한 곳에만, 충돌 없이" 들어가게 하는 장치다: - **`patch.match`의 구체성**: 이름·포트·context로 좁힐수록 의도한 listener/chain에만 적용. 매칭이 헐거우면 의도치 않은 chain까지 patch된다. - **여러 EnvoyFilter 간 순서**: `metadata.name` 기준 처리되며, 같은 지점을 여러 EnvoyFilter가 건드리면 **결과는 사실상 undefined** — 충돌 시 동작이 보장되지 않는다. - **`workloadSelector`**: 없으면 namespace(또는 root namespace면 mesh) 전체에 적용 → blast radius가 커진다. > [!warning] 이 note의 범위 경계 > `EnvoyFilter`의 **운영 가드레일**(누가 merge하나, root namespace 금지 규칙, upgrade마다 재검증, config_dump diff, 우선순위 사다리 `VS→DR→Telemetry→WasmPlugin→EnvoyFilter`)은 여기서 반복하지 않는다 — [xDS 5계층과 진단](xds__src-xds-layers-and-diagnosis.html) §07에 정리됨. 이 note는 "chain에 어떻게/어디에 들어가는가"라는 메커니즘만 다룬다. ## 07. 예시 — 실제 chain을 눈으로 확인하기 추상을 믿지 말고 Envoy가 받은 진짜 filter chain을 봐야 한다. listener 안의 network filter와 HTTP filter는 `proxy-config listener -o json`에 그대로 드러난다. ```bash # HCM 안의 HTTP filter 순서 확인 (lua/wasm/ext_proc가 router 앞에 있는지) istioctl proxy-config listener -n -o json \ | jq '.. | objects | select(.name=="envoy.filters.network.http_connection_manager") | .typedConfig.httpFilters[].name' ``` 기대 출력(예시 — Istio가 까는 기본 + 확장 1개): ```text "istio.metadata_exchange" "envoy.filters.http.cors" "envoy.filters.http.fault" "istio.alpn" "envoy.filters.http.rbac" "example.my-wasm-plugin" ← WasmPlugin/EnvoyFilter로 끼운 확장 "istio.stats" "envoy.filters.http.router" ← 항상 마지막 ``` 이 출력 하나로 §02~§06이 전부 검증된다: ① 이 리스트가 나온다는 것 = HCM이 깔렸다는 것(L7 chain 존재), ② 확장 필터(`example.my-wasm-plugin`)가 `router` 앞에 있다는 것 = §04의 종결성 규칙 충족, ③ rbac 뒤·router 앞에 있다는 것 = 인가된 요청만 보는 위치. **확장이 보이지 않으면** 다음 순서로 의심한다: 1. `WasmPlugin`/`EnvoyFilter`의 `match`/`workloadSelector`가 이 pod를 안 잡았다 → selector/namespace 확인. 2. ECDS sync 실패(`istioctl proxy-status`의 `ECDS` 컬럼 `STALE`) → §05의 별도 채널 문제. 3. HCM이 아예 없다(포트가 HTTP로 인식 안 됨) → 위 jq가 빈 결과 → §03 silent fallback. 이게 L7 정책이 통째로 안 먹는 1번 원인이다. config_dump·clusters 단의 1차 진단은 [Envoy Admin API 진단](xds__note-envoy-admin-api-diagnosis.html) 참조. apply 전후를 diff하면 "내가 끼운 필터가 정말 router 앞에 추가됐는지"를 한눈에 확인할 수 있다: ```bash istioctl proxy-config listener -n -o json > before.json kubectl apply -f wasmplugin.yaml istioctl proxy-config listener -n -o json > after.json diff -u before.json after.json # 새 HTTP filter가 router 앞에 추가됐는지 ``` ## 핵심 정리 ```text 2겹 chain : L4 network filter chain(tls_inspector/HCM/tcp_proxy) ⊃ HCM이 여는 L7 HTTP filter chain HCM : 바이트→HTTP 추상화 경계 = L7 세계의 경첩. 없으면(tcp_proxy) L7 정책(path/JWT/CORS/L7 RBAC) 전부 silent 무시 순서 : HTTP filter는 파이프라인, 마지막은 항상 router(terminal). 확장은 router 앞 + 인증/인가 필터 기준 상대 위치가 곧 정책 의미 확장 3경로 : 차이는 "실행 장소" 하나 — Lua(in-process 경량) / Wasm=WasmPlugin(sandbox VM, ECDS push) / ext_proc(외부 gRPC) EnvoyFilter: istiod 설정 위에 patch 연산. applyTo+match+operation(INSERT_BEFORE router가 정석)+value 충돌 : 같은 지점 다중 EnvoyFilter = undefined / workloadSelector 없으면 blast radius 큼 확인 : proxy-config listener -o json 의 httpFilters 순서 / proxy-status ECDS 컬럼 ``` 한 문장 요약: **모든 확장은 별개 메커니즘이 아니라 HCM의 L7 filter chain에 필터를 추가하는 같은 일이며, 차이는 그 필터가 어디서 실행되느냐(in-process / sandbox / 외부)뿐이다.** 운영 가드레일은 [xDS 5계층과 진단](xds__src-xds-layers-and-diagnosis.html) §07. ## What you might be missing - **HCM은 "또 하나의 network filter"다.** L4와 L7이 별도 시스템이 아니라, L4 chain 안에 HCM이 놓이면 그 지점부터 L7 chain이 열리는 **중첩 구조**다. 그래서 "L7 정책이 안 먹는다"의 1번 원인은 정책 오타가 아니라 **HCM이 안 깔린 것**(포트가 HTTP로 인식 안 됨)이다 — listener를 먼저 보라. - **확장 필터의 위치가 곧 정책 의미다.** RBAC 앞에 둔 Wasm은 미인증 요청도 처리하고, 뒤에 두면 허가된 것만 본다. `INSERT_BEFORE router`만 외우지 말고 **인증/인가 필터 기준 상대 위치**를 의식해야 보안 우회가 안 생긴다. - **Wasm의 설정 채널은 LDS/RDS가 아니라 ECDS다.** `WasmPlugin`이 안 먹으면 listener/route만 보지 말고 `istioctl proxy-status`의 `ECDS` 컬럼이 `STALE`인지 확인해야 한다 — 확장 설정은 별도 discovery로 push되기 때문이다. - **external processing은 데이터패스에 외부 의존을 들인다.** ext_proc/ext_authz 서버가 느리거나 죽으면 그 요청 경로 전체의 latency·가용성이 그 서버에 종속된다. Lua/Wasm은 in-process라 이 리스크가 없는 대신, 무거운 로직은 Envoy worker thread를 블로킹한다 — 경량은 in-process, 무거운/외부 의존은 ext_proc라는 트레이드오프를 의식할 것. - **여러 EnvoyFilter가 같은 지점을 patch하면 동작은 undefined다.** "잘 되던 EnvoyFilter가 다른 팀 것 추가 후 깨졌다"의 전형. 가능하면 `WasmPlugin`/`Telemetry` 같은 1급 API로 올리고, EnvoyFilter는 최후의 수단으로 남길 것([우선순위 사다리](xds__src-xds-layers-and-diagnosis.html) §07).