본문으로 건너뛰기
istiod 스케일링과 xDS 커넥션 재분배

09 · istiod 스케일링 — 커넥션은 왜 새 파드로 옮겨가지 않는가

  • istiod 메모리는 커넥션 가 아니라 커넥션 수 × 클러스터 config 크기로 정해집니다. 커넥션 하나의 단가가 클러스터 규모를 따라 움직입니다.
  • xDS는 장수 gRPC 스트림입니다. 스케일아웃해도 이미 맺어진 커넥션은 새 파드로 옮겨가지 않고 Istio에 능동적 재분배 기능은 없습니다.
  • 공식이 쥐고 있는 재분배 수단은 keepaliveMaxServerConnectionAge 강제 종료 하나입니다. Google Cloud 공식 문서 역시 이 불균형을 인정한 뒤 레플리카 다중화 + 사전 스케일링만 권합니다.
  • 주기를 절반으로 줄이면 재연결 레이트는 2배가 됩니다(지터는 ±10%라 창을 넓혀줄 뿐입니다). 상쇄하려면 재연결 1건의 단가 — 곧 커넥션당 config 크기 — 를 깎아야 합니다.
  • pilot_xds(연결 수) 기반 오토스케일링은 공식 권장이 아닙니다. 공식 차트 HPA의 기본 지표는 CPU 80% 하나뿐입니다.
  • 실측(§7): 커넥션 분포가 가장 험한 순간과 CPU가 가장 험한 순간은 다릅니다. CoV는 파드가 죽을 때, CPU·push 지연은 커넥션 총량이 늘 때 튑니다. 재분배 지표만으로 CPU 부하를 추정하면 엉뚱한 손잡이를 잡습니다.
  • GOMAXPROCSlimits.cpu가 정합니다(§8). 차트가 Downward API로 주입하고 kubelet이 math.Ceil로 올림하니 소수점 CPU limit은 quota와 GOMAXPROCS가 항상 어긋납니다. 정수 코어로 걸 것.
  • 주범은 버스트지 슬라이스 좌초가 아닙니다(§7). 좌초는 표본의 6.5%에서만 나타나는 2차 요인이었습니다. GOMAXPROCS=1이어도 OS 스레드는 17개라 좌초 표면적은 그대로 떠안으면서 병렬성만 잃는 쪽이 더 큰 문제입니다.

그때 무슨 일이 있었나. 대규모 이벤트 중 istiod가 20분 사이에 8대 재시작됐습니다. 커넥션 수(pilot_xds) 기반 KEDA 스케일링이 이미 걸려 있었고 24대 → 38대 스케일아웃도 정상 동작한 상태였습니다. 원인은 두 겹이었습니다 — 커넥션 한 개의 무게가 클러스터 규모를 따라 변했고(0.66 → 1.95 MB/conn), 스케일아웃해도 커넥션이 재분배되지 않아 기존 파드가 246~294 conn을 혼자 떠안았습니다. 이 문서는 그 두 성질의 근거를 공식 문서·소스코드 수준까지 내려가 정리하고 손잡이별 트레이드오프를 표로 남깁니다.

관련 문서: 02 컨트롤 플레인 해부: istiod — push가 CPU를 먹는 메커니즘과 Sidecar 스코핑의 기본 · 06 메시가 공짜로 주는 관측성

이 사건을 이야기 흐름으로 읽으려면: istiod 스케일링, 커넥션 수만 세면 될 줄 알았다 — 1차 스케일링을 걸고 2차 이벤트에서 깨지기까지의 서사와 실측 차트. 이 문서는 그 밑에 깔린 근거와 손잡이별 트레이드오프를 레퍼런스로 폅니다.

1. 커넥션 하나의 무게는 고정이 아니다

istiod는 커넥션마다 “그 proxy에게 줄 클러스터 전체의 뷰"를 계산해 들고 있습니다. 부하가 커넥션 수만으로 결정되지 않는 이유가 여기입니다.

istiod 메모리 ∝ 커넥션 수 × 클러스터 config 크기(endpoints)

실측으로 확인한 값입니다. 같은 “커넥션 100개"를 두고도 이벤트 전후로 단가가 3배 벌어졌습니다.

시점총 커넥션클러스터 endpoints커넥션당 비용
평시~1,000~1,7000.66 MB/conn
이벤트 피크~3,600~5,4001.95 MB/conn

일자별 회귀로 뽑은 모델과 검증:

istiod 파드 메모리 ≈ 240MB(base) + 400B × (파드당 커넥션 수 × 클러스터 총 endpoints)
  • 피크 당일 파드별 단면 상관계수 r = 0.962
  • 한 달치 후보 메트릭 25개 상관 스윕 결과, istiod 메모리와 같이 움직인 외부 지표는 클러스터 총 endpoints 수뿐 (Spearman ρ = 0.92)
  • 경과 시간과는 무관 ⇒ 누수(leak)가 아니라 순간의 규모 문제

공식 문서도 같은 방향으로 적습니다. Performance and Scalability는 istiod 리소스 사용량이 “배포 변경률·설정 변경률·연결된 proxy 수"에 비례한다고 적습니다.

커넥션 수 트리거의 사각지대가 이 성질입니다. sum(pilot_xds)/replicas를 임계로 세우는 순간 “모든 커넥션의 비용은 일정하다"는 전제가 함께 들어오는데, 그 전제가 깨지는 시점이 하필 규모가 가장 큰 날입니다.

2. xDS 커넥션은 재분배되지 않는다

각 sidecar는 istiod 파드 하나와 장수 gRPC 스트림(ADS)을 유지합니다. 한번 맺어지면 끊길 때까지 그 파드에 붙어 있고 Kubernetes Service의 로드밸런싱은 새 커넥션에만 적용됩니다.

