Egress TCP 병목 정본 — L4 proxy가 연결을 모으면 무엇이 고갈되는가
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
- 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의 실측 논의 참조.- 부분 장애 반경: 외부 기관 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 운영 정본 — 이 문서의 모체. 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 사례