🏠 목록 Egress route 스코핑 — metadata.namespace는 적용 범위가 아니다 📄 MD 원본 🌓 테마
istioegressvirtualserviceexportTosourceNamespacesidecarscopingxds

Egress route 스코핑 — metadata.namespace는 적용 범위가 아니다

NOTE

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를 쓰면 VirtualServicepayments 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 아님)
VirtualService in payments ns 네 필드 = 네 질문 WHERE stored? (ownership only) metadata.namespace WHICH proxy? (mesh=all sidecars) spec.gateways VISIBLE to which ns? (default '*') spec.exportTo WHICH workload? (applicability) tls.match.sourceLabels 네 축은 서로 orthogonal (독립)
그림 1. 한 VirtualService, 네 직교 축. 같은 리소스의 서로 다른 필드가 "어디 저장 / 어느 proxy / 어느 ns에 보임 / 어느 workload"라는 독립된 네 질문에 각각 답한다 — 저장 위치(namespace)는 적용 범위와 무관하다는 게 핵심 함정.

각 축이 답하는 질문과 함정을 부품표로:

레버 답하는 질문 기본값 함정
spec.gateways 어느 proxy 종류? mesh (모든 sidecar) 생략 = 전 sidecar에 적용. namespace로 안 좁혀짐
exportTo 어느 namespace에 보이나? * (전체) 누락 = 전역 가시성 → 누수의 근원
sourceNamespace/sourceLabels 그 중 어느 workload? (전체) 런타임 packet match가 아니라 applicability filter
Sidecar.egress.hosts sidecar가 import할 config 범위 (전체) 방화벽 아님 — config scoping일 뿐

meshVirtualService.spec.gatewaysreserved word로 "메시 안 모든 sidecar"를 뜻한다. gateways를 생략하면 기본값이 mesh다. 그래서 payments namespace의 VS가 기본적으로 전 sidecar에 적용될 수 있는 것이다.

좁히는 네 레버를 메커니즘으로:

3. 공간 지도 — 멀티-gateway egress에서 무엇이 어디에 앉나

egress gateway가 둘 이상이면 위 네 레버가 "선택"이 아니라 "전제"가 되는 이유를 한 장으로 본다. client namespace의 VS는 gateways: [mesh]로 sidecar에 붙어 트래픽을 gw로 보내고, gateway namespace의 VS는 gateways: [<gw>]로 gateway에 붙어 external로 내보낸다. 둘 다 exportTo: ["."]로 자기 namespace에 가둔다.

client namespace ServiceEntry (external) exportTo: ['.', '<gw>'] DestinationRule (gw svc) exportTo: ['.'] VirtualService (mesh→gw) gateways: [mesh] exportTo: ['.'] gateway namespace Gateway (server) VirtualService (gw→ext) gateways: [<gw>] exportTo: ['.'] external host sidecar → gw gw → external
그림 1. client-VS / gateway-VS 분리 — VS(mesh→gw)는 client ns에서 gateways:[mesh], VS(gw→ext)는 gateway ns에서 gateways:[<gw>]로 각자 자기 namespace의 exportTo:['.']로 관리. 한 VS가 두 leg를 겸하면 exportTo 좁힐 때 한쪽이 안 보이는 사고 방지.

여기서 ServiceEntryexportTo: ['.', '<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를 얼린 경로:

  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 상태를 직접 봤다:

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 상태.

핵심 정리

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 격리 수단으로 봐야 한다.