콘텐츠로 이동

StarRocks Operator는 왜 클러스터가 많아지면 멈추는가

문제: CR이 늘어나자 Operator가 멈췄다

섹션 제목: “문제: CR이 늘어나자 Operator가 멈췄다”

StarRocks Kubernetes Operator는 StarRocksCluster라는 CR(Custom Resource) 하나하나를 watch하면서, 그 안에 정의된 FE(Frontend)·CN(Compute Node)·BE(Backend) 컴포넌트를 실제 클러스터 상태와 맞추는(reconcile) 역할을 한다.

문제는 이 Operator가 단일 클러스터, 혹은 소수의 클러스터를 관리하는 것을 전제로 설계되었다는 점이다. CR이 한두 개일 때는 아무 문제가 없다. 그런데 하나의 클러스터 위에 수십 개의 StarRocks 클러스터(CR) 가 떠 있는 멀티테넌트 환경으로 가면 이야기가 달라진다.

CR이 특정 개수를 넘어서면서 Operator가 다음과 같은 증상을 보이기 시작했다.

  • 새 CR을 만들어도 한참 동안 reconcile이 시작되지 않는다.
  • 스펙을 바꿔도 반영이 지연된다.
  • 결국 Operator가 사실상 hang에 빠진 것처럼 보인다.

원인을 추적해보니, 코드에 하드코딩된 controller-runtime 설정값 세 가지가 맞물려 있었다.

1. reconcile 동시성이 1로 고정돼 있다

섹션 제목: “1. reconcile 동시성이 1로 고정돼 있다”

controller-runtime의 컨트롤러는 내부적으로 workqueue에서 CR의 key를 꺼내 reconcile하는 워커 goroutine을 돌린다. 이 워커 수가 바로 MaxConcurrentReconciles이고, 기본값은 1이다.

즉, 아무리 CR이 많아도 한 번에 단 하나의 CR만 reconcile된다. 나머지는 모두 큐에서 순서를 기다린다.

Reconcile WorkerWorker (1개뿐)WorkQueue(CR 50개의 key)Kubernetes API ServerCR 1개 reconcile이느리면 나머지 49개는전부 대기한 번에 1개만 dequeueFE/CN/BE 상태 조회 + 갱신(CR 1개당 여러 번 호출)

reconcile 하나가 가벼우면 워커 1개로도 충분하다. 하지만 StarRocks의 reconcile은 가볍지 않다. CR 하나마다 FE/CN/BE 각각의 StatefulSet·Service·ConfigMap 상태를 확인하고, 필요하면 API 서버에 갱신 요청을 보낸다. CR 하나를 처리하는 데 걸리는 시간 × 큐에 쌓인 CR 수만큼 선형으로 지연이 누적된다.

2. 2분마다 전체 CR이 다시 큐에 들어온다 (resync)

섹션 제목: “2. 2분마다 전체 CR이 다시 큐에 들어온다 (resync)”

controller-runtime 매니저에는 SyncPeriod라는 설정이 있고, StarRocks Operator는 이를 2분으로 잡고 있었다.

이전 글에서 정리했듯, resync는 API 서버를 다시 조회하는 게 아니다. 로컬 캐시에 있는 모든 객체를 핸들러에 다시 흘려보내 reconcile을 한 번 더 돌리는 안전장치다. 문제는 이게 전체 CR을 대상으로 한다는 것이다.

CR이 50개라면, 2분마다 50개의 reconcile 요청이 한꺼번에 큐에 들어온다. 그런데 (1)에서 봤듯 워커는 1개뿐이다. 한 번의 resync 사이클(50개 reconcile)을 2분 안에 못 끝내면, 다음 resync가 또 50개를 밀어 넣는다. 큐는 줄지 않고 계속 불어나고, Operator는 영원히 밀린 일을 따라잡지 못한다.

SyncPeriodWorkQueueWorker .1..SyncPeriod(2분 타이머)SyncPeriod(2분 타이머)WorkQueueWorkQueueWorker (1개)Worker (1개)0:00 CR 50개 enqueue처리 중... (느림)2:00 CR 50개 또 enqueue이전 사이클이 아직안 끝났는데 50개가또 쌓인다 → 큐 폭증4:00 CR 50개 또 enqueue

3. 클라이언트 rate limit(QPS/Burst)에 막힌다

