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 템플릿화 대상.
# (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 핵심 (배포 형상)
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 필수.
defaultSA 공유 시 신원 통제 무의미 (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 멘탈모델 — §4 동작 규칙(라우트 타입·정렬 지도·filter chain 병합)의 원리 해설
- Egress TCP 병목 정본 — §6 병목 표 5종의 메커니즘·산술·완화 운영값 전체
- TCP 병목 한계 축소 재현 랩 — §6·§7의 증상을 테스트 클러스터에서 직접 재현하는 절차
- Egress 신원 기반 통제 구성 — §5 구성의 테스트 클러스터 전체 빌드(SA 2개 차등 통제)와 검증 4단
- Egress 운영 정본 — L4 모니터링·graceful shutdown 등 운영 전반
- 이중 TLS 없는 egress 신원 — §3 결정의 반대 논증(Q1-only 환경에서는 passthrough+CNI로 충분)
- HTTPS over mTLS 구조 정본 — B 패턴(outer 종단, inner 통과) 자체의 해부
- Egress mTLS 실측 리포트 — "종단하면 SNI가 소비된다" 실측과 두 함정