--- type: src tags: [istio, egress, tcp, connection-pool, port-exhaustion, conntrack, keepalive, snat, operations, runbook] created: 2026-06-10 --- # Egress TCP 병목 정본 — L4 proxy가 연결을 모으면 무엇이 고갈되는가 > [!abstract] > egress gateway 도입은 mTLS 여부와 무관하게 **L4 proxy 하나를 전사 외부행 경로에 끼워 넣는 일**이다. > 이 문서는 그때 발생하는 TCP 병목 5종을 — Envoy 연결 상한 → ephemeral port/TIME_WAIT → > conntrack → 중간장비 half-open → drain — **산술적 한계 수치와 부딪히는 순서**까지 박아서 정리하고, > 완화 운영값 전체(YAML), reset 원인 분기 런북, Prometheus 알람까지 한 곳에 둔다. > 모니터링 일반론·graceful shutdown 상세는 [Egress 운영 정본](gw__src-egress-operations.html)에 있고, > 이 문서는 그 §03(연결 단위 자원 병목)을 수치·처방 레벨로 전개한 심화다. **대상 환경**: Istio 1.30 sidecar mode, egress gateway = `tcp_proxy`(PASSTHROUGH 또는 ISTIO_MUTUAL — 어느 쪽이든 동일 적용). **대상 독자**: gateway를 운영하게 될 사람, 도입 전 캐파 산정을 해야 하는 사람. **선행 개념**: [Egress 4-CRD 멘탈모델](gw__guide-egress-crd-mental-model.html). --- ## 01. 멘탈모델 — 문제의 뿌리는 단 하나 > **L4 proxy는 암호화된 바이트 파이프다. 앱 연결 1개 = gateway 소켓 2개(1:1, 재사용 불가)이고, gateway 도입은 전사에 분산되어 있던 출발지 IP 다양성을 의도적으로 소수 pod로 붕괴시킨다.** 이 한 문장에서 병목 전부가 연역된다. "1:1 + 집중"이라는 구조가 연결 단위로 세는 모든 자원(Envoy 풀, ephemeral port, conntrack 엔트리, FW 세션 테이블)을 동시에 압박한다. ``` [before] [after] pod1(IP1) ----\ pod1 --\ pod2(IP2) -----> partner-a:443 pod2 ---> gw1(IPa) --> partner-a:443 ... ----/ ... --/ gw2(IPb) --> pod500(IP500) pod500-/ port space: 28k x 500 port space: 28k x 2 ``` 포트 고갈은 새로 생기는 문제가 아니다. 원래 전사에 분산돼 있던 자원(src IP 다양성)을 gateway가 **보안상 이득(방화벽 등록 IP 축소)과 맞바꿔 포기**하는 구조라서 따라오는 청구서다. 동전의 양면이라는 점이 도입 보고서에 적혀야 할 문장이다. --- ## 02. 기본 산술 — 4-tuple에서 한계 수치가 나온다 TCP 연결 하나는 커널 입장에서 `(srcIP, srcPort, dstIP, dstPort)` 4-tuple로 식별된다. 4개가 모두 같은 연결은 동시에 2개 존재할 수 없다. ``` 1 connection = ( srcIP, srcPort, dstIP, dstPort ) | | | | gw pod 29,xxx partner-a 443 (fixed) (variable) (fixed) (fixed) ``` 외부 API 호출에서 dstIP:443은 고정, srcIP도 그 pod의 IP로 고정 — **변수는 srcPort 하나뿐**이다. 커널이 발신 연결에 자동으로 빌려주는 이 포트(ephemeral port)의 리눅스 기본 범위는 `32768–60999`, 약 **28,232개**. ``` usable srcPort pool (per srcIP, per destination) |--------------- 28,232 ports ----------------| 32768 60999 ``` **한계 ① 동시 연결**: gw pod 1개 → 목적지 1개의 동시 연결 상한 ≈ **28,000**. 설정이 아니라 산술이다. **한계 ② 신규 연결률**: 연결이 닫혀도 포트는 즉시 반환되지 않는다. 먼저 close한 쪽이 그 4-tuple을 **TIME_WAIT 상태로 60초**(커널 고정) 보존하고, proxy는 보통 먼저 닫는 쪽이라 TIME_WAIT는 gateway에 쌓인다. ``` t=0s t=5s t=65s connect --- close ---------- port returned |--- TIME_WAIT (60s) ---| port 29314 locked for this dst ``` 포트 하나가 "사용 시간 + 60초"를 점유하므로, short-lived 연결이 계속 생기면: ``` 28,232 ports ÷ 60s ≈ 470 new connections/sec (gw pod 1개 -> 목적지 1개) ``` 구체 예: pod 500개가 keep-alive 없이 각자 초당 1회 호출하면 gw에 500 conn/s가 모인다. gw pod 1개면 470/s를 넘어 수십 초 안에 `EADDRNOTAVAIL`(빌려줄 포트 없음)이 나기 시작한다. 처방의 우선순위가 ① 앱 keep-alive(분자 줄이기) ② replica 증설(분모 늘리기) ③ `tcp_tw_reuse`(60초 규칙 완화) 순인 이유가 이 분수식이다. --- ## 03. 왜 연결을 재사용 못 하나 — L7 vs L4의 본질 차이 "gateway가 외부행 연결을 모아서 재사용(pooling)하면 되지 않나"가 자연스러운 반론인데, 여기서 L7/L4 proxy의 근본 차이가 갈린다. ``` [L7 proxy (HTTP)] [L4 proxy (tcp_proxy) = egress gw] client A --\ client A ===[encrypted bytes]===> upstream A client B ---> [pool: conn x2] --> client B ===[encrypted bytes]===> upstream B client C --/ upstream client C ===[encrypted bytes]===> upstream C (요청 경계를 아니까 섞어 태움) (바이트 파이프. 섞으면 TLS 세션 깨짐) ``` L7 proxy는 요청의 경계를 알기 때문에 여러 클라이언트의 요청을 적은 수의 upstream 연결에 다중화할 수 있다. 그러나 egress gateway가 다루는 것은 앱↔외부 서버가 **종단간으로 맺은 TLS의 암호화된 바이트**다. 내용을 모르고, 안다 해도 다른 TLS 세션의 바이트와 섞을 수 없다. 그래서 1:1은 선택이 아니라 강제이고, PASSTHROUGH든 ISTIO_MUTUAL이든 동일하다(둘 다 inner TLS는 불투명). --- ## 04. 병목 5종 — 한계·증상·완화, 그리고 부딪히는 순서 | # | 병목 | 한계 (기본값) | 증상 | 완화 | |---|---|---|---|---| | 1 | **Envoy cluster 연결 상한** | 목적지(cluster)당 동시 **1,024** | 초과분 즉시 거부, access log flag `UO`, `upstream_cx_overflow` 증가 | 외부 호스트 DR `connectionPool.tcp.maxConnections` 명시 (§06-1) | | 2 | **Ephemeral port + TIME_WAIT** | pod당·목적지당 동시 ~28k, 신규 ~470 conn/s | 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, dropping packet" | 노드 sysctl `nf_conntrack_max` 상향 + `tcp_timeout_time_wait` 단축 (§06-5) | | 4 | **중간장비 idle timeout** | IDC FW 보통 30~60분 | half-open: 유휴 후 첫 송신에서 RST/timeout. 간헐적·재현 곤란 | DR `tcpKeepalive`를 FW timeout보다 짧게. FW 값 사전 확인 | | 5 | **수명 긴 연결 + 재배포** | `terminationDrainDuration` 기본 5s | 배포/스케일인 시 long-lived 연결 일괄 절단 | drain 연장 + PDB + 배포 윈도우. 상세는 [운영 정본 §04](gw__src-egress-operations.html) | **부딪히는 순서: 1 → 2 → 3.** 좁은 깔때기부터 막힌다 — Envoy 1024는 커널 포트 28k보다, 포트 28k는 conntrack 26만보다 좁다. 4는 용량이 아니라 시간 축 문제라 **트래픽이 적은 채널에서 먼저** 발현된다(바쁜 채널은 유휴해질 틈이 없다). ``` app --- sidecar --- [gw pod] ------------------ [gw node] --- FW --- external | | | 1) Envoy cluster cap 1,024 3) conntrack 4) FW idle 2) ephemeral ports 28k ~260k timeout (+TIME_WAIT 470/s) (half-open) ``` 병목 1이 특히 위험한 이유: sidecar 시절엔 절대 안 보이던 값이다. pod 하나가 한 목적지로 동시 1,024개를 열 일이 없으니까. gateway에선 **전사 트래픽이 cluster 하나로 합쳐지면서** 가장 먼저 부딪히는 벽이 된다. 그리고 클라이언트 증상이 AuthorizationPolicy 거부와 똑같은 reset류라서, flag(`UO`) 없이는 구분이 안 된다(§07). 병목 4에는 따로 적어야 할 함정이 하나 있다. **TCP keepalive와 Envoy `idleTimeout`은 역할이 다르다.** keepalive probe는 데이터가 아니라서 Envoy의 idle timer를 리셋하지 않는다. keepalive는 *방화벽*(패킷만 보면 됨)을 깨어 있게 하는 장치이고, Envoy 자신의 `idleTimeout`(tcp_proxy 기본 1h)은 채널의 최장 유휴 간격보다 길게 **직접** 설정해야 한다. "keepalive 넣었는데 1시간마다 끊겨요"의 정체가 이것이다. --- ## 05. SNAT 분기 — 위 계산 전체의 전제를 바꾸는 1순위 확인 항목 §02의 산술은 `srcIP = gw pod IP`를 전제한다. Calico `natOutgoing`이 켜져 있으면 노드를 떠날 때 src가 **노드 IP로 치환**되고, 전제가 무너진다. ``` [natOutgoing: off] [natOutgoing: on] gw pod(IPa) --> FW sees IPa gw pod(IPa) --> node(IPn) --> FW sees IPn gw pod(IPb) --> FW sees IPb gw pod(IPb) --/ (포트 공간 공유!) 방화벽 등록: pod CIDR 방화벽 등록: 노드 IP ``` 귀결 둘: - **같은 노드의 gw pod들이 노드 IP의 포트 공간을 공유**한다 — replica 증설 효과가 같은 노드 안에선 사라진다. `podAntiAffinity`가 권장이 아니라 필수가 되는 이유. - **방화벽 신청서의 등록 단위가 바뀐다** (pod CIDR ↔ 노드 IP). 캐파 계산과 보안 신청을 한 설정이 동시에 결정하므로, 도입 전 확인 1순위. pod CIDR이 BGP로 라우터블한 환경이면 pod IP 그대로 나가고 방화벽엔 pod CIDR을 등록한다. --- ## 06. 완화 운영값 전체 (YAML) 완화의 적용 위치가 전부 다르다 — DR(Envoy 풀·keepalive·idle), pod sysctl(포트 범위·tw_reuse), 노드 sysctl(conntrack), Helm(replica·antiAffinity·drain). **한 곳에서 다 해결되지 않는다는 것 자체가 설계 포인트다.** ### 06-1. 외부 호스트 DR — 채널당 1벌, 핵심 완화 지점 ```yaml 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 # 병목 1 제거. 측정한 peak x 2~3 여유 connectTimeout: 3s # 외부 장애 시 빠른 실패 (기본 10s) idleTimeout: 1800s # 채널 최장 유휴 간격보다 길게 (keepalive로 못 막음 — §04) maxConnectionDuration: 3600s # 연결 수명 상한 = scale-out 후 재분배 유도. # 절단 후 재연결에 민감한 전문 채널은 제외하거나 길게 tcpKeepalive: time: 300 # 병목 4: FW idle timeout(보통 1800s+)보다 충분히 짧게 interval: 30 probes: 3 # outlierDetection은 단일 IP 목적지에선 ejection = 전체 차단. # DNS가 다중 IP를 줄 때만 검토 — gw__report-2026-06-07_dns-resolution 참조 ``` ### 06-2. sidecar→gw 구간 DR — 이 hop의 cluster에도 같은 1024 기본값이 있다 ```yaml # 기존 egressgateway DR의 subset에 connectionPool 추가 subsets: - name: partner-a trafficPolicy: portLevelSettings: - port: { number: 443 } tls: { mode: ISTIO_MUTUAL, sni: api.partner-a.example.com } connectionPool: tcp: maxConnections: 4096 # 단, 이건 "클라이언트 pod당" 상한이라 보통 여유 있음 tcpKeepalive: { time: 300, interval: 30, probes: 3 } ``` ### 06-3. Gateway Helm values — 분산·포트·drain ```yaml replicaCount: 3 # 포트 공간 = 28k x replica (병목 2의 분모) autoscaling: enabled: true minReplicas: 3 maxReplicas: 6 targetCPUUtilizationPercentage: 70 # 연결 중심 부하엔 CPU 상관이 약함 — # upstream_cx_active 기반 custom metric 권장 affinity: podAntiAffinity: # SNAT(natOutgoing) 환경에서 필수 (§05): requiredDuringSchedulingIgnoredDuringExecution: # 같은 노드 = 노드IP 포트 공간 공유 - labelSelector: { matchLabels: { istio: egressgateway } } topologyKey: kubernetes.io/hostname nodeSelector: { node-role/egress: "true" } # 전용 노드풀 = FW src IP 안정화 tolerations: - { key: node-role/egress, operator: Exists } securityContext: # pod-level. ip_local_port_range는 safe sysctl sysctls: - { name: net.ipv4.ip_local_port_range, value: "10240 60999" } # 28k -> 50k - { name: net.ipv4.tcp_tw_reuse, value: "1" } # unsafe sysctl — 아래 06-4 선행 필요 podAnnotations: proxy.istio.io/config: | terminationDrainDuration: 300s # 기본 5s -> long-lived 배려 (병목 5) proxyMetadata: EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true" # drain 중 연결 0이면 조기 종료. # 부작용 있음 — What you might be missing 참조 service: type: ClusterIP # egress는 외부 노출 불필요 ports: - { name: status-port, port: 15021, targetPort: 15021 } - { name: tls-egress, port: 443, targetPort: 8443 } ``` ```yaml # PDB (gateway 차트 미제공, 별도 적용) apiVersion: policy/v1 kind: PodDisruptionBudget metadata: { name: egressgateway, namespace: istio-egress } spec: minAvailable: 2 selector: { matchLabels: { istio: egressgateway } } ``` ### 06-4. tcp_tw_reuse — unsafe sysctl이라 2단계 `tw_reuse`는 pod 네트워크 네임스페이스 값이라 노드 `/etc/sysctl.d`로는 적용되지 않는다(호스트 netns만 바뀜). kubelet 허용 + pod 선언이 정석: ```yaml # (1) kubelet (kubespray라면 egress 노드 그룹 vars): kubelet_config_extra_args: allowedUnsafeSysctls: - "net.ipv4.tcp_tw_reuse" # (2) 위 06-3 securityContext.sysctls의 tcp_tw_reuse=1 이 그제서야 admit됨. # 1 = outgoing 연결에 TIME_WAIT 포트 재사용 (TCP timestamps 활성 전제, 기본 on) ``` ### 06-5. 노드 레벨 — conntrack은 호스트 전역 ```bash # /etc/sysctl.d/90-egress-gw.conf (전용 egress 노드) net.netfilter.nf_conntrack_max = 1048576 net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30 # 기본 120s 단축 # 적용: sudo sysctl --system ``` --- ## 07. reset 원인 분기 런북 — 클라이언트 증상은 전부 비슷하다 AuthzPolicy 거부, filter chain 미매칭, 풀 초과, 포트 고갈이 클라이언트에선 모두 "SSL_ERROR_SYSCALL / Connection reset"류로 보인다. 구분은 gateway 쪽 시그니처로만 가능하다 — 이 표가 없으면 정상 통제 동작이 장애로 오인된다. | 시그니처 | 원인 | 병목# | |---|---|---| | access log flag `UO` | Envoy 연결 풀 초과 | 1 | | access log flag `UF` | gw→외부 connect 실패 (포트 고갈 또는 외부 장애) | 2 | | rbac debug 로그에 `denied` | AuthorizationPolicy 거부 — **정상 통제 동작** | — | | gw 로그 미도달 + sidecar 로그에 `PassthroughCluster` | mesh-side VS 미매칭 (sniHosts 오타/sidecar 미주입) | — | | handshake 즉시 실패 | DR `sni` 누락/오타, 또는 비-mesh 클라이언트 (정상 차단) | — | | 무응답 timeout (reset조차 없음) | conntrack silent drop | 3 | | 유휴 후 첫 요청만 실패 | 중간장비 half-open | 4 | | 정확히 N초에 절단 (기본 1h) | Envoy `idleTimeout` — keepalive로는 안 막힘 | 4 | **진단 순서** (구간별 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 vs conntrack_max ``` --- ## 08. 알람 (Prometheus) — 병목별 1:1 대응 | 알람 | 조건 | 의미 | 병목# | |---|---|---|---| | 연결 거부 | `rate(envoy_cluster_upstream_cx_overflow[5m]) > 0` | maxConnections 도달 | 1 | | 풀 사용률 | `upstream_cx_active / maxConnections > 0.8` | 선제 증설 신호 | 1 | | connect 실패 | `rate(envoy_cluster_upstream_cx_connect_fail[5m]) > 0` | 포트 고갈/외부 장애 | 2 | | TIME_WAIT | egress 노드 `node_sockstat_TCP_tw > 20000` | churn 과다 → 앱 keep-alive 캠페인 | 2 | | conntrack | `node_nf_conntrack_entries / limit > 0.7` | silent drop 임박 | 3 | 도입 전 캐파 산정의 입력값은 현재 sidecar 메트릭에서 나온다: 목적지별 `istio_tcp_connections_opened_total`로 peak 동시 연결 수와 conn/s를 먼저 측정할 것. --- ## 핵심 정리 | 항목 | 내용 | |---|---| | 뿌리 | L4 proxy = 암호화 바이트 파이프 → 1:1 강제 + src IP 집중. 모든 병목이 여기서 연역 | | 산술 | pod당·목적지당 동시 ~28k (ephemeral port), 신규 ~470 conn/s (÷ TIME_WAIT 60s) | | 순서 | Envoy 1024 → 포트 28k → conntrack 26만 (좁은 것부터). FW idle은 한가한 채널부터 | | 처방 위치 | DR / pod sysctl / 노드 sysctl / Helm — 네 레이어에 분산, 한 곳에서 안 끝남 | | 운영 | reset 분기는 flag로만 가능 (`UO`/`UF`/rbac/무응답) — 런북 §07이 본체 | --- ## What you might be missing - **conntrack은 gw 노드만의 문제가 아니다.** 트래픽이 경유하는 모든 지점(NetworkPolicy를 집행하는 노드, kube-proxy IPVS/iptables)의 conntrack이 churn을 받는다. kube-proxy 타임아웃과 keepalive 주기의 정합도 점검 대상. - **`EXIT_ON_ZERO_ACTIVE_CONNECTIONS`는 공짜가 아니다.** 활성 연결 수를 stats 폴링으로 판단하는데, 조회가 일시 실패하면 프록시가 abort한 사례가 보고됐고(istio/istio #50596), 끝나지 않는 장기 스트림에선 영원히 0이 안 돼 결국 SIGKILL에 의존한다. grace period 안전망 + 연결 최대 수명 상한과 반드시 짝으로 — [운영 정본 §05](gw__src-egress-operations.html)의 실측 논의 참조. - **부분 장애 반경**: 외부 기관 1곳의 응답 지연 → 그 cluster의 연결 적체 → gw 메모리/FD 소진 → 다른 목적지로 전이. 목적지별 DR `maxConnections`가 성능 튜닝이 아니라 **cluster 단위 격벽**인 이유. 메모리 산정 기준은 동시 연결 × 소켓 2 × per-connection buffer(기본 1MiB). - **카나리 측정 항목을 도입 전에 고정하라**: baseline(직접 egress) 대비 p99 연결수립시간, conn/s, TIME_WAIT 곡선, gw CPU/conn. "느려졌다" 논쟁을 데이터로 끝내는 장치다. - **상대 기관 관점**: 파트너 서버가 보는 src IP가 소수로 수렴한다 — 상대측 IP 기반 rate limit/이상탐지에 걸릴 수 있어 사전 공유가 필요하다. --- ## 참조 **아카이브 내부** - [Egress 운영 정본](gw__src-egress-operations.html) — 이 문서의 모체. L4+SNI 모니터링, graceful shutdown, SNAT 함정의 원형 - [TCP 병목 한계 축소 재현 랩](gw__guide-egress-tcp-failure-reproduction.html) — 이 문서의 병목 1·2·3·4를 테스트 클러스터에서 직접 재현 - [Egress Gateway 도입 가이드 (사내 공유본)](gw__guide-egress-adoption-passthrough-vs-mtls.html) — 이 내용이 의사결정 문서에 어떻게 압축되는가 - [circuit breaking 메커니즘](gw__note-circuit-breaking-mechanisms.html) — connectionPool=Envoy circuit_breakers의 일반론, UO vs UH - [Envoy cluster 해부](xds__src-cluster-anatomy.html) — DR → cluster 필드 컴파일 (connectionPool이 어디에 박히나) - [Envoy response flags](xds__src-envoy-response-flags.html) — UO/UF 플래그 사전 - [ServiceEntry DNS resolution](gw__report-2026-06-07_dns-resolution.html) — outlier detection을 단일 IP 목적지에 켜면 안 되는 이유 - [graceful drain: Envoy 리스너](gt__src-envoy-drain-listeners.html) — 병목 5의 메커니즘 정본 **외부** - [Envoy circuit breaking 기본값](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/circuit_breaking) — cluster당 max_connections 1024의 출처 - [istio/istio#50596](https://github.com/istio/istio/issues/50596) — EXIT_ON_ZERO_ACTIVE_CONNECTIONS abort 사례