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의 실측 worked example), 의도한 gateway가 route를 못 보거나(필요한 leg 누락).
2. 멘탈모델 — 한 리소스를 보는 네 개의 직교 축
핵심 그림 하나만 머리에 넣으면 된다. 한 traffic 리소스는 서로 다른 질문에 답하는 네 개의 독립 필드를 가진다. 어느 하나도 다른 것을 함의하지 않는다(직교).
metadata.namespace = 어디에 저장했나 (소유/관리 경계) ← 적용 범위 아님
spec.gateways = 어느 proxy 종류에 붙나 (mesh=sidecars / <gw>=gateway)
spec.exportTo = 어느 namespace에 보이나 (가시성)
tls.match.sourceX = 그 중 어느 workload에 (applicability selector, 런타임 match 아님)
flowchart TB R["VirtualService<br/>in payments ns"] R -->|metadata.namespace| Q1["WHERE stored?<br/>ownership only"] R -->|spec.gateways| Q2["WHICH proxy type?<br/>mesh = all sidecars"] R -->|spec.exportTo| Q3["VISIBLE to which ns?<br/>default '*' = all"] R -->|tls.match.sourceLabels| Q4["WHICH workload?<br/>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- 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에 가둔다.
flowchart LR
subgraph client_ns["client namespace"]
SE["ServiceEntry (external)<br/>exportTo: ['.', '<gw>']"]
DR["DestinationRule (gw svc)<br/>exportTo: ['.']"]
VS_mesh["VirtualService (mesh->gw)<br/>gateways: [mesh]<br/>exportTo: ['.']"]
end
subgraph gateway_ns["gateway namespace"]
GW["Gateway (server)"]
VS_gw["VirtualService (gw->ext)<br/>gateways: [<gw>]<br/>exportTo: ['.']"]
end
VS_mesh -->|sidecar to gw| GW
GW --> VS_gw
VS_gw -->|gw to external| ext["external host"]여기서 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를 통째 생략한다. 이 인과는 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 정정 참고):
- 이전 세션의 데모 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 ← 해소
원 서술은 (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 상태.
핵심 정리
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된다(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 실측 |
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 실측 |
| 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 실측 |
| C13. Envoy는 NACK 시 직전 good config를 서빙해 트래픽은 유지되고 새 config만 조용히 안 들어온다(무증상) | ✅ 문헌 확인 (실측은 보류) | envoyproxy.io/…/xds_protocol · T03 실측 — NACK 자체가 재현되지 않아 이 경로는 직접 관찰되지 않음 |
| C14. 데모 SE가 multi-IP DNS로 바뀌자 LOGICAL_DNS의 “단일 lb_endpoint” 제약으로 Envoy가 NACK했다 | 실측 불가 (doc-unverifiable) | envoyproxy.io/…/service_discovery · T03 실측 — 동일 조건 재현에서 NACK 미발생, 정확한 트리거는 공식 문서로 미확정 |
C15. 실제 outbound 차단은 Sidecar.egress.hosts가 아니라 outbound traffic policy(REGISTRY_ONLY)나 AuthorizationPolicy 담당 |
✅ 문헌 확인 | istio.io/latest/docs/reference/config/networking/sidecar/ |