🏠 목록 Egress TCP 병목 정본 — L4 proxy가 연결을 모으면 무엇이 고갈되는가 📄 MD 원본 📁 Files 🔒 Private 🌓 테마
istioegresstcpconnection-poolport-exhaustionconntrackkeepalivesnat

Egress TCP 병목 정본 — L4 proxy가 연결을 모으면 무엇이 고갈되는가

NOTE

egress gateway 도입은 mTLS 여부와 무관하게 L4 proxy 하나를 전사 외부행 경로에 끼워 넣는 일이다. 이 문서는 그때 발생하는 TCP 병목 5종을 — Envoy 연결 상한 → ephemeral port/TIME_WAIT → conntrack → 중간장비 half-open → drain — 산술적 한계 수치와 부딪히는 순서까지 박아서 정리하고, 완화 운영값 전체(YAML), reset 원인 분기 런북, Prometheus 알람까지 한 곳에 둔다. 모니터링 일반론·graceful shutdown 상세는 Egress 운영 정본에 있고, 이 문서는 그 §03(연결 단위 자원 병목)을 수치·처방 레벨로 전개한 심화다.

대상 환경: Istio 1.30 sidecar mode, egress gateway = tcp_proxy(PASSTHROUGH 또는 ISTIO_MUTUAL — 어느 쪽이든 동일 적용). 대상 독자: gateway를 운영하게 될 사람, 도입 전 캐파 산정을 해야 하는 사람. 선행 개념: Egress 4-CRD 멘탈모델.


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

부딪히는 순서: 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시간마다 끊겨요"의 정체가 이것이다. 세 필드(time/interval/probes)의 커널 매핑과 타임라인은 tcpKeepalive 필드 노트 참조.


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). 한 곳에서 다 해결되지 않는다는 것 자체가 설계 포인트다. 측정→값 도출→적용→검증의 실행 순서로 재편한 워크스루는 Egress TCP 문제별 처방전 참조.

06-1. 외부 호스트 DR — 채널당 1벌, 핵심 완화 지점

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 기본값이 있다

# 기존 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

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 }
# 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단계

이 절의 메커니즘 전개(netns 초기화가 상속이 아닌 이유, PAWS 안전 근거, PSA 포함 3중 관문, 검증·실패 모드)는 Pod 커널 파라미터 정본에 별도 문서로 정리되어 있다.

tw_reuse는 pod 네트워크 네임스페이스 값이라 노드 /etc/sysctl.d로는 적용되지 않는다(호스트 netns만 바뀜). kubelet 허용 + pod 선언이 정석:

# (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은 호스트 전역

# /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


참조

아카이브 내부 - Egress 운영 정본 — 이 문서의 모체. L4+SNI 모니터링, graceful shutdown, SNAT 함정의 원형 - TCP 병목 한계 축소 재현 랩 — 이 문서의 병목 1·2·3·4를 테스트 클러스터에서 직접 재현 - Egress Gateway 도입 가이드 (사내 공유본) — 이 내용이 의사결정 문서에 어떻게 압축되는가 - circuit breaking 메커니즘 — connectionPool=Envoy circuit_breakers의 일반론, UO vs UH - Envoy cluster 해부 — DR → cluster 필드 컴파일 (connectionPool이 어디에 박히나) - Envoy response flags — UO/UF 플래그 사전 - ServiceEntry DNS resolution — outlier detection을 단일 IP 목적지에 켜면 안 되는 이유 - graceful drain: Envoy 리스너 — 병목 5의 메커니즘 정본

외부 - Envoy circuit breaking 기본값 — cluster당 max_connections 1024의 출처 - istio/istio#50596 — EXIT_ON_ZERO_ACTIVE_CONNECTIONS abort 사례