Skip to Content
Blogistiod 스케일링, 커넥션 수만 세면 될 줄 알았다

istiod 스케일링, 커넥션 수만 세면 될 줄 알았다

지난 글에서는 트래픽을 받는 서비스 쪽 스케일링 지표를 다뤘습니다. reporter 관점 하나 차이로, 오토스케일링이 장애를 트래픽 감소로 읽어버리던 이야기였죠.

이번엔 그 반대편 이야기입니다. 트래픽을 직접 받지도 않는 컨트롤플레인, istiod입니다.

대규모 이벤트를 일주일쯤 치르던 중이었습니다. 피크가 지나가고 메트릭을 돌아보다가, istiod가 20분 사이에 8대나 재시작된 걸 발견했습니다.

재시작 직전 메모리는 limit의 94~98%까지 차 있었습니다. kube 메트릭은 컨테이너 종료 사유까지는 남겨주지 않아서 정황 판정이지만, OOMKill로 보기에 충분한 그림이었습니다.

당황스러웠던 건, istiod에는 이미 커넥션 수 기반 오토스케일링이 걸려 있었다는 점입니다. “istiod 한 대당 커넥션 100개”를 유지하도록 KEDA(외부 지표를 보고 파드 수를 조절해주는 쿠버네티스 오토스케일러) 트리거를 잡아뒀고, 실제로 이벤트 동안 스케일아웃도 정상적으로 일어났습니다.

스케일링은 됐는데, 왜 터졌을까요?

이 글은 두 번에 걸친 이야기입니다. istiod를 필요한 만큼 가변적으로 쓰겠다고 xDS 커넥션 기반 스케일링을 건 1차, 그리고 두 배 규모의 이벤트에서 그 기대가 깨지며 사전 스케일링부터 재연결 주기까지 손잡이 세 개를 돌리게 된 2차.


istiod는 무슨 일을 할까

원인을 따라가려면 먼저 istiod가 뭘 들고 있는 프로세스인지부터 봐야 합니다.

Istio 컨트롤플레인은 istiod라는 단일 데몬으로 통합되어 있습니다. 예전에 Pilot(설정 배포)·Citadel(인증서)·Galley(설정 검증)로 나뉘어 있던 컴포넌트들이 합쳐진 형태입니다.

하는 일을 줄이면 세 가지입니다.

  • Service Discovery + xDS 배포 — Kubernetes API에서 Service/Endpoints, Istio 설정 변경을 watch하고, 연결된 모든 Envoy sidecar에 xDS 프로토콜로 밀어줍니다.
  • 인증서 발급(CA) — mTLS에 쓰이는 워크로드 인증서를 발급하고 순환시킵니다.
  • 웹훅 — sidecar 자동 주입(mutating webhook)과 Istio 설정 검증(validating webhook)을 처리합니다.

이 중 스케일링과 메모리를 많이 사용하는 건 첫 번째, xDS 배포입니다.

watch (Service / Endpoints 변경)xDS push장수 gRPC 커넥션 (= pilot_xds)Kubernetes APIService · Endpointsistiod클러스터 뷰 계산·보관Envoy sidecarEnvoy sidecarEnvoy sidecar커넥션마다 "그 proxy에 줄 클러스터 전체 뷰"를 들고 있다 → 메모리 ∝ 커넥션 수 × config 크기

각 sidecar는 istiod 파드 하나와 장수 gRPC 스트림을 유지합니다. istiod 메트릭 pilot_xds가 바로 이 커넥션 수입니다.

그리고 istiod는 커넥션마다, 그 proxy에게 보내줄 클러스터 전체의 뷰 — 어떤 서비스가 있고, endpoints가 어디 있고, 라우팅이 어떻게 되는지 — 를 계산해서 들고 있습니다.

여기서 이 글 전체를 관통하는 성질이 나옵니다.

istiod의 메모리는 “커넥션 수”가 아니라 “커넥션 수 × 클러스터 config 크기”에 비례합니다.

Istio 공식 성능 문서 도 같은 이야기를 합니다. istiod의 리소스 사용량은 설정의 크기(서비스·endpoints 수)와 연결된 proxy 수에 비례한다고요.

이 성질이 커넥션 수 트리거의 사각지대를 만들었습니다.


1차 — istiod에도 오토스케일링을 걸다

