gRPC + Service Mesh 환경에서 만난 세 가지 Long-lived Connection 문제
gRPC와 service mesh를 함께 쓰는 시스템을 운영하다 보면, 얼핏 서로 관계없어 보이는 문제들이 비슷한 시기에 튀어나올 때가 있습니다.
- rollout 중 간헐적인
UNAVAILABLE에러 - 일부 Pod에만 트래픽이 몰리는 connection skew
- fan-out 요청에서 비정상적으로 높아지는 지연 시간
처음에는 각각 따로 해결해야 하는 문제처럼 보였습니다. 하나는 배포 문제, 하나는 load balancing 문제, 또 하나는 성능 문제였기 때문입니다.
그런데 실험을 진행할수록 세 문제를 하나로 묶는 공통 배경이 보이기 시작했습니다. 핵심은 long-lived connection이었습니다. gRPC의 HTTP/2 연결은 오래 살아 있고, service mesh는 그 연결 위에서 endpoint를 선택하고 유지합니다. 이 특성이 rollout, 분산, 성능에 연쇄적으로 영향을 줬습니다.
핵심 요약
| 항목 | 내용 |
|---|---|
| 공통 원인 | 세 문제 모두 long-lived connection + service mesh의 endpoint 선택 방식에서 비롯됨 |
| 확정 사항 | shutdown timing 설계, locality-aware distribution 조정, fan-out reduction이 유효했음 |
| 확인 필요 | 프로덕션 환경 동일 테스트, 재연결 시나리오 확대 검증 |
문제-원인-해결 한눈에 보기
flowchart TD
ROOT["long-lived connection<br/>(gRPC HTTP/2)"]
ROOT --> P1["문제 1<br/>rollout 중 UNAVAILABLE"]
ROOT --> P2["문제 2<br/>connection skew"]
ROOT --> P3["문제 3<br/>fan-out 지연"]
P1 --> F1["해결: shutdown timing 설계"]
P2 --> F2["해결: endpoint selection 우선 조정"]
P3 --> F3["해결: 호출 형태 재설계<br/>(batch API)"]
style ROOT fill:#5f2d2d,stroke:#ff6b6b,color:#fff
style F1 fill:#2d5f2d,stroke:#4a9eff,color:#fff
style F2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
style F3 fill:#2d5f2d,stroke:#4a9eff,color:#fff
배경: HTTP/1.1 vs gRPC (HTTP/2) 핵심 차이
| 특성 | HTTP/1.1 | gRPC (HTTP/2) |
|---|---|---|
| 연결 방식 | 요청마다 새 연결 또는 짧은 keep-alive | 하나의 연결에 모든 요청을 멀티플렉싱 |
| 연결 수명 | 짧음 (수 초 ~ 수십 초) | 긺 (수십 분 ~ 수 시간) |
| LB 재분배 기회 | 연결을 맺을 때마다 다른 Pod 선택 가능 | 연결 생성 시 한 번만 endpoint 결정 |
| 연결당 동시 요청 | 1 (또는 파이프라이닝) | 수백~수천 스트림 동시 가능 |
flowchart LR
subgraph "HTTP/1.1"
direction TB
H1C1[연결 1] --> H1P1[Pod A]
H1C2[연결 2] --> H1P2[Pod B]
H1C3[연결 3] --> H1P1b[Pod A]
end
subgraph "gRPC (HTTP/2)"
direction TB
G2C1["연결 1 (장기)"] --> G2P1["Pod A<br/>100% 고착"]
G2S1[stream 1] --> G2C1
G2S2[stream 2] --> G2C1
G2S3[stream 3] --> G2C1
end
style G2P1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
문제 1: rollout은 성공했지만 요청은 불안정했다
현상
애플리케이션은 graceful shutdown을 하도록 설정했는데도, rollout 시점에 gRPC UNAVAILABLE가 간헐적으로 발생했습니다. 애플리케이션 로그만 보면 종료가 천천히 진행되는 것처럼 보였지만, 실제 네트워크 경로에서는 더 빨리 무언가가 사라지고 있었습니다.
원인
이 문제를 이해하려면 애플리케이션만 보면 안 됩니다. service mesh sidecar까지 포함한 종료 순서를 같이 봐야 합니다.
- app보다 sidecar가 먼저 연결 정리를 끝내는 경우
- endpoint 제거가 전파되기 전에 종료 중인 Pod로 트래픽이 한 번 더 들어오는 경우
- 에러가 난 endpoint를 mesh가 바로 격리하지 못하는 경우
즉, 애플리케이션 하나의 graceful shutdown만으로는 충분하지 않았습니다. Kubernetes lifecycle, service registry 반영 타이밍, sidecar drain, client retry가 함께 맞물려야 했습니다.
실측: Fix 적용 전후 비교
Baseline (Fix 적용 전)
| 항목 | Test v1 (0.5초 간격) | Test v2 (0.2초 간격) |
|---|---|---|
| 총 요청 | 233건 | 600건 |
| 성공 (HTTP 200) | 233건 (100%) | 600건 (100%) |
| 실패 | 0건 | 0건 |
| 느린 요청 (>1s) | 0건 | 1건 (1.41s) |
| UNAVAILABLE 로그 | 0건 | 0건 |
분석: gRPC client retry(maxAttempts=3)가 Envoy 레벨 에러를 마스킹하고 있었습니다. 1건의 느린 요청(1.41s)은 retry가 실제로 발생했음을 시사합니다 (정상 응답 ~0.03s 대비).
Fix 후 단일 서비스 배포
| 항목 | 결과 |
|---|---|
| 총 요청 | 400건 |
| 성공 (HTTP 200) | 400건 (100%) |
| 느린 요청 (>1s) | 1건 (1.29s) |
| UNAVAILABLE 로그 | 0건 |
Fix 전후 비교
| 메트릭 | Fix 전 | Fix 후 | 변화 |
|---|---|---|---|
| 성공률 | 100% | 100% | 동일 |
| 느린 요청 최대 응답시간 | 1.41s | 1.29s | -0.12s 개선 |
Multi-hop gRPC Chain 동시 배포 검증
시나리오 A: 2개 서비스 동시 배포
| 항목 | 단일 hop | Multi-hop |
|---|---|---|
| 총 요청 | 500건 | 500건 |
| 성공 | 500건 (100%) | 500건 (100%) |
| 느린 요청 (>1s) | 0건 | 0건 |
시나리오 B: Multi-hop chain 양쪽 동시 배포
| 항목 | Multi-hop |
|---|---|
| 총 요청 | 47건 |
| 성공 | 47건 (100%) |
| 느린 요청 (>1s) | 1건 (2.85s) |
| UNAVAILABLE 로그 | 0건 |
분석: Multi-hop chain의 양쪽 끝을 동시 재배포해도 에러 0건. 시나리오 A에서는 느린 요청도 0건으로, Fix 후 Envoy drain이 정상 작동하여 retry 자체가 불필요해졌습니다.
해결: 종료 타이밍 시간관계 설계
종료 관련 설정은 독립 값이 아니라 시간 관계로 봐야 합니다.
gantt
title Pod 종료 시퀀스 (정상 설계)
dateFormat X
axisFormat %s초
section Kubernetes
SIGTERM 수신 :done, k1, 0, 0
terminationGracePeriod :active, k2, 0, 180
section Sidecar
drain 시작 :done, s1, 0, 0
활성 연결 0 시 즉시 종료 :s2, 0, 30
drain 대기 (최대 30s) :active, s3, 0, 30
section App
preStop sleep :done, a1, 0, 20
Spring graceful shutdown :active, a2, 20, 100
gRPC server shutdown :active, a3, 20, 95
핵심 부등식:
preStop(20s) < sidecar drain(30s) < app shutdown(80s) < terminationGracePeriod(180s)
| 설정 | 값 | 역할 | 어긋나면 |
|---|---|---|---|
preStop sleep |
20s | K8s Endpoints에서 제거될 시간 확보 | 짧으면 트래픽이 종료 중인 Pod으로 유입 |
terminationDrainDuration |
30s | Envoy sidecar가 기존 연결 정리 | preStop보다 짧으면 sidecar가 먼저 죽음 |
EXIT_ON_ZERO_ACTIVE_CONNECTIONS |
true | 활성 연결 없으면 즉시 종료 | false면 drain 시간을 항상 풀로 대기 |
SPRING_LIFECYCLE_TIMEOUT |
80s | Spring이 진행 중인 요청 완료 대기 | 짧으면 처리 중인 요청 강제 중단 |
terminationGracePeriodSeconds |
180s | K8s 강제 종료 안전망 | 짧으면 graceful shutdown 전에 SIGKILL |
영향 범위
| 영역 | 영향 | 비고 |
|---|---|---|
| 배포 프로세스 | rollout 중 오류 제거 | sidecar drain 연장 + outlier detection |
| 애플리케이션 | preStop, shutdown timeout 조정 필요 | Spring lifecycle, gRPC shutdown period |
| 운영 | probe 설정 재점검 필요 | startup/readiness/liveness 분리 설계 |
| QA | 재배포 시나리오 테스트 필요 | 단일/다중 서비스 동시 배포 검증 |
문제 2: 연결을 끊어도 트래픽은 다시 같은 Pod로 향했다
현상
요청 분포를 확인해 보니 일부 상황에서 트래픽이 특정 Pod에 거의 고정되었습니다.
초기 시도와 한계
처음에는 gRPC의 장기 연결 특성 때문이라고 생각했습니다. 그래서 가장 먼저 시도한 것은 연결 rotation이었습니다.
실험 1: Baseline (설정 없음, 2-Pod)
| 시나리오 | Total CV | 판정 | 비고 |
|---|---|---|---|
| Steady-state (부하만) | 0.02 | BALANCED | 2-Pod에서는 LEAST_REQUEST가 양쪽에 균등 분배 |
| Scale-out (2→4) | 1.09 | SKEWED | 기존 연결이 유지되는 동안 신규 Pod로 트래픽이 가지 않음 |
| Rolling Restart | 1.00 | SKEWED | 새 연결이 단일 Pod에만 맺어짐 → 100% 고착 |
| 반복 Restart (x2) | 0.73 | SKEWED | 여전히 편중 |
gantt
title Rolling Restart 시계열 (Baseline, 2-Pod)
dateFormat X
axisFormat %s초
section Pod-1 (기존)
트래픽 100% 유지 :done, p1, 0, 60
종료 :milestone, p1e, 60, 60
section Pod-2 (신규)
대기 :active, p2, 0, 60
트래픽 100% 이동 :done, p2a, 60, 120
실험 2: maxRequestsPerConnection=1000
1000회 요청 후 연결을 재생성하여 장기 분산 개선을 시도했습니다.
| 시나리오 | 지표 | Exp 1 (무제한) | Exp 2 (1000) | 변화 |
|---|---|---|---|---|
| Rolling restart | Total CV | 1.00 | 0.25 | 대폭 개선 |
| 반복 restart | Total CV | 0.73 | 0.001 | 극적 개선 |
| Rolling restart | Time-series CV | 1.53 | 1.77 | 악화 |
시계열 (1000, Rolling restart):
시간 Pod1 Pod2 Pod3 Pod4 Pod5
+56s 27 25 0 0 0 ← 기존 Pod
+91s 0 0 50 0 0 ← restart 후 → Pod A
+170s 0 0 0 0 52 ← 1000req rotation → Pod B
+260s 0 52 0 0 0 ← rotation → 다른 Pod
해석: 장기적 분산은 개선되었으나, 각 시점에서는 여전히 단일 Pod에 집중합니다.
실험 3: maxRequestsPerConnection=50 (2-Pod)
50회 요청(~10초)마다 rotation하여 순간 분산도 개선을 시도했습니다.
| 지표 | Exp 1 (무제한) | Exp 2 (1000) | Exp 3 (50) |
|---|---|---|---|
| Total CV | 1.00 | 0.25 | 0.10 |
| Time-series CV | 1.53 | 1.77 | 1.02 |
| cx_total/endpoint | 1 | 1~2 | 8~9 |
| 에러 | 0 | 0 | 0 |
시계열 (50, Rolling restart):
시간 Pod1(기존) Pod2(기존) Pod3(신규) Pod4(신규)
+11s 24 24 0 0 ← 기존 2개에 균등
+45s 31 18 0 0 ← rotation마다 전환
+113s 0 29 0 21 ← 과도기: 기존1 + 신규1
+158s 0 0 30 19 ← 신규 2개에 즉시 균등
+294s 0 0 23 28 ← 끝까지 균등 유지
해석: 2-Pod에서는 rotation 빈도 증가로 분산이 개선됩니다.
실험 4: 전환점 — 4-Pod에서 가설 기각
| 시도 | 방법 | Pod 수 | CV | 판정 |
|---|---|---|---|---|
| maxRequestsPerConn=50 | 50회마다 rotation | 4 | 0.92 | SKEWED |
4-Pod 실측 데이터:
| Endpoint | Zone | rq_total | cx_total | 비율 |
|---|---|---|---|---|
| Pod-1 | AZ-b | 1,348 | 27 | 100% |
| Pod-2 | AZ-d | 0 | 0 | 0% |
| Pod-3 | AZ-a | 0 | 0 | 0% |
| Pod-4 | AZ-c | 0 | 0 | 0% |
27번 연결을 끊고 다시 맺었지만, 매번 동일한 Pod를 선택했습니다.
근본 원인: Locality-Aware Load Balancing
flowchart TD
subgraph "문제: Locality-Aware LB"
direction TB
CL1[Client] -->|"priority 0"| P1["Pod 1<br/>(동일 AZ)"]
CL1 -.->|"priority 1<br/>(사용 안 함)"| P2[Pod 2]
CL1 -.->|"priority 1"| P3[Pod 3]
CL1 -.->|"priority 1"| P4[Pod 4]
end
subgraph "해결: distribute 설정"
direction TB
CL2[Client] -->|"25%"| PP1["Pod 1<br/>(AZ-a)"]
CL2 -->|"25%"| PP2["Pod 2<br/>(AZ-b)"]
CL2 -->|"25%"| PP3["Pod 3<br/>(AZ-c)"]
CL2 -->|"25%"| PP4["Pod 4<br/>(AZ-d)"]
end
style P1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
style PP1 fill:#2d5f2d,stroke:#4a9eff,color:#fff
style PP2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
style PP3 fill:#2d5f2d,stroke:#4a9eff,color:#fff
style PP4 fill:#2d5f2d,stroke:#4a9eff,color:#fff
문제는 연결이 오래 살아서만이 아니라, 재연결할 때도 같은 locality 집합으로 돌아간다는 점이었습니다. 연결을 끊는 것 자체는 재분배를 보장하지 않습니다.
해결: endpoint selection을 먼저 조정
적용 순서가 중요합니다. 순서를 바꾸면 안 됩니다.
- endpoint selection 정책 확인 — locality/priority 편향 확인
- locality-aware distribution 명시적 조정 — cross-zone 균등 분배
- connection rotation은 보조 수단 — 위 두 단계가 맞을 때만 의미 있음
| 검증 | 설정 | Pod 수 | CV | 판정 |
|---|---|---|---|---|
| distribute 적용 | 4개 AZ 균등 25% | 4 | 0.002 | BALANCED |
| 최종 검증 | 동일 설정 | 8 | 0.071 | BALANCED |
영향 범위
| 영역 | 영향 | 비고 |
|---|---|---|
| 네트워크 트래픽 분산 | 트래픽 편중 대폭 개선 | CV 0.92 → 0.002 |
| DestinationRule 설정 | localityLbSetting.distribute 추가 필요 | 기존 설정에 영향 없음 |
| 운영 | Envoy /clusters 메트릭 모니터링 필요 | rq_total 분포 정기 확인 |
문제 3: 오류는 없었지만 fan-out 요청의 지연은 컸다
현상
특정 API는 내부적으로 downstream gRPC를 매우 많이 fan-out하고 있었습니다. 기능적으로는 정상이고 오류도 없었지만, 지연 시간은 예상보다 컸습니다.
원인
장기 연결 하나 위에 많은 stream이 몰리면 packet density가 높아지고, service mesh proxy의 event loop가 그 부하를 직렬화된 형태로 받는 순간이 생깁니다.
flowchart LR
subgraph "Before: 1000회 fan-out"
GW1[Gateway] -->|"1000회 호출"| PR1[Proxy]
PR1 -->|"단일 연결에<br/>stream 1000개"| SVC1[Downstream Service]
end
subgraph "After: 배치 API"
GW2[Gateway] -->|"1회 호출<br/>(batch)"| PR2[Proxy]
PR2 -->|"stream 1개"| SVC2[Downstream Service]
end
style PR1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
style PR2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
해결: 호출 형태 재설계
실측 요약
| 비교 항목 | 변경 전 | 변경 후 | 변화 |
|---|---|---|---|
| 요청 형태 | downstream fan-out 1000회 | batch API 1회 | 구조 단순화 |
| end-to-end latency | 기준값 100 | 16 | -84% |
| 병목 위치 | proxy event loop, packet density | 단일 batch 호출 중심 | 병목 축소 |
| 접근 | 방법 | 효과 |
|---|---|---|
| pool tuning | 연결 수, concurrent stream 조정 | 한계 있음 |
| batch API 전환 | 1000회 호출 → 1회 batch 호출 | end-to-end latency -84% |
fan-out 자체가 과하면, 가장 큰 이득은 더 많은 튜닝이 아니라 호출 수를 줄이는 방향에서 나옵니다.
영향 범위
| 영역 | 영향 | 비고 |
|---|---|---|
| API 설계 | fan-out 호출을 batch API로 재설계 필요 | 기존 API 유지 가능 (점진 전환) |
| 성능 | latency 대폭 개선 | -84% 확인 |
| downstream 서비스 | batch endpoint 추가 필요 | 기존 단건 API는 영향 없음 |
| QA | batch API 정합성 테스트 필요 | 기존 단건 호출 결과와 동일 확인 |
공통 원인: 세 문제를 하나로 묶는 관점
flowchart TD
CORE["long-lived connection<br/>(gRPC HTTP/2 특성)"]
CORE --> C1["연결은 오래 산다<br/>→ 재분배 기회 적음"]
CORE --> C2["endpoint 선택이<br/>연결 생성 시점에 결정됨"]
CORE --> C3["proxy는 단순 전달자가 아님<br/>→ drain, balancing, pool 동작이<br/>실제 행태를 바꿈"]
C1 --> P1["문제 1: rollout 중<br/>sidecar 종료 타이밍 충돌"]
C2 --> P2["문제 2: locality 편향이<br/>재연결에도 고착"]
C1 --> P3["문제 3: 단일 연결에<br/>stream 과밀집"]
C3 --> P1
C3 --> P2
C3 --> P3
style CORE fill:#5f2d2d,stroke:#ff6b6b,color:#fff
| 공통 특성 | 문제 1에 미친 영향 | 문제 2에 미친 영향 | 문제 3에 미친 영향 |
|---|---|---|---|
| 연결이 오래 삶 | sidecar가 먼저 죽어도 연결이 남아 에러 | 장기 연결이 locality 편중을 고착화 | 단일 연결에 stream이 과밀집 |
| endpoint 선택 시점 | drain 후 재연결 시점이 중요 | 최초 선택된 locality에서 벗어나지 못함 | 해당 없음 |
| proxy의 능동적 역할 | drain, outlier detection이 종료 행태 결정 | locality-aware balancing이 분포 결정 | event loop 직렬화가 병목 |
운영 체크리스트
Rollout
- app graceful shutdown만 보고 있지 않은가 — sidecar drain 시간도 함께 설계했는가
- sidecar drain 시간이 preStop보다 충분히 긴가
- readiness 제거와 endpoint 전파 시간을 고려했는가
- retry가 문제를 숨기고만 있지는 않은가 — retry 없이도 에러가 0인가
Load Distribution
- skew가 connection lifetime 문제인지, selection policy 문제인지 분리했는가
- locality-aware policy가 특정 집합에 과도한 우선순위를 주고 있지 않은가
- reconnect가 실제 분산으로 이어지는지 메트릭으로 확인했는가
Performance
- high fan-out 요청이 proxy에서 density 문제를 만들고 있지 않은가
- connection pool 튜닝 전에 호출 수 자체를 줄일 수 있는가
- batch API로 바꾸면 병목을 더 크게 줄일 수 있지 않은가
리스크 및 예외 케이스
| # | 리스크 | 영향 | 대응 방안 | 확인 방법 |
|---|---|---|---|---|
| 1 | 프로덕션에서 다른 네트워크 topology로 재현 가능 | 프로덕션 적용 전 검증 필요 | 프로덕션과 동일한 zone/Pod 구성에서 재테스트 | 프로덕션 Canary 배포 후 메트릭 비교 |
| 2 | Spring/gRPC 버전에 따른 graceful shutdown 동작 차이 | shutdown timeout 설정이 다르게 동작할 가능성 | 주요 버전별 shutdown 동작 단위 테스트 | 버전 업그레이드 시 회귀 테스트 |
| 3 | batch API 전환 시 downstream 서비스 부하 패턴 변화 | 순간 부하가 집중될 가능성 | batch 크기 제한, rate limiting 적용 | 부하 테스트로 p99 latency 확인 |
후속 액션
| # | 액션 | 담당자 | 완료 기준 | 우선순위 |
|---|---|---|---|---|
| 1 | 프로덕션 환경 동일 테스트 수행 | 미정 | rollout/skew/fan-out 3개 시나리오 프로덕션 검증 완료 | 높음 |
| 2 | QA 환경 반복 테스트 | 미정 | multi-hop chain 동시 배포 시나리오 통과 | 높음 |
| 3 | 종료 타이밍 설정값 문서화 | 미정 | 모든 서비스의 timeout/drain/probe 설정 단일 문서 정리 | 중간 |
| 4 | Envoy 메트릭 대시보드 구축 | 미정 | rq_total, cx_active, CV 값을 Grafana에서 실시간 확인 | 중간 |
| 5 | batch API 확대 적용 | 미정 | fan-out 패턴 조사 후 우선순위별 batch 전환 | 낮음 |
마무리
운영 문제는 대개 증상 단위로 관측됩니다. 배포 오류, skew, 성능 저하처럼 각각 다른 이름으로 드러나지만, 원인을 따라가 보면 같은 구조적 특성이 여러 형태로 나타나는 경우가 있습니다.
gRPC + service mesh 환경에서 이 문제들을 관통하는 공통 배경은 long-lived connection이었습니다.
이 관점을 얻고 나니 해결 순서도 달라졌습니다. rollout은 종료 타이밍의 문제로, skew는 endpoint selection의 문제로, fan-out 지연은 호출 구조의 문제로 다시 분리해서 볼 수 있었습니다. 그리고 각각의 설정값보다 먼저, 시스템이 연결을 어떻게 만들고 얼마나 오래 유지하며 어디로 보내는지를 이해하는 쪽이 훨씬 중요하다는 점을 배웠습니다.
비슷한 현상을 겪고 계시다면, 문제를 세 개로 나누기 전에 먼저 하나의 질문을 던져 보세요.
이 시스템의 long-lived connection은 지금 어디에서, 어떤 방식으로, 얼마나 오래 영향을 미치고 있는가?
대개 해답의 절반은 그 질문 안에 들어 있습니다.