🏠 목록 Egress 신원 기반 통제 — mTLS 신원이 공용 gateway에 멀티테넌시를 만든다 📄 MD 원본 📁 Files 🔒 Private 🌓 테마
istioegressmtlsspiffeidentityauthorizationpolicysecuritymulti-tenancy

Egress 신원 기반 통제 — mTLS 신원이 공용 gateway에 멀티테넌시를 만든다

NOTE

"egress gateway만 세우면 외부 통신이 통제된다"는 직관의 빈칸을 메우는 문서. 방화벽·NetworkPolicy는 "외부로 나가는 유일한 경로가 gateway인가"(Q1) 에만 답하고, "그 gateway를 누가, 어느 목적지로 쓸 수 있나"(Q2) 에는 답하지 못한다. Q2의 판정 재료가 mesh mTLS가 운반하는 SPIFFE 신원이고, 판정 장치가 gateway 위의 AuthorizationPolicy(principal × SNI) 다. 이 문서는 ① 그 논리를 통제 체인(경로 강제 → 검문소 → 신원 판정)으로 세우고 ② SA 2개가 서로 다른 목적지만 허용받는 테스트 클러스터 전체 구성(YAML 주석 포함)과 검증·함정까지 따라간다. 이중 TLS 구조 자체의 해부는 HTTPS over mTLS 정본, 4-CRD 직관은 Egress 4-CRD 멘탈모델이 정본 — 본 문서는 그 구조 위에 올라가는 "통제" 를 다룬다.

대상 환경: Istio 1.30.0, sidecar mesh, Helm gateway chart (테스트 클러스터) 대상 독자: egress gateway를 보안 요건(최소권한·감사 추적) 충족 수단으로 도입하려는 DevOps/SRE 범위: 신원 기반 통제의 논리 → 테스트 클러스터 구성 → 검증 매트릭스 → 설계 결정 노트(분리 전략·TCP·wildcard) 선행 개념: egress 2-leg 라우팅(4-CRD 멘탈모델), SPIFFE 신원(정본), AuthorizationPolicy 평가(멘탈모델)


1. 멘탈모델 한 문장 — 통제는 체인이고, 신원은 그 마지막 고리다

강제(enforcement)는 경로가 하고(NetworkPolicy·방화벽), 판정은 검문소(gateway)가 하며, 판정에 "누가"를 공급하는 유일한 장치가 sidecar↔gateway 구간의 mesh mTLS(SPIFFE 인증서)다.

NetworkPolicy / IDC firewall : "egress gw 경유 외 외부행 차단"   (경로 강제, L3/L4)
        |
        v
egress gateway               : 유일한 검문소가 됨
        |
        v
검문소에서 "누가 -> 어디로" 판정 필요   <- 여기서 신원(SPIFFE)이 등장

이 체인에서 어느 고리 하나만 빠져도 통제가 성립하지 않는다.

신원 기반 authz는 결국 "전용 gateway N개를 정책 N줄로 치환" 하는 장치다. 물리 분리 없이 공용 gateway 위에 논리적 멀티테넌시를 만드는 것 — 이게 이 패턴의 본질이다.


2. 왜 신원인가 — 흔한 두 반론을 메커니즘으로 해소

반론 1: "ServiceEntry로 특정 namespace에서 외부 주소를 못 보게 막으면 되지 않나"

ServiceEntry(+exportTo)·REGISTRY_ONLY는 차단 장치가 아니라 설정 배포 범위 제어다. "team-a namespace에는 이 외부 호스트 설정을 안 내려준다"일 뿐이고, 집행 주체가 클라이언트 자신의 sidecar라는 게 구조적 약점이다. sidecar는 pod와 같은 network namespace에 있어서, root 권한을 가진 침해 pod는 sidecar를 건너뛰고 직접 나갈 수 있다. Istio 공식 Security Best Practices가 REGISTRY_ONLY를 보안 경계가 아닌 best-effort로 간주하라고 명시하는 이유다. 그래서 집행은 공격자가 통제할 수 없는 지점(CNI NetworkPolicy, IDC 방화벽, gateway)으로 옮겨야 한다.