스케일아웃으로 istiod-c가 떠도 기존 커넥션은 옮겨가지 않습니다 — 새 파드는 '새로 맺어지는 커넥션'만 받으므로 한동안 빈손입니다
도식 텍스트
  • istiod-a — 294 conn · 과부하
  • istiod-b — 246 conn
  • istiod-c (신규) — 3 conn · idle
  • Envoy sidecar — 기존 커넥션 다수
  • Envoy sidecar — 기존 커넥션
  • Envoy sidecar — 새로 맺은 것만
  • xDS push

⇒ 스케일아웃은 desired replicas까지만 답하고 커넥션이 어느 파드로 가는가는 트리거의 관할 밖입니다.

문서에도 적혀 있으니 우리 클러스터만의 사정이 아닙니다. Google Cloud 서비스 메시 트러블슈팅 문서의 원문:

Large changes in cluster size might cause a temporarily unbalanced load, due to the long-lived connections.

같은 문서가 내놓는 대응은 두 개뿐입니다 — istiod 레플리카를 여러 개 유지할 것, 그리고 대규모 확장이 예상되면 사전 스케일링(pre-scaling). 커넥션을 새 파드로 옮겨주는 기능이 따로 있는 게 아닙니다. 부작용으로 Envoy에 gRPC config stream closed: 13이 뜬다는 것까지 같은 문서에 적혀 있습니다.

Istio 1.20+ 릴리스노트에서도 “커넥션 밸런싱/재분배"를 다루는 항목은 찾지 못했습니다. keepaliveMaxServerConnectionAge 강제 종료가 사실상 유일한 공식 메커니즘입니다.

실측 관찰

파드별 pilot_xds를 1분 해상도로 3시간 그려보면 공백이 그대로 보입니다.

  • 스케일아웃으로 새 파드가 떠도 커넥션은 **바닥(2~6 conn)**에서 시작
  • 파드 교체가 일어나자 갈 곳 잃은 커넥션이 살아남은 파드로 쏟아져 한 파드가 154 conn(그 시점 평균의 2배)까지 상승
  • 바닥에 붙은 파드가 평균 밴드까지 올라오는 데 약 30분, 전체가 고르게 퍼지는 데 약 40분 — keepalive 30분 주기와 맞는 시간 스케일
  • 이벤트 당일은 더 심해서 쏠림 해소에 27~42분, 그 사이 기존 파드는 294 conn