원래 istiod는 고정 대수로 운영하고 있었습니다.

고정 대수의 문제는 명확합니다. 평시에 맞추면 이벤트 때 모자라고, 이벤트에 맞추면 평시 내내 낭비입니다.

istiod의 부하 단위는 위에서 봤듯 “연결된 proxy 수”니까, 스케일링 지표도 자연스럽게 커넥션 수 pilot_xds가 됐습니다.

# istiod 한 대당 커넥션 100개를 유지 - type: prometheus name: pilot-xds-connections metadata: serverAddress: https://<prometheus>/ query: sum(pilot_xds) threshold: "100"

KEDA가 이 값으로 desired replicas를 계산합니다. 프록시가 늘면 istiod도 따라 늘고, 이벤트가 끝나면 같이 줄어드는 그림이죠.

이걸로 istiod를 필요한 만큼만 가변적으로 쓸 수 있을 거라 기대했습니다.

실제로 평시에는 잘 동작했습니다. 대당 100 conn 언저리를 유지하면서, 클러스터가 커지고 줄어드는 걸 얌전히 따라다녔으니까요.


2차 — 배수가 두 배로 뛴 날

그러다 더 큰 이벤트가 왔습니다. 트래픽 배수가 지난번의 1.0에서 2.0으로, 두 배 규모였습니다.

스케일아웃 자체는 잘 일어났습니다. 24대가 38대까지 늘었으니까요.

그런데 글머리의 그 장면이 여기서 나옵니다. 당일 오후를 1분 해상도로 복기하면 이렇게 흘러갔습니다.

시각상황
~15:38평온. 24대가 평균 94 conn 수준으로 고르게 분산
15:40~15:57트래픽 서지. KEDA가 24대 → 38대로 스케일아웃
15:46~16:16커넥션 불균형 고원. 기존 파드는 246294 conn까지 치솟는데, 신규 파드는 150 conn
15:48~16:10기존 파드들이 하나둘 limit에 근접 — 최고 limit의 98.0%
16:00~16:208대 재시작. 전부 커넥션이 몰려 있던 파드들
16:16~16:28재시작으로 커넥션 강제 재분배. 71~112 conn 밴드로 수렴, 파드당 메모리도 피크의 절반 이하로 안정

파드를 늘렸는데 기존 파드가 터졌습니다.

두 가지가 겹친 결과였는데, 하나씩 뜯어보겠습니다.


사각지대 1 — 커넥션 한 개의 무게가 변한다

커넥션 100개 기준으로 스케일링을 걸었을 때 간과했던 건, “모든 커넥션의 비용이 일정하다”는 전제였습니다.

실측은 달랐습니다. 이벤트 전후로 커넥션당 메모리 비용을 재보면:

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

같은 커넥션 100개인데 비용이 3배가 됐습니다.

이유는 위에서 본 istiod의 성질 그대로입니다. 커넥션 하나가 들고 있는 건 “클러스터 전체의 뷰”라서, 이벤트로 서비스 파드가 3배로 늘어나면(= endpoints 3배) 커넥션 하나의 무게도 3배가 됩니다.

일자별 데이터로 회귀를 돌려보면 깔끔한 선형 모델이 나옵니다.

istiod 파드 메모리 ≈ 240MB(base) + 400B × (파드당 커넥션 수 × 클러스터 총 endpoints)

피크 당일 파드별 단면으로도 상관계수 r=0.962로 들어맞았습니다.

처음엔 “이벤트가 길어지니 메모리가 누적된 것 아닌가(leak)?”를 의심했는데, 데이터는 아니라고 답했습니다. 커넥션당 비용은 경과 시간이 아니라 그 시점의 endpoints 수에만 비례했습니다.

한 달치 후보 메트릭 25개를 놓고 상관 스윕을 돌려봐도, istiod 메모리와 같이 움직인 외부 지표는 클러스터 총 endpoints 수뿐이었습니다(Spearman ρ=0.92).

결론 : 위험한 건 이벤트의 “길이”가 아니라 순간의 “규모(conn × endpoints)“였습니다.


사각지대 2 — 커넥션은 새 파드로 옮겨가지 않는다

그래도 스케일아웃이 됐으면 부하가 나눠졌어야 하지 않나? 라는 의문이 남습니다.