반론 2: "어차피 egress 노드가 아니면 방화벽에서 막히는데, 그걸로 충분하지 않나"

절반은 맞다. 방화벽과 신원은 다른 질문에 답하는 장치라서 분리해야 한다.

Q1. 외부로 나가는 유일한 경로가 gateway인가?      -> 방화벽이 답함 (O)
Q2. gateway를 "누가" "어느 목적지로" 쓸 수 있나?   -> 방화벽은 못 답함

방화벽이 보는 건 gateway 노드 CIDR → 대외기관 IP뿐이다. 모든 워크로드가 같은 SNAT IP로 나가니 app-a와 app-b를 구분할 수 없고, gateway Service는 ClusterIP라 클러스터 내 모든 pod가 도달 가능하다. 보안 요건이 "모든 외부 통신은 통제된 경로 + 전사 공통 allowlist"까지라면 passthrough + 방화벽으로 충분하다. 요건에 워크로드 단위 최소권한(least privilege)과 주체 식별 가능한 감사 추적이 포함될 때 비로소 신원이 필요해진다 — 그 판정을 할 수 있는 유일한 지점이 gateway이고, 판정 재료가 신원이기 때문이다.

✓ A/B 선택의 실제 분기점

"passthrough(A)냐 이중 TLS(B)냐"는 기술 선호가 아니라 사내 보안 심사 요건 문서에 "워크로드별 차등 통제·주체 식별"이 명시되어 있는지로 판가름한다. 없으면 A로 시작하고, 요건이 생기면 같은 토폴로지에서 Gateway tls.mode + DR trafficPolicy + AuthorizationPolicy만 추가하는 증분 적용이 가능하다(델타 3곳). 같은 환경에서 신원을 CNI pod-selector로 대신 식별하는 반대 선택의 근거는 이중 TLS 없는 egress 신원.

신원이 있을 때만 가능해지는 통제

제어 내용 신원 없이 가능?
워크로드별 목적지 allowlist app-a→PG사만, app-b→신용평가사만 ✗ (전 워크로드 동일 권한)
default-deny + 신청·승인 운영 정책 1줄 = 승인 1건, 감사 대응 직결 △ (목적지 단위만)
감사 로그 "어느 SA가 언제 어디로" — pod IP churn 무관
즉시 회수 사고 시 해당 SA 정책 삭제로 차단

사실관계 한 줄 — 이중 TLS는 "표준 패턴"이 아니라 "공식 문서화된 변형"

공식 task 문서(Egress Gateways)는 단순 SNI passthrough까지만 제시한다. ISTIO_MUTUAL 이중 TLS가 등장하는 곳은 공식 블로그·하드닝 문맥이다 — egress SNI 라우팅 블로그(2023)는 gateway를 mesh 내부 클라이언트 전용으로 잠그려면 sidecar↔gateway에 ISTIO_MUTUAL을 강제해야 하고 그 결과 TLS가 두 겹이 된다고 명시하고, Security Best Practices는 egress 보호를 gateway + NetworkPolicy 결합으로 설명한다. 즉 기본 권장은 passthrough가 맞고, 신원 기반 통제가 요건일 때의 문서화된 경로가 이중 TLS다.


3. 부품표 — 각 부품 = 통제 질문 하나

라우팅 4-CRD의 역할은 4-CRD 멘탈모델이 정본. 여기서는 통제 관점에서 각 부품이 답하는 질문만 다시 정렬한다.

