homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-10egresstcpconnection-poolport-exhaustionconntrack

Egress TCP 병목 재현 랩 — 한계를 줄여서 부딪힌다

ABSTRACT

TCP 병목 정본의 한계 수치(Envoy 1024, 포트 28k, conntrack 26만)는 그대로 재현하려면 연결 수만 개가 필요하다. 이 랩의 기법은 반대다 — 한계를 5~20으로 줄여서, 같은 메커니즘을 연결 수십 개로 관찰한다. 재현 4종 각각이 운영에서 만날 실패 시그니처(UO / UF / 무응답 / 정시 절단) 하나씩을 직접 떠올리고, 그 시그니처 구분이 곧 reset 분기 런북이 된다.

대상 환경: Istio 1.30.0, Egress 신원 기반 통제 구성의 테스트 클러스터(ns istio-egress의 egressgateway + ns egress-test의 netshoot-a)가 떠 있는 상태. 대상 독자: 도입 보고서의 병목 표를 “봤다"에서 “겪었다"로 바꾸고 싶은 사람. 범위: 병목 1·2·3·4 재현 + 복구. 완화 운영값 자체는 정본 §06으로 위임.


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. 관찰 도구 준비

# 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/<pid>/fd처럼 프로세스 단위 정보를 보려면 이게 필요하다.


2. 재현 1 — Envoy cluster 연결 상한 (운영 기본 1024 → 5)

부딪히는 순서 1번, 가장 먼저 맞을 함정. 상한을 5로 줄이고 10개 연결을 시도한다.

# 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
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의 운영값으로 재생성).


3. 재현 2 — Ephemeral 포트 고갈 + TIME_WAIT (28,232개 → 20개)

ip_local_port_range는 K8s safe sysctl이라 pod 단위로 바로 설정 가능하다(kubelet allowlist 불필요).

# values-egress.yaml에 추가 (gateway 차트의 securityContext = pod-level)
securityContext:
  sysctls:
  - name: net.ipv4.ip_local_port_range
    value: "32768 32787"        # 재현용: 포트 20개
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"'
실측 확인(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 커널 파라미터 정본 참고.

관찰 명령 (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 축소로 모사한다.

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)
$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 전용 테스트 노드가 분리되어 있을 때만 수행할 것.

# 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에.


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” 두 조건을 다 갖춰야 이 절의 표대로 관측된다.

참조

아카이브 내부

외부

검증 기록 (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 실측(호스트가 실제 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 실측(사이드카 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 실측
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

Files