--- title: 결정 브리프(초안) — egress(L4)×GSLB 검증: 세션 단절은 기우였고, 그래도 STRICT를 쓰는 이유 date: 2026-07-09 type: guide domain: istio tags: [istio, egress, dns, gslb, service-entry, tls-passthrough, decision-brief] --- > [!abstract] > **사내 공유용 초안 (리뷰 대기).** 우리 환경 — 앱이 직접 HTTPS를 맺고 egress gateway는 TLS를 > 열어보지 않고 통과시키는(L4 passthrough) 구성, 외부 도메인은 GSLB가 리전 VIP를 로드밸런싱 — > 에서 `ServiceEntry` 설정을 검증한 결과다. 요약 세 줄: > **① 도입 초기에 걱정했던 "GSLB가 IP를 바꾸면 기존 세션이 끊긴다"는 기우였다** (공식 문서 + > 직접 실측으로 확인, 증적 첨부). > **② 그래도 `resolution: DNS`(STRICT)를 표준으로 쓴다** — 이유는 세션이 아니라 비용 없음·전사 > 표준화·절체 시점 기록, 그리고 GSLB를 복수 A레코드로 바꾸는 순간 살아나는 장애 격리(outlier)다. > **③ 단일 A레코드인 현 구조에서 절체 대비의 실체는 (계획 절체) 앱 커넥션 수명 관리, > (장애 절체) GSLB 감지 속도와 TTL이다 — 복수 A레코드 전환이 가장 큰 구조 개선이다.** ## 1. 우리 환경 ```mermaid flowchart LR APP["client app
HTTPS를 직접 맺음"] --> SC["sidecar
암호화된 바이트만 통과"] SC --> GW["egress gateway
SNI만 보고 통과 - L4"] GW --> VA["VIP-A"] GW -.-> VB["VIP-B"] DNS["GSLB DNS
리전 VIP 로드밸런싱
+ 장애/점검 시 절체"] -.->|"IP 목록 갱신"| GW ``` 앱이 TLS를 직접 맺으므로 sidecar와 egress gateway는 HTTP 요청/응답을 볼 수 없고 암호화된 바이트를 나르기만 한다(L4). 이 사실이 이 문서 전체를 지배한다 — **Envoy가 HTTP 요청 단위로 개입하는 기능(요청 재시도 등)은 이 경로에 없다.** `ServiceEntry.resolution`은 "Envoy가 이 도메인의 IP 목록을 어떻게 관리하는가"를 정한다: | 설정 | Envoy 내부 이름 | 동작을 한 줄로 | |---|---|---| | `DNS` | STRICT_DNS | DNS 응답을 **IP 목록 그 자체**로 쓴다. DNS가 갱신되면 목록도 갱신 | | `DNS_ROUND_ROBIN` | LOGICAL_DNS | 목록을 안 만든다. **새 연결을 만드는 순간에만** 최신 DNS 응답의 첫 IP를 쓴다 | ## 2. 걱정 1 — "GSLB가 IP를 바꾸면 기존 세션이 끊긴다?" → 기우로 확인 걱정의 논리는 이랬다: "STRICT는 DNS가 갱신되면 IP 목록을 즉시 교체한다니까, 그 순간 그 IP로 통신 중이던 연결도 끊길 것이다." 두 방향에서 검증했다. **공식 문서** — [Envoy service discovery](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery)의 STRICT_DNS 원문은 "will **drain** traffic from any existing connection pools"다. drain은 "지금 하는 일은 끝내게 두고 새 일만 안 준다"는 뜻이지, 끊는다(close)가 아니다. "IP가 바뀌면 기존 연결을 즉시 정리"는 오히려 **일부러 켜야 하는 별도 옵션**으로 존재한다 ([`close_connections_on_host_set_change`](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto), 기본 false). 기본이 즉시 정리라면 이 옵션이 있을 이유가 없다. **실측** — 40초짜리 응답을 받는 도중에 A레코드를 바꾸고 Envoy 카운터를 4개 시점에서 찍었다: ```mermaid 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 ``` | 관측 항목 | 시작 전 | +12초 (flip 후) | +30초 | 요청 종료 +3초 | |---|---|---|---|---| | DNS 조회 결과 | A | **B (flip 반영됨)** | B | B | | Envoy의 IP 목록 | A | A | **B로 교체됨** | B | | 활성 연결 수 | 0 | **1 (계속 살아있음)** | **1** | 0 | | 연결 종료 카운터 | 38 | 38 (변화 없음) | 38 (변화 없음) | **39 (+1, 요청이 끝난 뒤에만)** | IP 목록이 B로 바뀐 뒤에도 기존 연결은 살아서 스트리밍을 계속했고, 연결이 닫힌 것은 요청을 마친 클라이언트가 닫은 시점이다. STRICT/LOGICAL 양쪽, 그리고 sidecar가 TLS를 대신 맺는 L7 경로까지 **4가지 조합 전부에서 진행 중 요청은 완주**했다(원표는 [Q&A 문서의 실측 부록](/docs/istio/egress/gslb-flip-inflight-qa/)). **결론: "세션 끊김" 걱정으로 resolution을 고를 필요가 없다 — 어느 쪽도 안 끊는다.** ## 3. 그럼 왜 STRICT인가 — 세션이 아니라 "장애 때 작동하는 장치" 때문 세션에 차이가 없다면 무엇으로 고르는가. 먼저 오해 하나를 제거하자: **로드밸런싱 동작은 두 설정이 사실상 같다.** Envoy는 어느 쪽이든 DNS를 백그라운드 주기 (TTL, 수 초~수십 초)로만 갱신하므로, 한 주기 안의 새 연결은 전부 같은 응답 결과로 간다. GSLB의 "질의당 분산"은 양쪽 모두 이 주기 단위로 뭉개진다 — LOGICAL을 골라도 더 고운 분산을 얻지 못한다. (egress gateway 인스턴스가 여러 개면 각자 다른 시점에 갱신하므로 전체 분포는 평균화된다.) *(메커니즘 추론 — 갱신 주기 동작은 공식 문서 기반, 분산 양자화 자체의 실측은 없음)* 그리고 하나 더 정직하게 깔아두자: **GSLB가 A레코드를 하나만 반환하는 현 구조에서는 두 설정의 실질 동작 차이가 거의 없다.** 목록이 항상 1개라서, "죽은 VIP를 골라내고 남은 멤버로 우회"하는 outlier detection도 우회할 곳이 없어 효과가 없다(자세한 메커니즘은 "outlier detection이 하는 일과 안 하는 일" 절). 그럼에도 STRICT를 **표준**으로 정하는 근거는 "강력한 효과"가 아니라 다음 네 가지다: 1. **비용이 없다.** STRICT의 유일한 비용(목록이 바뀔 때 놀고 있는 연결이 정리되는 것)은 L4인 우리 환경에서 발생하지 않는다 — 기존 연결을 아예 안 건드린다(위 실측). 2. **전사 표준화가 가능한 쪽은 STRICT뿐이다.** 외부 도메인 중에는 복수 A레코드를 주는 곳이 반드시 있고(GSLB 외 일반 도메인 포함), LOGICAL은 그 순간 설정 거부(CDS NACK)로 통신이 깨진다(실측 확인). SE마다 설정이 갈라지는 것 자체가 운영 비용이다. 3. **절체가 기록된다.** STRICT는 GSLB flip이 membership 이벤트로 카운터에 남아 "언제 절체됐나"를 장애 시각과 대조할 수 있다. LOGICAL은 무기록인 데다 목록 라벨이 실제 행선지와 어긋나는 함정까지 있다(실측). 단, 과장하지 않는다 — L4 장수명 연결에서는 STRICT도 "제거된 VIP에 남은 연결이 endpoint 목록에서 안 보이는" 사각지대가 있어(운영 시 주의 절 참조) 관측 우위는 제한적이다. 4. **GSLB를 복수 A레코드 반환으로 바꾸는 순간 outlier가 살아난다.** 실측(실패율 51.7%→0.21%)은 목록에 살아있는 대안이 함께 있던 조건에서 나온 수치다 — 그 구조가 되면 STRICT는 "권장"이 아니라 "필수"가 된다. 참고 — LOGICAL이 맞는 자리는 따로 있다: 판별 질문은 "응답에서 IP가 빠졌을 때 그 빠짐에 의미가 있는가"다. 사내 GSLB는 VIP 몇 개짜리 작은 고정 풀이고 빠짐 = 의도(장애·점검·절체)라서 그 의미를 목록에 반영하는 STRICT가 맞다. 반면 수백 IP 풀에서 매번 일부만 잘라 보여주는 대형 SaaS/CDN은 빠짐에 아무 의미가 없어서(빠진 IP도 멀쩡) 목록을 안 만드는 LOGICAL이 맞다 — "질의마다 응답이 다르다"는 사실 자체는 기준이 아니다. ## 4. 절체가 일어나면 실제로 벌어지는 일 절체의 이유에 따라 완전히 다른 두 상황이 된다. ### 계획 절체 (점검 배수 — 옛 VIP가 살아있는 상태) → 무손실 가능 ``` t=0 GSLB가 A레코드를 VIP-A -> VIP-B로 변경 t+TTL Envoy IP 목록 = [B] - 진행 중이던 요청 : 그대로 완료 (실측) - 새 연결 : B로 (실측) - 앱이 keepalive로 쥔 기존 연결 : 계속 A로 (추론, 미실측) ... 앱 커넥션 수명이 다해 새로 맺을 때 B로 ``` 무손실이다. 단 **"옛 VIP가 조용해지는 시간"은 Istio가 아니라 앱의 커넥션 수명이 결정한다** — L4에서는 요청이 끝나도 Envoy가 연결을 회수하지 않으므로. 절체 runbook에 필요한 것: 1. flip 후 **옛 VIP를 앱 커넥션 최대 수명만큼 살려둘 것** (바로 내리면 아래 장애 절체가 됨) 2. 앱 커넥션 풀의 **max lifetime 상한 합의** (절체 완료 목표 시간보다 짧게) 3. 배수 완료 판단은 DNS가 아니라 **옛 VIP의 연결 수 모니터링**으로 ### 장애 절체 (VIP 사망 → GSLB가 감지 후 변경) → 유손실 구간이 구조적으로 존재 ``` t=0 VIP-A 사망 - A 위의 연결/진행 중 요청 : 실패가 앱까지 전달 (서버가 죽은 것이므로 당연) t=0~W 새 연결이 아직 목록에 있는 A를 집으면 : 연결 실패가 앱까지 전달 W (단일 A레코드, 현 구조) = GSLB 감지 + TTL + Envoy 갱신 W (복수 A레코드로 바꾸면) = outlier 격리 ~수 초 t>W 목록 = [B], 정상 ``` 주의: "세션 안 끊김" 확인은 **IP 변경 때문에는 안 끊긴다**는 뜻이다. 서버가 죽으면 그 위의 요청은 당연히 죽고, L4에서는 Envoy가 이 실패를 앱 대신 흡수하지 못한다(실측: 완화 없이 요청 44%가 실패로 앱 도달). **단일 A레코드인 현 구조에서 W를 줄이는 실질 레버는 Envoy가 아니라 GSLB에 있다** — 감지 주기와 TTL. 이 구간엔 목록에 죽은 VIP 하나뿐이라 Envoy 차원의 우회가 성립하지 않기 때문이다. Envoy/앱 쪽에서 유효한 것과 한계: - **짧은 connect timeout (1초)** — 죽은 IP를 만나도 1초 만에 실패 판정 (요청이 매달리는 것 방지 — 단일 A에서도 유효) - **앱의 연결 실패 한정 재시도** — 단, W 안에서 즉시 재시도하면 같은 죽은 VIP를 다시 만난다. **재시도 간격(백오프)이 W를 넘어설 때만** 구제가 된다 — W가 수 초대가 아니라면 재시도 횟수보다 백오프 설계가 중요 - **outlier detection** — 현 구조(단일 A)에서는 우회 효과 없음. 복수 A레코드로 바꾸면 W 자체가 수 초로 줄어드는 구조가 된다 (실측 51.7%→0.21%가 그 조건) **따라서 "GSLB가 복수 A레코드를 반환하게 할 수 있는가"가 이 설계의 가장 큰 갈림길이다** — 가능하다면 장애 절체의 유손실 구간이 "GSLB 감지+TTL"(수십 초 단위 가능성)에서 "outlier 격리"(수 초)로 구조적으로 줄어든다. ## 5. outlier detection이 하는 일과 안 하는 일 사내 공유 때 반드시 나올 질문: "outlier가 되면 뭐가 되는데? 실패하면 DNS 질의를 다시 하나?" **아니다 — DNS와 outlier는 서로를 호출하지 않는 별개의 장치다.** 둘 다 같은 IP 목록을 만지되 역할이 다르다: | 장치 | 역할 | 트리거 | |---|---|---| | DNS 갱신 | 목록에 **누가 있는가** (명단) | 자기 주기 (TTL) — 실패와 무관하게 돈다 | | outlier detection | 명단 중 **누구에게 새 연결을 줄 것인가** (건강 표시) | 연결 실패 누적 | | 앱 재시도 | **이미 실패한 그 요청**의 구제 | 실패 그 자체 | outlier가 하는 일: 어떤 VIP로의 연결 실패가 연속 3회 쌓이면, 그 VIP를 **목록에서 지우는 게 아니라 "일시 제외" 표시**를 한다(우리 설정 기준 30초, 반복되면 제외 시간이 늘어난다). 그동안 새 연결은 목록에 남은 다른 VIP로만 간다. 제외 시간이 지나면 다시 후보에 넣어 살아났는지 자연스럽게 확인된다. outlier가 **안 하는** 일 세 가지: 1. **DNS 재질의를 유발하지 않는다.** DNS 갱신은 자기 주기로만 돈다. outlier는 그 주기 사이의 공백("GSLB는 아직 모르는 죽은 VIP")을 메우는 장치다. 2. **이미 실패한 요청을 살려내지 못한다.** 격리는 다음 연결부터의 예방이다. 첫 1~3건의 실패는 앱에 도달한다 — 그걸 흡수하는 것이 앱 재시도의 몫이다. 3. **목록에 대안이 없으면 우회시킬 곳도 없다.** GSLB가 A레코드를 하나만 반환하는 순간의 목록은 VIP 1개다 — 그게 죽어 있으면 outlier로도 갈 곳이 없고, 복구는 DNS 갱신(TTL)을 기다려야 한다. **outlier의 효과는 목록에 살아있는 대안이 있을 때 성립한다** — 우리 실측 (51.7%→0.21%)도 목록에 정상 endpoint가 함께 있던 조건이다. 덧붙여 예시 설정의 `maxEjectionPercent: 50`은 목록이 1개일 때 그 하나를 제외하는 것 자체를 막으므로, 단일 A레코드 구조에서 outlier는 문자 그대로 아무 일도 하지 않는다. GSLB가 복수 A레코드를 반환하게 할 수 있다면 이 장치의 가치가 온전해진다 (STRICT만 복수 A를 수용한다는 앞 절과 연결되는 지점). ## 6. 전체 설정 예시 ### 권장 세트 (우리 환경: 앱 HTTPS + L4 passthrough) ```yaml # 외부 도메인 등록 — 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) --- # 죽은 VIP 자동 격리 — 실측에서 실패율 51.7% → 0.21%를 만든 설정 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 # 그 VIP를 30초간 목록에서 일시 제외 maxEjectionPercent: 50 # 단 목록의 절반까지만 (전멸 방지) minHealthPercent: 0 ``` 앱 팀에 요청할 두 가지: ① 연결 실패(connect timeout/reset) 한정 재시도 1~2회 + connect timeout 1~3초, ② 커넥션 풀 max lifetime을 절체 완료 목표 시간 이하로. ### 안티 예시 두 가지 ```yaml # (1) GSLB 도메인에 LOGICAL — 쓰지 않는다 spec: resolution: DNS_ROUND_ROBIN # outlier 무력 + 복수 A레코드에서 설정 거부 + 관측 어긋남 ``` ```yaml # (2) L7 경로(원격 TLS를 sidecar가 대신 맺는 팀)에서 기본 재시도 끄기 — 쓰지 않는다 http: - retries: attempts: 0 # Istio가 기본 탑재한 "연결 실패 시 다른 IP로 재시도"가 꺼진다 ``` (L7 경로 팀 참고: Istio는 VS에 retries를 안 쓰면 "연결 실패 → 다른 IP로 최대 2회 재시도"를 자동 적용한다 — 같은 실측에서 L7이 86/86 무손실이었던 이유. 또 DR에서 포트별 `portLevelSettings`를 쓰면 outlier도 반드시 그 안에 넣어야 적용된다.) ## 7. 운영 시 주의 - **연결 정리 상태를 endpoint 목록으로 판단하지 말 것.** 목록에서 제거된 VIP에 아직 살아있는 연결은 `istioctl proxy-config endpoints`에 보이지 않는다(cluster 합계 `upstream_cx_active` 카운터로만 보임). 실측으로 확인된 관측 사각지대. - **미실측 범위**: ① 계획 절체에서 "앱이 쥔 keepalive 연결이 옛 VIP에 남는다"는 메커니즘 추론 (실측은 40초 단일 요청 생존까지), ② 수 분급 초장기 스트림의 drain 동작, ③ HTTP/2 경로. 해당 워크로드 적용 전 추가 확인 필요. ## 8. 근거 자료 (읽는 순서) 1. [Q&A — GSLB가 IP를 바꾸면 내 요청은 어떻게 되나](/docs/istio/egress/gslb-flip-inflight-qa/) — 쉬운 입구 + 실측 부록(모든 원본 수치) 2. [SE resolution × TLS passthrough — in-flight 요청의 운명](/docs/istio/egress/se-resolution-passthrough-inflight/) — 4조합 정밀 비교 + 공식 문서 대조 3. [DNS/GSLB resolution 재현 랩](/docs/istio/egress/dns-gslb-repro-lab/) — 직접 재현 가능한 절차·킷 4. [ServiceEntry resolution 이론 runbook](/docs/istio/egress/report-dns-resolution/) — 필드 정본 (초기 권장이 뒤집힌 교정 이력 포함) 5. 공식 문서: [Envoy — Service discovery](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery) · [cluster.proto — CommonLbConfig](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto) 6. 원시 리포트·manifest·재현 스크립트: [files.homelab89.com/istio-egress/dns-resolution/](https://files.homelab89.com/istio-egress/dns-resolution/) > [!note] > 이 문서는 초안이다. 사내 공유 전에 확인할 것: ① **GSLB 응답 패턴** — dig를 여러 번 쳐서 > A레코드가 몇 개 오는지(복수면 outlier 효과의 전제가 충족되고 STRICT 필수 근거도 강해짐), > 고정형인지 회전형인지, TTL 몇 초인지. ② **절체 완료 목표 시간** — 앱 커넥션 max lifetime > 권장값이 여기서 나옴. ③ **초장기 스트리밍 워크로드 유무** — 미실측 범위 해당 여부.