egress ServiceEntry는 resolution: DNS(STRICT)를 써도 된다 — 세션 단절 검증과 기본값 선택 근거
사내 공유용 완성본 (단독으로 읽히도록 작성 — 외부 참조 없음). egress gateway를 지나는
외부 도메인의 ServiceEntry를 resolution: DNS(STRICT)로 표준화하는 결정과 그 근거를 담았다.
결론 두 줄:
① 도입 초기에 걱정했던 “GSLB가 IP를 바꾸면 기존 세션이 끊긴다"는 기우였다 — 공식 문서와
직접 실측 양쪽으로 확인했고, 실측 원시 데이터를 이 문서 말미 부록에 발췌 첨부했다.
② 기조는 “반드시 STRICT"가 아니라 “STRICT를 그대로 써도 문제가 없다"이다. 그 위에서,
우리 환경에서는 3절의 장점 네 가지로 STRICT를 기본값으로 쓰고, LOGICAL이 더 맞는 상황의
판별 기준도 3절에 함께 적었다.
절체가 실제로 일어난 순간의 운영 시나리오(계획·장애 절체 runbook)는 별도 운영 가이드 문서로
제공한다.
근거 등급 — 이 문서의 모든 주장에는 근거 등급을 붙였다: [실측] = 재현 랩에서 직접 측정(원시 데이터 발췌를 부록에 첨부), [공식 문서] = Envoy 공식 문서 원문 인용, [논리 귀결] = 앞의 두 사실로부터의 직접 도출. 이 세 등급에 들지 못하는 추정성 서술은 싣지 않았다.
1. 환경 설명
flowchart LR
APP["client app<br/>HTTPS를 직접 맺음"] --> SC["sidecar<br/>암호화된 바이트만 통과"]
SC -->|"Istio mTLS 터널"| GW["egress gateway<br/>SNI만 보고 통과 - L4"]
GW --> VA["VIP-A"]
GW -.-> VB["VIP-B"]
DNS["GSLB DNS<br/>VIP 하나만 반환이 기본<br/>+ 장애/점검 시 절체"] -.->|"IP 목록 갱신"| GW실제 구성은 sidecar↔egress gateway 구간을 Istio mTLS로 감싸는 mTLS passthrough다. 앱이 TLS를 직접 맺으므로 sidecar와 egress gateway는 HTTP 요청/응답을 볼 수 없고 암호화된 바이트를 나르기만 한다(L4). sidecar↔gateway 구간의 Istio mTLS는 이 사실을 바꾸지 않는다 — mTLS는 나르는 통로를 한 겹 더 감쌀 뿐이고, gateway가 앱의 TLS를 열지 못하는 것은 같다. 그래서 테스트 랩은 mTLS 구간을 뺀 단순 passthrough로 구성했고, 이 문서의 실측은 그 구성에서 나온 것이다. 이 “L4"라는 사실이 문서 전체를 지배한다 — Envoy가 HTTP 요청 단위로 개입하는 기능(요청 재시도 등)은 이 경로에 없다.
이 gateway가 처리하는 외부 도메인은 DNS 응답 관점에서 두 부류다:
| 부류 | DNS 응답 | 예 |
|---|---|---|
| 사내 GSLB 도메인 | VIP 하나만 반환이 기본 — 장애·점검 때 다른 VIP로 바꿔서 반환 | 사내·제휴 서비스 |
| 외부 일반 도메인 | 복수 IP를 반환할 수 있음 | 파트너 API, SaaS |
이 문서에서 절체란 GSLB가 도메인의 A레코드(도메인→IP 매핑)를 다른 VIP로 바꾸는 일을 말한다 — 계획된 점검 배수든, 장애 대응이든.
ServiceEntry.resolution은 “Envoy가 이 도메인의 IP 목록을 어떻게 관리하는가"를 정한다:
| 설정 | Envoy 내부 이름 | 동작을 한 줄로 |
|---|---|---|
DNS |
STRICT_DNS | DNS 응답을 IP 목록 그 자체로 쓴다. DNS가 갱신되면 목록도 갱신 |
DNS_ROUND_ROBIN |
LOGICAL_DNS | 목록을 안 만든다. 새 연결을 만드는 순간에만 최신 DNS 응답의 첫 IP를 쓴다 |
2. STRICT DNS여도 세션 단절은 없다
걱정의 논리는 이랬다: “STRICT는 DNS가 갱신되면 IP 목록을 즉시 교체한다니까, 그 순간 그 IP로 통신 중이던 연결도 끊길 것이다.” 두 방향에서 검증했다.
공식 문서 — Envoy service discovery의
STRICT_DNS 원문은 “will drain traffic from any existing connection pools"다. drain은
“지금 하는 일은 끝내게 두고 새 일만 안 준다"는 뜻이지, 끊는다(close)가 아니다. “IP가 바뀌면
기존 연결을 즉시 정리"는 오히려 일부러 켜야 하는 별도 옵션으로 존재한다
(close_connections_on_host_set_change,
기본 false). 기본이 즉시 정리라면 이 옵션이 있을 이유가 없다.
실측 — 40초짜리 응답을 받는 도중에 절체(A레코드를 옛 VIP → 새 VIP로 변경)를 일으키고, Envoy의 상태를 4개 시점에서 찍었다:
sequenceDiagram
participant C as client (curl)
participant E as Envoy (L4)
participant A as server A (기존 IP)
participant D as GSLB DNS
C->>E: HTTPS 요청 (40초짜리 응답)
E->>A: TCP 연결, 스트리밍 시작
Note over D: +5초 - A레코드를 B로 변경
D-->>E: 백그라운드 갱신 - IP 목록은 B로
Note over E,A: 기존 연결은 그대로 둠
A-->>C: 스트리밍 계속
A-->>C: +40초 - 409,610바이트 전량 수신, HTTP 200위 그림의 각 시점에서 실제로 확인된 것을 말로 옮기면:
| 시점 | DNS는 뭐라 답하나 | Envoy의 IP 목록 | 진행 중이던 40초 요청은 |
|---|---|---|---|
| 시작 | 옛 VIP(A) | 옛 VIP(A) | 스트리밍 시작 |
| 절체 +7초 | 새 VIP(B) — 절체 반영됨 | 아직 옛 VIP (갱신 주기 전) | 계속 스트리밍 |
| 절체 +25초 | 새 VIP(B) | 새 VIP(B)로 교체 완료 | 목록이 바뀌었는데도 계속 스트리밍 |
| 요청 완료 | 새 VIP(B) | 새 VIP(B) | 409,610바이트 전량 수신, HTTP 200 — 연결이 닫힌 것은 이때뿐 |
(카운터 원값: “연결 종료” 누적 카운터는 절체 전 38 → 요청 진행 내내 38 그대로 → 요청이 끝난 뒤에야 39. 활성 연결 수는 요청 내내 1을 유지. 실행 기록 원본은 부록 A.)
IP 목록이 새 VIP로 바뀐 뒤에도 기존 연결은 살아서 스트리밍을 계속했고, 연결이 닫힌 것은 요청을 마친 클라이언트가 닫은 시점이다. STRICT/LOGICAL 양쪽, 그리고 sidecar가 TLS를 대신 맺는 L7 경로까지 4가지 조합 전부에서 진행 중 요청은 완주했다. 결론: “세션 끊김” 걱정으로 resolution을 고를 필요가 없다 — 어느 쪽도 안 끊는다.
3. STRICT DNS를 써도 된다 — 그리고 기본값으로 쓰는 이유
먼저 이 절의 기조를 분명히 한다. 결론은 “반드시 STRICT"가 아니다. 2절에서 확인했듯 STRICT를 그대로 써도 세션 문제는 없다는 것이고, 그 위에서 우리 환경의 기본값으로는 STRICT가 유리하다는 것이다. LOGICAL이 더 맞는 상황도 분명히 있으므로 판별 기준까지 함께 적는다.
전제 — 두 설정이 같은 부분부터:
- 로드밸런싱 동작은 사실상 같다. Envoy는 연결을 만드는 순간에 DNS를 묻지 않는다 — “Envoy never synchronously resolves DNS in the forwarding path” [공식 문서]. 어느 설정이든 백그라운드 주기(TTL)로 갱신된 최신 결과를 쓸 뿐이므로, LOGICAL을 골라도 더 고운 분산을 얻는 것이 아니다 [논리 귀결].
- 사내 GSLB 도메인처럼 A레코드가 항상 VIP 하나뿐이면 실질 동작 차이는 거의 없다. 목록이 항상 1개라서 무엇을 골라도 같은 곳으로 간다.
두 설정이 갈리는 지점 — 그리고 LOGICAL이 맞는 자리
두 설정의 진짜 차이는 “명단 관리를 하느냐"다. STRICT는 DNS 응답을 IP 명단 그 자체로 관리한다 — 갱신 주기마다 응답에 새로 나타난 IP는 명단에 추가하고, 응답에서 사라진 IP는 명단에서 제거한다. LOGICAL은 명단을 아예 만들지 않는다 — 새 연결을 만드는 순간 최신 응답의 첫 IP 하나만 쓴다. 이 차이 때문에 그 도메인의 DNS 응답이 어떤 성격이냐에 따라 맞는 쪽이 갈린다.
LOGICAL이 맞는 자리 — 응답이 매번 크게 달라지는 대형 도메인. 수백~수천 대 서버 풀에서 질의마다 몇 개씩만 잘라 돌려주는 대형 SaaS·CDN형 도메인이 있다. 이런 응답에서 어떤 IP가 이번에 안 나왔다는 것은 그 서버가 죽었다는 뜻이 아니라 이번 응답에 안 뽑혔다는 뜻일 뿐이다 (빠진 서버도 멀쩡히 살아 있다). 그런데 STRICT는 그 빠짐을 “제거"로 받아들이므로, 갱신 주기마다 멀쩡한 서버를 명단에서 뺐다 넣었다 반복하게 된다 — 의미 없는 명단 갈아엎기다. Envoy 공식 문서도 LOGICAL의 용도를 정확히 이 상황으로 설명한다: 질의마다 다른 결과를 돌려주는 대규모 DNS 라운드로빈 서비스에는 명단을 유지하지 않는 LOGICAL이 적합하다 [공식 문서].
STRICT가 맞는 자리 — 응답의 빠짐에 의미가 있는 도메인. 사내 GSLB 도메인처럼 VIP 몇 개짜리 작은 고정 풀에서는, 응답에서 VIP가 빠지거나 바뀌는 것 자체가 신호(장애·점검·절체)다. 그 신호를 명단에 반영해 주는 STRICT가 자연스럽다.
판별 질문은 한 줄이다: “DNS 응답에서 IP가 빠졌을 때, 그 빠짐에 의미가 있는가?” 의미가 있으면(작은 고정 풀, 빠짐 = 절체 신호) STRICT, 없으면(거대 풀의 회전 응답, 빠짐 = 이번 추첨 탈락) LOGICAL. 주의할 점 하나 — “질의마다 응답이 다르다"는 겉모습만으로는 판별할 수 없다. 사내 GSLB도 리전 분산으로 질의마다 다른 VIP를 줄 수 있지만, 그 풀은 작고 고정이며 빠짐에 의미가 있으므로 STRICT 쪽이다.
덧붙여 우리 환경(L4)에서는 회전형 대형 도메인에 STRICT를 쓰더라도 사고가 나지는 않는다 — 명단이 자주 갈리는 군더더기가 생길 뿐, 기존 연결은 건드리지 않는다(2절 실측). 즉 어느 쪽을 골라도 “문제가 생기는” 조합은 없다. 남는 질문은 “기본값을 무엇으로 두는 것이 운영에 유리한가"이고, 그 답이 STRICT인 이유가 다음 네 가지다.
기본값을 STRICT로 두는 이유 네 가지
- 유지 비용이 없다 [실측]. STRICT의 유일한 비용(IP 목록이 바뀔 때 놀고 있는 연결이 정리되는 것)은 L4인 우리 환경에서 발생하지 않는다 — 기존 연결을 아예 안 건드린다(2절 실측).
- 기본값 하나로 더 넓게 커버되는 쪽이 STRICT다 [실측]. 사내 GSLB 도메인은 단일 VIP가 기본이지만, 같은 egress가 처리하는 외부 일반 도메인(파트너 API·SaaS)은 지금도 복수 IP를 반환할 수 있다(1절의 두 부류). 같은 2-IP 상태에서 STRICT는 실제로 두 IP 모두에 트래픽을 분산했고(curl 12회 중 7/5), LOGICAL은 통신 자체는 정상이지만 첫 IP 하나만 썼다(curl 15회 전부) — 나머지 IP는 조용히 무시된다. DNS가 복수 IP를 준 의도(분산·페일오버 후보)를 LOGICAL은 실현하지 못한다. 기본값을 LOGICAL로 두면 복수 IP 도메인마다 예외 판단이 필요해지지만, STRICT를 기본값으로 두면 예외가 필요한 쪽은 위의 “회전형 대형 도메인"뿐이다.
- 절체가 기록된다 [실측]. STRICT는 절체가 membership 이벤트로 Envoy 카운터에 남아 “언제 절체됐나"를 장애 시각과 대조할 수 있다. LOGICAL은 무기록이다. (단, 과장하지 않는다 — STRICT도 사각지대가 있다. 목록에서 제거된 옛 VIP에 아직 살아있는 연결은 endpoint 목록 조회에는 보이지 않고 cluster 합계 카운터로만 보인다[실측]. 우위는 “기록이 있다” 수준이다.)
- 복수 IP 도메인에서는 죽은 IP 자동 격리(outlier detection)가 실제로 작동한다 [실측 — 복수 endpoint 조건]. 목록에 살아있는 대안이 함께 있는 조건에서 실패율 51.7% → 0.21%를 실측했다(부록 D). LOGICAL은 같은 상태에서도 첫 IP 하나만 쓰므로 이 장치가 성립하지 않는다. outlier detection이 하는 일을 정확히 말하면: 연결 실패가 누적된 IP를 일정 시간 목록에서 “일시 제외"하고 새 연결을 남은 IP로만 보내는 장치다 — DNS 재질의를 유발하지도, 이미 실패한 요청을 살려내지도 못하며, 목록에 대안이 없으면 우회시킬 곳도 없다.
그래서 이것도 정직하게 적어둔다 — 단일 VIP인 사내 GSLB 도메인에서는 장애 순간 목록에 죽은 VIP 하나뿐이라 Envoy 차원의 우회가 성립하지 않고, 장애 절체의 실질 레버는 Envoy가 아니라 GSLB의 감지 속도와 DNS TTL이다 [논리 귀결].
권장 설정
# 외부 도메인 등록 — resolution: DNS(STRICT)
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: partner-api
spec:
hosts: ["api.partner.example"] # GSLB가 VIP를 관리하는 외부 도메인
location: MESH_EXTERNAL
resolution: DNS # STRICT_DNS: DNS 응답 = IP 목록
ports:
- number: 443
name: tls
protocol: TLS # 앱이 직접 TLS를 맺음 → Envoy는 통과만(L4)
---
# 죽은 IP 자동 격리 — 실측에서 실패율 51.7% → 0.21%를 만든 설정 (복수 IP 도메인에서 유효)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: partner-api-outlier
spec:
host: api.partner.example
trafficPolicy:
connectionPool:
tcp:
connectTimeout: 1s # 죽은 IP의 연결 실패를 1초 안에 판정
outlierDetection:
splitExternalLocalOriginErrors: true
consecutiveLocalOriginFailures: 3 # 연결 실패(연속 3회)면
interval: 5s # 5초마다 판정해서
baseEjectionTime: 30s # 그 IP를 30초간 목록에서 일시 제외
maxEjectionPercent: 50 # 단 목록의 절반까지만 (전멸 방지)
minHealthPercent: 0
앱 팀에 요청할 두 가지: ① 연결 실패(connect timeout/reset) 한정 재시도 12회 + connect
timeout 13초, ② 커넥션 풀 max lifetime을 절체 완료 목표 시간 이하로. ②의 이유: L4에서는
요청이 끝나도 Envoy가 연결을 회수하지 않아, 앱이 keepalive로 쥔 연결은 절체 후에도 계속 옛
VIP로 간다(부록 B 실측) — 계획 절체 때 옛 VIP가 조용해지는 시간을 결정하는 것은 Istio가
아니라 앱의 커넥션 수명이다.
4. 실측 데이터
모든 수치는 재현 랩(Kubernetes 1.30 + Istio 1.30.0)에서 직접 측정했다. 외부 도메인과 GSLB는 랩 DNS(CoreDNS)와 backend 2개로 모사했고, 절체는 랩 DNS의 A레코드를 실험 도중 바꾸는 방식으로 일으켰다. 요약:
| 실측 항목 | 방법 | 결과 | 원시 기록 |
|---|---|---|---|
| 진행 중 요청 생존 — 4조합 (STRICT·LOGICAL × L7·L4) | 40초 스트리밍 응답 도중 절체, 4개 시점에서 DNS 응답·Envoy IP 목록·연결 카운터 스냅샷 | 4조합 전부 완주 (409,610바이트, HTTP 200) | 부록 A |
| keepalive 연결 고정 | 하나의 TLS 연결을 유지한 채 순차 요청, 도중 절체 (2런) | 같은 연결은 계속 옛 VIP, 연결을 새로 맺어야 새 VIP (연결 생성 카운터로 교차 증명) | 부록 B |
| 복수 A레코드 활용 | DNS가 2-IP를 응답하는 상태에서 반복 curl | STRICT는 두 IP 분산(7/5), LOGICAL은 첫 IP만(15/15) — 통신은 양쪽 다 정상 | 부록 C |
| 죽은 IP + 자동 격리 | 목록에 죽은 IP가 남은 상태로 연속 요청, outlier 적용 전/후 비교 | L4는 완화 없이 요청 44%가 실패로 앱 도달, outlier 적용 시 실패율 51.7% → 0.21% (복수 endpoint 조건) | 부록 D |
각 실측의 실행 기록 원본은 이 문서 말미 부록에 발췌해 실었다. 잘라내지 않은 전문(원시 리포트 11편 + 측정 스크립트의 무가공 트랜스크립트 15개, 총 26파일)과 재현용 manifest·스크립트 일체는 보존돼 있으며 요청 시 전달한다.
공식 문서 근거
- Envoy — Service discovery (STRICT_DNS/LOGICAL_DNS의 동작 정의, drain 원문): https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery
- Envoy — cluster.proto (
close_connections_on_host_set_change, 기본 false): https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto - Envoy — outlier_detection.proto (격리 동작·
always_eject_one_host기본 false): https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/outlier_detection.proto
부록 — 실측 원시 데이터 발췌
카운터는 전부 트래픽이 지나는 지점의 Envoy(sidecar)에서 pilot-agent request GET stats로 찍은
누적값이라, 같은 런 안의 델타(변화량)로만 판정했다.
부록 A — 진행 중 요청 생존 (40초 스트림 도중 절체)
A-1. L4 passthrough × STRICT (2026-07-09) — 실제 사용한 명령과 응답 로그 전문.
/slow(40초 응답)를 수신하는 도중 t=+5초에 A레코드를 pod-A(10.255.194.107) →
pod-B(10.255.126.38)로 변경하고, 4개 시점(BEFORE / MID1 +12s / MID2 +30s / AFTER 종료+3s)에서
같은 관측 3종(DNS 응답, Envoy endpoint 목록, Envoy 연결 카운터)을 반복했다.
실제 사용한 명령:
# 40초 스트림 요청 — client pod(netshoot) 안에서 백그라운드 실행
curl -sk -m 90 -o /tmp/ptX.bin \
-w "CURL_EXIT=%{exitcode} SIZE=%{size_download} TIME=%{time_total} HTTPCODE=%{http_code}" \
https://gslb-pt.lab.internal/slow
# t=+5s 절체 — 랩 DNS(CoreDNS hosts 파일)의 A레코드를 pod-B로 재기록
kubectl -n dns-lab exec deploy/lab-dns -c writer -- sh -c \
"printf '%s gslb.lab.internal\n%s gslb-pt.lab.internal\n' '<svc-IP>' '<pod-B IP>' > /hosts/addn"
# 각 시점 스냅샷 3종 (BEFORE/MID1/MID2/AFTER 4회 동일하게 실행)
kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb-pt.lab.internal
istioctl proxy-config endpoints deploy/netshoot.dns-lab \
--cluster "outbound|443||gslb-pt.lab.internal"
kubectl -n dns-lab exec deploy/netshoot -c istio-proxy -- \
pilot-agent request GET "stats?filter=gslb-pt" \
| grep -E 'upstream_cx_total|upstream_cx_active|upstream_cx_destroy|membership_change|downstream_cx_total'
요약 표 (아래 로그 전문에서 뽑은 값):
| counter | BEFORE | MID1(+12s) | MID2(+30s) | AFTER(+43s) |
|---|---|---|---|---|
| dig 응답 | pod-A | pod-B (절체 반영) | pod-B | pod-B |
| endpoint 덤프(cluster) | pod-A만 | 여전히 pod-A만 | pod-B로 교체 | pod-B만 |
| upstream_cx_active | 0 | 1 | 1 | 0 |
| upstream_cx_destroy | 38 | 38 (Δ0) | 38 (Δ0) | 39 (Δ+1, 완료 직후) |
실행 로그 전문 (가공 없음, 측정 하네스가 남긴 그대로):
# D-2 passthrough in-flight + flip — 2026-07-09_132039
=== BEFORE @ 13:20:39 ===
-- dig gslb-pt --
10.255.194.107
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.194.107:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 4
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 0
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 38
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 43
CURL_LAUNCH=13:20:39 (T0=1783570839) -- https://gslb-pt.lab.internal/slow, -k (SNI passthrough, cert CN mismatch expected)
FLIP_ISSUED=13:20:44 (t=+5s) gslb-pt: 10.255.194.107 -> 10.255.126.38
FLIP_DONE=13:20:44 (t=+5s)
[t=+12s]
=== MID1 @ 13:20:51 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.194.107:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 4
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 1
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
[t=+30s]
=== MID2 @ 13:21:09 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.126.38:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 5
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 1
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
CURL_WAIT_DONE=13:21:19 (t=+40s)
-- curl result --
CURL_EXIT=0 SIZE=409610 TIME=40.050531 HTTPCODE=200
-- curl stderr --
[t=+43s]
=== AFTER @ 13:21:22 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.126.38:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 5
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 0
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 39
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 23
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 23
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 26
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
-- received file check (netshoot) --
409610 /tmp/ptX.bin
backend-a
XXXXX
## 복구: gslb-pt -> backend-a 현재 pod IP로 재원복
10.255.194.107
backend-a
[http_code=200]
로그 읽는 안내: MID1에서 dig는 이미 pod-B인데 endpoint 목록은 아직 pod-A(Envoy의 자체 DNS
갱신 주기가 아직 안 돌았음), MID2에서 membership_change 4→5로 목록이 pod-B로 교체됐다.
그 전 구간 내내 upstream_cx_active: 1(우리의 스트림)이 유지되고 destroy 계열은 전부
정지 상태 — destroy가 +1 되는 것은 curl이 40초를 다 받고 끝난 AFTER 시점뿐이다. 수신 파일
검사(409,610바이트, 첫 줄 backend-a)가 “전량을 절체 전 backend에서 받았다"의 확인이다.
A-2. L4 passthrough × LOGICAL (2026-07-09, 2런 동일 패턴). 같은 시나리오:
| 시점 | dig 결과 | membership_change | upstream_cx_active | upstream_cx_destroy |
|---|---|---|---|---|
| BEFORE | pod-A | 11 | 0 | 2973 |
| MID1 +12s | pod-B (절체 반영) | 11 (Δ0 — 기록조차 없음) | 1 | 2973 |
| MID2 +30s | pod-B | 11 | 1 | 2973 |
| AFTER +43s | pod-B | 11 | 0 | 2974 |
run1: CURL_EXIT=0 SIZE=409610 TIME=40.055717 HTTPCODE=200
run2: CURL_EXIT=0 SIZE=409610 TIME=40.047827 HTTPCODE=200
A-3. L7(sidecar가 TLS를 대신 맺는 경로) × STRICT (2026-07-02). 같은 시나리오:
| counter | BEFORE | MID1(+12s) | MID2(+30s) | AFTER(+43s) |
|---|---|---|---|---|
| membership_change | 17 | 18 (+1, 절체가 기록됨) | 18 | 18 |
| upstream_cx_active | 0 | 1 | 1 | 0 |
| upstream_cx_destroy | 51 | 51 (Δ0) | 51 (Δ0) | 52 (+1) |
| upstream_cx_destroy_with_active_rq | 1 | 1 | 1 | 1 (Δ0) |
CURL_EXIT=0 SIZE=409610 TIME=40.045811 CODE=200 (body = backend-a — 무절단 완주)
연결 종료(destroy)가 절체 시점이 아니라 요청 완료 직후에만 +1 — “IP 목록에서 host를 제거해도
in-flight 연결은 완료까지 유예 후 폐쇄(graceful drain)“의 직접 증거다.
destroy_with_active_rq(활성 요청을 문 채 끊긴 연결)는 전 구간 Δ0 — 요청을 문 채 끊긴 연결은
한 건도 없었다.
A-4. L7 × LOGICAL (2026-07-02, 2런 동일): membership_change·destroy 계열 전 구간 Δ0,
curl 완주(SIZE=409610 CODE=200 × 2런). 네 조합 모두 완주 — 차이는 요청이 끝난 뒤 연결의
운명뿐이다(STRICT는 완료 즉시 폐쇄해 다음 요청부터 새 IP, LOGICAL은 연결이 pool에 남아 재사용
시 옛 IP 지속).
부록 B — keepalive 연결 고정 (계획 절체 시나리오)
python http.client.HTTPSConnection 하나를 유지한 채 순차 요청, t=+5초에 절체. 로컬
포트(getsockname)와 실제 접속지(getpeername)를 요청마다 직접 기록했다 (2026-07-09, Run 1):
| t | 이벤트 | 로컬포트 | peer (실제 접속지) | body |
|---|---|---|---|---|
| t=0 | REQ1 (연결 오픈) | 40186 | 10.255.194.124 (pod-a) | backend-a |
| t=+5s | 절체 발사 pod-a → pod-b (+9s dig로 반영 확인) | — | — | — |
| t=15 | REQ2 (같은 연결) | 40186 (불변) | 10.255.194.124 (여전히 옛 IP) | backend-a |
| t=25 | REQ3 (같은 연결) | 40186 (불변) | 10.255.194.124 | backend-a |
| t=25+ | REQ4 (close 후 새 연결) | 59332 (변경) | 10.255.126.38 (pod-b) | backend-b |
Envoy 쪽 교차 증거 — 연결 생성 누적 카운터 upstream_cx_total:
2984 → 2985(+1, REQ1) → 2985(Δ0, REQ2/3 재사용) → 2986(+1, REQ4) — 요청 4번에 연결 생성은
정확히 2번. Run 2도 동일 패턴(+1, Δ0, +1) 재현. 서버 keepalive_timeout(65초) 안에서의 정직한
재사용이며, 재연결 폴백 분기는 2런 모두 트리거되지 않았다.
부록 C — 복수 A레코드 활용 (STRICT vs LOGICAL)
랩 DNS가 A레코드 2개를 반환하도록 변경한 뒤 두 설정을 같은 조건에서 비교 (2026-07-09):
$ dig gslb.lab.internal (5회 반복, 매회 동일하게 2개 반환)
gslb.lab.internal. 4 IN A 10.250.161.145 (backend-a)
gslb.lab.internal. 4 IN A 10.250.183.248 (backend-b)
LOGICAL(DNS_ROUND_ROBIN) — cluster 멤버는 첫 IP 하나뿐, curl 15회 전부 backend-a:
$ pilot-agent request GET clusters | grep gslb.lab.internal
outbound|443||gslb.lab.internal::10.250.161.145:443::rq_total::15
outbound|443||gslb.lab.internal::10.250.161.145:443::rq_success::15
(10.250.183.248 멤버 행 자체가 없음 — 두 번째 IP는 조용히 무시.
에러·설정 거부 없음: istiod 로그 reject 매치 0, cds.update_rejected 0)
같은 2-A레코드 상태에서 STRICT — cluster 멤버 2개, curl 12회가 실제 분산:
$ pilot-agent request GET clusters | grep gslb.lab.internal
outbound|443||gslb.lab.internal::10.250.161.145:443::... <- backend-a
outbound|443||gslb.lab.internal::10.250.183.248:443::... <- backend-b (멤버 2개)
$ curl x12 응답 순서: a,a,a,b,b,a,b,a,a,a,b,b (backend-a 7 / backend-b 5, 전부 200)
부록 D — 죽은 IP + 자동 격리 (outlier detection)
backend-a를 내려(dead IP) IP 목록에 죽은 IP와 정상 IP가 공존하는 조건을 만들고, 40초 동안 L4 passthrough 경로와 L7 경로에 같은 조건으로 동시 부하 (2026-07-09):
| 경로 | 시도 | 성공 | 실패 | 실패 유형 |
|---|---|---|---|---|
| L4 passthrough | 41 | 23 (56.1%) | 18 (43.9%) | connect timeout 13건 + TLS 연결 실패 5건 — 전부 client에 그대로 전달 |
| L7 (대조) | 86 | 86 (100%) | 0 | Envoy가 연결 실패를 자동 재시도 — upstream_rq_retry_success 46/46 |
같은 dead-IP 조건에서 L4에 outlier detection(6절 권장 설정과 동일) 적용 전/후:
| stage | 총시도 | 성공 | 실패 | 실패율 |
|---|---|---|---|---|
| outlier 없음 | 29 | 14 | 15 | 51.7% |
| outlier 적용 | 2905 | 2899 | 6 | 0.21% |
outlier_detection.ejections_enforced_total: 2
...::10.255.194.107:443::health_flags::/failed_outlier_check <- 죽은 IP가 격리됨
...::10.255.126.38:443::health_flags::healthy <- 새 연결은 전부 이쪽으로
0.21%가 정확히 0이 아닌 이유: 격리 판정(연속 실패 3회)을 채우는 동안의 초반 실패는 client에 도달한다 — L4에는 이를 흡수할 요청 단위 재시도가 없다. 그리고 이 효과 전체가 “목록에 살아있는 대안이 있다"는 전제 위에서만 성립한다(3절 근거 4의 조건).
사내 공유 전에 확인할 것: ① GSLB 응답 패턴 — dig를 여러 번 쳐서 A레코드가 몇 개 오는지(복수면 outlier 효과의 전제가 충족되고 STRICT 필수 근거도 강해짐), 고정형인지 회전형인지, TTL 몇 초인지. ② 절체 완료 목표 시간 — 앱 커넥션 max lifetime 권장값이 여기서 나옴. ③ 초장기 스트리밍 워크로드 유무 — 이번 실측은 40초급 스트림까지 검증했고, 수 분급 스트림의 유예 상한과 HTTP/2 경로는 미실측이다.