섹션 제목: “3. 클라이언트 rate limit(QPS/Burst)에 막힌다”

설령 워커를 늘려 동시 처리를 한다 해도, 그 모든 reconcile은 결국 Kubernetes API 서버로의 호출로 이어진다. controller-runtime의 client-go 클라이언트에는 클라이언트 측 rate limiter가 걸려 있다.

  • QPS: 초당 허용 요청 수 (controller-runtime 기본 20)
  • Burst: 순간적으로 허용하는 버스트 (기본 30)

CR이 많아 동시에 쏟아지는 API 호출이 이 한도를 넘으면, client-go는 요청을 클라이언트 쪽에서 큐잉하며 의도적으로 지연시킨다. 로그에는 client-side throttling 경고가 찍히고, reconcile은 또 한 번 느려진다. 동시성을 올려도 QPS/Burst가 그대로면 이 벽에 막힌다.

종합: 세 값이 서로를 악화시킨다

섹션 제목: “종합: 세 값이 서로를 악화시킨다”

세 병목은 독립적이지 않다.

  • 동시성 1 → reconcile 처리 속도가 낮다
  • 2분 resync → 그 느린 처리량에 주기적으로 전체 CR을 다시 얹는다
  • QPS/Burst 20/30 → 처리 속도를 올리려 해도 API 호출 단계에서 다시 막힌다

CR이 적을 때는 한 사이클을 2분 안에 여유롭게 끝내므로 셋 다 문제가 안 된다. 하지만 CR 수가 임계점을 넘는 순간, 큐가 발산하면서 Operator 전체가 멈춘 것처럼 보이게 된다.