통제 질문 답 = 부품 핵심 한 줄
"gateway를 mesh 내부만 쓰게 강제하려면?" Gateway tls.mode: ISTIO_MUTUAL 리스너가 mesh CA 발급 client cert를 요구 — 없으면 handshake 거부
"gateway가 목적지를 무엇으로 분기하나?" DR tls.sni sidecar가 만드는 outer ClientHello의 SNI에 원본 호스트 적재 = 리스너 매칭 키
"신원은 어디서 추출되나?" outer mTLS 종단 시 client cert SAN의 spiffe://...source.principal
"누가 → 어디로의 판정은?" AuthorizationPolicy principals × connection.sni. deny-all + 명시 allow 쌍이 기본형
"이 외부 호스트가 존재하긴 하나?" ServiceEntry 주소록 등록(배포 제어일 뿐, 집행 아님 — §2)
"우회는 누가 막나?" NetworkPolicy + 방화벽 Istio 밖. 일반 워크로드 external egress deny, gw pod+DNS만 허용

요청 한 번의 통제 흐름 (어디서 거부되는가)

[app pod, sa=app-a]
        | (1) HTTPS 요청 (inner TLS, dst=api.partner.com)
        v
   [sidecar Envoy]
        | (2) VS 매칭(mesh, sniHosts) -> egress gw로 라우팅
        | (3) outer ISTIO_MUTUAL 래핑
        |     client cert SAN = spiffe://cluster.local/ns/team-a/sa/app-a
        v
   [egress gw listener, mode=ISTIO_MUTUAL]
        | (4) mesh CA로 client cert 검증 -> principal 추출
        |     * cert 없음/타 CA = handshake 거부   <- "내부만 강제"의 실체
        | (5) AuthorizationPolicy: principal x connection.sni 평가
        |     * 매칭 allow 없음 = connection reset <- 신원 기반 거부 (403 아님!)
        | (6) outer TLS 벗김 -> inner TLS 바이트를 SNI 기반 라우팅
        v
   [api.partner.com:443]   <- inner TLS는 여기서 종단 (end-to-end 보존)

거부 지점이 둘이다: (4) handshake 거부 = "mesh 밖 클라이언트", (5) connection reset = "신원은 있으나 권한 없음". 이 구분이 §6 검증과 운영 디버깅의 골격이 된다.


4. 구성 따라하기 — 테스트 클러스터에 신원 차등 통제 세우기

증명하려는 것: 같은 gateway를 공유하는 두 워크로드(SA만 다름)가 서로 다른 목적지만 허용받는다.

ns: egress-test                          ns: istio-egress
+--------------------+                  +----------------------+
| netshoot-a         |                  | egressgateway        |
| (sa: app-a)        |   outer mTLS     | Deployment (chart)   |
|  app -> sidecar ---+----------------->| Envoy :8443          |
+--------------------+   SNI=target     |  1) mTLS termination |
                          host          |  2) AuthzPolicy 평가  |
+--------------------+   Svc:443        |  3) SNI로 라우팅      |
| netshoot-b         |    -> pod:8443   +----------+-----------+
| (sa: app-b)        |                             | raw TCP
+--------------------+                             | (inner TLS 그대로)
                                                   v
                                  api-a.example.com:443
                                  api-b.example.net:443

목표 정책 매트릭스:

출발 api-a api-b
sa: app-a ✅ 허용 ❌ 거부
sa: app-b ❌ 거부 ✅ 허용
sidecar 없는 pod → gw 직접 ❌ handshake 거부

포트 흐름 한 줄: sidecar → gw Service:443 → gw pod:8443(Envoy listener) → 외부:443. Gateway CRD의 포트는 컨테이너 포트인 8443으로 선언한다 (non-root Envoy는 443 바인딩 불가, Service가 443→8443 매핑).

4.1 Gateway 설치 (Helm)

kubectl create namespace istio-egress
# values-egress.yaml
service:
  type: ClusterIP          # egress는 외부 노출 불필요. ClusterIP로 mesh 내부에서만 접근
  ports:
  - name: status-port
    port: 15021            # readiness probe용
    targetPort: 15021
  - name: tls-egress
    port: 443              # sidecar가 바라보는 Service 포트
    targetPort: 8443       # Envoy가 실제 listen할 포트 (Gateway CRD와 일치해야 함)
