--- title: Egress TCP 병목 재현 랩 — 한계를 줄여서 부딪힌다 date: 2026-06-10 type: guide domain: istio tags: [egress, tcp, connection-pool, port-exhaustion, conntrack] --- > [!abstract] > [TCP 병목 정본](/docs/istio/egress/tcp-bottlenecks/)의 한계 수치(Envoy 1024, 포트 28k, > conntrack 26만)는 그대로 재현하려면 연결 수만 개가 필요하다. 이 랩의 기법은 반대다 — > **한계를 5~20으로 줄여서, 같은 메커니즘을 연결 수십 개로 관찰한다.** 재현 4종 각각이 > 운영에서 만날 실패 시그니처(`UO` / `UF` / 무응답 / 정시 절단) 하나씩을 직접 떠올리고, > 그 시그니처 구분이 곧 reset 분기 런북이 된다. **대상 환경**: Istio 1.30.0, [Egress 신원 기반 통제 구성](/docs/istio/security/egress-mtls-identity-control/)의 테스트 클러스터(ns `istio-egress`의 egressgateway + ns `egress-test`의 netshoot-a)가 떠 있는 상태. **대상 독자**: 도입 보고서의 병목 표를 "봤다"에서 "겪었다"로 바꾸고 싶은 사람. **범위**: 병목 1·2·3·4 재현 + 복구. 완화 운영값 자체는 [정본 §06](/docs/istio/egress/tcp-bottlenecks/)으로 위임. --- ## 0. 원칙 — 왜 줄여서 부딪히나 > **28,000개 연결을 만들어 한계에 도달하는 게 아니라, 한계를 5~20으로 줄여 같은 메커니즘을 소규모로 관찰한다.** ``` [운영 한계] [랩 재현] maxConnections: 1024(기본) maxConnections: 5 ephemeral ports: 28,232 ip_local_port_range: 20개 nf_conntrack_max: ~260k nf_conntrack_max: 200 idle timeout(FW): 30~60min idleTimeout: 30s | | +--- 같은 메커니즘, 같은 시그니처 ---+ ``` 공유 테스트 클러스터에서 안전하고, 외부 sandbox 엔드포인트에 가는 부하도 연결 수십 개 수준이라 무해하다. (그래도 대상 LB에 이상탐지가 있다면 사전 공유 권장.) 비유 하나: 둑의 높이를 1m로 낮추고 물 한 양동이로 범람을 관찰하는 것. **한계: 비율이 다른 현상은 못 본다** — 예컨대 연결 수만 개 규모에서만 나타나는 Envoy 메모리 압박·FD 고갈은 이 기법으로 재현되지 않는다. --- ## 1. 관찰 도구 준비 ```bash # gw pod에 디버그 컨테이너 부착 (ss, conntrack 등 사용) GW=$(kubectl get pod -n istio-egress -l istio=egressgateway -o jsonpath='{.items[0].metadata.name}') kubectl debug -n istio-egress $GW -it --image=registry.example.com/netshoot:latest \ --target=istio-proxy -- bash # 이 셸에서: ss -tan state time-wait | wc -l 등 실행 # Envoy 통계 조회 (별도 터미널, 반복 사용할 함수) stats() { kubectl exec -n istio-egress deploy/egressgateway -c istio-proxy -- \ pilot-agent request GET stats | grep "api-a" | grep -E "$1" } # 부하 발생용 alias A="kubectl exec -n egress-test deploy/netshoot-a -c netshoot --" ``` `kubectl debug --target=istio-proxy`로 붙이는 이유: ephemeral 컨테이너는 같은 pod의 다른 컨테이너와 **네트워크 네임스페이스는 이미(= `--target` 없이도) 공유**하므로 `ss`로 istio-proxy의 소켓이 보이는 데 `--target`은 필요 없다. `--target`이 실제로 추가하는 건 **프로세스(PID) 네임스페이스 공유**뿐이라, `ps`나 `/proc//fd`처럼 프로세스 단위 정보를 보려면 이게 필요하다. --- ## 2. 재현 1 — Envoy cluster 연결 상한 (운영 기본 1024 → 5) 부딪히는 순서 1번, 가장 먼저 맞을 함정. 상한을 5로 줄이고 10개 연결을 시도한다. ```yaml # dr-api-a-tiny.yaml — 외부 호스트용 DR. gw의 upstream cluster에 적용됨 apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: name: api-a-external namespace: istio-egress spec: host: api-a.example.com trafficPolicy: connectionPool: tcp: maxConnections: 5 # 재현용. 미설정 시 기본 1024 ``` ```bash kubectl apply -f dr-api-a-tiny.yaml # 동시 연결 10개 보유 (sleep이 stdin을 잡아 5분간 연결 유지) $A bash -c 'for i in $(seq 1 10); do (sleep 300 | openssl s_client -connect api-a.example.com:443 \ -servername api-a.example.com -quiet > /tmp/c$i.log 2>&1) & done; sleep 15; grep -l "BEGIN CERT" /tmp/c*.log | wc -l' ``` | 관찰 | 명령 | 기대 | |---|---|---| | 성공 연결 수 | 위 마지막 출력 | **5** (6번째부터 handshake 실패) | | 거부 카운터 | `stats upstream_cx_overflow` | 5 이상 증가 | | 활성 연결 | `stats upstream_cx_active` | 5에서 고정 | | access log | gw 로그의 response flag | **`UO`** (Upstream Overflow) | **핵심 체감**: 클라이언트 증상이 AuthorizationPolicy 거부와 똑같은 "TLS 실패/reset"이다. flag(`UO` vs rbac 로그)로만 구분 가능 — 런북에 들어갈 내용이 바로 이것. 복구: `kubectl delete dr api-a-external -n istio-egress` (운영에서는 [정본 §06-1](/docs/istio/egress/tcp-bottlenecks/)의 운영값으로 재생성). --- ## 3. 재현 2 — Ephemeral 포트 고갈 + TIME_WAIT (28,232개 → 20개) `ip_local_port_range`는 K8s **safe sysctl**이라 pod 단위로 바로 설정 가능하다(kubelet allowlist 불필요). ```yaml # values-egress.yaml에 추가 (gateway 차트의 securityContext = pod-level) securityContext: sysctls: - name: net.ipv4.ip_local_port_range value: "32768 32787" # 재현용: 포트 20개 ``` ```bash helm upgrade egressgateway oci://registry.example.com/charts/istio/gateway \ --version 1.30.0 -n istio-egress -f values-egress.yaml # short-lived 연결 반복 (요청마다 gw->외부 신규 연결 = 포트 1개 + TIME_WAIT 60s) $A bash -c 'ok=0; fail=0; for i in $(seq 1 60); do curl -s -m 3 -o /dev/null https://api-a.example.com/api/ping \ && ok=$((ok+1)) || fail=$((fail+1)); sleep 0.5 done; echo "ok=$ok fail=$fail"' ``` > [!warning] 실측 확인(T56) — 위 스크립트 그대로는 재현이 안 될 수 있다 > **순차(sequential)** 로 0.5초 간격을 두고 curl을 반복하면, 이 랩과 같은 커널 기본값(`net.ipv4.tcp_tw_reuse=2`)에서는 같은 목적지로의 새 connect가 TIME_WAIT 소켓을 즉시 재사용해버려 20개 포트로도 실패가 전혀 재현되지 않는다(실측: 60회 순차 루프 200/200 성공, 완료까지 1.5초). 포트+TIME_WAIT 고갈을 실제로 유발하려면 **동시(concurrent) 요청**으로 20개 포트 예산을 순간적으로 초과시켜야 한다 — 재현 1처럼 `& ... wait` 패턴으로 바꿔서 실행할 것(실측: 60개 동시 요청 중 12개가 connect 실패). > > 더 중요한 점은 그다음이다. 이 pod-wide(컨테이너가 아닌 파드 전체 netns) sysctl 좁히기를 **앱 컨테이너와 istio-proxy가 같은 pod를 공유하는 사이드카 워크로드**에 적용하면, 클라이언트→사이드카 루프백(`127.0.0.1:15001`) 구간이 사이드카→업스트림 구간과 **같은 20개 포트 풀을 공유**한다. 그 결과 실패는 Envoy의 프록시 로직에 도달하기도 전에 클라이언트 자신의 `connect()` 단계에서 끝나버려, access log에 `UF`가 전혀 남지 않고 `upstream_cx_connect_fail`도 0에서 움직이지 않는다(실측: 12건이 실제로 실패했는데도 access log·카운터 모두 무반응 — 커널 레벨에서는 실재하는 실패가 Envoy 관측 지표에는 완전히 비가시적이었다). 이 랩처럼 **전용 gw pod**(별도 앱 컨테이너 없이 istio-proxy 단독)에 좁히기를 적용하는 구성은 그 루프백 경합 자체가 없어 아래 표의 기대(`UF`/카운터 증가)가 성립할 개연성이 높지만, 이 조합 자체는 별도로 재검증되지 않았다 — 진단 시 항상 access log와 `pilot-agent request GET clusters`(이 클러스터 기본 stats 필터는 `/stats`에서 outbound cluster의 연결 카운터를 노출하지 않는다)로 실제 `UF` 여부를 교차 확인할 것. 관련 메커니즘은 [Pod 커널 파라미터 정본](/docs/istio/egress/pod-sysctl-netns/) 참고. | 관찰 | 명령 (debug 컨테이너) | 기대 | |---|---|---| | TIME_WAIT 적체 | `watch 'ss -tan state time-wait \| wc -l'` | ~20까지 증가 후 정체 | | 포트 소진 시점 | 위 curl 출력 | 약 20번째부터 fail 증가, **60초 지나면 일부 회복** (TIME_WAIT 만료 = 포트 반환의 직접 증거) | | connect 실패 | `stats upstream_cx_connect_fail` | 증가 | | access log | response flag | **`UF`** (Upstream connection Failure) | 이 실험이 보여주는 산술: **포트 20개 ÷ 60s ≈ 0.33 conn/s**가 지속 가능 상한. 운영 기본값(28,232개)에 같은 분수식을 적용한 것이 정본의 470 conn/s다 — 수치는 달라도 식이 같다는 걸 직접 확인하는 게 이 재현의 목적. 복구: `sysctls` 블록 제거 후 `helm upgrade`. --- ## 4. 재현 3 — 유휴 절단 (idle timeout 1h → 30s) 중간장비(FW)가 유휴 세션을 끊는 상황을 Envoy `idleTimeout` 축소로 모사한다. ```yaml apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: name: api-a-external namespace: istio-egress spec: host: api-a.example.com trafficPolicy: connectionPool: tcp: idleTimeout: 30s # 재현용 (tcp_proxy 기본 1h) ``` ```bash $A bash -c 'time (sleep 120 | openssl s_client \ -connect api-a.example.com:443 \ -servername api-a.example.com -quiet)' # 기대: 120초를 못 채우고 약 30초에 연결 종료. gw access log의 duration ≈ 30000ms ``` **이 재현에 함정 하나가 같이 들어 있다**: 여기에 `tcpKeepalive`를 추가해도 30초 절단은 그대로다. keepalive probe는 데이터가 아니라서 **Envoy의 idle timer를 리셋하지 않는다.** 역할이 다르다 — keepalive는 *방화벽*(패킷만 보면 됨)을 깨어 있게 하고, `idleTimeout`은 Envoy 자신의 기준이라 채널의 최장 유휴 간격보다 길게 직접 설정해야 한다. 직접 keepalive를 넣고 다시 돌려 "그래도 끊긴다"를 확인하면 이 구분이 박힌다. 복구: DR 삭제 또는 운영값(`idleTimeout: 1800s`)으로 교체. --- ## 5. 재현 4 — conntrack 포화 (선택, 전용 노드에서만) > [!warning] > `nf_conntrack_max`는 **노드 전역**이라 그 노드의 모든 pod에 영향을 준다. gw 전용 테스트 노드가 분리되어 있을 때만 수행할 것. ```bash # gw 노드에서 sudo sysctl -w net.netfilter.nf_conntrack_max=200 watch 'conntrack -C; dmesg | tail -3' # 재현 2의 curl 루프 실행 -> dmesg에 "nf_conntrack: table full, dropping packet" sudo sysctl -w net.netfilter.nf_conntrack_max=262144 # 복구 ``` **관찰 포인트**: 증상이 reset이 아니라 **무응답 timeout**이다(silent drop — 커널이 패킷을 버릴 뿐 아무에게도 통보하지 않음). 재현 1·2와 클라이언트 체감은 비슷한데 gateway access log에 단서가 약하다는 것까지가 재현 내용 — 그래서 이 병목만 노드 메트릭(conntrack 사용률)으로 선행 감지해야 한다. --- ## 6. 정리 — 4개의 시그니처가 곧 런북 | 재현 | 축소한 한계 | 실패 시그니처 | 운영에서의 대응 | |---|---|---|---| | 1 | maxConnections 5 | flag `UO`, `upstream_cx_overflow` | 외부 호스트 DR 풀 상향 | | 2 | 포트 20개 | flag `UF`, 60s 후 부분 회복 | keep-alive 캠페인, replica 분산, 커널 튜닝 | | 3 | idleTimeout 30s | 정확히 N초 절단, keepalive 무력 | idleTimeout을 유휴 간격보다 길게 | | 4 | conntrack 200 | 무응답 timeout, dmesg table full | 노드 sysctl + 사용률 알람 | 전체 분기표(rbac denied, PassthroughCluster, handshake 즉시 실패 포함)는 [정본 §07](/docs/istio/egress/tcp-bottlenecks/)에. --- ## What you might be missing - **재현 2의 "60초 후 회복"이 이 랩에서 가장 가치 있는 관찰이다.** TIME_WAIT 60초가 추상 지식이 아니라 fail→ok 전환 시각으로 보인다. 운영에서 "간헐적으로 실패하다 저절로 낫는" 패턴을 만나면 이 곡선을 떠올릴 것. - **재현들 사이에 DR 이름이 겹친다** (`api-a-external`). 재현 1과 3을 연달아 할 때 이전 spec이 남아 있으면 두 한계가 동시에 걸려 관찰이 오염된다 — 각 재현 후 복구(삭제)를 건너뛰지 말 것. - **`helm upgrade`로 sysctl을 바꾸면 pod가 재생성된다** — 재현 2 직전까지 쌓인 TIME_WAIT·통계가 리셋된다. 통계 비교는 항상 같은 pod 세대 안에서. - **이 기법의 사각**: 비율이 한계에 비례하지 않는 현상(메모리 압박, FD 고갈, CPU saturation)은 축소 재현이 안 된다. 그쪽은 부하 도구(예: 실제 conn/s를 만드는 tcpkali류)와 전용 환경이 필요한 별개 작업. - **재현 2는 순차 루프로는 재현되지 않고(T56), pod-wide sysctl을 사이드카 워크로드에 걸면 실패가 나도 Envoy 관측 지표에 전혀 안 남을 수 있다.** 위 §3 경고 참고 — "동시 부하"와 "전용 gw pod" 두 조건을 다 갖춰야 이 절의 표대로 관측된다. --- ## 참조 **아카이브 내부** - [Egress TCP 병목 정본](/docs/istio/egress/tcp-bottlenecks/) — 이 랩이 재현하는 병목 5종의 메커니즘·산술·완화 운영값 - [Egress 신원 기반 통제 구성](/docs/istio/security/egress-mtls-identity-control/) — 이 랩의 선행조건인 테스트 클러스터 빌드 - [Egress Gateway 도입 가이드 (사내 공유본)](/docs/istio/egress/adoption-passthrough-vs-mtls/) — 재현 결과가 도입 문서의 병목 표·체크리스트로 압축된 형태 - [Egress 운영 정본](/docs/istio/egress/operations/) — L4 모니터링·graceful shutdown 등 운영 전반 - [Envoy response flags](/docs/istio/xds-envoy/envoy-response-flags/) — `UO`/`UF` 사전 - [Pod 커널 파라미터 정본](/docs/istio/egress/pod-sysctl-netns/) — pod netns sysctl 초기화·`tcp_tw_reuse` 메커니즘 **외부** - [Envoy circuit breaking](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/circuit_breaking) — 재현 1의 한계값 출처 ## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6) 검증 방법: 공식 문서(Envoy/Istio/Kubernetes) 대조 + homelab 클러스터 실측(istio-verify 네임스페이스, DestinationRule·pod sysctl 적용 후 access log·`/clusters` 관찰). | 주장 | 판정 | 근거 | |---|---|---| | DR `connectionPool.tcp.maxConnections` 미설정 시 Envoy 기본값은 1024 | ✅ 문헌 확인 | envoyproxy.io/.../circuit_breaking | | `maxConnections` 초과 시 503 + `UO`, `upstream_cx_overflow` 증가 | ✅ 실측 확인 | envoyproxy.io/.../substitution_formatter · [T52 실측](/docs/istio/egress/circuit-breaking-mechanisms/files/verify/T52/)(호스트가 실제 mesh registry 이름(`svc.cluster.local`)과 일치해야 정책이 붙는다는 전제 확인) | | `net.ipv4.ip_local_port_range`는 k8s safe sysctl — pod-level로 바로 설정 가능(kubelet allowlist 불필요) | ✅ 문헌 확인 | kubernetes.io/.../sysctl-cluster/ | | ephemeral 포트 고갈로 connect 실패 시 `UF` + `upstream_cx_connect_fail` 증가 | 🔬 실측 반증 — 본문 교정 | envoyproxy.io/.../substitution_formatter · [T56 실측](files/verify/T56/result.txt)(사이드카 pod-wide 좁히기에서는 클라이언트→사이드카 루프백이 같은 포트풀을 점유해 Envoy가 실패를 아예 관측 못함; 순차 루프도 `tcp_tw_reuse=2`로 재현 자체가 안 됨) | | TIME_WAIT 기본 60초는 커널 하드코딩, sysctl로 조정 불가 | ✅ 문헌 확인 | vincent.bernat.ch/.../tcp-time-wait-state-linux | | `idleTimeout` 미설정 시 tcp_proxy 기본 유휴 타임아웃은 1시간 | ✅ 문헌 확인 | istio.io/.../destination-rule/ | | `tcpKeepalive`를 설정해도 `idleTimeout`에 의한 절단은 그대로(keepalive probe는 idle timer를 리셋하지 않음) | ✅ 실측 확인 | envoyproxy.io/.../faq/configuration/timeouts · [T16 실측](/docs/istio/egress/tcp-keepalive-fields/files/verify/T16/) | | `nf_conntrack_max`는 노드 전역 설정, pod 단위로 격리되지 않음 | ✅ 문헌 확인 | kubernetes.io/.../sysctl-cluster/ | | conntrack 테이블 포화 시 커널이 silent drop, 클라이언트는 reset 아닌 무응답 timeout | ✅ 문헌 확인 | suse.com/support/kb/000020149 | | `kubectl debug --target=istio-proxy`가 필요한 이유는 istio-proxy와의 네트워크 네임스페이스 공유다 | ❌ 오류 — 본문 교정 | kubernetes.io/.../debug-running-pod/(네트워크 네임스페이스는 파드 정의상 이미 공유되며, `--target`이 추가하는 건 PID 네임스페이스 공유) | | `apiVersion: networking.istio.io/v1` DestinationRule은 Istio 1.30 대상 재현에서 유효한 API 버전 | ✅ 문헌 확인 | istio.io/blog/2024/v1-apis/ | | 외부 호스트 DR의 `trafficPolicy`는 egress gateway의 upstream cluster에 적용됨 | ✅ 문헌 확인 | istio.io/.../destination-rule/ | | 재현 4의 `nf_conntrack_max` 복구값 262144가 해당 노드의 표준 기본값 | 실측 불가 | 근거 없음 — 이 값은 노드 메모리 크기 기반 커널 계산값이라 배포판·환경마다 다르고 고정된 "기본값"으로 문서화되어 있지 않음 | | 재현 2의 축소 산술(포트20÷60s)을 운영 기본값(28,232개)에 적용하면 정본의 470 conn/s가 나온다는 상호검증 | 실측 불가 | 홈랩 규모로는 28,232개 포트를 실제로 소진시키는 부하(tcpkali류 전용 도구·환경 필요) 재현 불가 | | 리눅스 기본 ephemeral 포트 범위는 32768~60999, 총 28,232개 | ✅ 문헌 확인 | manpages.ubuntu.com/.../IP_LOCAL_PORT_RANGE |