교체 구간에 떠오른 파드 중 일부는 커넥션을 받아보지도 못하고 3분 만에 사라졌는데, 업스트림에 같은 모양의 리포트가 있습니다 — istiod CPU가 push 중에 스파이크로 튀면 HPA가 파드를 과하게 늘리고(사례에선 ~5대 → 30대+), 새로 뜬 파드는 push에 참여도 못 한 채 몇 분 뒤 스케일다운되는 스레싱(istio/istio#42634). 이 이슈는 helm 차트에 HPA behavior 지원을 추가하는 PR #44425로 닫혔습니다.

3. 공식이 말하는 스케일링 지표

pilot_xds 기반 오토스케일링은 istio.io가 권장한 적이 없습니다. 커뮤니티 관행(Prometheus adapter로 커스텀 메트릭화 → HPA/KEDA)이지 공식 문서에 실린 방식이 아닙니다.

공식 차트(manifests/charts/istio-control/istio-discovery/values.yaml)의 HPA 기본값:

기본값
autoscaleEnabledtrue
autoscaleMin1 (Best Practices는 프로덕션에서 2 이상 권고 — 단일 replica는 admission webhook 단일장애점)
autoscaleMax5
cpu.targetAverageUtilization80

공식 지표는 CPU 사용률 하나뿐이고 커스텀 메트릭 HPA는 사용자가 별도 구성해야 합니다.

메트릭의 1차 출처는 소스(pilot/pkg/xds/monitoring.go)입니다. 실제 정의된 것들:

메트릭Help 문자열 (원문)
pilot_xds (label version)Number of endpoints connected to this pilot using XDS
pilot_proxy_convergence_time설정 변경 → proxy가 필요한 설정을 모두 수신하기까지의 지연
pilot_proxy_queue_timeproxy가 push 큐에서 대기한 시간
pilot_xds_push_time (label type)lds/rds/cds/eds push 소요 시간
pilot_xds_config_size_bytes클라이언트에 push된 설정 크기 분포
pilot_debounce_time디바운스 시작 → 병합된 push가 큐에 들어가기까지
pilot_pushcontext_init_secondspush context 초기화 총 소요 시간
pilot_push_triggers (label type)push가 유발된 횟수, 사유별
pilot_inbound_updates (label type)pilot이 수신한 업데이트 총수
pilot_servicespilot이 아는 서비스 총수

이름에 속기 쉬운 것

  • pilot_xds_pushes는 성공 카운터가 아니라 에러 카운터입니다. Help 문자열이 “Pilot build and send errors for lds, rds, cds and eds"이고 label도 cds_senderr/eds_senderr/lds_senderr/rds_senderr입니다.
  • pilot_xds_write_timeout, pilot_total_xds_rejects현재 master 소스에 존재하지 않습니다. 3rd-party 블로그에만 나오는 이름이니 알럿에 걸면 아무 에러 없이 빈 결과만 냅니다.

트리거로 쓰기엔 부적합하지만 알럿으로는 쓸 만한 것들:

# 핫 파드 커넥션 쏠림 — 재시작의 직접 선행지표
max by (pod) (pilot_xds) > 200

# istiod 메모리 limit 근접
container_memory_working_set_bytes{container="discovery"} / limit > 0.85

4. keepaliveMaxServerConnectionAge — 유일한 재분배 손잡이

CLI 기본값과 차트 기본값이 다르다

경로기본값
pilot-discovery --keepaliveMaxServerConnectionAge2562047h47m16.854775807s (사실상 무제한)
Helm 차트 values.yaml30m

2562047h47m16.854775807s는 Go time.Duration의 MaxInt64로, 사실상 off입니다. helm 경로로 배포된 istiod가 30분마다 커넥션을 끊는 건 차트가 주입한 값 때문이지 바이너리 기본 동작이 아닙니다. pkg/keepalive/options.goDefaultOption()MaxServerConnectionAge: Infinity를 반환합니다.

지터는 이미 들어가 있다

같은 파일의 주석:

Maximum duration a connection may persist before the server terminates it with a GoAway message. A randomized offset is incorporated to prevent synchronized connection terminations.

이 지터는 하위 grpc-go가 MaxConnectionAge에 붙이는 **±10%**입니다. istio가 구현한 것도 아니고 이 수치를 재정의하지도 않습니다. grpc-go 원문:

A random jitter of +/-10% will be added to MaxConnectionAge to spread out connection storms.

주기를 줄이면 무엇이 늘어나는가

지터가 있으므로 늘어나는 건 순간적인 종료 폭발이 아닙니다. 정상상태 재연결 레이트 그 자체입니다.

재연결 레이트 ≈ 총 커넥션 수 / maxConnectionAge
지터가 흩어주는 창 = maxConnectionAge × ±10%

커넥션 3,600개 기준:

설정재연결 레이트지터 창
30m~2 conn/s±3분
15m~4 conn/s±1.5분

2배입니다. 결정론적이라 예측은 됩니다. 되돌리려면 빈도를 낮추거나 재연결 1건의 단가를 낮춰야 합니다.

5. 재연결 1건의 단가를 낮추는 손잡이

여기서 §1의 계수가 다시 걸립니다. 공식 configuration-scoping 문서의 문장:

Each configuration has a cost (in CPU and memory, primarily) to maintain and keep up to date. At large scales, it is critical to limit the configuration scope to avoid excessive resource consumption.

커넥션당 config 크기는 메모리 단가이기만 한 게 아니라 재연결 CPU 단가이기도 합니다. 재연결 빈도를 2배로 올렸다면 상쇄 수단은 단가 쪽에 있습니다.

설정 스코핑 (ROI 1순위)

수단레이어효과
Sidecaregress.hosts워크로드별그 프록시가 import할 설정을 명시적으로 제한
exportTo서비스별서비스 소유자가 노출 네임스페이스를 제어
discoverySelectors컨트롤플레인매칭 안 되는 네임스페이스를 istiod가 아예 무시 — 위 둘보다 상위 필터

공식 문서가 배제 1순위로 지목하는 건 헤드리스 서비스(HTTP 타입 제외) 입니다. 인스턴스 수에 비례해 설정이 커져서 특히 비쌉니다. 스코프 제한은 트래픽 강제(enforcement)가 아니어서 스코프 밖 목적지로 가는 요청은 unmatched traffic으로 처리됩니다. 이 점은 알고 써야 합니다.

push·요청 레이트 제어

소스(pilot/pkg/xds/discovery.go)에 thundering herd 방지 장치가 명시적으로 있습니다.

RequestRateLimit *rate.Limiter
// rate.NewLimiter(rate.Limit(features.RequestLimit), 1)
//
// RequestRateLimit limits the number of new XDS requests allowed.
// This helps prevent thundering herd of incoming requests.

WaitForRequestLimit의 주석은 거부의 의도까지 밝힙니다 — “Client will connect to another instance in best case, or retry with backoff.” 거부당한 클라이언트를 다른 인스턴스로 보내려는 설계입니다.

환경변수기본값역할
PILOT_MAX_REQUESTS_PER_SECOND1.21+ min(15+5*procs, 100) 자동 스케일 (이전 고정 25.0)위 리미터의 rate
PILOT_PUSH_THROTTLE1.21+ 동일 공식 자동 스케일 (이전 고정 100)동시 push 허용 개수
PILOT_DEBOUNCE_AFTER100ms이벤트를 묶기 위한 최소 지연
PILOT_DEBOUNCE_MAX10s디바운스 최대 대기, 도달 시 강제 push
PILOT_ENABLE_EDS_DEBOUNCEtrueEDS도 디바운스 대상에 포함
리미터가 병목인지부터 확인해야 합니다. 커넥션 3,600개 / 15분 = ~4 conn/s인데, PILOT_MAX_REQUESTS_PER_SECOND의 자동 기본값은 2 vCPU 기준으로도 25/s입니다. 이 규모에서 리미터가 재연결을 억제할 가능성은 낮습니다 — 곧 관측되는 CPU는 억제되지 않은 실제 작업량입니다. 리미터를 만지기 전에 pilot_xds_config_size_bytes·pilot_xds_push_time으로 단가부터 보는 게 순서입니다.

그 밖의 수단

  • Delta xDS (ISTIO_DELTA_XDS) · 1.22부터 기본 true — 변경분만 전송해 push 비용↓. 재연결 절감폭은 실측 필요 — 아래 단서 참조.
  • HPA behavior stabilization window · 차트 지원됨 (#42634 → PR #44425) — 파드 churn 자체를 억제. scaleDown이 느려짐. KEDA는 advanced.horizontalPodAutoscalerConfig.behavior로 전달.
  • 사전 스케일링 · Google 공식 권고 — 이벤트 일정을 미리 아는 경우에만. 평시 낭비와 맞바꿈.
  • ambient / ztunnel · 근본 해법 — 커넥션이 파드당 → 노드당 1개, ztunnel용 xDS는 L4 전용이라 훨씬 작습니다. 마이그레이션 비용, L7 기능엔 waypoint 필요.
  • 리비전 기반 샤딩 · 가능하나 목적 밖 — istio.io/rev로 프록시를 리비전별 istiod에 고정. 업그레이드/카나리아용 설계라 정적 분할일 뿐 동적 재분배는 없습니다.
  • 클라이언트측 idle_timeout (EnvoyFilter) · 커뮤니티 기법 — 서버 강제 종료 대신 프록시 아웃바운드에 idle timeout을 주입해 재연결 유도. 근거 설명이 없어 추정 수준.

Delta xDS와 재연결 — 확실하지 않은 부분

xDS delta 프로토콜에는 재연결용 initial_resource_versions 필드가 있어서 클라이언트가 이미 가진 리소스 버전을 알려주면 서버가 차분만 보낼 수 있습니다. 원리상 재연결 비용 절감이 설계 목표입니다. Istio 팀 스스로 “완벽한 최소 diff는 아직 아니다"라고 밝힌 상태라 구현 완성도는 별개 변수입니다.

⇒ 켜기 전후로 pilot_xds_config_size_bytes·pilot_xds_push_time을 비교해 실측으로 판단할 것. 문서만 보고 절감을 가정하지 말 것.

6. 사례 — 확인된 것과 미확인

조사에서 “istiod를 스케일아웃했는데 재분배가 안 돼 OOM"을 그대로 다룬 named 회사의 공개 포스트모템은 찾지 못했습니다. 조각별 근거는 이렇습니다.

  • Google Cloud (Anthos/CSM) 트러블슈팅 문서 — 장수 커넥션발 부하 불균형을 공식 인정. 30분 max-age + 레플리카 다중화 + 사전 스케일링 권고.
  • istio/istio#42634 — istiod CPU 스파이크 → HPA 스레싱(~5대 → 30대+), 새 파드가 push 참여 못 함. PR #44425로 차트에 behavior 추가.
  • istio/istio#57809 — 2,500노드에서 Gateway 롤링 재시작 시 xDS 수신 지연으로 Ready 실패. 재분배 문제는 아니지만 대규모 istiod 지연이 장애로 이어진 사례.
  • Charles Xu (전 Google Cloud Istio 팀 / 전 Cruise / 현 Snowflake) — “long-lived gRPC connections that persist indefinitely, creating uneven load distribution across istiod replicas over time” — 같은 문제를 서술. 실제 인시던트인지 일반론인지 글에서 구분되지 않음.
  • grpc-go keepalive/keepalive.goMaxConnectionAge ±10% 지터 (1차 출처, 확정).

미확인으로 남긴 것:

  • istiod 자체(데이터플레인 proxy 말고 컨트롤플레인)의 graceful shutdown 메커니즘·플래그 — 공식 문서 미발견
  • “재연결 시 istiod가 전체 스냅샷을 재계산하는가"를 다룬 공식 서술 — 미발견. 소스상으로는 push context가 설정 변경 시 한 번 빌드돼 캐싱되고 신규 커넥션은 그걸 필터링해 씁니다. 그래서 비용의 주원인은 재계산보다 다수 스트림의 초기 전체 push + gRPC/TLS 핸드셰이크 쪽으로 추론됩니다
  • Uber/Pinterest/Lyft 등의 istiod 커넥션 재분배 공개 사례 — 미발견. “Netflix가 Istio로 하루 1000억 요청” 류 서술은 저품질 매체에만 나와 인용 보류

7. 실측 — 2026-07-25, keepalive 15m 적용 이후

초판 결론을 뒤집었습니다. 커넥션 데이터만 봤을 때는 “09:40 파드 대량 소멸이 진짜 스파이크"라고 적었으나, CPU·스로틀·convergence·push_time 네 지표를 붙여보니 스케일아웃 구간이 네 축 전부에서 더 나빴습니다. 아래는 정정된 내용입니다. 커넥션 분포만으로 CPU 부하를 추정하면 안 된다는 게 이 절의 첫 교훈입니다.

데이터: Grafana Explore 내보내기, 08:2911:44(195분), 15초 해상도, 파드 컬럼 66개, 활성 2434대. 파일 내 파드가 전부 동일 ReplicaSet(646bd458b8)입니다.

09:40~09:47 — 파드 20대가 90초 만에 사라졌다

시각등장소멸소멸 파드가 들고 있던 커넥션
09:40010365
09:41210615
09:42144316
09:4616229
합계 1,525 conn 강제 이탈
  • 09:41:00 활성 24 → 5대, 총 커넥션 862 → 169
  • 09:41:30 12대가 864를 나눠 받으며 f4cgm 한 대가 138 conn
  • 09:47~09:56 7jp9c 한 대가 156 conn 독식, 같은 시각 최소 파드는 1 conn
  • CoV 146.5%가 약 15분 고착, 완전 재수렴까지 19.2분

같은 ReplicaSet 안에서 20대가 동시에 죽었습니다. 재배포였다면 새 RS 해시가 떴을 테니 노드 드레인·축출·스팟 회수 같은 노드 레벨 이벤트가 유력합니다(미확인 — 노드 이벤트 대조 필요).

재연결 레이트 — 정상 순환과 교체발 강제 재접속의 분리

15분 버킷으로 나눠 파드별 커넥션 증가분의 합(정상 순환)과 파드 소멸로 통째로 이탈한 양(강제 재접속)을 따로 셌습니다.

구간활성총 conn정상 재연결교체발 강제CoV
평시 08:29~09:2924~8000.40~0.48/s015~19%
09:29~09:44248550.68/s1.80/s111%
피크 10:59~11:4434~2,9001.02~1.06/s010~13%
(6월) 피크 16:00~16:4530~2,9500.65~0.74/s09~23%
(6월) 16:45~17:00252,0202.33/s2.22/s57%

읽어낼 것:

  1. keepalive 15m은 의도대로 작동합니다. 거의 같은 커넥션 규모(2,900 vs 2,950)에서 정상 재연결이 0.7/s → 1.0/s로 약 1.5~2배. 주기를 절반으로 줄인 효과와 맞습니다.
  2. 스케일아웃 수렴이 빨라졌습니다. 오늘 10:29~11:00에 24 → 34대로 늘리며 CoV 25.9% → 9.8%까지 17.5분. 6월엔 같은 일에 36.5분이 걸렸습니다.
  3. 커넥션 재분배 관점에서는 파드 교체가 가장 격했습니다. 09:2909:44 버킷의 합계는 이론치의 2.6배, 6월 16:4517:00 버킷은 4.1배.
측정 방법의 한계 — 정상 재연결 수치는 하한입니다. 파드별 증가분의 합으로 셌기 때문에 15초 스텝 안에서 끊기고 다시 붙은 것이 상쇄되면 잡히지 않습니다. 실측 1.0/s는 이론치(2,900/900s = 3.2/s)의 0.3배인데 이 격차의 상당 부분은 측정 방식 탓입니다. 이 수치는 절대값으로 읽으면 안 됩니다. 6월 대비 상대 비교로만 읽어야 합니다.

CPU·스로틀을 붙이니 결론이 뒤집혔다

같은 날 09:30~11:00을 15초 해상도로, 네 지표를 한 시간축에 정렬한 결과입니다.

구간CPU 최대(코어)스로틀 최대pilot_proxy_convergence_time p99pilot_xds_push_time p99
평시 09:30~09:390.0160.060.1000.060
파드 대량소멸 09:40~09:470.1392.000.3140.076
쏠림 고착 09:48~10:000.0640.540.3250.080
회복 10:01~10:170.0320.200.1180.072
스케일아웃 10:18~11:000.2072.430.9400.782

스케일아웃 구간이 네 축 전부에서 더 나쁩니다. pilot_xds_push_time p99가 0.0099초 → 0.782초로 79배, convergence p99가 0.099초 → 0.940초로 9.5배 뜁니다. 커넥션이 1,029 → 2,908로 3배 가까이 늘어난 구간이라 push 부하가 파드 교체보다 훨씬 컸습니다.

정정된 결론: 커넥션 분포(CoV)가 가장 험한 순간과 CPU가 가장 험한 순간은 다릅니다. CoV는 파드가 죽을 때 튀고 CPU·push 지연은 커넥션 총량이 늘어날 때 뜁니다. 재분배 지표만 보고 CPU 부하를 추정하면 엉뚱한 손잡이를 잡게 됩니다.

스로틀 쿼리부터 바로잡는다

처음 뽑은 sum(rate(container_cpu_cfs_throttled_periods_total[2m])) by (pod)에는 세 문제가 있었습니다. container 필터가 없어 파드 샌드박스 계열까지 합산될 수 있고 분모가 없어 해석에 “초당 10 period"라는 가정이 필요하며 [2m]은 8샘플 평활이라 관측 최대가 순간 피크가 아닙니다.

sum(rate(container_cpu_cfs_throttled_periods_total{container="discovery"}[1m])) by (pod)
  /
sum(rate(container_cpu_cfs_periods_total{container="discovery"}[1m])) by (pod)

보정 결과 피크 스로틀 분율 30.9%(p99 21.2%, 중앙값 0.9%). 조인된 (파드, 시각) 표본 9,217개 중 77.2%에서 스로틀이 발생했고 평시조차 평균 0.46%로 0이 아닙니다.

그런데 같은 파드·같은 시각의 CPU와 짝지어 보면 숫자가 이상합니다.

10:39:30  파드 7jp9c    (CPU limit 600m ⇒ quota 60ms/100ms)
  CPU 평균     = 0.174 코어 = period당 17.4ms   ← quota의 29%
  스로틀 분율 f = 0.309                          ← 31%의 period가 잘림

quota의 29%만 쓰면서 31%의 period에서 잘렸습니다. 숫자만 놓고 “스로틀된 period가 quota를 다 썼다"고 보면 그 몫(0.309 × 60ms = 18.5ms)만으로 전체 평균 17.4ms를 넘어버립니다.

이 어긋남이 바로 CFS의 알려진 결함이다

여기서 “그럼 limit이 600m이 아닌가?“로 새면 안 됩니다. 전제가 틀렸습니다. 스로틀된 period가 반드시 quota를 다 쓴 것은 아닙니다.

글로벌 quota는 CPU별 runqueue에 슬라이스(기본 5ms) 단위로 배치 전송됩니다. 커널 문서 원문:

/proc/sys/kernel/sched_cfs_bandwidth_slice_us (default=5ms)

For efficiency run-time is transferred between the global pool and CPU local “silos” in a batch fashion.

However all but 1ms of the slice may be returned to the global pool if all threads on that cpu become unrunnable.

Once a slice is assigned to a cpu it does not expire.

즉 받아간 몫이 통째로 날아가는 게 아니라 1ms(min_cfs_rq_runtime)만 남기고 반환되고 남은 슬라이스는 만료되지도 않아 다음 period로 이월됩니다. 그래도 CPU를 여러 개 훑을수록 1ms씩은 잔류하므로 실사용이 quota에 못 미쳐도 스로틀이 걸리는 상황이 생깁니다. kubernetes/kubernetes#67577로 보고된 현상입니다. 제기자 본인도 “Kubernetes 버그가 아니라 Linux CFS quota 메커니즘의 한계"라고 밝혔습니다.

“CPU 사용률 29%인데 스로틀 31%“는 이 결함의 전형적인 서명입니다.

좌초 규모를 역산해보면 — 생각보다 작다

이 절의 초판은 틀렸습니다. “실효 quota ≈ 21ms, 명목의 3분의 1"이라고 적었는데, 근거로 삼은 가장 빡빡한 제약이 10:45:45에 갓 뜬 파드의 첫 1분 표본에서 나왔습니다. startup 버스트로 잘리는데 1분 rate가 그걸 눌러 제약이 과하게 조여듭니다. 아래는 파드 나이 5분 이상만 남기고 다시 계산한 값입니다.

관측된 스로틀 강도에 맞는 quota를 거꾸로 풀면 이렇게 됩니다.

① 스로틀된 period가 실효 quota를 소진하므로   f × Q_eff ≤ avg  ⇒  Q_eff ≤ min(avg / f)
② avg는 Q_eff와 그 이하 값의 가중혼합이므로    avg < Q_eff      ⇒  Q_eff > max(avg)
필터Q_eff 상한환산
없음 (초판)21.5ms~215m ← 갓 뜬 파드에 오염됨
파드 나이 ≥5분37.1ms~371m

좌초가 전혀 없다고 가정했을 때(quota 60ms 그대로) 앞뒤가 안 맞는 표본은 6.5%뿐입니다. 나머지 93.5%는 명목 60ms로 설명됩니다.

⇒ 좌초는 소수 구간에서 더해지는 2차 요인이지 주범이 아닙니다. 주범은 앞 절의 버스트입니다.

왜 GOMAXPROCS=1에서 특히 심한가 — 스레드 17개

사건 당시 istiod 프로세스의 OS 스레드 총량은 17개였습니다. GOMAXPROCS=1인데도 그렇습니다.

GOMAXPROCS는 P(goroutine 실행용 논리 프로세서) 개수를 제한할 뿐, OS 스레드 개수를 줄이지 않습니다. sysmon, GC 마크 워커, netpoller, 시스템콜에 블록된 스레드는 전부 P 카운트 밖에서 돌고 전부 같은 cgroup quota에 청구됩니다.

quota 60ms는 5ms 슬라이스로 쪼개져 CPU별로 넘어갑니다. 스레드가 잠들면 1ms만 남기고 반환되므로 새는 건 CPU 방문당 1ms 남짓 — 표본의 6.5%에서만 명목 60ms로 설명이 안 됐습니다
도식 텍스트
  • quota 60ms — limit 600m · period당
  • 5ms씩 CPU로 — sched_cfs_bandwidth_slice_us
  • OS 스레드 17개 — P는 1개 · 조각보다 많다
  • 대부분 반환 — 1ms만 남기고 글로벌 풀로
  • CPU당 1ms 잔류 — min_cfs_rq_runtime
  • 분할
  • CPU별 배분
  • 조금씩 잔류

GOMAXPROCS=1은 이 상황에서 최악의 조합입니다.

  • 스트랜딩은 그대로 얻습니다 — 스레드 17개가 여러 CPU에 흩어지는 건 GOMAXPROCS와 무관합니다.
  • 병렬성은 잃습니다 — P가 하나라 CPU-bound 작업(xDS 마샬링·직렬화)은 직렬화됩니다.

즉 멀티스레드 프로세스의 좌초 표면적은 다 떠안으면서 그 대가로 얻어야 할 병렬 처리량은 못 받습니다. “GOMAXPROCS를 낮췄으니 quota를 천천히 태울 것"이라는 직관은 성립하지 않습니다.

남는 것 셋

  • 평균 사용률은 CPU limit 사이징의 근거가 못 됩니다. throttled_periods / periods를 같이 보지 않으면 “CPU 여유 있는데 왜 느리지"에서 조사가 멈춥니다.
  • quota가 작을수록 버스트를 못 흡수합니다. 60ms 예산으로는 한 period 안의 짧은 스파이크 하나도 못 넘깁니다. 슬라이스 잔류(CPU당 1ms)도 quota가 작을수록 비율로 커집니다. limit을 올리는 것이 둘 다에 듣는 수단인 이유입니다(§8).
  • 뾰족한 파드는 평균 알럿에 안 걸립니다. 스로틀 상위 12개 시점의 argmax가 거의 전부 9jvvj 한 대였고 10:48:30에는 CPU 최대가 다른 파드(7jp9c)인데 스로틀 최대는 여전히 9jvvj였습니다. CPU를 덜 쓰면서 더 잘립니다.

히스토그램 분위수 읽을 때. 평시값 0.0990·0.0099는 실제 지연이 아닌 버킷 경계 아티팩트입니다(0.1s·0.01s 버킷 바로 아래로 보간). 평시엔 “그 버킷보다 빠릅니다” 이상은 알 수 없고 의미 있는 신호는 0.94/0.78로 튄 구간뿐입니다.

아직 확인 못 한 것 — 09:35~09:50 노드 이벤트(파드 20대 동시 소멸의 원인이 드레인/축출/스팟 회수인지).

8. GOMAXPROCS는 CPU limit이 정한다 — 차트 배선의 함정

§7에서 GOMAXPROCS=1이 나왔는데, 이 값은 아무도 설정한 적이 없습니다. limits.cpu가 만들어낸 값입니다.

limits.cpu: 600m을 한 번 걸면 그 값이 두 군데로 흘러가는데, 한쪽은 올림되고 한쪽은 안 됩니다.

  • Go 런타임 쪽 — 차트가 limits.cpuGOMAXPROCS로 넣어줍니다. 정수여야 하므로 kubelet이 올림합니다. 0.6 → 1. Go는 “1코어 쓸 수 있다"고 믿고 자기 설정을 그에 맞춥니다.
  • 커널 쪽 — CFS quota는 올림이 없습니다. 0.6코어 = 100ms마다 60ms, 딱 그만큼입니다.

Go는 1코어짜리라 생각하고 일을 벌이는데, 커널은 0.6코어에서 끊습니다.

같은 600m인데 위 갈래에서는 1코어로 올림되고, 아래 갈래에서는 0.6코어 그대로입니다. Go는 1코어라 믿고 커널은 0.6만 줍니다 — 이 어긋남이 스로틀의 출발점
도식 텍스트
  • Go 런타임이 믿는 값 — 1코어
  • 커널이 강제하는 값 — 0.6코어
  • limits.cpu: 600m — 내가 건 값 하나
  • Istio 차트 — GOMAXPROCS env로 주입
  • kubelet — 올림: 0.6 → 1
  • GOMAXPROCS = 1 — Go: 1코어 쓸 수 있다
  • PushThrottle = 20 — 동시 push 슬롯
  • CFS quota 60ms — 커널: 0.6코어만 허용
  • 올림한다
  • 여기서 파생
  • 올림 없다

차트가 Downward API로 주입한다

Istio 1.19부터 istiod Deployment 템플릿에 하드코딩돼 있습니다(1.24.1 deployment.yaml 197-205행).

- name: GOMEMLIMIT
  valueFrom:
    resourceFieldRef:
      resource: limits.memory
- name: GOMAXPROCS
  valueFrom:
    resourceFieldRef:
      resource: limits.cpu
      divisor: "1"

values.yaml로 토글할 수 없는 템플릿 고정 블록입니다. 도입 PR #46253의 근거는 “Basically this gives us performance improvements for free” 였습니다. 1.19 체인지노트에는 “Added an automatically set GOMEMLIMIT and GOMAXPROCS to all deployments to improve performance” 로 실렸습니다.

kubelet이 올림으로 계산한다

resourceFieldRef의 값 변환은 커널이 아니라 kubelet 쪽 코드가 합니다(pkg/api/v1/resource/helpers.go).

func convertResourceCPUToString(cpu *resource.Quantity, divisor resource.Quantity) (string, error) {
	c := int64(math.Ceil(float64(cpu.MilliValue()) / float64(divisor.MilliValue())))
	return strconv.FormatInt(c, 10), nil
}

math.Ceil입니다. ceil(600/1000) = 1GOMAXPROCS=1.

소수점 CPU limit은 이 배선에서 항상 어긋납니다.

계산limit 600m일 때
GOMAXPROCSceil(limit_cores)올림1 (런타임은 1.0코어를 태울 준비를 함)
CFS quotalimit_cores × 100ms정확값60ms/100ms (커널은 0.6코어만 허용)
명목 어긋남런타임 준비치 ÷ 커널 허용치1.7배
실효 어긋남§7의 슬라이스 잔류를 얹으면1.7배보다 조금 더

1000m 미만의 어떤 값을 넣어도 GOMAXPROCS는 1로 올림되지만 quota는 그 값 그대로입니다. 작게 걸수록 어긋남이 커지고 슬라이스 조각 수가 줄어 스트랜딩 비율까지 함께 나빠집니다. 1500m·2500m 같은 값도 마찬가지고 정수 코어(1000m·2000m·4000m)만 둘이 일치합니다.

PushThrottle까지 딸려 온다

pilot/pkg/features/tuning.go(1.24.1):

procs := runtime.GOMAXPROCS(0)
// 1: 20 / 2: 25 / 4: 35 / 32: 100
return min(15+5*procs, 100)

push는 프록시별 goroutine으로 병렬 디스패치됩니다(pilot/pkg/xds/discovery.godoSendPushes, 세마포어 크기 = features.PushThrottle).

슬롯 20개가 잘못된 값은 아닙니다 — 동시성과 병렬성은 다릅니다.

push 한 건은 [설정 조립·마샬링·TLS](CPU-bound, P 필요) + [스트림에 쓰고 네트워크 대기](I/O-bound, P 불필요)로 나뉩니다. 뒤쪽이 대부분이고 Go에서 I/O 대기는 P를 점유하지 않으므로 1코어에서 goroutine 20개가 떠 있는 것 자체는 정상입니다. 식의 기본값 15도 I/O 겹치기용 바닥값이고 코어당 +5가 병렬성 몫입니다.

CPU-bound 구간이 P 하나에 직렬화되고 그 P마저 quota에서 잘리는 게 문제입니다. 20이 많은 게 아닙니다. 20건이 만드는 CPU 수요를 받아낼 자리가 없습니다.

§7에서 pilot_xds_push_time p99가 79배 뛴 게 이 구조와 정합합니다. istiod에는 xDS 응답 캐시가 있어 스코프가 같은 프록시끼리는 마샬링을 재사용하므로 실제 CPU-bound 비중은 프로파일 없이 단정할 수 없습니다 — 소스 구조에서 나온 추론입니다.

Istio 1.24와 Go 버전 — 런타임이 구제해주지 않는다

Go 1.25(2025-08 GA)부터 런타임이 cgroup CPU limit을 읽어 GOMAXPROCS를 자동 설정합니다. 릴리스 노트 원문:

“On Linux, the runtime considers the CPU bandwidth limit of the cgroup containing the process, if any. If the CPU bandwidth limit is lower than the number of logical CPUs available, GOMAXPROCS will default to the lower limit. … The Go runtime does not consider the “CPU requests” option.

“Both of these behaviors are automatically disabled if GOMAXPROCS is set manually via the GOMAXPROCS environment variable or a call to runtime.GOMAXPROCS.”

그런데 Istio 1.24.1의 go.modgo 1.22.0입니다. Go 1.25 이전이라 이 기능이 없습니다.

Istio 1.24.1 (Go ≥1.22)Go 1.25+ 로 빌드될 경우
GOMAXPROCS 결정 주체차트 Downward API 주입값이 전부런타임이 cgroup에서 자동 산출
반올림kubelet의 math.CeilGo도 올림(go.dev 블로그: “Go always rounds up”)
env var 명시 시그 값이 그대로자동 로직이 꺼진다

두 번째 열의 함정에 주의할 것 — Istio 차트는 GOMAXPROCS 환경변수를 명시적으로 주입하므로 훗날 istiod가 Go 1.25+ 로 빌드돼도 런타임 자동 감지는 계속 꺼진 상태로 남습니다. 차트 배선이 런타임보다 우선합니다.

차트 기본값에는 CPU limit이 없다

1.24.1 차트의 pilot resources 기본값은 request만 있습니다.

resources:
  requests:
    cpu: 500m
    memory: 2048Mi
  # limits 섹션 없음

limit이 없으면 Downward API는 노드 allocatable로 대체됩니다. Kubernetes 공식 문서:

“If CPU and memory limits are not specified for a container, and you use the downward API to try to expose that information, then the kubelet defaults to exposing the maximum allocatable value for CPU and memory based on the node allocatable calculation.”

CPU limit을 거는 순간 GOMAXPROCS가 노드 코어 수에서 그 값으로 떨어집니다. 스로틀 상한을 걸었다고 생각했는데 병렬성까지 같이 줄었고 소수점 값이면 §2단계의 어긋남까지 더해집니다.

권고

선택지quotaGOMAXPROCS평가
limit 없음 (차트 기본)없음노드 코어 수스로틀은 사라지나 큰 노드에서 과병렬
600m (현재)60ms/100ms1버스트 못 흡수 + 직렬화. 최악 조합
"1"100ms/100ms1어긋남은 해소, 직렬화는 그대로
"2"200ms/100ms2버스트 여유 3.3배 + 병렬 2배 + PushThrottle 25
resources:
  requests:
    cpu: 500m
    memory: 2Gi
  limits:
    cpu: "2"        # 정수로. 소수점은 quota와 GOMAXPROCS가 어긋난다
    memory: 2Gi

"2"는 §7에서 버스트 시 단일 P 포화가 보였다는 근거에서 나온 출발점이지 실측으로 확정한 값이 아닙니다. 조정 후 §7의 네 지표를 다시 떠서 pilot_xds_push_time p99가 내려오는지로 검증할 것.

메모리도 같이 봅니다. GOMEMLIMITlimits.memory에서 같은 방식으로 주입되므로 메모리 limit을 차트 기본 request(2048Mi)보다 낮게 잡으면 Go의 소프트 메모리 상한까지 함께 낮아집니다.

이 문서에서 가져갈 것

  • 커넥션 수 트리거는 “모든 커넥션의 비용이 일정하다"는 전제를 깔고 그 전제는 규모가 가장 큰 날 깨집니다. 임계를 잡을 때 conn × endpoints를 같이 봐야 합니다.
  • Istio에 커넥션 능동 재분배는 없습니다. keepaliveMaxServerConnectionAge 강제 종료가 전부이고 Google 공식 권고도 레플리카 다중화 + 사전 스케일링 두 개뿐입니다.
  • 주기를 절반으로 줄이면 재연결 레이트가 2배가 됩니다. 지터(±10%)는 창을 넓혀줄 뿐 총량을 줄이지 않습니다.
  • CPU를 되돌리는 지렛대는 빈도가 아니라 단가 쪽에 있습니다 — Sidecar·exportTo·discoverySelectors로 커넥션당 config 크기를 깎는 것.
  • 재분배 지표와 CPU 지표는 다른 순간에 튑니다(§7). CoV는 파드 20대가 죽은 09:40에 146%로 튀었지만 CPU·스로틀·pilot_xds_push_time은 커넥션이 3배로 불어난 스케일아웃 구간에서 더 나빴습니다(push p99 79배). 커넥션 분포만 보고 CPU 부하를 추정하지 말 것.
  • 평균 사용률로 CPU limit을 사이징하지 말 것(§7). CPU 그래프는 여유로워 보이는데 (파드, 시각) 표본의 77.2%에서 스로틀이 발생했고 피크 분율은 30.9%였습니다. throttled_periods / periods를 같이 보지 않으면 이 상태는 보이지 않습니다.
  • quota는 명목대로 다 쓰이지 않을 수 있지만 규모를 과장하지 말 것(§7). CFS는 quota를 CPU별 5ms 슬라이스로 넘기는데, 스레드가 잠들면 1ms만 남기고 반환됩니다(kubernetes#67577, 커널 문서의 min_cfs_rq_runtime). 표본의 6.5%에서만 명목 60ms로 설명이 안 됐습니다 — 2차 요인이지 주범이 아닙니다.
  • 역산에는 갓 뜬 파드를 빼라(§7). 초판이 “실효 210m"라는 틀린 결론에 간 이유가 이것입니다. startup 버스트로 잘리는데 1분 rate가 눌러서 제약이 과하게 조여듭니다. 나이 5분 필터를 걸면 상한이 21.5ms → 37.1ms로 벌어집니다.
  • GOMAXPROCS를 낮춰도 OS 스레드는 안 줄어듭니다(§7). GOMAXPROCS=1인데 프로세스 스레드는 17개였습니다. 좌초 표면적은 그대로 떠안고 병렬성만 잃는 조합입니다.
  • GOMAXPROCS는 설정하는 게 아니라 limits.cpu에서 파생됩니다(§8). 차트 Downward API + kubelet math.Ceil 조합이라 소수점 limit은 항상 어긋나고 PILOT_PUSH_THROTTLE까지 그 값에서 파생됩니다. Istio 1.24는 Go 1.22 기반이라 Go 1.25의 컨테이너 인식 런타임도 없습니다.
  • pilot_xds_pushes는 에러 카운터입니다. pilot_xds_write_timeout·pilot_total_xds_rejects는 존재하지 않습니다. 알럿 걸기 전에 :15014/metrics를 직접 스크랩해 이름을 확인할 것.

소스

§8(GOMAXPROCS 사슬) 근거:

마지막 수정 일자