podAnnotations:
  # gateway chart는 injection 방식이라 이미지가 istiod 설정을 따름.
  # 사설 레지스트리 이미지를 명시적으로 고정:
  sidecar.istio.io/proxyImage: registry.example.com/istio/proxyv2:1.30.0
resources:
  requests: { cpu: 100m, memory: 128Mi }
helm install egressgateway \
  oci://registry.example.com/charts/istio/gateway \
  --version 1.30.0 -n istio-egress -f values-egress.yaml

# 라벨 확인 — 이후 Gateway CRD selector와 AuthzPolicy selector가 이 라벨을 참조함
kubectl get pods -n istio-egress --show-labels
# 기대: istio=egressgateway 라벨 존재. 다르면 아래 모든 selector를 실제 값으로 교체

4.2 테스트 클라이언트 — 신원의 단위는 ServiceAccount

# clients.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: egress-test
  labels:
    istio-injection: enabled        # sidecar 자동 주입
---
apiVersion: v1
kind: ServiceAccount
metadata: { name: app-a, namespace: egress-test }   # 신원 = SA. 이게 정책의 주체가 됨
---
apiVersion: v1
kind: ServiceAccount
metadata: { name: app-b, namespace: egress-test }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: netshoot-a, namespace: egress-test }
spec:
  replicas: 1
  selector: { matchLabels: { app: netshoot-a } }
  template:
    metadata:
      labels: { app: netshoot-a }
    spec:
      serviceAccountName: app-a                     # 핵심: 워크로드별 전용 SA
      containers:
      - name: netshoot
        image: registry.example.com/netshoot:latest
        command: ["sleep", "infinity"]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: netshoot-b, namespace: egress-test }
spec:
  replicas: 1
  selector: { matchLabels: { app: netshoot-b } }
  template:
    metadata:
      labels: { app: netshoot-b }
    spec:
      serviceAccountName: app-b
      containers:
      - name: netshoot
        image: registry.example.com/netshoot:latest
        command: ["sleep", "infinity"]

4.3 라우팅 CRD 4종

# routing.yaml
# (1) ServiceEntry: 외부 호스트를 레지스트리에 등록.
#     sidecar와 gateway 양쪽 모두 이게 있어야 라우팅 가능
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
  name: external-apis
  namespace: istio-egress
spec:
  hosts:
  - api-a.example.com
  - api-b.example.net
  ports:
  - number: 443
    name: tls
    protocol: TLS          # TLS = passthrough 대상 (HTTPS로 쓰면 종단 시도하므로 주의)
  resolution: DNS          # gateway가 클러스터 DNS로 FQDN 해석해서 나감
---
# (2) Gateway: gw pod의 Envoy에 8443 리스너 생성.
#     ISTIO_MUTUAL = mesh CA 클라이언트 인증서 요구. "내부 클라이언트만" 강제의 실체
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: egress-tls
  namespace: istio-egress
spec:
  selector:
    istio: egressgateway   # 4.1에서 확인한 pod 라벨
  servers:
  - port:
      number: 8443         # 컨테이너 포트 (Service의 targetPort와 일치)
      name: tls-egress
      protocol: TLS
    hosts:
    - api-a.example.com          # 정적 환경이므로 열거 (와일드카드 지양)
    - api-b.example.net
    tls:
      mode: ISTIO_MUTUAL   # B안의 핵심 한 줄. outer mTLS 종단 + 신원 추출
---
# (3) DestinationRule: sidecar -> gw 구간의 outer TLS 설정.
#     subset마다 sni를 다르게 — gw 리스너가 outer SNI로 목적지를 분기하기 위함.
#     sni 누락 = gw에서 매칭 실패 = 최다 빈도 장애 포인트
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: egressgateway
  namespace: istio-egress