여기가 두 번째 사각지대입니다. xDS 커넥션은 장수 gRPC 스트림이라, 한번 맺어지면 끊길 때까지 그 istiod 파드에 붙어 있습니다.

Kubernetes Service의 로드밸런싱은 새 커넥션에만 적용되니, 신규 istiod 파드는 새로 맺어지는 커넥션만 받을 수 있습니다.

Envoy sidecarEnvoy sidecarEnvoy sidecarEnvoy sidecarEnvoy sidecarEnvoy sidecaristiod-a294 connistiod-b246 connistiod-c3 conn+scale-out기존 커넥션은 살아있는 동안 그 파드에 고정 — 새 파드는 "새 커넥션"만 받는다재분배는 커넥션이 끊길 때만 (keepalive 기본 30분 주기) → 그동안 기존 파드가 부하를 떠안는다

Istio 차트는 이를 위해 keepaliveMaxServerConnectionAge(기본 30m)로 커넥션을 주기적으로 끊어 순환시키는데, 뒤집어 말하면 최악의 경우 30분 동안은 재분배가 일어나지 않는다는 뜻입니다.

저희만의 사정은 아니었습니다. Google Cloud의 서비스 메시 트러블슈팅 문서도 “클러스터 크기가 크게 바뀌면 장수 커넥션 때문에 일시적으로 부하가 불균형해진다”고 적어두고, 대응으로 레플리카를 여러 개 유지할 것사전 스케일링을 권합니다. 커넥션을 새 파드로 옮겨주는 기능이 따로 있는 게 아니라, 그 두 가지가 권고의 전부입니다.

실제 데이터로 보면 이 공백이 그대로 보입니다. 그날 전후의 istiod 파드별 커넥션 수를 3시간 그려본 그래프입니다. 그 시각에 커넥션을 못 받고 있던 파드들은 아이보리색으로 강조했습니다.

istiod 파드별 xDS 커넥션 수(pilot_xds) 3시간 실측입니다. 선 하나가 istiod 파드 하나입니다.

기존 파드새로 뜬 파드커넥션이 쏠린 파드커넥션을 못 받아 idle인 새 파드파드 교체 구간
0408012016015:0015:3016:0016:3017:0017:3018:00파드 교체스케일아웃한 파드에 154 conn (평균의 3배)방금 뜬 새 파드는 이제야 받기 시작 — 2~6 conn~40분 뒤에야 재수렴

스케일아웃으로 새 파드가 떠도(초록 선), 커넥션은 바닥 근처에서 시작합니다.

그러다 파드 교체가 일어나자 갈 곳 잃은 커넥션들이 살아남은 파드로 쏟아지고, 한 파드가 154 conn(그 시점 평균의 2배)까지 치솟습니다.

17:00 부근만 보면 커넥션이 다 같이 늘어난 것처럼 읽히지만 — 실제로는 끊긴 커넥션들의 재접속 물결이 중간 밴드를 통째로 밀어올린 것이고, 같은 시각 아이보리색 파드들은 2~6 conn으로 바닥에 붙어 있습니다.

주황이 “쏠려서 터진 쪽”이라면, 아이보리는 “커넥션을 못 받아 빈손으로 노는(idle) 쪽”입니다. 둘 다 같은 문제의 양면이죠.

