v1.36 — Kubernetes Server-Side Sharded List/Watch: 컨트롤러 수평 확장이 API 서버 부하로 이어지지 않게 만들기 (KEP-5866, Alpha)
| 항목 | 내용 |
|---|---|
| 프로젝트 | Kubernetes |
| 관련 릴리스 | v1.36 "ハル(Haru)" (2026-04-22) — Alpha |
| KEP | KEP-5866: Server-side Sharded List and Watch (SIG API Machinery) |
| 구현 PR | kubernetes/kubernetes#136657 — [KEP-5866] Sharded List and Watch (2026-03-17 머지, size/XXL) |
| 기능 게이트 | ShardedListAndWatch (기본값 false, Alpha, kube-apiserver) |
| 발견 출처 | Kubernetes v1.36: Server-Side Sharded List and Watch (릴리스 블로그) |
| 검증 근거 | KEP-5866 README 원문, PR #136657 실제 diff(k8s.io/apimachinery/pkg/sharding, k8s.io/apiserver/pkg/sharding, storage/selection_predicate.go, storage/cacher/) |
대규모 클러스터에서 kube-state-metrics처럼 Pod 같은 고변동(high-churn) 리소스를 감시하는 컨트롤러를 여러 레플리카로 수평 확장하면, 지금까지는 각 레플리카가 전체 이벤트 스트림을 다 받은 뒤 자기 몫이 아닌 걸 버리는 방식(client-side sharding)을 쓸 수밖에 없었다. v1.36은 ListOptions에 shardSelector 필드를 추가해, 어떤 해시 범위의 오브젝트만 받을지를 API 서버가 직접 필터링해서 보내주는 서버 사이드 샤딩 기능을 Alpha로 도입했다.
kube-apiserver는 클라이언트가 LIST/WATCH로 리소스를 조회할 때, 매번 etcd를 직접 두드리지 않고 **워치 캐시(Cacher)**라는 인메모리 계층을 거친다. Cacher는 etcd의 변경 스트림을 한 번만 구독해 메모리에 유지하고, 그 위에 붙은 각 워처(cacheWatcher, 클라이언트 연결 하나당 하나)에게 이벤트를 팬아웃한다. 이때 각 워처가 "내가 요청한 조건에 맞는 이벤트만" 받도록 걸러주는 게 filter 함수이고, 이 필터는 storage.SelectionPredicate(라벨 셀렉터·필드 셀렉터를 담는 구조체)를 기반으로 만들어진다.
기존 SelectionPredicate.Matches()는 오브젝트 전체가 아니라, 리소스 타입별 AttrFunc가 미리 뽑아둔 labels.Set/fields.Set(예: metadata.name, spec.nodeName 같은 몇 개 필드만 담은 맵)을 받아 비교한다. 이렇게 설계된 이유는 성능이다 — 워치 캐시를 지나가는 이벤트마다 매번 전체 오브젝트를 역직렬화하고 CEL 같은 범용 표현식을 돌리면 비용이 크기 때문에, "이 리소스 타입에서 필터링에 쓸 수 있는 필드는 이것뿐"이라고 미리 한정해둔 것이다. 문제는 이 구조가 "UID를 해시해서 특정 범위에 속하는지"처럼 임의 필드에 대한 계산을 요구하는 샤딩 조건은 표현할 수 없다는 점이다.
kube-state-metrics는 이미 레플리카별로 UID 범위를 나눠 자기 몫만 처리하는 client-side sharding을 자체 구현해 쓰고 있었다. 방식은 각 레플리카가 전체 LIST/WATCH 스트림을 그대로 받아서, 애플리케이션 레벨에서 자기 담당 범위가 아닌 오브젝트를 버리는 것이다. 레플리카를 늘려도 API 서버가 내보내는 트래픽 총량은 그대로이므로(레플리카 수 × 전체 스트림), 네트워크 대역폭과 클라이언트 쪽 역직렬화 CPU가 레플리카 수에 비례해서 늘어난다 — 정작 "일부만 받는다"는 샤딩의 이득은 서버-클라이언트 사이 전송 구간에서는 전혀 실현되지 않는다.
[변경 전] client-side sharding: API 서버는 항상 "전체" 스트림을 내보낸다
┌─────────────┐
│ kube-apiserver│ (모든 워처에게 동일한 전체 스트림 전달)
└──────┬──────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
Replica 0 Replica 1 Replica 2
(전체 Pod 이벤트 수신) (전체 Pod 이벤트 수신) (전체 Pod 이벤트 수신)
│ 역직렬화 │ 역직렬화 │ 역직렬화
│ UID 범위 0~1/3 만 유지 │ UID 범위 1/3~2/3 만 유지 │ UID 범위 2/3~1 만 유지
▼ ▼ ▼
나머지 2/3는 버림 나머지 2/3는 버림 나머지 2/3는 버림
문제: 네트워크 전송량 = N(레플리카 수) × 전체 스트림 크기.
레플리카를 늘려서 처리량을 넓히려 할수록 API 서버 송출 트래픽과
클라이언트 CPU가 함께 늘어난다 — 수평 확장의 이득이 상쇄된다.
PR #136657은 meta/v1.ListOptions에 ShardSelector 문자열 필드를 추가하고, 이를 처리하는 새 패키지 k8s.io/apimachinery/pkg/sharding(해시·타입 정의)와 k8s.io/apiserver/pkg/sharding(파서)를 만들었다. 클라이언트는 다음과 같은 CEL 스타일 함수형 문법으로 자신이 담당할 해시 범위를 지정한다.
GET /api/v1/pods?watch=true&shardSelector=shardRange(object.metadata.uid, '0x0000000000000000', '0x8000000000000000')
서버는 지정된 필드(object.metadata.uid 또는 object.metadata.namespace)의 값을 FNV-1a 64비트 해시로 변환하고, 그 값이 [hexStart, hexEnd) 범위에 들어오는지로 필터링한다. ||로 여러 범위를 이어 붙여 비연속 구간도 표현할 수 있다.
// staging/src/k8s.io/apimachinery/pkg/sharding/hash.go
func HashField(value string) string {
h := fnv.New64a()
h.Write([]byte(value))
return fmt.Sprintf("%016x", h.Sum64())
}이 값이 storage.SelectionPredicate에 새로 추가된 ShardSelector 필드(sharding.Selector 타입)로 실려간다. 일반 스토어 코드(registry/generic/registry/store.go)는 ListPredicate/Watch 진입점에서 options.ShardSelector 문자열을 파싱해 이 필드를 채우고, SelectionPredicate.Matches()는 라벨/필드 매칭보다 먼저 MatchesSharding()을 호출해 범위를 벗어난 오브젝트를 걸러낸다.
기존 라벨/필드 셀렉터가 "미리 뽑아둔 속성 집합"만으로 동작했던 것과 달리, 샤딩 판정은 UID 원본 값에 해시를 걸어야 하므로 전체 오브젝트가 필요하다. 그래서 워치 캐시의 cacheWatcher 필터 시그니처 자체가 func(string, labels.Set, fields.Set) bool에서 func(string, labels.Set, fields.Set, runtime.Object) bool로 확장됐다 — 이벤트가 워처에 전달되기 직전, Cacher 안에서 오브젝트 전체를 들고 있는 시점에 필터링이 끝난다는 뜻이다. 결과적으로 각 레플리카는 애초에 자기 몫이 아닌 이벤트를 네트워크로 받지도, 역직렬화하지도 않는다.
클라이언트가 실제로 샤딩된 응답을 받았는지 구분할 수 있도록, 서버는 ListMeta에 새 ShardInfo 구조체(적용된 selector 문자열을 그대로 echo)를 채워 돌려준다. 구버전 API 서버는 shardSelector 파라미터를 그냥 모르는 쿼리 파라미터로 무시하고 전체 스트림을 보내므로, 클라이언트는 응답에 ShardInfo가 없으면 "서버가 샤딩을 지원하지 않는다"고 판단해 기존 client-side 필터링으로 안전하게 폴백할 수 있다.
왜 고정 개수의 "가상 버킷"이 아니라 임의 해시 범위(prefix range)로 나눴는가: KEP는 대안으로 고정 N개의 virtual bucket(예: 1024개) 방식을 검토했지만, 버킷 수를 나중에 바꾸기 어렵다는 문제로 기각했다. 대신 64비트 해시 공간 전체를 하나의 연속된 선으로 보고 [start, end) 구간으로 나누는 방식을 택했다 — 샤드 수를 조정할 때 재배치되는 구간의 비율을 작게 유지할 수 있다는 게 근거다.
왜 명시적 쿼리 파라미터 대신 CEL 스타일 함수형 문법을 택했는가: ?shardingKey=uid&shardRangeStart=0&shardRangeEnd=8처럼 명시적 파라미터를 쓰는 안도 검토됐다. 이쪽이 파싱은 단순하지만, 샤딩 전용 필드가 ListOptions에 영구히 들러붙어 API 표면을 넓히고, 향후 다른 조합(예: 다른 필드 기준 샤딩)을 추가할 때마다 새 파라미터가 필요해진다는 단점이 있었다. 반대로 shardRange(field, start, end) 같은 함수형 표현은 라벨/필드 셀렉터가 이미 써온 "문자열로 실려가고 클라이언트/서버 양쪽에서 타입 검증되는" 패턴을 재사용하면서, 향후 다른 샤딩 전략을 문법 확장만으로 수용할 수 있는 여지를 남긴다. 대가로 전용 파서(k8s.io/apiserver/pkg/sharding/parser.go)를 새로 만들고 유지보수해야 하는 부담과, 워치 캐시가 느려지지 않도록 문법을 의도적으로 완전한 CEL보다 좁게 제한해야 하는 제약이 따른다.
왜 라벨 기반 샤딩(오브젝트에 controller-shard: "N" 라벨을 붙이는 방식)을 채택하지 않았는가: 기존 LabelSelector 인프라를 그대로 쓸 수 있다는 장점에도 불구하고 기각됐다. 샤드 개수가 바뀔 때마다 시스템에 존재하는 모든 대상 오브젝트를 재라벨링해야 하는데, 이는 etcd에 대량 쓰기(write amplification)를 유발한다 — 애초에 "컨트롤러 부하를 줄이자"는 목적을 위해 API 서버/etcd에 새로운 대형 쓰기 부하를 만드는 셈이라 본말이 전도된다.
리샤딩(resharding) 로직 자체는 이 KEP의 범위 밖으로 명시적으로 남겼다: API 서버는 해시 범위만 제공할 뿐, 몇 개의 샤드로 나눌지·범위를 어떻게 재배치할지는 여전히 클라이언트가 결정한다(Non-Goal로 명시). 서버가 조율 로직까지 가져가면 설계가 커지고 컨트롤러마다 다른 리밸런싱 전략을 강제하게 되므로, 우선 "해시 범위로 필터링만 해주는 원시 기능"을 먼저 내놓고 상위 조율은 각 컨트롤러(혹은 향후 별도 KEP)에 맡기는 쪽을 택했다.
[변경 후] shardSelector가 워치 캐시 안에서 필터링을 끝낸다
Client 요청: LIST/WATCH ?shardSelector=shardRange(object.metadata.uid, '0x0', '0x8000000000000000')
│
▼
registry/generic/registry/store.go
ListPredicate() / Watch()
│ sharding.Parse(options.ShardSelector)
▼
storage.SelectionPredicate{ ShardSelector: sharding.Selector }
│
▼
storage/cacher (Cacher)
cacheWatcher.filter(key, labels.Set, fields.Set, runtime.Object)
│ MatchesSharding(obj): FNV-1a(obj.metadata.uid) ∈ [start, end)?
├─ 범위 밖 ──▶ 이벤트 폐기 (네트워크로 나가지 않음)
└─ 범위 안 ──▶ 워처에 전달
│
▼
응답 ListMeta.ShardInfo{ selector: "..." } 로 echo
(클라이언트가 실제로 샤딩됐는지 검증/병합 가능)
레거시 API 서버(shardSelector 미지원)에 같은 요청을 보내면
→ 알 수 없는 쿼리 파라미터로 무시 → 전체 스트림 반환 → ShardInfo 없음
→ 클라이언트는 이를 감지해 기존 client-side 필터링으로 폴백
- Alpha 단계라
client-go의 Informer/Reflector가 아직shardSelector를 정식으로 노출하지 않는데(KEP는 이를 Beta 졸업 기준으로 명시), 실제로 어떤 형태의 헬퍼 API로 굳어질까? - 리샤딩(샤드 개수 변경)을 컨트롤러가 무중단으로 처리하는 패턴이 커뮤니티에서 어떻게 표준화될까 — KEP는 이를 의도적으로 범위 밖에 남겨뒀다.
kube-controller-manager자체를 샤딩하는 후속 KEP가 나온다면, 이번에 만들어진 해시/파서 라이브러리를 그대로 재사용할까 아니면 다른 요구사항이 추가로 붙을까?
- KEP-5866: Server-side Sharded List and Watch (kubernetes/enhancements)
- kubernetes/kubernetes#136657 — [KEP-5866] Sharded List and Watch
- Kubernetes v1.36: Server-Side Sharded List and Watch (릴리스 블로그)
본문 참고 자료로 충분했다.