--- type: note tags: [istio, egress, virtualservice, exportTo, sourceNamespace, sidecar, scoping, xds, nack] created: 2026-06-09 --- # Egress route 스코핑 — `metadata.namespace`는 적용 범위가 아니다 > [!abstract] > 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 가이드](cfg__guide-gateway-https.html) · [egress CRD 멘탈모델](mm__guide-crd-mental-model.html). --- ## 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 / =gateway) spec.exportTo = 어느 namespace에 보이나 (가시성) tls.match.sourceX = 그 중 어느 workload에 (applicability selector, 런타임 match 아님) ``` ```mermaid flowchart TB R["VirtualService
in payments ns"] R -->|metadata.namespace| Q1["WHERE stored?
ownership only"] R -->|spec.gateways| Q2["WHICH proxy type?
mesh = all sidecars"] R -->|spec.exportTo| Q3["VISIBLE to which ns?
default '*' = all"] R -->|tls.match.sourceLabels| Q4["WHICH workload?
applicability filter"] Q2 -.orthogonal.- Q3 Q3 -.orthogonal.- Q4 ``` 각 축이 답하는 질문과 함정을 부품표로: | 레버 | 답하는 질문 | 기본값 | 함정 | |---|---|---|---| | `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](mm__note-sidecar-scope.html) - **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: []`로 gateway에 붙어 external로 내보낸다. 둘 다 `exportTo: ["."]`로 자기 namespace에 가둔다. ```mermaid flowchart LR subgraph client_ns["client namespace"] SE["ServiceEntry (external)
exportTo: ['.', '<gw>']"] DR["DestinationRule (gw svc)
exportTo: ['.']"] VS_mesh["VirtualService (mesh->gw)
gateways: [mesh]
exportTo: ['.']"] end subgraph gateway_ns["gateway namespace"] GW["Gateway (server)"] VS_gw["VirtualService (gw->ext)
gateways: [<gw>]
exportTo: ['.']"] end VS_mesh -->|sidecar to gw| GW GW --> VS_gw VS_gw -->|gw to external| ext["external host"] ``` 여기서 `ServiceEntry`만 `exportTo: ['.', '']`로 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를 얼린 경로: 1. 이전 세션의 데모 ServiceEntry `example-logicaldns`(`resolution: DNS_ROUND_ROBIN` → Envoy **LOGICAL_DNS**)가 **`exportTo` 없이**(=전역) 떠 있었다. 2. 그 사이 `example.com`이 Cloudflare로 옮겨가 **multi-IP**(IPv4 여럿 + IPv6)가 됐다. LOGICAL_DNS cluster는 "단일 lb_endpoint" 제약이 있어 Envoy가 이를 **거부(NACK)** 했다. 3. xDS push는 **트랜잭션**이다 — cluster 하나가 NACK되면 **그 push 전체가 거부**되고, 같은 push에 실려야 할 다른 gateway의 listener까지 통째 드롭됐다. 전역 export 때문에 **모든** egress gateway(기존 production-intent gateway 포함)가 동시에 NACK 상태가 됐다. 4. 조치: 데모 SE에 **`exportTo: ["."]`** 추가 → gateway는 더 이상 이 SE를 받지 않아 NACK 해소. 데모(sidecar용 LOGICAL_DNS 비교) 목적은 유지. 확인 — proxy의 push 상태를 직접 봤다: ```bash 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 상태](../istio/xds__note-data-plane-sync-state.html). ## 핵심 정리 - `metadata.namespace`는 **저장 위치(소유/관리 경계)**일 뿐, 적용 범위가 아니다. istiod가 mesh 전역으로 config를 push하기 때문. - 적용 범위 = `gateways`(어느 proxy) · `exportTo`(어디에 보이나) · `sourceNamespace/sourceLabels`(어느 workload) · `Sidecar.egress.hosts`(import 범위)의 **직교 조합**. 어느 하나도 다른 것을 함의하지 않는다. - ISTIO_MUTUAL로 메시 mTLS를 **종단한 gateway listener**는 `tcp` route로 받아야 한다 — SNI가 이미 소비돼 `tls`/`sniHosts` route를 걸면 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 격리** 수단으로 봐야 한다.