--- type: guide tags: [istio, egress, mtls, spiffe, identity, authorizationpolicy, security, multi-tenancy, how-to] created: 2026-06-10 --- # Egress 신원 기반 통제 — mTLS 신원이 공용 gateway에 멀티테넌시를 만든다 > [!abstract] > "egress gateway만 세우면 외부 통신이 통제된다"는 직관의 빈칸을 메우는 문서. 방화벽·NetworkPolicy는 > **"외부로 나가는 유일한 경로가 gateway인가"(Q1)** 에만 답하고, **"그 gateway를 누가, 어느 목적지로 > 쓸 수 있나"(Q2)** 에는 답하지 못한다. Q2의 판정 재료가 **mesh mTLS가 운반하는 SPIFFE 신원**이고, > 판정 장치가 **gateway 위의 AuthorizationPolicy(principal × SNI)** 다. 이 문서는 ① 그 논리를 통제 > 체인(경로 강제 → 검문소 → 신원 판정)으로 세우고 ② SA 2개가 서로 다른 목적지만 허용받는 **테스트 > 클러스터 전체 구성**(YAML 주석 포함)과 검증·함정까지 따라간다. 이중 TLS 구조 자체의 해부는 > [HTTPS over mTLS 정본](id__src-https-over-mtls.html), 4-CRD 직관은 > [Egress 4-CRD 멘탈모델](mm__guide-crd-mental-model.html)이 정본 — 본 문서는 그 구조 **위에 > 올라가는 "통제"** 를 다룬다. **대상 환경:** Istio **1.30.0**, sidecar mesh, Helm gateway chart (테스트 클러스터) **대상 독자:** egress gateway를 보안 요건(최소권한·감사 추적) 충족 수단으로 도입하려는 DevOps/SRE **범위:** 신원 기반 통제의 논리 → 테스트 클러스터 구성 → 검증 매트릭스 → 설계 결정 노트(분리 전략·TCP·wildcard) **선행 개념:** egress 2-leg 라우팅([4-CRD 멘탈모델](mm__guide-crd-mental-model.html)), SPIFFE 신원([정본](../istio/sec__note-mtls-spiffe-identity.html)), AuthorizationPolicy 평가([멘탈모델](../istio/sec__src-authorizationpolicy-mental-model.html)) --- ## 1. 멘탈모델 한 문장 — 통제는 체인이고, 신원은 그 마지막 고리다 > **강제(enforcement)는 경로가 하고(NetworkPolicy·방화벽), 판정은 검문소(gateway)가 하며, > 판정에 "누가"를 공급하는 유일한 장치가 sidecar↔gateway 구간의 mesh mTLS(SPIFFE 인증서)다.** ``` NetworkPolicy / IDC firewall : "egress gw 경유 외 외부행 차단" (경로 강제, L3/L4) | v egress gateway : 유일한 검문소가 됨 | v 검문소에서 "누가 -> 어디로" 판정 필요 <- 여기서 신원(SPIFFE)이 등장 ``` 이 체인에서 어느 고리 하나만 빠져도 통제가 성립하지 않는다. - **경로 강제 없이 gateway만** → 침해된 pod가 sidecar를 우회(iptables 조작, UID 1337)해 직접 나간다. Istio 레이어는 우회를 못 막는다. - **검문소까지 만들고 신원 없이** → gateway가 보는 건 source pod IP(휘발)와 SNI(클라이언트 제공 값)뿐. **gateway에 도달 가능한 모든 pod가 gateway에 설정된 모든 경로를 쓸 수 있다.** PG사 경로를 하나 뚫는 순간 전사 모든 워크로드가 PG사로 나갈 수 있는 상태가 된다. 신원 기반 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](https://istio.io/latest/docs/ops/best-practices/security/)가 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이고, 판정 재료가 신원이기 때문이다. > [!tip] A/B 선택의 실제 분기점 > "passthrough(A)냐 이중 TLS(B)냐"는 기술 선호가 아니라 **사내 보안 심사 요건 문서에 "워크로드별 > 차등 통제·주체 식별"이 명시되어 있는지**로 판가름한다. 없으면 A로 시작하고, 요건이 생기면 **같은 > 토폴로지에서 Gateway `tls.mode` + DR `trafficPolicy` + AuthorizationPolicy만 추가**하는 증분 적용이 > 가능하다([델타 3곳](mm__guide-crd-mental-model.html)). 같은 환경에서 신원을 CNI pod-selector로 > 대신 식별하는 반대 선택의 근거는 [이중 TLS 없는 egress 신원](id__note-identity-without-mtls.html). ### 신원이 있을 때만 가능해지는 통제 | 제어 | 내용 | 신원 없이 가능? | |---|---|---| | 워크로드별 목적지 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)](https://istio.io/latest/blog/2023/egress-sni/)는 gateway를 mesh 내부 클라이언트 전용으로 잠그려면 sidecar↔gateway에 ISTIO_MUTUAL을 강제해야 하고 그 결과 TLS가 두 겹이 된다고 명시하고, [Security Best Practices](https://istio.io/latest/docs/ops/best-practices/security/)는 egress 보호를 gateway + NetworkPolicy 결합으로 설명한다. 즉 **기본 권장은 passthrough가 맞고, 신원 기반 통제가 요건일 때의 문서화된 경로가 이중 TLS**다. --- ## 3. 부품표 — 각 부품 = 통제 질문 하나 라우팅 4-CRD의 역할은 [4-CRD 멘탈모델](mm__guide-crd-mental-model.html)이 정본. 여기서는 **통제 관점에서 각 부품이 답하는 질문**만 다시 정렬한다. | 통제 질문 | 답 = 부품 | 핵심 한 줄 | |---|---|---| | "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) ```bash kubectl create namespace istio-egress ``` ```yaml # 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 } ``` ```bash 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 ```yaml # 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종 ```yaml # 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 } ``` > [!note] gateway용 leg-2 라우트가 `tcp`가 아니라 `tls`/sniHosts인 이유 > ISTIO_MUTUAL 종단은 **outer** SNI를 소비하지만, 이 구성은 **호스트 2개를 한 리스너에서 분기**해야 > 하므로 종단 *전* outer SNI로 filter chain을 고른다(8443 match가 그것). 단일 호스트라 분기가 필요 > 없으면 leg-2를 `tcp`로 흘리는 변형도 있다 — 그 비대칭의 원리는 > [4-CRD 멘탈모델 §7](mm__guide-crd-mental-model.html)과 [실측 리포트](rpt__report-2026-06-08_egress-mtls.html) 참조. ### 4.4 AuthorizationPolicy — 통제의 본체 ```yaml # 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 정렬 지도](mm__guide-crd-mental-model.html)에 더해, 통제 레이어가 추가하는 정렬 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//sa/" 마지막 두 칸 <- 추가 | +--------------------------------------------------------------+ +- selector 라벨 ----------------------------------------------+ | gw pod label(istio=egressgateway) | | == Gateway.selector == AuthzPolicy.selector | +--------------------------------------------------------------+ +- 포트 체인 --------------------------------------------------+ | Service 443 -> targetPort 8443 == Gateway.port | | DR portLevelSettings.port = 443 (Service 포트 기준) | +--------------------------------------------------------------+ ``` --- ## 5. 테스트 매트릭스 — 무엇을 실행하고 무엇이 나와야 하나 ```bash 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 클라이언트 차단 | > [!warning] 거부는 HTTP 403이 아니다 > L4(TLS) 라우트라 RBAC 거부 = **연결 끊김**이다. curl에서는 `OpenSSL SSL_connect: SSL_ERROR_SYSCALL` > 또는 `Connection reset by peer`로 보인다. 모니터링·런북에 이걸 명시하지 않으면 운영에서 정책 거부를 > "장애"로 오인한다. 메커니즘은 [AuthorizationPolicy 멘탈모델](../istio/sec__src-authorizationpolicy-mental-model.html)의 > HTTP vs TCP 절 참조. --- ## 6. 무엇을 봐야 하는가 — 검증 4단 (이 순서가 곧 운영 디버깅 표준) 진단 멘탈모델: **외부 기관 문제는 (a)에서, 인가 문제는 (b)에서, 라우팅 문제는 (c)에서 끝난다. tcpdump는 마지막 수단.** **(a) 거쳐갔는지 — gateway access log** ```yaml # Telemetry로 access log 활성화 (이미 전사 설정이 있으면 생략) apiVersion: telemetry.istio.io/v1 kind: Telemetry metadata: { name: egress-logs, namespace: istio-egress } spec: accessLogging: - providers: [{ name: envoy }] ``` ```bash kubectl logs -n istio-egress deploy/egressgateway | tail # 테스트 1 실행 후 기대: 목적지 cluster가 외부 호스트로 찍힘 # ... outbound|443||api-a.example.com ... ... api-a.example.com # 마지막 필드(REQUESTED_SERVER_NAME) = outer SNI. DR sni 설정이 동작한 증거 ``` **(b) 누가 평가됐는지 — RBAC 디버그 로그** ```bash 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** ```bash # 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** ```bash # 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](../istio/gt__MOC-graceful-termination.html)의 시나리오가 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 - **SA 위생이 전제조건이다.** 워크로드들이 `default` SA를 공유하면 principal이 전부 같아져 신원 모델 전체가 무의미해진다. workload당 전용 SA가 이 패턴의 숨은 선행 작업이고, 수백 앱 규모면 거버넌스(Kyverno로 default SA 사용 금지 등)부터 필요하다. - **TLS 라우트의 authz 조건은 connection 레벨뿐이다.** principal, namespace, `connection.sni`, IP까지. passthrough라 HTTP path/method/header 조건은 불가 — 보안팀에 약속할 수 있는 통제 범위를 처음부터 명확히 해야 한다. L7 조건이 필요하면 gateway가 종단하는 다른 패턴이 필요하다. - **SNI는 클라이언트 제공 값이다.** 다만 정적 라우트에선 SNI를 위조해도 위조한 그 호스트로 라우팅될 뿐 임의 IP 터널링은 안 된다. wildcard 호스트를 쓰기 시작하면 이 보장이 약해진다 — 그게 위 블로그가 inner SNI 재검사를 강조하는 이유다. 그리고 inner TLS의 실제 인증서 검증은 끝까지 **앱 책임**이다. "gateway 통과 = 검증 완료"로 오해하는 팀이 나오지 않게 정책으로 명문화할 것. - **3중 통제 없이는 gateway가 보안 경계가 아니다.** ① CNI NetworkPolicy(일반 워크로드 external egress deny, gw pod+DNS만 허용) ② IDC 방화벽(egress 전용 노드풀 CIDR만 허용) ③ `outboundTrafficPolicy: REGISTRY_ONLY`(보조). Istio CRD만으로 구성하면 심사에서 "우회 경로 통제"를 지적받는다. 그리고 **DNS 자체가 exfiltration 채널**이다 — 외부 도메인 resolution 경로(내부 resolver forwarding 정책, Istio DNS proxying)도 설계에 포함해야 한다. - **감사 로그는 신원 확보로 끝나지 않는다.** access log 포맷에 `DOWNSTREAM_PEER_URI_SAN`(SPIFFE ID)을 넣고 보존 기간 요건(예: PCI-DSS 1년/3개월 즉시조회)까지 연결해야 감사 요건이 완성된다. - **gateway는 SPOF이자 chokepoint다.** 전사 egress가 한 곳에 모인다. HPA·PDB·graceful drain, 특히 대외 long-lived connection(전문 통신, gRPC) drain 시나리오를 함께 설계할 것. --- ## 9. 참조 **아카이브 내부** - [Egress Gateway 도입 가이드 (사내 공유본)](cfg__guide-adoption-passthrough-vs-mtls.html) — 이 통제 모델이 채택된 **의사결정 문서** (Passthrough vs mTLS 비교·근거·표준 1벌) - [Egress TCP 병목 정본](ref__src-tcp-bottlenecks.html) — 이 구성 위에 gateway를 운영할 때의 연결·포트·conntrack 한계와 완화 운영값 - [TCP 병목 한계 축소 재현 랩](cfg__guide-tcp-failure-reproduction.html) — 이 문서의 테스트 클러스터를 그대로 써서 병목을 직접 재현 - [HTTPS over mTLS 구조 정본](id__src-https-over-mtls.html) — 이중 TLS 패턴 자체의 해부 (이 문서의 토대) - [Egress 4-CRD 멘탈모델](mm__guide-crd-mental-model.html) — 4-CRD 직관·정렬 지도·tls/tcp 비대칭 - [이중 TLS 없는 egress 신원](id__note-identity-without-mtls.html) — passthrough + Calico로 같은 결과를 내는 **반대 선택**의 근거 - [Egress mTLS 실측 리포트](rpt__report-2026-06-08_egress-mtls.html) — 홈랩 검증과 두 함정 - [mTLS/SPIFFE 신원](../istio/sec__note-mtls-spiffe-identity.html) — 신원 발급·검증 파이프라인 정본 - [AuthorizationPolicy 멘탈모델](../istio/sec__src-authorizationpolicy-mental-model.html) — 평가 위치·순서, TCP 거부=reset의 원리 - [보안 리소스 trio](../istio/sec__note-security-resource-trio.html) — "증명은 인증이, 차단은 인가가"의 역할 분담 **외부** - [istio.io blog (2023) — Routing egress traffic to wildcard destinations](https://istio.io/latest/blog/2023/egress-sni/) — ISTIO_MUTUAL로 gateway를 mesh 내부 전용으로 잠그는 패턴의 출처 - [istio.io — Security Best Practices](https://istio.io/latest/docs/ops/best-practices/security/) — REGISTRY_ONLY는 best-effort, egress 보호는 gateway+NetworkPolicy 결합