--- title: Egress route 스코핑 — `metadata.namespace`는 적용 범위가 아니다 date: 2026-06-09 type: note domain: istio tags: [egress, virtualservice, exportto, sidecar, xds] --- > [!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 가이드](/docs/istio/egress/gateway-https/) · [egress CRD 멘탈모델](/docs/istio/egress/crd-mental-model/). --- ## 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의 실측 worked example), 의도한 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](/docs/istio/egress/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: []`로 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를 **통째 생략**한다. 이 인과는 homelab 재현(T07, 아래 검증 기록)으로도 그대로 확인됐다 — filterChains 길이가 정상 시 1(>0)에서 tls/sniHosts route 전환 후 정확히 0으로 떨어졌고 istiod 로그에 동일 문구가 찍혔다. ## 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를 얼린 것으로 **당시 기록한** 경로(아래 2026-07-05 정정 참고): 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 ← 해소 ``` > [!warning] 2026-07-05 정정 > 원 서술은 (1) LOGICAL_DNS cluster가 multi-IP 자체만으로 자동 NACK된다고 단정했고, (2) 그 원인을 xDS의 > **트랜잭션성(한 push는 all-or-nothing)**으로 설명했다. 두 지점 모두 정밀도 문제가 있다. > > - Envoy 공식 문서는 LOGICAL_DNS가 "매 신규 커넥션마다 DNS 응답의 첫 IP만 쓰고 나머지는 무시한다"고 설명할 뿐, > multi-IP 자체가 NACK을 유발한다고는 말하지 않는다. "단일 lb_endpoint" 검증 에러는 주로 > ClusterLoadAssignment에 **정적으로 명시된** endpoint가 2개 이상일 때 걸리는 것으로 보이며, 순수 `hosts` > 필드 기반 실시간 DNS 결과가 여러 개라는 사실만으로 이 검증에 걸리는지는 공식 문서로 확정할 수 > 없다(doc-unverifiable). > - homelab에서 동일 시나리오(전역 노출 DNS_ROUND_ROBIN ServiceEntry + 물리적으로 분리된 두 egress gateway)를 > **Istio 1.30.0으로 재현**했을 때는(T03) 양쪽 게이트웨이의 CDS `configStatus`가 계속 `SYNCED`로 유지됐고 > istiod 로그에도 NACK·거부 흔적이 전혀 없었다 — 즉 "전역 노출된 리소스 하나가 물리적으로 분리된 다른 > gateway까지 동반 NACK시킨다"는 인과가 이 조건에서는 재현되지 않았다. > > 원 사건이 "일어나지 않았다"고 단정하는 것은 아니다 — 6/9 당시는 실제 외부 DNS(example.com)의 응답 변화였고 > 재현은 DNS_ROUND_ROBIN + 고정 2-IP static endpoint로 근사한 것이라 조건이 완전히 같지는 않다. 다만 > **"xDS 트랜잭션성이 원인"이라는 인과 설명은 부정확**하며, 실제 blast radius가 있다면 그 원인은 "같은 전역 > 노출 리소스가 각 proxy에 독립적으로 전달되어 각자 자기 CDS를 거부했을 가능성" 쪽이 더 정합적이다. 상세는 > 문서 끝 검증 기록(C12/C14) 참고. 교훈 두 가지 — 단, (a)는 이 조건의 재현에서는 확인되지 않았음에 유의(위 정정 참고). (a) `exportTo` 누락 = 전역 누수이고, 당시엔 그 비용이 "config가 좀 더 퍼진다" 수준이 아니라 *전 gateway의 config 갱신 동결*까지 간 것으로 관측·기록했다 — 다만 1.30.0 재현(T03)에서는 동일한 cross-gateway 동결이 재현되지 않아 이 인과의 일반화 가능성은 열린 질문으로 남는다. (b) Envoy는 NACK 시 **직전 good config를 계속 서빙**하므로 트래픽은 살아있고(증상 없음), 새 변경이 조용히 반영 안 되는 **무증상 동결**이 된다(이 자체는 Envoy xDS 프로토콜의 표준 동작이며, 이번 재현에서는 NACK이 발생하지 않아 이 경로를 직접 관찰하진 못했다) → [data-plane sync 상태](/docs/istio/xds-envoy/data-plane-sync-state/). ## 핵심 정리 - `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된다(T07로 실측 확인). - 같은 external host를 여러 mesh VirtualService가 잡으면 머지 불가 → `ConflictingMeshGatewayVirtualServiceHosts`(IST0109) 충돌. - `exportTo` 누락 = **전역 누수**이며, 2026-06-09 당시엔 그로 인해 전역으로 새어 들어간 SE 하나의 NACK이 같은 push의 다른 gateway listener까지 드롭시켜 **전 gateway config 동결**로 번지는 것으로 관측·기록했다(무증상) — 다만 2026-07-05 homelab 재현(T03)에서는 이 cross-gateway 동결 자체가 재현되지 않아, "xDS 트랜잭션성"이라는 인과 설명은 부정확하고 실제 blast radius는 조건(SE의 resolution 모드, 실패 양상 등)에 따라 달라질 수 있다(상세는 검증 기록 C12/C14). ## 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를 같이 얼릴 잠재력이 있다는 뜻이다. 다만 이 인과가 물리적으로 완전히 분리된 gateway 사이에서도 그대로 재현되는지는 homelab 실측(T03, 2026-07-05)에서 확인되지 않았다 — 재현되지 않았다고 리스크가 없다는 뜻은 아니지만, `exportTo: ["."]`를 governance 차원의 위생 습관으로 두는 것과 "이것 하나만으로 blast radius가 확정적으로 격리된다"고 과신하는 것은 별개다. --- ## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6) 검증 방법 요약: 공식 문서(istio.io/envoyproxy.io/GitHub 이슈) 대조 + homelab 클러스터 실측(T07 — ISTIO_MUTUAL 종단 리스너의 tls/tcp route 전환 재현, T03 — 전역 노출 ServiceEntry + 물리적으로 분리된 두 egress gateway의 blast-radius 재현 시도). | 주장 | 판정 | 근거 | |---|---|---| | C1. `metadata.namespace`는 저장 위치일 뿐이며, istiod는 저장 namespace와 무관하게 전역 후보 config로 보고 적용 대상을 별도로 계산한다 | ✅ 실측 확인 | istio.io/.../deployment/architecture/ · [T37 실측](/docs/istio/egress/identity-without-mtls/files/verify/T37/) | | C2. `spec.gateways` 생략 시 기본값은 `mesh`(모든 sidecar) | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/virtual-service/ | | C3. `exportTo` 기본값은 `*`(전체 namespace에 노출) | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/virtual-service/ | | C4. `exportTo: ["."]`는 선언 namespace로 가시성을 제한하며 VS/SE/DR 모두 지원 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/analysis/ist0109/ | | C5. `tls.match.sourceNamespace/sourceLabels`는 런타임 매치가 아니라 applicability selector | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/virtual-service/ | | C6. `Sidecar.egress.hosts`는 방화벽이 아니라 config import scoping — scope 밖 트래픽은 (기본 ALLOW_ANY에서) 여전히 허용될 수 있음 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/sidecar/ | | C7. gateway의 tls route/tcp route 여부는 ServiceEntry/Gateway port의 protocol 필드로 결정 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/gateway/ | | C8. `protocol: TLS` + `sniHosts`는 종단 없이 SNI 기준 라우팅 가능 | ✅ 문헌 확인 | istio.io/.../ingress-sni-passthrough/ | | C9. `protocol: TCP` + tcp route는 SNI를 못 보고 host/port·subnet 수준 L4 라우팅만 가능 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/gateway/ | | C10. ISTIO_MUTUAL 종단 listener에 tls/sniHosts route를 걸면 filter chain 0개로 listener가 omit되며 tcp route로 받아야 함 | ✅ 실측 확인 | github.com/istio/istio/issues/37293 · [T07 실측](/docs/istio/egress/https-over-mtls/files/verify/T07/) | | C11. sidecar(mesh gateway) 쪽은 같은 host VS 머지가 지원 안 되며 IST0109로 감지 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/analysis/ist0109/ | | C12. xDS push는 트랜잭션이라 전역 노출된 리소스 하나의 NACK이 물리적으로 분리된 다른 gateway의 listener까지 동반 드롭시킨다 | 🔬 실측 반증 — 본문 교정 | envoyproxy.io/.../xds_protocol · [T03 실측](files/verify/T03/result.txt) | | C13. Envoy는 NACK 시 직전 good config를 서빙해 트래픽은 유지되고 새 config만 조용히 안 들어온다(무증상) | ✅ 문헌 확인 (실측은 보류) | envoyproxy.io/.../xds_protocol · [T03 실측](files/verify/T03/result.txt) — NACK 자체가 재현되지 않아 이 경로는 직접 관찰되지 않음 | | C14. 데모 SE가 multi-IP DNS로 바뀌자 LOGICAL_DNS의 "단일 lb_endpoint" 제약으로 Envoy가 NACK했다 | 실측 불가 (doc-unverifiable) | envoyproxy.io/.../service_discovery · [T03 실측](files/verify/T03/result.txt) — 동일 조건 재현에서 NACK 미발생, 정확한 트리거는 공식 문서로 미확정 | | C15. 실제 outbound 차단은 `Sidecar.egress.hosts`가 아니라 outbound traffic policy(REGISTRY_ONLY)나 AuthorizationPolicy 담당 | ✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/sidecar/ |