해결: 하드코딩 값을 Helm으로 끄집어내다 (PR #769)

섹션 제목: “해결: 하드코딩 값을 Helm으로 끄집어내다 (PR #769)”

근본 원인이 환경에 맞지 않는 고정값이라면, 답은 그 값을 운영자가 환경에 맞게 조절할 수 있게 만드는 것이다. PR #769은 이 세 가지(+α) 설정을 Operator 플래그로 노출하고, 다시 Helm 차트 값으로 끌어올렸다. 이제 이미지 재빌드 없이 values만 바꿔 튜닝할 수 있다.

노출된 설정은 다음과 같다.

플래그의미기본값비고
--max-concurrent-reconciles동시에 도는 reconcile 워커 수1올리면 병렬 처리
--sync-period주기적 resync 간격2m늘리면 주기적 부하 감소
--kube-api-qps클라이언트 초당 요청 한도0 → controller-runtime 기본 200이면 인자 자체를 생략
--kube-api-burst클라이언트 버스트 한도0 → controller-runtime 기본 300이면 인자 자체를 생략
# values.yaml — starrocksOperator 하위
starrocksOperator:
# CR이 많은 환경에서 병렬 처리량을 늘린다
maxConcurrentReconciles: 5
# 주기적 전체 reconcile 부하를 줄인다
syncPeriod: "10m"
# API 호출 한도를 환경에 맞게 상향
kubeApiQps: 50
kubeApiBurst: 100

설계에서 신경 쓴 부분은 기존 사용자가 아무것도 안 해도 동작이 똑같아야 한다는 것이다.

  • 모든 기본값은 기존 하드코딩 값과 동일하다 (max-concurrent-reconciles=1, sync-period=2m).
  • kube-api-qps/kube-api-burst는 기본값 0일 때 플래그 자체를 전달하지 않아, controller-runtime의 원래 기본값(20/30)이 그대로 적용된다.
  • 따라서 Helm 차트를 다시 렌더링하지 않고 Operator 이미지만 올려도 안전하다.

부모 차트(kube-starrocks)의 values는 scripts/create-parent-chart-values.sh로 서브차트 값에서 재생성된다.

실험: 세 값은 정말 부하를 좌우하는가

섹션 제목: “실험: 세 값은 정말 부하를 좌우하는가”

설정을 노출했으니, 각 값이 실제로 부하를 얼마나 좌우하는지 측정했다. 이 문제는 특히 API 서버 응답이 간헐적으로 느려지는 환경에서 두드러졌으므로, 핵심 질문을 이렇게 잡았다 — “API에 지연이 끼고 CR이 여러 개일 때, 이 세 값이 클러스터 생성 시간과 상시 트래픽을 얼마나 바꾸는가?”

빠른 반복 검증을 위해 envtest(실제 kube-apiserver + etcd를 띄우되 노드·kubelet은 없는 경량 환경) 위에서 Operator를 구동하고, Operator → API 서버 경로의 병목만 측정했다. 실제 환경의 API 지연은 프록시(toxiproxy)로 RTT를 인위 주입해 재현했다.

항목
측정 방식envtest — 실제 kube-apiserver + etcd, 노드/kubelet 없음
측정 대상StarRocks Operator(kube-starrocks) → kube-apiserver 호출
API 지연 재현toxiproxy로 Operator↔apiserver 양방향 RTT 주입
비교 축패치 전(기본값) ↔ 패치 후(동시성·QPS·sync-period 상향)

⚠️ 측정 범위 주의 — 아래 수치는 “하한선”이다. envtest엔 kubelet이 없어 StatefulSet을 만들어도 Pod가 안 뜬다. 그래서 Operator는 FE의 StatefulSet·Service를 만든 뒤 “FE Pod Ready 대기”에서 멈추고, CN·BE는 아예 실행되지 않는다. 즉 측정값은 CR 1건이 유발하는 전체 API 호출 중 FE 단계까지만 잡힌다. 실제 환경에선 CN·BE까지 떠서 호출·resync가 곱으로 늘어나므로, 여기서 보이는 개선폭은 실제로는 더 크게 나타날 바닥값으로 읽어야 한다.

출발점 — CR 하나가 API를 얼마나 두드리나

섹션 제목: “출발점 — CR 하나가 API를 얼마나 두드리나”

먼저 부하의 단위를 쟀다. CR 1개를 만들고 30초간 API audit 로그를 세어보니, FE 생성까지만 12회의 호출이 찍혔다. service·statefulset마다 create·update·patch가 반복되고, status 서브리소스 패치만 3회다. 여기서 전체 컴포넌트(FE+CN+BE+FE-proxy)로 환산하면 CR 1건당 약 30회. CR이 수십 개면 이 호출이 곱으로 쌓인다 — 모든 부하의 출발점이다.

노브 1 — max-concurrent-reconciles: 직렬 처리가 API 지연을 만나면 터진다

섹션 제목: “노브 1 — max-concurrent-reconciles: 직렬 처리가 API 지연을 만나면 터진다”

기본값 1은 CR을 한 건씩 순차 처리한다. 평소엔 티가 안 나지만, API 왕복이 느려지는 순간 직렬 처리는 재앙이 된다. CR 50개를 만들되 RTT 100ms(왕복 200ms)를 주입한 결과:

조건완료 시간CR당 평균
기본값 (동시성 1)41.1s822 ms
패치 (동시성 50 + QPS 상향)0.22s4.4 ms
xychart-beta
  title "동시성 1→50 — RTT 100ms·CR 50개 생성 완료 시간 (초)"
  x-axis ["기본값 (동시성 1)", "패치 (동시성 50)"]
  y-axis "완료 시간 (초)" 0 --> 45
  bar [41.1, 0.22]

기본값은 CR을 한 건씩 처리하며 호출마다 왕복 200ms가 누적돼 41초가 걸렸다. 동시성을 50으로 풀자 50개 CR의 호출이 거의 동시에 나가고 HTTP/2 multiplexing이 받쳐주며 0.22초 — 약 187배 단축됐다. 직렬 병목 자체보다, 그게 RTT와 곱해질 때 폭발한다는 게 핵심이다.

RTT가 없는 환경(CR 100개)에서도 효과는 있지만 결이 다르다:

조건완료 시간throttling
기본값8.9s0
동시성 50만5.99s1회
전체 패치0.60s0

RTT가 없어도 동시성만 올리면 8.9s→5.99s로 1.5배 빨라진다. 그런데 이번엔 throttling이 1회 떴다 — 동시 호출을 늘리니 이번엔 QPS 상한(20)에 걸리기 시작한 것이다. 그래서 다음 노브가 따라와야 한다.

노브 2 — kube-api-qps/burst: 동시성을 올리면 반드시 같이 올려야 한다

섹션 제목: “노브 2 — kube-api-qps/burst: 동시성을 올리면 반드시 같이 올려야 한다”

동시성을 50으로 키워 호출을 한꺼번에 쏟으면, client-go의 클라이언트 측 rate limiter(QPS=20/Burst=30)에 걸려 스스로 요청을 지연시키는 self-throttling이 발생한다.

조건throttling 이벤트
동시성 50, QPS 기본(20)1회
동시성 50, QPS 1000회

QPS/Burst를 100/200으로 올리자 throttling이 사라졌다. 즉 QPS는 단독 레버가 아니라 동시성 상향의 짝이다 — 워커만 늘리고 QPS를 그대로 두면 늘린 만큼 클라이언트 큐에서 다시 막힌다. FE만 뜨는 이 환경에서도 1회 관측됐으니, CN까지 뜨는 실제 환경에선 빈도가 더 높다.

노브 3 — sync-period: 가만히 있어도 깔리는 상시 부하

섹션 제목: “노브 3 — sync-period: 가만히 있어도 깔리는 상시 부하”

앞 두 노브가 “생성하는 순간”의 부하라면, sync-period아무것도 안 바뀌어도 주기적으로 깔리는 상시 트래픽을 좌우한다. CR 100개를 만든 뒤 5분간 초당 API 호출율을 쟀다:

조건5분간 이벤트평균 req/s
기본값 (sync-period 2m)3451.15
패치 (sync-period 30m)950.32
xychart-beta
  title "sync-period 2m→30m — CR 100개 상시 트래픽 (평균 req/s)"
  x-axis ["sync 2m (기본)", "sync 30m (패치)"]
  y-axis "평균 req/s" 0 --> 1.3
  bar [1.15, 0.32]

resync 주기를 2분→30분으로 늘리자 상시 트래픽이 약 1/4로 줄었다. 이 트래픽의 대부분은 상태가 안 바뀐 CR을 다시 도는 것 — 순수한 낭비다. 그리고 이 환경은 FE만 떠서 이벤트가 적은데도 이 정도다. CN이 다수 뜨는 실제 환경에선 resync 대상이 훨씬 많아 상시 부하가 곱으로 커지고, 그만큼 주기를 늘리는 효과도 커진다.

노브무엇을 바꾸나검증된 효과
max-concurrent-reconciles 1 → 50직렬 → 병렬 처리RTT 100ms·CR 50개에서 41.1s → 0.22s. API가 느릴수록 효과가 폭발
kube-api-qps/burst 20/30 → 100/200클라이언트 rate limit 완화동시성 상향 시 생기는 self-throttling 1회 → 0회. 동시성의 필수 짝
sync-period 2m → 30m주기적 resync 간격상시 트래픽 1.15 → 0.32 req/s (≈1/4). 변경 없는 CR의 낭비 reconcile 감소

세 값 모두 환경에 따라 정답이 다르다 — API가 느린지, CR이 몇 개인지, drift 보정이 얼마나 급한지에 따라. 그래서 코드에 박지 않고 Helm으로 노출한 것이 옳았다.

  • StarRocks Operator는 소수 CR을 전제로 설계되어, controller-runtime의 동시성(1)·resync(2분)·rate limit(20/30)이 하드코딩돼 있었다.
  • 이 고정값들은 CR이 적을 땐 무해하지만, 다수 클러스터 환경에서 큐가 발산하며 Operator를 hang에 빠뜨린다.
  • PR #769은 이 값들을 플래그·Helm values로 노출해 이미지 재빌드 없이 환경에 맞게 튜닝할 수 있게 했고, 기본값은 그대로 둬 하위 호환성을 지켰다.
  • envtest에 RTT를 주입해 패치 전후를 측정한 결과: 동시성 1→50은 RTT 100ms·CR 50개 환경에서 생성 시간을 41.1s → 0.22s로 줄였고, sync-period 2m→30m은 상시 트래픽을 약 1/4(1.15→0.32 req/s)로 낮췄으며, kube-api-qps 상향은 동시성을 올릴 때 따라붙는 self-throttling(1회→0회)을 제거했다.

결국 이 작업의 핵심은 새로운 기능이 아니라 “환경에 따라 달라야 하는 값을 코드에 박아두지 마라” 는 원칙이다. Operator처럼 다양한 규모에서 돌아야 하는 컨트롤러일수록, 동시성·주기·rate limit 같은 운영 파라미터는 운영자의 손에 쥐여주는 것이 옳다.