spec:
  host: egressgateway.istio-egress.svc.cluster.local
  subsets:
  - name: api-a
    trafficPolicy:
      portLevelSettings:
      - port: { number: 443 }                 # Service 포트 기준
        tls:
          mode: ISTIO_MUTUAL                  # outer를 mesh mTLS로 래핑
          sni: api-a.example.com # outer ClientHello의 SNI에 원본 호스트 적재
  - name: api-b
    trafficPolicy:
      portLevelSettings:
      - port: { number: 443 }
        tls:
          mode: ISTIO_MUTUAL
          sni: api-b.example.net
---
# (4) VirtualService: 호스트당 1개. 라우트 2단 구성
#     [tls #1] mesh(sidecar)에서: 이 SNI면 gw subset으로 보내라
#     [tls #2] gateway에서: outer 종단 후, 이 SNI면 진짜 외부 호스트로 보내라
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: api-a-via-egress
  namespace: istio-egress
spec:
  hosts: ["api-a.example.com"]
  gateways:
  - mesh                            # 모든 sidecar에 적용되는 예약어
  - istio-egress/egress-tls
  tls:
  - match:
    - gateways: [mesh]
      port: 443
      sniHosts: ["api-a.example.com"]   # 앱이 보낸 inner SNI
    route:
    - destination:
        host: egressgateway.istio-egress.svc.cluster.local
        subset: api-a                                # -> DR subset (sni 적재)
        port: { number: 443 }
  - match:
    - gateways: [istio-egress/egress-tls]
      port: 8443
      sniHosts: ["api-a.example.com"]   # gw에 도착한 outer SNI
    route:
    - destination:
        host: api-a.example.com         # ServiceEntry의 호스트
        port: { number: 443 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: api-b-via-egress
  namespace: istio-egress
spec:
  hosts: ["api-b.example.net"]
  gateways: [mesh, istio-egress/egress-tls]
  tls:
  - match:
    - gateways: [mesh]
      port: 443
      sniHosts: ["api-b.example.net"]
    route:
    - destination:
        host: egressgateway.istio-egress.svc.cluster.local
        subset: api-b
        port: { number: 443 }
  - match:
    - gateways: [istio-egress/egress-tls]
      port: 8443
      sniHosts: ["api-b.example.net"]
    route:
    - destination:
        host: api-b.example.net
        port: { number: 443 }
ℹ gateway용 leg-2 라우트가 `tcp`가 아니라 `tls`/sniHosts인 이유

ISTIO_MUTUAL 종단은 outer SNI를 소비하지만, 이 구성은 호스트 2개를 한 리스너에서 분기해야 하므로 종단 outer SNI로 filter chain을 고른다(8443 match가 그것). 단일 호스트라 분기가 필요 없으면 leg-2를 tcp로 흘리는 변형도 있다 — 그 비대칭의 원리는 4-CRD 멘탈모델 §7실측 리포트 참조.

4.4 AuthorizationPolicy — 통제의 본체

# authz.yaml
# deny-all: ALLOW 정책에 rules가 없으면 아무것도 허용 안 함 = 기본 거부.
# 이게 없으면 "신원은 보이는데 통제는 없는" 상태가 됨 — 가장 흔한 구성 실수
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: deny-all
  namespace: istio-egress
spec:
  selector:
    matchLabels: { istio: egressgateway }
---
# 허용 1건 = 정책 1건. "누가(principal) -> 어디로(sni)"가 그대로 승인 문서가 됨
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-app-a-to-api-a
  namespace: istio-egress
spec:
  selector:
    matchLabels: { istio: egressgateway }
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/egress-test/sa/app-a"]  # outer mTLS 인증서의 SPIFFE ID
    when:
    - key: connection.sni
      values: ["api-a.example.com"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-app-b-to-api-b
  namespace: istio-egress
spec:
  selector:
    matchLabels: { istio: egressgateway }
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/egress-test/sa/app-b"]
    when:
    - key: connection.sni
      values: ["api-b.example.net"]

정렬 지도 — 같은 문자열이 어디서 일치해야 하나

4-CRD 정렬 지도에 더해, 통제 레이어가 추가하는 정렬 2묶음:

+- 외부 host 문자열 (목적지당 1개) -----------------------------+
|  SE.hosts == Gateway.servers.hosts == VS.hosts               |
|  == VS sniHosts(양 leg) == DR.subsets[].tls.sni              |
|  == AuthzPolicy when.connection.sni                <- 추가    |
+--------------------------------------------------------------+
+- 신원 문자열 ------------------------------------------------+
|  Deployment.serviceAccountName(app-a)                        |
|  == AuthzPolicy principals 의                                 |
|     "cluster.local/ns/<ns>/sa/<sa>" 마지막 두 칸    <- 추가    |
+--------------------------------------------------------------+
+- selector 라벨 ----------------------------------------------+
|  gw pod label(istio=egressgateway)                           |
|  == Gateway.selector == AuthzPolicy.selector                  |
+--------------------------------------------------------------+
+- 포트 체인 --------------------------------------------------+
|  Service 443 -> targetPort 8443 == Gateway.port               |
|  DR portLevelSettings.port = 443 (Service 포트 기준)           |
+--------------------------------------------------------------+

5. 테스트 매트릭스 — 무엇을 실행하고 무엇이 나와야 하나

A="kubectl exec -n egress-test deploy/netshoot-a -c netshoot --"
B="kubectl exec -n egress-test deploy/netshoot-b -c netshoot --"
# 명령 기대 결과 증명하는 것
1 $A curl -sv https://api-a.example.com/api/ping 200 정상 경로
2 $A curl -sv https://api-b.example.net/healthz connection reset 신원 기반 거부
3 $B curl -sv https://api-b.example.net/healthz 200 SA별 차등
4 $B curl -sv https://api-a.example.com/api/ping reset 대칭 확인
5 sidecar 없는 pod에서 openssl s_client -connect egressgateway.istio-egress:443 handshake 실패 비-mesh 클라이언트 차단
⚠ 거부는 HTTP 403이 아니다

L4(TLS) 라우트라 RBAC 거부 = 연결 끊김이다. curl에서는 OpenSSL SSL_connect: SSL_ERROR_SYSCALL 또는 Connection reset by peer로 보인다. 모니터링·런북에 이걸 명시하지 않으면 운영에서 정책 거부를 "장애"로 오인한다. 메커니즘은 AuthorizationPolicy 멘탈모델의 HTTP vs TCP 절 참조.


6. 무엇을 봐야 하는가 — 검증 4단 (이 순서가 곧 운영 디버깅 표준)

진단 멘탈모델: 외부 기관 문제는 (a)에서, 인가 문제는 (b)에서, 라우팅 문제는 (c)에서 끝난다. tcpdump는 마지막 수단.

(a) 거쳐갔는지 — gateway access log

# Telemetry로 access log 활성화 (이미 전사 설정이 있으면 생략)
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata: { name: egress-logs, namespace: istio-egress }
spec:
  accessLogging:
  - providers: [{ name: envoy }]
kubectl logs -n istio-egress deploy/egressgateway | tail
# 테스트 1 실행 후 기대: 목적지 cluster가 외부 호스트로 찍힘
# ... outbound|443||api-a.example.com ... <client pod IP> ... api-a.example.com
# 마지막 필드(REQUESTED_SERVER_NAME) = outer SNI. DR sni 설정이 동작한 증거

(b) 누가 평가됐는지 — RBAC 디버그 로그

istioctl proxy-config log deploy/egressgateway -n istio-egress --level rbac:debug
kubectl logs -n istio-egress deploy/egressgateway -f | grep rbac
# 테스트 2 실행 시 기대:
#   principal: cluster.local/ns/egress-test/sa/app-a ... enforced denied
# -> "gateway가 SPIFFE 신원을 보고 판정한다"의 직접 증거. 확인 후 --level rbac:info로 복원

(c) 설정이 내려갔는지 — istioctl

# gw에 8443 리스너 + SNI별 filter chain 2개가 생성됐는지
istioctl proxy-config listeners deploy/egressgateway -n istio-egress --port 8443
# sidecar가 api-a SNI를 gw subset으로 라우팅하는지
istioctl proxy-config listeners deploy/netshoot-a -n egress-test --port 443 -o json | grep -A3 sni
# mesh 인증서 존재 확인
istioctl proxy-config secret deploy/egressgateway -n istio-egress

(d) 이중 TLS의 실체 — 인증서 비교 + tcpdump

# inner: 앱이 보는 인증서 = 진짜 목적지 인증서 (end-to-end TLS 보존의 증거)
$A curl -v https://api-a.example.com/api/ping 2>&1 | grep -E "subject|issuer"

# outer: 와이어에서 보이는 건 gw로 가는 TLS 한 겹뿐
$A tcpdump -i eth0 -nn 'tcp port 8443' -c 20
# 기대: 목적지가 외부 IP가 아니라 gateway pod IP:8443.
# ClientHello의 SNI는 api-a.example.com (DR이 적재), 그러나 이 세션의 인증서는 Istio CA 발급.
# inner TLS는 이 안에 캡슐화되어 보이지 않음 <- "tcpdump에 TLS가 여러 번 보인다"의 정확한 구조

자주 깨지는 곳 — 증상이 아니라 왜

증상 원인 (메커니즘)
gw 로그에 아예 안 찍힘 sidecar VS 매칭 실패 — 미주입(istio-proxy 컨테이너 확인) 또는 sniHosts 오타. sidecar 로그에서 PassthroughCluster로 직행 중인지 확인
gw까지 오는데 reset DR sni 누락/오타 → 리스너 filter chain 매칭 실패. 또는 deny-all만 있고 allow 누락
8443 리스너 없음 Gateway selector ≠ pod 라벨, 또는 CRD 포트를 8443이 아닌 443으로 선언 (Envoy는 targetPort에 bind)
handshake 즉시 실패 클라이언트가 비-mesh (그게 정상 동작), 또는 gw는 ISTIO_MUTUAL인데 sidecar DR이 평문

7. 설계 결정 노트 — 운영 규모로 갈 때의 세 갈림길

7.1 Gateway "분리"는 세 레이어를 구분해서 말해야 한다

Gateway CRD ----(selector)----> Deployment(pods) ----> Node(전용 노드풀)
[config, 무료]                  [리소스, 비용]          [IP 대역, 방화벽]

Gateway CRD는 gateway pod의 Envoy에 listener 설정을 붙이는 선언일 뿐이라 하나의 Deployment에 여러 Gateway CRD를 붙일 수 있다. 팀별/목적지별 CRD 분리는 설정 격리일 뿐 비용이 거의 없고, 진짜 비용은 Deployment를 나눌 때 발생한다.

서비스마다 egress gateway pod 배포는 비권장이다: - 서비스 N개 × (Deployment+HPA+PDB+모니터링+방화벽 source IP 등록)이 선형 증가 — egress 중앙화의 목적 자체가 무너진다. 대외 기관 whitelist에 등록할 IP가 늘어나는 건 금융 환경에서 실질 페널티. - Envoy는 유휴 상태에도 메모리를 점유한다. - 신원 기반 authz가 이미 공유 pod 위의 논리적 테넌트 격리를 달성하므로 물리 분리의 추가 보안이 거의 없다.

Deployment 분리가 정당화되는 예외: 망분리 구역/전용선 등 네트워크 경로 자체가 다를 때, 특정 대외 기관의 장애 반경·QoS를 물리적으로 떼야 할 때, noisy neighbor가 실측될 때. 권장 구조는 망 존 단위 소수 풀(2~4개) + CRD/AuthzPolicy 논리 분리. 전용 노드풀(taint/affinity)은 ① 방화벽 등록용 SNAT source IP 고정 ② 일반 워크로드와 장애 격리를 동시에 얻는다.

7.2 MySQL 등 raw TCP — 가능하지만 라우팅 키가 포트로 바뀐다

ServiceEntry(TCP 포트) + Gateway TCP listener + VS tcp 라우트로 가능하고, outer ISTIO_MUTUAL도 L4 래핑이라 신원 authz가 유지된다. 단 SNI가 없다 — 평문 MySQL은 당연히 없고, TLS를 켠 MySQL/PostgreSQL도 프로토콜 내부 STARTTLS 협상이라 연결 시작 시점에 ClientHello가 안 보인다. 따라서 라우팅 키는 gateway 포트 번호가 되고, 목적지 DB마다 전용 listener 포트를 할당해야 한다(DB 3개 → 포트 3개). DB는 long-lived connection이라 gateway drain 시 일괄 끊김 영향도 HTTPS보다 크다 — graceful termination MOC의 시나리오가 TCP에서 더 민감하게 재등장한다.

7.3 wildcard 목적지와 EnvoyFilter — 정적 환경엔 불필요

근거 블로그가 EnvoyFilter를 쓰는 건 wildcard 도메인 동적 라우팅 전용이고, 이중 TLS·신원 통제와는 독립 부품이다. 표준 VS의 TLS 라우트는 *.example.org를 매칭할 수는 있어도 목적지는 정적 단일 호스트로만 보낼 수 있다. 이 간극("매칭은 wildcard, 포워딩은 고정")을 메우려면 연결마다 inner SNI를 재검사해 동적 TCP proxy의 목적지로 쓰는 Envoy 기능이 필요한데, 이건 VS로 설정 불가라 EnvoyFilter가 등장한다.

[정적 목적지 N개]  VS: sniHosts 매칭 -> 고정 host 라우팅    <- 표준 CRD로 충분 (본 문서)
[wildcard 도메인]  VS: 매칭은 가능, 목적지가 동적이어야 함   <- EnvoyFilter 필요 (블로그)

같은 이유로 DR sni 필드의 유무도 모순이 아니라 토폴로지 차이다: gateway가 outer SNI로 호스트를 분기하는 정적 구조에선 필수(매칭 키), 블로그처럼 전량을 internal listener로 넘겨 inner SNI를 재검사하는 구조에선 불필요(outer SNI를 매칭에 안 씀). Gateway hosts: ["*"]도 트래픽 필터가 아니라 "어떤 VS가 바인딩될 수 있나"의 범위 선언이라 동적 구조에선 정상 — 단 정적 환경에선 열거가 맞다.


8. What you might be missing


9. 참조

아카이브 내부 - Egress Gateway 도입 가이드 (사내 공유본) — 이 통제 모델이 채택된 의사결정 문서 (Passthrough vs mTLS 비교·근거·표준 1벌) - Egress TCP 병목 정본 — 이 구성 위에 gateway를 운영할 때의 연결·포트·conntrack 한계와 완화 운영값 - TCP 병목 한계 축소 재현 랩 — 이 문서의 테스트 클러스터를 그대로 써서 병목을 직접 재현 - HTTPS over mTLS 구조 정본 — 이중 TLS 패턴 자체의 해부 (이 문서의 토대) - Egress 4-CRD 멘탈모델 — 4-CRD 직관·정렬 지도·tls/tcp 비대칭 - 이중 TLS 없는 egress 신원 — passthrough + Calico로 같은 결과를 내는 반대 선택의 근거 - Egress mTLS 실측 리포트 — 홈랩 검증과 두 함정 - mTLS/SPIFFE 신원 — 신원 발급·검증 파이프라인 정본 - AuthorizationPolicy 멘탈모델 — 평가 위치·순서, TCP 거부=reset의 원리 - 보안 리소스 trio — "증명은 인증이, 차단은 인가가"의 역할 분담

외부 - istio.io blog (2023) — Routing egress traffic to wildcard destinations — ISTIO_MUTUAL로 gateway를 mesh 내부 전용으로 잠그는 패턴의 출처 - istio.io — Security Best Practices — REGISTRY_ONLY는 best-effort, egress 보호는 gateway+NetworkPolicy 결합