Envoy는 listener의 network filter와 HCM의 HTTP filter chain으로 요청을 처리하며, Lua·Wasm·external processing으로 재컴파일 없이 확장된다
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계층과 진단 §07로 위임하고, 이 note는 멘탈모델에 집중한다.
대상환경 Istio 1.30 / Envoy · 대상독자 L7 정책·확장이 "조용히 무시되는" 이유를 메커니즘으로 알고 싶은 DevOps/SRE · 범위 filter chain 구조와 확장 주입 메커니즘(가드레일은 링크) · 선행개념 xDS layer 개관, 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로 인식 안 되면 라우팅/정책이 안 먹는" 증상의 근본 이유다.
이 그림이 xDS와 어떻게 맞물리는지도 한 줄로 잡아두면 진단이 쉬워진다: L4 network filter chain은 LDS로 내려오는 listener 설정의 일부이고, HCM 안의 route 결정은 RDS가 채운다. 즉 한 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이다.
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 필터(요청 방향 순서, 대략):
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·가용성이 데이터패스에 들어옴 |
선택 기준의 멘탈모델은 "실행 장소" 축에서 바로 읽힌다: 로직이 가볍고 동기면 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 해석 §05).
06. EnvoyFilter — chain에 patch를 직접 삽입하는 메커니즘
WasmPlugin/Telemetry 같은 1급 API로 표현되지 않는 변경은 EnvoyFilter로 직접 chain을 patch한다. 멘탈모델은 단순하다: EnvoyFilter는 새 설정을 만드는 게 아니라 istiod가 이미 만든 Envoy 설정 위에 patch 연산을 얹는다. 그래서 강력하지만 깨지기 쉽다 — patch 대상이 istiod가 만든 내부 구조이고, 그 구조는 upgrade마다 바뀔 수 있기 때문이다.
patch는 4요소로 "어디에 / 무엇을 / 어떻게 / 무슨 값"을 지정한다:
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가 커진다.
EnvoyFilter의 운영 가드레일(누가 merge하나, root namespace 금지 규칙, upgrade마다 재검증, config_dump diff, 우선순위 사다리 VS→DR→Telemetry→WasmPlugin→EnvoyFilter)은 여기서 반복하지 않는다 — xDS 5계층과 진단 §07에 정리됨. 이 note는 "chain에 어떻게/어디에 들어가는가"라는 메커니즘만 다룬다.
07. 예시 — 실제 chain을 눈으로 확인하기
추상을 믿지 말고 Envoy가 받은 진짜 filter chain을 봐야 한다. listener 안의 network filter와 HTTP filter는 proxy-config listener -o json에 그대로 드러난다.
# HCM 안의 HTTP filter 순서 확인 (lua/wasm/ext_proc가 router 앞에 있는지)
istioctl proxy-config listener <pod> -n <ns> -o json \
| jq '.. | objects | select(.name=="envoy.filters.network.http_connection_manager")
| .typedConfig.httpFilters[].name'
기대 출력(예시 — Istio가 까는 기본 + 확장 1개):
"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 앞에 있다는 것 = 인가된 요청만 보는 위치. 확장이 보이지 않으면 다음 순서로 의심한다:
WasmPlugin/EnvoyFilter의match/workloadSelector가 이 pod를 안 잡았다 → selector/namespace 확인.- ECDS sync 실패(
istioctl proxy-status의ECDS컬럼STALE) → §05의 별도 채널 문제. - HCM이 아예 없다(포트가 HTTP로 인식 안 됨) → 위 jq가 빈 결과 → §03 silent fallback. 이게 L7 정책이 통째로 안 먹는 1번 원인이다.
config_dump·clusters 단의 1차 진단은 Envoy Admin API 진단 참조.
apply 전후를 diff하면 "내가 끼운 필터가 정말 router 앞에 추가됐는지"를 한눈에 확인할 수 있다:
istioctl proxy-config listener <pod> -n <ns> -o json > before.json
kubectl apply -f wasmplugin.yaml
istioctl proxy-config listener <pod> -n <ns> -o json > after.json
diff -u before.json after.json # 새 HTTP filter가 router 앞에 추가됐는지
핵심 정리
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계층과 진단 §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는 최후의 수단으로 남길 것(우선순위 사다리 §07).