재분배는 “커넥션이 끊기는 순간”에만 일어나니, 그 순간을 놓치고 뜬 파드는 다음 순환까지 idle 그대로인 거죠. 17:00 직전에 뜬 파드 중 둘은 커넥션을 받아보지도 못하고 3분 만에 사라지기도 했습니다(짧은 아이보리 토막). 업스트림에도 같은 모양의 리포트가 있습니다 — istiod CPU가 push 중에 튀면 HPA가 파드를 과하게 늘리는데, 새로 뜬 파드는 push에 참여도 못 한 채 몇 분 뒤 스케일다운되는 스레싱(istio/istio#42634 ).

바닥에 붙은 파드가 평균 밴드까지 올라오는 데 약 30분, 전체가 다시 고르게 퍼지는 데 약 40분이 걸렸습니다. keepalive 30분 주기를 생각하면 딱 맞는 시간 스케일이죠.

타임라인의 그날은 이보다 더 심했습니다. 커넥션 쏠림이 다 풀리기까지 27~42분이 걸렸고, 그 사이 기존 파드는 294 conn — 차트의 핫 파드(154)보다 훨씬 무거운 부하를 떠안았습니다.

평시의 3배 커넥션을, 그것도 피크의 무거워진 단가로 혼자 떠안은 셈이라, 모델에 넣어보면 파드 하나가 limit을 사실상 다 채우는 값이 나옵니다.

실제로 가장 뜨거웠던 파드는 limit의 98%까지 갔고, 그대로 재시작 명단에 올랐습니다.


그래서 뭘 바꿨나

재시작된 8대는 “평균이 높아서”가 아니라 “재분배 공백 동안 커넥션이 쏠려서” 죽었습니다.

desired replicas를 아무리 정확히 계산해도, 파드 간 불균형은 트리거의 관할 밖입니다. 트리거는 “몇 대를 띄울까”까지만 답하고, “커넥션이 어느 파드로 가는가”는 답하지 못하니까요.

⇒ 조절해야 하는 건 커넥션의 (스케일링 기준)만이 아니었습니다. 언제부터 나눠 들 것인가(사전 스케일링), 그리고 얼마나 자주 다시 맺게 할 것인가(재연결 주기)까지 — 손잡이 세 개를 같이 돌렸습니다.

  • 이벤트 사전 스케일링 트리거 추가 — 이번 서지는 예측 불가능한 게 아니라 날짜도 배수도 미리 아는 이벤트였습니다. 그런데 커넥션 수 트리거는 반응형이라 서지가 온 뒤에야 늘고, 그 공백에 신규 커넥션이 전부 기존 파드로 꽂힙니다. 그래서 이벤트 일정·배수를 외부 지표로 읽는 KEDA 트리거를 얹어, 시작 전에 배수만큼 미리 깔아뒀습니다. 신규 커넥션이 처음부터 새 파드로 분산되니 쏠림의 총량 자체가 줄어듭니다. (KEDA는 트리거 간 max를 취하므로, 추가는 스케일아웃 방향으로만 작용합니다.)
  • 대당 커넥션 임계 100 → 80 하향 — 사각지대 1의 교훈대로, 커넥션 100개의 무게는 클러스터 규모에 따라 변합니다. limit을 올리는 대신 대당 기준 자체를 낮춰서, 커넥션이 무거워지는 규모에서도 파드당 메모리에 헤드룸이 남게 했습니다.
  • keepaliveMaxServerConnectionAge 30m → 15m 강제 설정 — 커넥션을 더 자주 끊어 순환시켜서, 재분배 공백 자체를 절반으로 줄였습니다. 재연결이 잦아지는 만큼 CPU가 늘어나는 트레이드오프는 감수했습니다. 여담으로, 이 30m도 istiod 바이너리의 기본값이 아니라 Istio 차트가 주입하는 값입니다. pilot-discovery 플래그 자체의 기본값은 사실상 무한대거든요.

세 번째 손잡이는 대가가 생각보다 정직하게 돌아왔습니다.

종료 시각에는 하위 gRPC 구현이 ±10% 지터를 붙입니다. istiod 소스 주석에도 그렇게 적혀 있고요 — “A randomized offset is incorporated to prevent synchronized connection terminations.”

그러니 주기를 절반으로 줄였을 때 생기는 건 순간적인 종료 폭발이 아닙니다. 지터가 흩어주는 창이 ±90초로 좁아질 뿐, 늘어나는 건 정상상태 재연결 레이트 그 자체입니다. 커넥션 총량이 같다면 정확히 2배죠.

그리고 재연결 한 건의 비용은 다시 사각지대 1의 계수에 걸립니다. 공식 문서가 설정 스코프 이야기를 하면서 못 박아둔 문장이 있거든요 — 설정 하나를 유지하는 데는 “주로 CPU와 메모리 비용이 든다”고요.

⇒ 커넥션당 config 크기는 메모리 단가이기만 한 게 아니라 재연결 CPU 단가이기도 합니다. 재연결 빈도를 2배로 올렸으면, 상쇄하려면 단가를 깎아야 한다는 뜻입니다. Sidecar·exportTo·discoverySelectors가 그 자리에 있는 손잡이고요.

단가를 어디까지 깎을 수 있을지는 아직 숙제로 남아 있습니다.

그리고 이 참에 배치도 하나 손봤습니다.

지금까지 Istio 컴포넌트는 전부 전용 노드풀(N타입)에 묶어서 배치하고 있었습니다. 트래픽을 직접 받아내는 게이트웨이라면 몰라도, istiod까지 거기 있어야 할 이유는 사실 없었습니다.

istiod는 트래픽 경로 바깥의 컨트롤플레인이라, 필요한 건 전용 노드의 네트워크 성능이 아니라 스케일아웃할 자리니까요.

그래서 istiod는 일반 노드풀로 옮겼습니다. 이벤트 때 수십 대까지 늘어나는 워크로드를 전용 노드 몇 대에 몰아넣는 것보다, 자리가 넉넉한 일반 풀에서 늘어나게 두는 편이 스케일링 관점에서 자연스럽습니다. 전용 노드는 그만큼 데이터플레인이 온전히 쓸 수 있게 됐고요.

여기에 더해, 트리거로 쓰기엔 부적합하지만 알럿으로는 적합한 지표들을 걸어뒀습니다.

# 핫 파드 커넥션 쏠림 — 그날의 직접 선행지표 max by (pod) (pilot_xds) > 200 # istiod 메모리 limit 근접 container_memory_working_set_bytes{container="discovery"} / limit > 0.85

정리

  • 1차 : 고정 대수로 쓰던 istiod에 커넥션 수(pilot_xds) 기반 KEDA 스케일링을 걸었습니다. 필요한 만큼 가변적으로 쓰겠다는 기대였죠.
  • 2차 : 배수가 두 배인 이벤트에서 istiod 8대가 재시작됐습니다. 스케일아웃은 정상 동작했는데도요.
  • 원인 하나, istiod 메모리는 커넥션 수가 아니라 **커넥션 수 × 클러스터 config 크기(endpoints)**에 비례합니다. 커넥션당 비용이 0.66 → 1.95 MB/conn으로 변하는 걸 커넥션 수 트리거는 볼 수 없었습니다.
  • 원인 둘, xDS는 장수 gRPC라 스케일아웃해도 커넥션이 재분배되지 않습니다. 쏠림이 풀리는 데 27~42분이 걸리는 동안 기존 파드가 부하를 떠안습니다.
  • 그래서 손잡이 세 개를 같이 돌렸습니다. 이벤트 사전 스케일링으로 쏠림의 총량을 줄이고, 대당 임계 100 → 80으로 커넥션 무게 변화에 여유를 두고, keepalive 주기 30m → 15m로 재분배 공백을 절반으로 줄였습니다.
  • 덤으로, 전용 노드풀에 묶여 있던 istiod를 일반 노드풀로 옮겼습니다. 컨트롤플레인에 필요한 건 전용 노드가 아니라 스케일아웃할 자리였습니다.

읽어보면 좋은 글 :

공식 문서와 소스를 어디까지 파봤는지, 그리고 이번에 안 고른 선택지들은 어떤 트레이드오프였는지 — 그 상세는 따로 정리해뒀습니다.

istiod 스케일링과 xDS 커넥션 재분배  — 메트릭 정의의 함정, keepalive 기본값의 출처, ambient·Delta xDS·리비전 샤딩까지.

지난 글이 “어떤 트래픽을 세느냐”의 문제였다면, 이번 글은 손잡이 세 개의 문제였습니다.

커넥션을 언제부터 나눠 들 것인가(사전 스케일링), 몇 개씩 들게 할 것인가(스케일링 기준), 그리고 얼마나 자주 다시 맺게 할 것인가(재연결 주기). istiod는 이 셋을 같이 조절해야 잘 쓸 수 있는 물건이었습니다.

지표 하나로 스케일링을 걸어놓고 잘 된다고 믿고 있었는데, 그 지표의 숨은 전제가 깨지는 순간은 결국 규모가 가장 큰 날 — 가장 아픈 날에 찾아오네요.

미리 깔아두는 사전 스케일링과 keepalive 15분이 다음 큰 이벤트에서 어떻게 버텨줄지는, 겪어보고 후기로 남겨보겠습니다.

⇒ 그 후기는 다음 글에 있습니다. 재연결 주기를 절반으로 줄인 대가가 CPU 스로틀링으로 돌아왔거든요.