Egress route 스코핑 — metadata.namespace는 적용 범위가 아니다
Istio traffic 리소스의 metadata.namespace는 "어디에 저장했는가"(소유 경계)이지 "어느 proxy에 적용되는가"(적용 범위)가 아니다. 적용 범위는 gateways · exportTo · sourceNamespace/sourceLabels · Sidecar.egress.hosts 네 레버의 직교 조합으로 따로 결정된다. 이 분리를 모르면 egress 설정이 다른 namespace sidecar로 새거나(전역 누수), gateway가 필요한 route를 못 본다. egress gateway를 여러 개(passthrough용 / mTLS용) 두는 순간 스코핑은 선택이 아니라 전제가 된다.
대상독자: 멀티-gateway egress를 구성하며 "어느 client가 어느 gateway를 타는가"를 안전하게 강제하려는 SRE. 선행개념: VirtualService/ServiceEntry/Sidecar CRD, xDS push 모델. 환경: homelab (k8s v1.30.6, Istio 1.30.0, CNI Calico). 실측 사례는 §4. 구성 랩 연결: egress gateway HTTPS 가이드 · egress CRD 멘탈모델.
1. 배경 — 왜 "namespace에 담았으니 거기만 적용"이 아닌가
처음 Istio를 쓰면 VirtualService를 payments namespace에 만들면 그 route가 payments pod에만 적용된다고 가정하기 쉽다. Kubernetes의 다른 리소스(ConfigMap, Secret 등)는 namespace가 곧 작용 경계이기 때문이다. Istio traffic 리소스는 그렇지 않다.
이유는 Istio의 config 전파 모델에 있다. istiod는 mesh 안 모든 proxy(sidecar + gateway)를 향해 config를 push하는 단일 컨트롤 플레인이다. VirtualService 하나가 어느 namespace에 저장됐든, istiod는 그것을 mesh 전역 후보 config로 보고, "이 proxy가 이 route를 받아야 하나?"를 별도의 규칙으로 계산한다. 즉 저장 위치(metadata.namespace)와 적용 대상(어느 proxy)은 설계상 분리돼 있고, 그 둘을 잇는 게 아래 네 레버다.
이 분리가 존재하는 이유는 실용적이다 — 한 팀이 자기 namespace에 리소스를 두면서도(소유권), 그 route를 mesh 전체 또는 특정 gateway에 걸 수 있어야(공유) 하기 때문이다. 분리를 모르고 namespace만 믿으면 두 방향으로 사고가 난다: route가 의도보다 넓게 새거나(전역 누수 → §4의 NACK 전파), 의도한 gateway가 route를 못 보거나(필요한 leg 누락).
2. 멘탈모델 — 한 리소스를 보는 네 개의 직교 축
핵심 그림 하나만 머리에 넣으면 된다. 한 traffic 리소스는 서로 다른 질문에 답하는 네 개의 독립 필드를 가진다. 어느 하나도 다른 것을 함의하지 않는다(직교).
metadata.namespace = 어디에 저장했나 (소유/관리 경계) ← 적용 범위 아님
spec.gateways = 어느 proxy 종류에 붙나 (mesh=sidecars / <gw>=gateway)
spec.exportTo = 어느 namespace에 보이나 (가시성)
tls.match.sourceX = 그 중 어느 workload에 (applicability selector, 런타임 match 아님)
각 축이 답하는 질문과 함정을 부품표로:
| 레버 | 답하는 질문 | 기본값 | 함정 |
|---|---|---|---|
spec.gateways |
어느 proxy 종류? | mesh (모든 sidecar) |
생략 = 전 sidecar에 적용. namespace로 안 좁혀짐 |
exportTo |
어느 namespace에 보이나? | * (전체) |
누락 = 전역 가시성 → 누수의 근원 |
sourceNamespace/sourceLabels |
그 중 어느 workload? | (전체) | 런타임 packet match가 아니라 applicability filter |
Sidecar.egress.hosts |
sidecar가 import할 config 범위 | (전체) | 방화벽 아님 — config scoping일 뿐 |
mesh는 VirtualService.spec.gateways의 reserved word로 "메시 안 모든 sidecar"를 뜻한다. gateways를 생략하면 기본값이 mesh다. 그래서 payments namespace의 VS가 기본적으로 전 sidecar에 적용될 수 있는 것이다.
좁히는 네 레버를 메커니즘으로:
exportTo: ["."]— 리소스를 선언된 namespace 안에서만 보이게 한다. 기본값은 전체 export(*). ServiceEntry·VirtualService·DestinationRule 모두 지원. 소유권 분리와 전역 누수 차단에 가장 직접적인 레버.sourceNamespace/sourceLabels(tls.match) — 어떤 workload에 이 route를 적용할지 거르는 selector. 패킷이 들어왔을 때 매칭하는 게 아니라, istiod가 "이 proxy에 이 route를 줄까"를 정할 때 쓰는 applicability filter다.Sidecar.egress.hosts— 해당 sidecar가 import할 config 범위를 줄인다(성능 + governance). 단 이는 방화벽이 아니라 config scoping이다. scope 밖으로 보낸 트래픽은 차단되는 게 아니라 unmatched로 처리될 수 있다. → Sidecar scope- client-VS / gateway-VS 분리 — egress gateway 패턴에서 한 VS가 "sidecar→gw"와 "gw→external" 두 일을 겸하면,
exportTo로 한쪽을 좁힐 때 다른 leg가 안 보이는 사고가 난다. 둘을 나누고 각각 자기 namespace에서exportTo: ["."]로 관리하는 게 명확하다(아래 지도).
3. 공간 지도 — 멀티-gateway egress에서 무엇이 어디에 앉나
egress gateway가 둘 이상이면 위 네 레버가 "선택"이 아니라 "전제"가 되는 이유를 한 장으로 본다. client namespace의 VS는 gateways: [mesh]로 sidecar에 붙어 트래픽을 gw로 보내고, gateway namespace의 VS는 gateways: [<gw>]로 gateway에 붙어 external로 내보낸다. 둘 다 exportTo: ["."]로 자기 namespace에 가둔다.
여기서 ServiceEntry만 exportTo: ['.', '<gw>']로 gateway namespace에도 보이게 하는 점에 주목 — gateway가 external host를 cluster로 알아야 outbound가 성립하기 때문이다. 나머지는 ['.']로 가둬 누수를 막는다.
tls route vs tcp route — gateway listener에서 갈리는 적용성
스코핑과 별개로, gateway가 route를 "받는 방식"도 적용성의 일부다. 같은 host/port라도 라우트 타입은 ServiceEntry/Gateway port의 protocol로 결정된다.
| 구성 | 동작 |
|---|---|
protocol: TLS + tls.sniHosts |
ClientHello SNI 기준 라우팅 가능 (종단 안 함) |
protocol: TCP + tcp route |
SNI 못 봄. host/port·subnet 수준 L4 라우팅만 |
| ISTIO_MUTUAL로 종단한 gateway listener | tls/sniHosts route를 걸면 filter chain 0개 → listener omit. tcp route로 받아야 함 |
마지막 줄이 멀티-gateway mTLS 구성의 핵심 함정이다. 왜 깨지는가: gateway가 outer 메시 mTLS를 종단하면 ClientHello SNI는 종단 과정에서 이미 소비됐다. 종단 뒤 내부 바이트(앱 TLS)에는 더 이상 라우터가 볼 SNI가 없으므로 tcp_proxy로 흘려야 한다. 종단 listener에 tls route를 걸면 매칭할 SNI가 없어 filter chain이 0개가 되고, istiod는 listener missed network filter / must have more than 0 chains로 그 listener를 통째 생략한다.
4. 같은 host 충돌, 그리고 실측 — exportTo 누락이 전 gateway를 마비시킨 사례
같은 host를 여러 mesh VS가 잡으면 — 충돌
sidecar 쪽에서는 같은 host에 대한 VirtualService 머지가 지원되지 않는다. 여러 VS가 동일 hostname을 mesh gateway에 붙이면 ConflictingMeshGatewayVirtualServiceHosts(IST0109)로 감지된다. 해결책은 (a) 하나로 합치거나, (b) hostname을 유니크하게 하거나, (c) exportTo로 namespace scope를 줄이는 것. 멀티-gateway egress에서 "tier별로 다른 gateway"를 만들 때, 각 tier가 같은 external host를 mesh route로 중복 정의하지 않도록 exportTo·sourceLabels로 갈라야 하는 이유다.
실측 worked example — 누락 하나가 일으킨 무증상 동결
2026-06-09 이중 gateway 랩 구성 중, 새 egress gateway의 listener가 안 떴다. 직교 분리를 무시한 단 하나의 리소스가 전 gateway를 얼린 경로:
- 이전 세션의 데모 ServiceEntry
example-logicaldns(resolution: DNS_ROUND_ROBIN→ Envoy LOGICAL_DNS)가exportTo없이(=전역) 떠 있었다. - 그 사이
example.com이 Cloudflare로 옮겨가 multi-IP(IPv4 여럿 + IPv6)가 됐다. LOGICAL_DNS cluster는 "단일 lb_endpoint" 제약이 있어 Envoy가 이를 거부(NACK) 했다. - xDS push는 트랜잭션이다 — cluster 하나가 NACK되면 그 push 전체가 거부되고, 같은 push에 실려야 할 다른 gateway의 listener까지 통째 드롭됐다. 전역 export 때문에 모든 egress gateway(기존 production-intent gateway 포함)가 동시에 NACK 상태가 됐다.
- 조치: 데모 SE에
exportTo: ["."]추가 → gateway는 더 이상 이 SE를 받지 않아 NACK 해소. 데모(sidecar용 LOGICAL_DNS 비교) 목적은 유지.
확인 — proxy의 push 상태를 직접 봤다:
istioctl proxy-status
# NAME CDS LDS ...
# egress-gw-... SYNCED STALE (NOT SENT) ← 동결 증상
# (exportTo 추가 후 재확인)
# egress-gw-... SYNCED SYNCED ← 해소
교훈 두 가지. (a) exportTo 누락 = 전역 누수이고, 그 비용은 "config가 좀 더 퍼진다" 수준이 아니라 전 gateway의 config 갱신 동결까지 간다. (b) Envoy는 NACK 시 직전 good config를 계속 서빙하므로 트래픽은 살아있고(증상 없음), 새 변경이 조용히 반영 안 되는 무증상 동결이 된다 → data-plane sync 상태.
핵심 정리
metadata.namespace는 저장 위치(소유/관리 경계)일 뿐, 적용 범위가 아니다. istiod가 mesh 전역으로 config를 push하기 때문.- 적용 범위 =
gateways(어느 proxy) ·exportTo(어디에 보이나) ·sourceNamespace/sourceLabels(어느 workload) ·Sidecar.egress.hosts(import 범위)의 직교 조합. 어느 하나도 다른 것을 함의하지 않는다. - ISTIO_MUTUAL로 메시 mTLS를 종단한 gateway listener는
tcproute로 받아야 한다 — SNI가 이미 소비돼tls/sniHostsroute를 걸면 filter chain 0개로 listener가 통째 omit된다. - 같은 external host를 여러 mesh VirtualService가 잡으면 머지 불가 →
ConflictingMeshGatewayVirtualServiceHosts(IST0109) 충돌. exportTo누락 = 전역 누수. xDS push는 트랜잭션이라 전역으로 새어 들어간 SE 하나의 NACK이 같은 push의 다른 gateway listener까지 드롭시켜 전 gateway config 동결로 번진다(무증상).
What you might be missing
스코핑은 "성능 최적화"로 오해되기 쉽지만, 멀티-gateway egress에서는 정확성과 격리의 전제다. metadata.namespace에 리소스를 나눠 담는 것만으로 적용 범위가 갈린다고 믿으면, mesh route는 전역으로 새고 host 충돌·NACK 전파가 일어난다. "리소스를 어디 뒀나"와 "어느 proxy에 적용되나"를 항상 분리해서 보고, egress gateway가 둘 이상이면 exportTo(가시성) + sourceLabels(적용 대상)를 명시적으로 박아야 한다.
한 단계 더: Sidecar.egress.hosts를 방화벽으로 착각하지 말 것 — 이건 config import 범위일 뿐, scope 밖 트래픽을 차단하지 않는다. 실제 차단은 outbound traffic policy(REGISTRY_ONLY)나 AuthorizationPolicy의 몫이다. 또한 NACK 전파의 폭발 반경은 exportTo 범위와 정비례한다 — 전역으로 새어든 리소스 하나의 reject가 그 config를 공유하는 모든 proxy를 같이 얼린다. 그래서 exportTo: ["."]는 governance가 아니라 blast radius 격리 수단으로 봐야 한다.