--- type: guide tags: [istio, egress, mtls, passthrough, adoption, decision, tcp, operations] created: 2026-06-10 --- # Istio Egress Gateway 도입 가이드 — Passthrough vs mTLS Passthrough > 대상: 플랫폼/인프라/보안 담당자 > 환경: on-prem Kubernetes, Istio 1.30.0 (sidecar mode), PCI-DSS 준수 IDC > 결정 요약: **mTLS Passthrough(ISTIO_MUTUAL) 패턴 채택.** proxy 도입에 따른 TCP 운영 이슈는 방식과 무관한 공통 비용이며 별도 완화 설정으로 대응함. --- ## 1. 배경 및 목표 - 모든 대외(외부 기관) 통신을 통제된 단일 경로(egress gateway)로 수렴 필요. - 보안 요건: ① 외부행 경로 강제, ② 워크로드 단위 최소권한(어떤 앱이 어떤 목적지로 나가는지 차등 통제), ③ 주체 식별 가능한 감사 추적, ④ mesh 외부 클라이언트의 gateway 사용 차단. - 제약: 앱은 HTTPS를 직접 발신(종단간 TLS 유지 필요). 앱 코드/프로토콜 변경 불가. ``` [app + sidecar] ---> [egress gateway] ---> IDC FW ---> 대외기관 API (HTTPS 발신) (유일한 출구) (gw 대역만 허용) ``` --- ## 2. 두 방안 비교 두 방식 모두 앱↔외부 서버의 **종단간 TLS는 유지**됨(gateway는 inner TLS를 복호화하지 않음). 차이는 sidecar→gateway 구간을 mesh mTLS로 한 겹 더 감싸는지 여부. ``` A. TLS Passthrough app --[원본 TLS]--> sidecar --[원본 TLS 그대로]--> gw --[원본 TLS]--> 외부 gw는 SNI만 보고 통과 (신원 정보 없음) B. mTLS Passthrough (ISTIO_MUTUAL) app --[원본 TLS]--> sidecar --[mesh mTLS[원본 TLS]]--> gw --[원본 TLS]--> 외부 gw가 outer만 종단 -> SPIFFE 신원 추출 ``` | 항목 | A. Passthrough | B. mTLS Passthrough | |---|---|---| | gateway에서 호출 주체 식별 | 불가 (pod IP뿐, IP는 휘발성) | **가능** (SPIFFE: ns/sa 단위) | | AuthorizationPolicy 표현력 | 목적지(SNI)·포트만 | **주체(principal) × 목적지** | | 워크로드별 차등 통제 | 불가 — gateway 도달 가능한 모든 pod가 allowlist 합집합 사용 | 가능 — "app-a→A기관만" 식 최소권한 | | mesh 외부 클라이언트 차단 | 불가 (gateway Svc는 클러스터 전역 도달 가능) | **TLS handshake 단계에서 거부** (mesh CA 인증서 요구) | | 감사 로그 | src pod IP | **SPIFFE ID** (`DOWNSTREAM_PEER_URI_SAN`) | | 사고 시 즉시 회수 | 목적지 단위 전체 차단만 | 해당 SA 정책 1건 삭제 | | 설정 복잡성 | 낮음 (DR 불필요, VS 양쪽 tls 라우트) | 높음 (Gateway hosts ↔ DR sni ↔ VS 3자 정합) | | 디버깅 | 와이어에 원본 TLS 한 겹 | mesh 구간만 TLS 2겹 (gw→외부는 단일 TLS로 동일) | | 연결 수립 레이턴시 | 기준 | 신규 연결당 outer 핸드셰이크 1회 추가 | | CPU | 기준 | sidecar(암호화)·gw(복호화) 증가 | | 학습 곡선 | 낮음 | 라우트 타입 규칙, 인증서 체계 등 학습 필요 | --- ## 3. 결정: mTLS Passthrough 채택 근거 **Passthrough의 결손은 구조적이며 운영 중 메울 수 없음.** - 검문소(gateway)는 만들어지지만 "누가"를 판정할 재료가 없음. 한 목적지를 뚫는 순간 전사 모든 워크로드(비-mesh pod 포함)에 그 경로가 개방됨 — 최소권한 요건 미충족. - 감사 시점에 pod IP → 워크로드 역추적은 IP 휘발성 때문에 신뢰 불가 — 주체 식별 요건 미충족. - 추후 신원이 필요해지면 결국 ISTIO_MUTUAL 전환이 유일한 경로 → 지금의 구축 비용을 그 시점에 지불하게 됨. **mTLS의 비용은 인지하고 감수함.** - 비용: 신규 연결 수립 레이턴시 증가(핸드셰이크 1회분), 암호화 CPU 부하, 초기 설정 복잡성과 학습 곡선, mesh 구간 TLS-in-TLS 디버깅. - 감수 근거: 이 비용은 **설계 시점에 1회 지불되고 템플릿에 동결**됨(목적지 추가 = 표준 1벌 복사). 반면 passthrough의 결손은 런타임에 상존하며 사고·심사 시점에 청구됨. 가시성과 트래픽 통제가 비용을 상회한다고 판단. - 디버깅 부담의 실제 범위: 장애 다발 구간인 gw→외부는 **단일 TLS로 passthrough와 동일.** 이중 TLS는 mesh 내부 hop에 한정되며, 해당 구간 1차 진단 도구는 tcpdump가 아닌 access log / istioctl로 표준화(§7). 참고: 두 모드는 같은 gateway Deployment에서 포트 단위로 공존 가능(예: 8443=ISTIO_MUTUAL, 9443=PASSTHROUGH). 필요 시 목적지별 점진 전환 경로 존재. --- ## 4. 아키텍처 ``` ns: app-namespace ns: istio-egress +------------------+ +-----------------------------------+ | app + sidecar | outer mTLS | egressgateway Deployment (3+) | | (sa: app-a) ----+--------------->| :8443 ISTIO_MUTUAL | +------------------+ Svc:443 | 1) tls_inspector: outer SNI로 | -> pod:8443 | filter chain 선택 | | 2) outer 종단 -> principal 추출 | | 3) AuthzPolicy(principal x sni) | | 4) tcp_proxy -> 외부:443 | +-----------------+-----------------+ | (inner TLS만, 단일 겹) v 전용 노드풀 -> IDC FW -> 대외기관 ``` **핵심 동작 규칙 (트러블슈팅의 기준)** | 규칙 | 내용 | |---|---| | 라우트 타입 = 종단 여부 | 미종단 hop(sidecar, PASSTHROUGH gw) = `tls` 라우트(sniHosts 매칭) / 종단 hop(ISTIO_MUTUAL gw) = `tcp` 라우트 | | 단일 포트 다중 목적지 | 목적지당 Gateway CR 1개(hosts로 분리) → istiod가 단일 리스너에 SNI별 filter chain 병합. **CR 간 hosts 중복 금지** | | DR `sni` 필드 | outer ClientHello의 SNI에 원본 호스트명 적재 → filter chain 선택 키. **누락 = handshake reset (최다 빈도 설정 오류)** | | 거부 = HTTP 403 아님 | L4 라우트이므로 RBAC 거부는 **connection reset**으로 나타남 | --- ## 5. 구성 방법 (목적지 1개 = 표준 1벌) 목적지 추가 시 `ServiceEntry 항목 + Gateway CR + DR subset + VirtualService + AuthorizationPolicy` 한 벌. Helm/ApplicationSet 템플릿화 대상. ```yaml # (0) ServiceEntry — 외부 호스트 레지스트리 등록 (공통, hosts에 추가) apiVersion: networking.istio.io/v1 kind: ServiceEntry metadata: { name: external-apis, namespace: istio-egress } spec: hosts: ["api.partner-a.example.com"] ports: [{ number: 443, name: tls, protocol: TLS }] resolution: DNS # NONE이면 gw 자기 루프 발생 --- # (1) Gateway — 목적지당 1개. ISTIO_MUTUAL = mesh 인증서 요구 apiVersion: networking.istio.io/v1 kind: Gateway metadata: { name: egress-partner-a, namespace: istio-egress } spec: selector: { istio: egressgateway } servers: - port: { number: 8443, name: tls-partner-a, protocol: TLS } # name은 CR마다 유일하게 hosts: ["api.partner-a.example.com"] tls: { mode: ISTIO_MUTUAL } --- # (2) DestinationRule — sidecar->gw outer mTLS + SNI 적재 apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: { name: egressgateway, namespace: istio-egress } spec: host: egressgateway.istio-egress.svc.cluster.local subsets: - name: partner-a trafficPolicy: portLevelSettings: - port: { number: 443 } tls: { mode: ISTIO_MUTUAL, sni: api.partner-a.example.com } connectionPool: tcp: maxConnections: 4096 tcpKeepalive: { time: 300, interval: 30, probes: 3 } --- # (3) VirtualService — mesh쪽 tls / gateway쪽 tcp (종단 규칙) apiVersion: networking.istio.io/v1 kind: VirtualService metadata: { name: partner-a-via-egress, namespace: istio-egress } spec: hosts: ["api.partner-a.example.com"] gateways: [mesh, istio-egress/egress-partner-a] tls: - match: - { gateways: [mesh], port: 443, sniHosts: ["api.partner-a.example.com"] } route: - destination: host: egressgateway.istio-egress.svc.cluster.local subset: partner-a port: { number: 443 } tcp: - match: - { gateways: [istio-egress/egress-partner-a], port: 8443 } route: - destination: { host: api.partner-a.example.com, port: { number: 443 } } --- # (4) AuthorizationPolicy — deny-all(1회) + 허용 1건 = 승인 1건 apiVersion: security.istio.io/v1 kind: AuthorizationPolicy metadata: { name: deny-all, namespace: istio-egress } spec: selector: { matchLabels: { istio: egressgateway } } # rules 없음 = 기본 거부 --- apiVersion: security.istio.io/v1 kind: AuthorizationPolicy metadata: { name: allow-app-a-to-partner-a, namespace: istio-egress } spec: selector: { matchLabels: { istio: egressgateway } } action: ALLOW rules: - from: [{ source: { principals: ["cluster.local/ns/team-a/sa/app-a"] } }] when: [{ key: connection.sni, values: ["api.partner-a.example.com"] }] --- # (5) 외부 호스트 DR — Envoy 기본 상한/keepalive 보정 (§6 참조) apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: { name: partner-a-external, namespace: istio-egress } spec: host: api.partner-a.example.com trafficPolicy: connectionPool: tcp: maxConnections: 4096 # 기본 1024 함정 제거 connectTimeout: 3s idleTimeout: 1800s # 채널 최장 유휴 간격보다 길게 maxConnectionDuration: 3600s # scale-out 후 재분배 유도 (민감 채널은 제외) tcpKeepalive: { time: 300, interval: 30, probes: 3 } # FW idle timeout보다 짧게 ``` **Gateway Helm values 핵심 (배포 형상)** ```yaml replicaCount: 3 # 포트 공간 = 28k x replica affinity: podAntiAffinity: # SNAT 환경에서 필수 (노드IP 포트 공간 공유 방지) requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: { matchLabels: { istio: egressgateway } } topologyKey: kubernetes.io/hostname nodeSelector: { node-role/egress: "true" } # 전용 노드풀 = FW src IP 안정화 securityContext: sysctls: - { name: net.ipv4.ip_local_port_range, value: "10240 60999" } - { name: net.ipv4.tcp_tw_reuse, value: "1" } # kubelet allowedUnsafeSysctls 필요 podAnnotations: proxy.istio.io/config: | terminationDrainDuration: 300s proxyMetadata: { EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true" } service: type: ClusterIP ports: - { name: tls-egress, port: 443, targetPort: 8443 } ``` 별도 적용: PodDisruptionBudget(minAvailable: 2). --- ## 6. 공통 고려사항 — L4 Proxy 도입에 따른 TCP 이슈 > **mTLS 채택 여부와 무관하게 적용되는 공통 비용.** gateway는 tcp_proxy(L4)로 동작하므로 앱 연결 1개 = gw 소켓 2개의 1:1 매핑이며 연결 재사용이 원리적으로 불가(암호화된 바이트 파이프). 전사에 분산되어 있던 출발지 IP가 gw pod 소수로 수렴하는 집중 효과가 모든 이슈의 근원. | # | 병목 | 한계 (기본값) | 증상 | 완화 | |---|---|---|---|---| | 1 | Envoy cluster 연결 상한 | 목적지당 **1,024** | 초과분 즉시 거부, flag `UO`, `upstream_cx_overflow` 증가 | 외부 호스트 DR `maxConnections` 명시 (§5-(5)) | | 2 | Ephemeral port + TIME_WAIT | gw pod 1개→목적지 1개: 동시 **약 28k**, 신규 **약 470 conn/s** (28,232포트÷60s) | connect 실패, flag `UF`, EADDRNOTAVAIL | 앱 keep-alive 가이드(근본), replica 분산+antiAffinity, `ip_local_port_range` 확장, `tcp_tw_reuse=1` | | 3 | conntrack 테이블 | 노드 전역 약 26만 (TIME_WAIT류 120s 잔류) | **무응답 timeout** (silent drop), dmesg "table full" | 노드 sysctl: `nf_conntrack_max=1048576`, `tcp_timeout_time_wait=30` | | 4 | 중간장비 idle timeout | FW 보통 30~60분 | half-open: 유휴 후 첫 송신에서 RST/timeout, 간헐적·재현 곤란 | DR `tcpKeepalive`를 FW timeout보다 짧게. FW 값 사전 확인 | | 5 | 수명 긴 연결 + 재배포 | drain 기본 5s | 배포 시 long-lived 연결 일괄 절단 | `terminationDrainDuration` 연장, PDB, 배포 윈도우 정책 | **부딪히는 순서**: 보통 1 → 2 → 3 (좁은 것부터). 4는 트래픽 적은 채널에서 먼저 발현. **도입 전 확인 체크리스트** - [ ] **SNAT 여부** (Calico `natOutgoing`): on이면 외부가 보는 src = 노드 IP → 같은 노드 gw pod들이 포트 공간 공유 + FW 등록 단위 변경. **포트 예산·방화벽 신청을 동시에 결정하는 1순위 항목** - [ ] 목적지별 peak 동시 연결 수·conn/s 측정 (`istio_tcp_connections_opened_total`) → 캐파 산정 입력값 - [ ] gw↔외부 구간 FW idle timeout 값 - [ ] 채널별 연결 성격 분류: short-lived(포트 churn 위험) vs long-lived(drain·half-open 위험) - [ ] 상대 기관에 src IP 수렴 사전 공지 (IP 기반 rate limit/이상탐지 대비) - [ ] 앱팀 대상 connection pool/keep-alive 가이드 배포 (passthrough 구간이라 gw가 강제 불가) - [ ] istiod HA 점검: 인증서 로테이션(24h TTL)이 egress 가용성에 직결 --- ## 7. 운영: 진단·모니터링 **진단 순서 (구간별 1차 도구 — tcpdump는 최후 수단)** ``` 1) 거쳐갔는가? gw access log (목적지 cluster + DOWNSTREAM_PEER_URI_SAN) 2) 왜 거부됐나? istioctl proxy-config log --level rbac:debug -> principal 판정 확인 3) 설정 정합? istioctl proxy-config listeners (filter chain SNI/cluster), istioctl analyze 4) 커널 레벨? ss -tan state time-wait | wc -l, conntrack -C ``` **reset 원인 분기 (클라이언트 증상이 모두 비슷하므로 필수 런북)** | 시그니처 | 원인 | |---|---| | access log flag `UO` | 연결 풀 초과 (병목 1) | | access log flag `UF` | gw→외부 connect 실패 (병목 2 또는 외부 장애) | | rbac debug에 denied | AuthorizationPolicy 거부 (정상 통제 동작) | | gw 로그에 미도달 + sidecar에 `PassthroughCluster` | mesh-side VS 미매칭 (sniHosts 오타/미주입) | | handshake 즉시 실패 | DR `sni` 누락/오타 또는 비-mesh 클라이언트 (정상 차단) | | 무응답 timeout | conntrack drop (병목 3) | | 유휴 후 첫 요청 실패 | half-open (병목 4) | **알람 (Prometheus)** | 알람 | 조건 | 의미 | |---|---|---| | 연결 거부 | `rate(envoy_cluster_upstream_cx_overflow[5m]) > 0` | maxConnections 도달 | | 풀 사용률 | `upstream_cx_active / maxConnections > 0.8` | 선제 증설 신호 | | connect 실패 | `rate(envoy_cluster_upstream_cx_connect_fail[5m]) > 0` | 포트 고갈/외부 장애 | | TIME_WAIT | egress 노드 `node_sockstat_TCP_tw > 20000` | churn 과다 → keep-alive 캠페인 | | conntrack | `entries / limit > 0.7` | silent drop 임박 | --- ## 8. 전제조건 및 한계 (필독) - **egress gateway 자체는 보안 경계가 아님** (Istio 공식 문서 명시). 침해된 pod의 sidecar 우회를 Istio는 막을 수 없음. 다음 3중 통제가 도입의 전제: 1. **CNI NetworkPolicy**: 일반 워크로드 external egress default-deny, egress gw + DNS만 허용 2. **전용 노드풀 + IDC 방화벽**: 외부행은 egress 노드 대역만 허용 3. Istio `outboundTrafficPolicy: REGISTRY_ONLY` (보조 수단, best-effort) - **SA 위생이 신원 모델의 전제**: 워크로드당 전용 ServiceAccount 필수. `default` SA 공유 시 신원 통제 무의미 (Kyverno 정책으로 강제 권장) - AuthorizationPolicy 표현 범위는 connection 레벨(principal, SNI, 포트)까지. **HTTP path/method 통제는 불가** (passthrough 구조의 본질적 한계 — 보안 요건 협의 시 명시) - SNI는 클라이언트 제공 값. inner TLS의 서버 인증서 검증은 앱 책임으로 유지됨을 정책 문서화 ## 9. 참고 자료 - Istio 공식 task: Egress Gateways — https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway/ - Istio Security Best Practices (REGISTRY_ONLY는 best-effort) — https://istio.io/latest/docs/ops/best-practices/security/ - Istio blog: Routing egress traffic to wildcard destinations (ISTIO_MUTUAL 패턴, wildcard 필요 시) — https://istio.io/latest/blog/2023/egress-sni/ ## 참조 (아카이브) 이 가이드는 사내 공유본 원문 그대로다. 각 절의 메커니즘·실측·심화는 아카이브의 아래 문서로 분기한다. - [Egress 4-CRD 멘탈모델](mm__guide-crd-mental-model.html) — §4 동작 규칙(라우트 타입·정렬 지도·filter chain 병합)의 원리 해설 - [Egress TCP 병목 정본](ref__src-tcp-bottlenecks.html) — §6 병목 표 5종의 메커니즘·산술·완화 운영값 전체 - [TCP 병목 한계 축소 재현 랩](cfg__guide-tcp-failure-reproduction.html) — §6·§7의 증상을 테스트 클러스터에서 직접 재현하는 절차 - [Egress 신원 기반 통제 구성](id__guide-mtls-identity-control.html) — §5 구성의 테스트 클러스터 전체 빌드(SA 2개 차등 통제)와 검증 4단 - [Egress 운영 정본](ref__src-operations.html) — L4 모니터링·graceful shutdown 등 운영 전반 - [이중 TLS 없는 egress 신원](id__note-identity-without-mtls.html) — §3 결정의 **반대 논증**(Q1-only 환경에서는 passthrough+CNI로 충분) - [HTTPS over mTLS 구조 정본](id__src-https-over-mtls.html) — B 패턴(outer 종단, inner 통과) 자체의 해부 - [Egress mTLS 실측 리포트](rpt__report-2026-06-08_egress-mtls.html) — "종단하면 SNI가 소비된다" 실측과 두 함정