---
type: note
tags: [istio, circuit-breaking, connection-pool, outlier-detection, resilience]
created: 2026-06-07
---
# Istio 회로 차단은 connection pool 한도로 부하를 빠르게 잘라내고 outlier detection으로 unhealthy 인스턴스를 격리해 연쇄 장애를 끊는다
> [!abstract] 이 문서가 다루는 것
> Istio의 "circuit breaking"은 단일 기능이 아니라 **두 개의 독립된 방어선**이다 — ① `connectionPool` 한도(동시성·pending·retry 상한 = Envoy `circuit_breakers`)로 **클라이언트가 upstream을 과부하시키지 못하게 빠르게 실패**시키고, ② `outlierDetection`으로 **에러를 뱉는 endpoint를 LB pool에서 일시 격리**한다. 둘 다 `CircuitBreaker` CRD가 아니라 **DestinationRule `trafficPolicy`** 에 정의되고 istiod가 Envoy cluster로 컴파일한다. 이 note는 *왜 이 메커니즘이 존재하고 어떻게 동작하는가*에 집중하고, 필드별 컴파일 매핑·전체 YAML 레퍼런스는 [Envoy Cluster 해부](xds__src-cluster-anatomy.html)로 위임한다.
>
> **대상환경**: Istio 1.30 / Envoy sidecar · **대상독자**: cascading failure를 끊고 싶은 DevOps/SRE · **범위**: 멘탈모델·메커니즘·관측 · **선행개념**: DestinationRule, Envoy cluster, response flag
## 01. 배경 — 왜 회로 차단이 필요한가 (cascading failure의 메커니즘)
회로 차단을 이해하려면 먼저 **무엇으로부터 보호하는가**를 알아야 한다. 답은 **연쇄 장애(cascading failure)** 다. 분산 시스템에서 한 서비스의 느려짐이 전체로 번지는 가장 흔한 경로가 이것이다.
메커니즘은 이렇다. upstream B가 느려지면(GC pause, DB 락, GPU 큐 적체 등), B를 호출하는 A의 요청들이 응답을 기다리며 **A 안에 쌓인다**. 요청 하나당 thread·socket·메모리를 점유한 채 blocking되므로, B가 느린 시간 동안 A의 자원이 고갈된다. 이제 A 자신이 느려지고, A를 호출하는 C에서 같은 일이 반복된다. **느림이 호출 체인을 거슬러 올라가며 증폭**되어, 결국 B 하나의 문제가 mesh 전체를 마비시킨다.
```text
B 느려짐
→ A의 요청이 응답 못 받고 쌓임 (thread/conn/mem 점유)
→ A 자원 고갈 → A도 느려짐
→ C의 요청이 A를 기다리며 쌓임
→ ... 체인 전체로 전파 (cascading failure)
```
핵심 통찰은 **"느린 실패가 빠른 실패보다 나쁘다"** 는 것이다. B가 즉시 거절(fast fail)했다면 A는 자원을 즉시 회수하고 fallback·degraded mode로 전환할 수 있다. 하지만 무한정 기다리면(slow fail) 자원이 묶인 채 장애가 번진다. 회로 차단의 본질은 **느린 실패를 빠른 실패로 바꿔 장애 전파의 사슬을 끊는 것**이다.
여기에 두 번째 문제가 겹친다. upstream이 여러 host(Pod)로 이뤄질 때, 그중 **일부만 죽는** 경우다. LB는 죽은 host로도 계속 트래픽을 보내고, 그 요청들은 매번 실패한다 — 정상 host가 멀쩡한데도 서비스 에러율이 올라간다. 죽은 host를 **자동으로 솎아내는** 장치가 따로 필요하다.
Istio는 이 두 문제를 **두 개의 다른 메커니즘**으로 푼다. 사람들이 "circuit breaker 켜자"고 뭉뚱그리는 그 한 단어 뒤에는, 성격이 전혀 다른 두 방어선이 있다.
## 02. 핵심 아키텍처 — 방향이 다른 두 방어선 (멘탈모델 anchor)
> [!key] 한 문장 멘탈모델
> 머릿속에 잡을 그림은 **방향의 차이**다. connection pool은 *내가 upstream을 얼마나 밀어붙이는가*(나가는 트래픽의 **양**을 자름 → fail fast), outlier detection은 *upstream의 어느 host가 나쁜가*(받는 쪽 **host**를 솎음 → passive health check). 전자는 traffic shaping, 후자는 런타임 host 판별이다. 이 둘을 한 덩어리로 묶으면 "한도를 걸었는데 왜 endpoint가 안 빠지지?" 같은 혼선이 생긴다.
이 anchor 하나에서 나머지가 다 따라 나온다. 01절의 두 문제 — *과부하로 인한 자원 고갈*과 *일부 host의 죽음* — 가 정확히 이 두 메커니즘에 대응한다.
```mermaid
flowchart LR
DR["DestinationRule
trafficPolicy"]
DR --> CP["connectionPool
(동시성/pending/retry 한도)"]
DR --> OD["outlierDetection
(에러 endpoint 격리)"]
CP -->|컴파일| CB["Envoy cluster
circuit_breakers.thresholds"]
OD -->|컴파일| ODT["Envoy cluster
outlier_detection"]
CB --> OV["한도 초과 → 즉시 503 UO
(빠른 실패 = fail fast)"]
ODT --> EJ["연속 에러 endpoint
→ pool에서 eject"]
```
여기서 먼저 잡아야 할 구조적 사실: **`CircuitBreaker`라는 CRD는 없다.** 두 메커니즘 모두 **DestinationRule의 `trafficPolicy`** 안에 필드로 들어가고, istiod가 그것을 xDS로 각 sidecar의 Envoy cluster 설정으로 **컴파일**한다. `connectionPool`은 cluster의 `circuit_breakers.thresholds`로, `outlierDetection`은 cluster의 `outlier_detection`으로 간다. 즉 "회로 차단을 켠다"는 건 **목적지(host)를 가리키는 DestinationRule을 쓰는 일**이지 별도 리소스를 만드는 일이 아니다.
두 메커니즘은 같은 cluster 안에 공존하지만 **독립적으로 동작**한다. connectionPool 한도에 걸려도 outlier detection 판정과 무관하고, 그 반대도 마찬가지다. 보통 둘을 같은 DestinationRule에 같이 둔다 — 하나는 양을, 하나는 host를 막으므로 상호 보완적이다.
| 축 | connection pool (`connectionPool`) | outlier detection (`outlierDetection`) |
|---|---|---|
| 답하는 질문 | "이 upstream으로 동시에 **얼마나** 보낼까?" | "이 upstream의 **어느 host**가 나쁜가?" |
| 보호 대상 | **upstream 전체** — 과부하로 무너지지 않게 | **개별 endpoint** — 죽은 host로 계속 보내지 않게 |
| 트리거 | 현재 동시성/pending/retry가 한도 초과 | 특정 host가 연속으로 5xx/connect-fail |
| 효과 | 새 요청을 **즉시 거절**(503 `UO`) | 그 host를 LB pool에서 **일정 시간 제외** |
| Envoy 매핑 | `circuit_breakers.thresholds` | `outlier_detection` |
| 성격 | traffic shaping (fail fast) | passive health check |
| 비유 (한계) | 입구의 인원 제한 — 단, 사람이 아니라 *동시 in-flight 요청* 수다 | 고장난 계산대에 손님 안 보냄 — 단, 능동 점검이 아니라 *실패한 손님으로 판정* |
### 2-1. connection pool — fail fast가 핵심 가치
connection pool 한도의 목적은 01절에서 본 그대로 **느린 실패를 빠른 실패로 바꾸는 것**이다. 한도가 없으면 upstream이 느려질 때 요청이 무한정 쌓이고(queue buildup), 클라이언트 쪽 thread·메모리가 고갈되어 연쇄 장애가 된다. 한도를 걸면 "쌓이기 전에 잘라내" 클라이언트가 빠르게 다른 경로(retry budget, fallback, degraded mode)로 전환한다.
핵심 키 3종의 의미 — **각 필드가 무슨 질문에 답하는지**로 보면 외우지 않아도 된다:
- **`tcp.maxConnections`** — "host당 TCP connection을 몇 개까지?" destination host당 최대 connection 수. **단, HTTP/2에서는 하나의 connection이 다수 request를 multiplex**하므로 사실상 1 connection으로 충분하고, 이때 실질 동시성 상한은 `maxConnections`가 아니라 `http2MaxRequests`다. HTTP/1.1이라야 connection 수 ≈ 동시성이다. (이 함정을 놓치면 "maxConnections를 1로 줄였는데 왜 동시 요청이 안 막히지?"가 된다 — gRPC/HTTP2였던 것.)
- **`http.http1MaxPendingRequests`** — "ready connection이 없을 때 몇 개까지 줄 세울까?" 대기열(pending queue) 상한. 이걸 넘으면 더 기다리지 않고 즉시 거절한다. fail fast의 직접 제어 손잡이.
- **`http.maxRequestsPerConnection`** — "connection 하나를 몇 요청까지 재사용할까?" `1`이면 keep-alive를 사실상 끄는 것(매 요청마다 새 connection). 한도라기보다 connection 재사용 정책이다.
추가로 `http.http2MaxRequests`(destination으로의 active request 최대치)와 `http.maxRetries`(cluster 전체 outstanding retry 상한)도 같은 `circuit_breakers.thresholds`로 간다. **각 필드가 Envoy cluster의 어느 칸으로 컴파일되는지, 어떤 필드는 `circuit_breakers`가 아니라 `common_http_protocol_options`로 가는지**의 전체 표는 [Envoy Cluster 해부 §5.2](xds__src-cluster-anatomy.html)에 있다 — 여기서 반복하지 않는다.
### 2-2. outlier detection — 트래픽 자체가 신호인 passive health check
connection pool은 upstream을 하나의 덩어리로 보고 *양*을 자를 뿐, 그 안의 어느 host가 죽었는지는 모른다. 그 빈자리를 메우는 게 outlier detection이다. 01절의 두 번째 문제 — *일부 host만 죽는 경우* — 가 여기서 해결된다.
결정적 성격: outlier detection은 **요청을 실제로 보내본 결과로 host의 건강을 판정**하는 passive health check다. 별도 probe를 쏘는 active health check가 **아니다** — 흐르는 트래픽 자체가 건강 신호다. 그래서 "트래픽이 없으면 판정도 없다"는 비자명한 함정이 따라온다(05절·What you might be missing).
격리 사이클은 **"빼봤다가 → 시간이 지나면 다시 넣어보고 → 또 나쁘면 더 오래 뺀다"** 는 backoff 구조다. 이 반복이 host의 자동 회복을 가능하게 한다.
```mermaid
flowchart TD
H["host: 정상
(in pool)"] -->|consecutive5xx 임계 도달| EJ1["1차 eject
baseEjectionTime"]
EJ1 -->|시간 경과| BACK["pool 복귀
(probe 트래픽)"]
BACK -->|또 임계 도달| EJ2["2차 eject
baseEjectionTime x ejection 횟수"]
EJ2 -->|계속 나쁘면| EJN["N차 eject
점점 길어짐"]
EJN -->|정상 회복| H
```
평가 주기와 핵심 동작 — 역시 **무엇을 답하는 손잡이인지**로:
- **`interval`** — "얼마나 자주 평가할까?" 매 `interval`마다 Envoy가 각 host의 에러 누적을 평가한다.
- **`consecutive5xxErrors` / `consecutiveGatewayErrors`** — "연속 몇 번 실패하면 뺄까?" **둘은 집계 대상이 다르다**: `consecutiveGatewayErrors`는 HTTP **502/503/504 응답만** 세고 TCP connect failure는 안 센다. connect 단계 실패로 빼려면 `consecutiveLocalOriginFailures`(+`splitExternalLocalOriginErrors: true`)가 필요하다. (분류 상세 → [Envoy Cluster 해부 §5.4](xds__src-cluster-anatomy.html))
- **`baseEjectionTime`** — "최소 얼마나 격리할까?" **재격리 시 배수로 증가**한다 — 같은 host가 또 나빠지면 격리 시간이 `baseEjectionTime × 그 host의 누적 eject 횟수`로 늘어난다. 만성적으로 나쁜 host를 점점 오래 빼두는 backoff.
- **`maxEjectionPercent`** — "전체의 몇 %까지 뺄까?" 격리 비율 상한.
- **`minHealthPercent`** — "healthy가 이 밑이면 어떻게?" 이 밑으로 떨어지면 outlier detection을 **꺼버린다**(panic mode 유사) — 다 빼버리면 갈 곳이 없으므로.
## 03. 적용·검증 — 한도를 걸고, UO/stat로 발동을 확인한다
### 3-1. 두 메커니즘을 한 DestinationRule에 — 전체 YAML
회로 차단은 목적지를 가리키는 DestinationRule 하나로 켠다. 아래는 두 방어선을 함께 건 완전한 파일이다(apply 그대로).
```yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: llm-server
namespace: mesh-demo
spec:
host: llm-server.mesh-demo.svc.cluster.local # 이 host로 가는 cluster에 정책 부착
trafficPolicy:
connectionPool: # 방어선 ① 양 자르기 (fail fast)
tcp:
maxConnections: 100 # HTTP/1.1 기준 host당 conn; HTTP/2면 사실상 1
connectTimeout: 300ms # (참고: circuit_breakers 아님 → cluster.connect_timeout)
http:
http1MaxPendingRequests: 1000 # 대기열 상한; 넘으면 즉시 503 UO
http2MaxRequests: 10000 # HTTP/2 실질 동시성 상한
maxRetries: 2 # cluster 전체 동시 retry 상한
outlierDetection: # 방어선 ② 나쁜 host 솎기 (passive HC)
consecutive5xxErrors: 7 # 연속 5xx 7번이면 eject
consecutiveGatewayErrors: 5 # 502/503/504 연속 5번 (connect fail은 미집계)
interval: 30s # 30초마다 평가
baseEjectionTime: 5m # 최초 5분, 재격리마다 배수 증가
maxEjectionPercent: 50 # 최대 절반까지만 격리
minHealthPercent: 50 # healthy 50% 미만이면 OD 비활성
```
**정렬 지도** — 같은 magic string이 어디서 어디로 매칭되는지:
```text
DestinationRule spec.host == Envoy cluster 이름의 fqdn 부분
llm-server.mesh-demo.svc.cluster.local
└─→ cluster outbound|80||llm-server.mesh-demo.svc.cluster.local
(direction|port|subset|fqdn 규칙; subset 비면 빈칸)
connectionPool.* == cluster.circuit_breakers.thresholds (한도 4종) + 일부는 다른 칸
outlierDetection.* == cluster.outlier_detection
```
### 3-2. 한도 발동의 관측 — UO와 overflow stat
한도에 걸려 거절된 요청은 **장애가 아니라 정책 발동**이다. 이걸 식별하는 신호가 두 층위에 있다.
**access log response flag** 층위에서는 `UO`(`UpstreamOverflow`)가 찍힌다. `UO`의 의미는 "upstream이 죽었다"가 아니라 **"Envoy가 정한 동시성/connection/pending/retry 한도를 넘어서 Envoy가 더 보내지 않았다"** 이다. `503 UO`를 보면 app/DB 상태가 아니라 **DestinationRule `connectionPool`(특히 pending·http2MaxRequests·maxRetries)** 부터 의심한다. (flag 전체 의미·UC/UF 등은 [Envoy response flag](xds__src-envoy-response-flags.html))
**Envoy stat** 층위에서는 어느 한도에 걸렸는지가 cluster별 counter로 드러난다:
| stat | 어느 한도 초과인가 |
|---|---|
| `cluster..upstream_cx_overflow` | `maxConnections` 초과 (connection 정원) |
| `cluster..upstream_rq_pending_overflow` | `http1MaxPendingRequests` 초과 (대기열 정원) |
| `cluster..upstream_rq_pending_failure_eject` | pending 중 endpoint eject로 실패 |
| `cluster..circuit_breakers..cx_open` (gauge `1`) | connection circuit breaker가 현재 open 상태 |
| `cluster..circuit_breakers..rq_pending_open` | pending circuit breaker open |
확인 명령과 기대 출력:
```bash
# sidecar의 cluster별 circuit breaking stat 조회 (admin은 localhost:15000)
istioctl proxy-config bootstrap -n mesh-demo >/dev/null # 접근 가능 확인
kubectl exec -n mesh-demo -c istio-proxy -- \
pilot-agent request GET 'stats?filter=upstream_(cx|rq_pending)_overflow' \
| grep llm-server
```
기대 출력 (한도에 부딪혔다면 0이 아닌 누적값):
```text
cluster.outbound|80||llm-server.mesh-demo.svc.cluster.local.upstream_cx_overflow: 142
cluster.outbound|80||llm-server.mesh-demo.svc.cluster.local.upstream_rq_pending_overflow: 5031
```
overflow counter가 증가 중이면 한도가 실제로 트래픽을 자르고 있다는 직접 증거다. admin API로 데이터 플레인 실제 stat을 읽는 일반 절차는 [Envoy admin API 진단](xds__note-envoy-admin-api-diagnosis.html) 참조.
## 04. 두 메커니즘이 만나는 실패 모드 — UO vs UH
운영에서 가장 헷갈리는 지점은 **두 메커니즘이 만들어내는 503의 의미가 다르다**는 것이다. 02절의 anchor(방향의 차이)가 그대로 두 가지 503으로 나타난다 — 하나는 양을 잘랐을 때, 하나는 host가 다 빠졌을 때.
```text
503 UO (UpstreamOverflow)
= connectionPool 한도 초과로 Envoy가 거절
= "보낼 수는 있는데 정원이 찼다" → connectionPool/retry 폭증을 본다
503 UH (NoHealthyUpstream)
= LB pool에 healthy endpoint가 하나도 없다
= outlierDetection이 너무 많이 eject했거나, 실제로 다 죽었거나
→ maxEjectionPercent, 실제 backend 상태를 본다
```
이 둘이 **상호작용해 사고를 키울 수 있다.** endpoint가 적은 서비스(예: Pod 2개)에 `maxEjectionPercent`를 높게 잡으면, 일시적 5xx 한 번에 정상 endpoint까지 빠져 `UH`가 난다. 그 사이 들어온 요청은 갈 곳이 없어 다시 5xx → 남은 host도 임계 도달 → 더 빠짐, 즉 **outlier detection이 스스로 장애를 증폭**시킨다. `minHealthPercent`로 하한 안전장치를 두는 이유다. (maxEjectionPercent ↔ UH 함정의 운영 detail → [Envoy Cluster 해부 §5.4](xds__src-cluster-anatomy.html))
retry와의 상호작용도 주의해야 한다. upstream이 느릴 때 blanket retry를 켜두면, retry가 connection pool 동시성을 더 밀어붙여 `maxRetries`/`http2MaxRequests` 한도에 더 빨리 부딪히고(`UO` 폭증), upstream이 GPU queue 같은 자원이면 retry가 큐를 더 채워 **tail latency를 악화**시킨다. 한도와 retry budget은 짝으로 설계해야 한다.
## 핵심 정리
```text
circuit breaking = connectionPool(과부하 차단) + outlierDetection(나쁜 endpoint 격리)
→ CircuitBreaker CRD는 없다. 둘 다 DestinationRule trafficPolicy → Envoy cluster로 컴파일.
→ 보호 대상: cascading failure(느림→자원고갈→체인 전파)와 일부 host 사망.
방향 anchor:
connectionPool = 내가 upstream을 얼마나 미는가 (양 자름, fail fast = traffic shaping)
outlierDetection = upstream의 어느 host가 나쁜가 (host 솎음, passive health check)
connectionPool (fail fast):
maxConnections → host당 conn 상한 (HTTP/2면 사실상 1, 실질 상한은 http2MaxRequests)
http1MaxPendingRequests → 대기열 상한 (넘으면 즉시 503 UO)
maxRequestsPerConnection→ conn당 request 수 (1이면 keep-alive off)
maxRetries → cluster 전체 동시 retry 상한
초과 신호: access log UO, stat upstream_cx_overflow / upstream_rq_pending_overflow
outlierDetection (passive health check):
consecutive5xx / consecutiveGatewayErrors(502/503/504만) / consecutiveLocalOriginFailures(connect)
interval(평가 주기), baseEjectionTime(재격리마다 배수 증가)
maxEjectionPercent(상한), minHealthPercent(이하면 OD 끔)
503 의미 구분:
UO = connectionPool 한도 (정책 발동, 장애 아님)
UH = healthy endpoint 0 (과한 eject 또는 실제 다 죽음)
```
## What you might be missing
- **`UO`는 장애가 아니라 정책이 정상 동작한 결과다.** `503 UO`를 보고 app/DB를 디버깅하기 시작하면 시간을 버린다. 먼저 `upstream_cx_overflow`/`upstream_rq_pending_overflow` stat과 DestinationRule `connectionPool`을 본다. 한도가 너무 빡빡한 것이지 backend가 죽은 게 아닐 수 있다.
- **HTTP/2·gRPC에서 `maxConnections`는 동시성 한도가 아니다.** multiplexing 때문에 connection 하나가 수천 request를 나르므로, `maxConnections`를 줄여도 동시성이 안 막힌다. gRPC/HTTP2 cluster의 실질 동시성 손잡이는 `http2MaxRequests`다. 이걸 모르면 한도를 걸어도 효과가 없어 보인다.
- **outlier detection은 active health check가 아니다.** 트래픽이 없으면 판정할 신호도 없다 — 호출이 뜸한 endpoint는 죽어 있어도 eject되지 않고, 트래픽이 들어오는 순간 실패한다. Kubernetes readiness probe(active)와 outlier detection(passive)은 보완 관계지 대체 관계가 아니다.
- **`consecutiveGatewayErrors`는 connect failure를 안 센다.** backend가 connection refused/timeout으로 실패하는 상황(흔한 장애)에서 `consecutiveGatewayErrors`만 걸어두면 endpoint가 영원히 안 빠진다. connect 단계 격리는 `consecutiveLocalOriginFailures` + `splitExternalLocalOriginErrors: true`가 필요하다.
- **endpoint가 적은 서비스에서 outlier detection은 양날의 검이다.** Pod 2~3개 서비스에 공격적 `maxEjectionPercent`를 걸면 일시 에러에 정상 host까지 빠져 `UH` → 남은 host 과부하 → 추가 eject로 장애를 스스로 키운다. `minHealthPercent` 하한과 보수적 `maxEjectionPercent`가 필수다.
- **필드별 Envoy 컴파일 위치는 직관과 다를 수 있다.** `connectionPool`의 모든 필드가 `circuit_breakers`로 가지 않는다 — `connectTimeout`·`tcpKeepalive`·`idleTimeout`·`maxRequestsPerConnection`은 cluster의 다른 칸(`connect_timeout`, `common_http_protocol_options` 등)으로 간다. 한도 4종만 `circuit_breakers.thresholds`다. 전체 매핑 표는 [Envoy Cluster 해부](xds__src-cluster-anatomy.html)에 있다.