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 하나가 가벼우면 워커 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는 영원히 밀린 일을 따라잡지 못한다.
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 기본 20 | 0이면 인자 자체를 생략 |
--kube-api-burst | 클라이언트 버스트 한도 | 0 → controller-runtime 기본 30 | 0이면 인자 자체를 생략 |
# 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.1s | 822 ms |
| 패치 (동시성 50 + QPS 상향) | 0.22s | 4.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.9s | 0 |
| 동시성 50만 | 5.99s | 1회 |
| 전체 패치 | 0.60s | 0 |
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 100 | 0회 |
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) | 345 | 1.15 |
| 패치 (sync-period 30m) | 95 | 0.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-period2m→30m은 상시 트래픽을 약 1/4(1.15→0.32 req/s)로 낮췄으며,kube-api-qps상향은 동시성을 올릴 때 따라붙는 self-throttling(1회→0회)을 제거했다.
결국 이 작업의 핵심은 새로운 기능이 아니라 “환경에 따라 달라야 하는 값을 코드에 박아두지 마라” 는 원칙이다. Operator처럼 다양한 규모에서 돌아야 하는 컨트롤러일수록, 동시성·주기·rate limit 같은 운영 파라미터는 운영자의 손에 쥐여주는 것이 옳다.