상태: 초안 (2026-10-07 갱신). 공식 문서로 확인한 서술이 바탕이고, **8절의 격리된 환경 시험과 끝의 「kopf 오퍼레이터 직접 시험」, 「LWS 직접 시험」**만 직접 확인한 값입니다. 나머지는 아직 실측하지 않았습니다. 글 끝의 「확인한 버전과 날짜」와 「확인하지 못한 것」을 먼저 읽으십시오. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
쿠버네티스 CRD 와 오퍼레이터로 LLM 서빙 배포 자동화하기
LLM 서빙 배포에는 Deployment 가 표현하지 못하는 요구가 모여 있습니다. 여러 노드에 걸친 리더와 워커, 가중치 로딩이라는 선행 단계, 비율을 지켜야 하는 롤아웃, GPU 토폴로지, 파드 하나가 아니라 묶음을 단위로 하는 스케일입니다. 쿠버네티스는 이를 CRD(Custom Resource Definition)와 오퍼레이터로 확장합니다. 이 글은 그 API의 현재 모양과 오퍼레이터의 핵심 패턴을 정리하고 격리된 실습 환경에서 확인한 동작을 근거로 붙입니다. 프로덕션 규모의 Go 오퍼레이터를 운영한 경험은 제게 없다는 점을 먼저 밝힙니다.
이 글을 읽고 나면 면접에서 설명할 수 있는 것
- Deployment 로 LLM 서빙을 표현하기 어려운 이유와, LeaderWorkerSet(LWS), KServe, Dynamo 가 각각 어떤 추상화를 더하는지.
- 조정(reconcile) 루프의 멱등성, 레벨 기반 트리거, 소유 참조와 finalizer, 상태 조건, 백오프, 리더 선출이 왜 그렇게 설계되었는지.
- CRD 버전 전환 절차, 롤아웃 전략, Argo CD 와 오퍼레이터가 같은 필드를 두고 싸우는 문제의 해결 방법.
1. 왜 Deployment 하나로는 부족한가
- 스케일의 단위가 그룹입니다. 모델 복제본 하나가 여러 노드의 GPU에 걸치면 복제본은 리더 파드와 워커 파드의 묶음입니다. 한 파드가 죽으면 집합 통신이 깨지므로 LWS는 그룹 전체를 다시 만드는 정책(
RecreateGroupOnPodRestart)을 CRD 기본값으로 둡니다. - 선행 단계가 길고 비쌉니다. 8편에서 본 것처럼 가중치 로딩이 분 단위입니다. GPU가 한 장뿐인 환경에서는
strategy: Recreate를 쓰기도 합니다. 새 파드를 먼저 띄우는 롤링 업데이트에는 여분의 GPU가 필요하기 때문입니다. - 조정된 롤아웃. 프리필과 디코드를 분리한 서빙은 두 역할의 용량 비율을 업데이트 내내 지켜야 하며, LWS의 DisaggregatedSet 이 이를 위한 API입니다.
- 토폴로지와 갱 스케줄링. 그룹의 파드는 같은 토폴로지 도메인에서 전부 함께 스케줄되어야 합니다. Dynamo 문서는 다중 노드에 Grove와 KAI Scheduler(기본), 또는 LWS와 Volcano 조합을 안내합니다.
2. LWS, KServe, Dynamo 의 API 모양
| 프로젝트(확인한 버전) | CRD(API 그룹/버전) | 눈여겨볼 필드 |
|---|---|---|
| LWS v0.11.1 | LeaderWorkerSet (leaderworkerset.x-k8s.io/v1) |
replicas(그룹 수), leaderWorkerTemplate.size, restartPolicy, rolloutStrategy.rollingUpdateConfiguration(maxUnavailable 기본 1, maxSurge 기본 0, partition), groupIdentity(변경 불가), scale 서브리소스 |
| LWS v0.11.1 | DisaggregatedSet (disaggregatedset.x-k8s.io/v1) |
roles[]마다 LWS 하나, 롤아웃을 역할 전체에서 조정 |
| KServe v0.21.0 | InferenceService(v1beta1), ServingRuntime(v1alpha1) |
predictor 등, 런타임이 지원 모델 형식을 선언 |
| KServe v0.21.0 | LLMInferenceService(v1alpha1, 저장은 v1alpha2) |
model, template, worker(다중 노드면 LWS), prefill, router, rolloutStrategy, kvCacheOffloading(v1alpha2에만) |
| Dynamo v1.5.0 | DynamoGraphDeployment(nvidia.com, 저장은 v1beta1) |
components[](v1alpha1에서는 services 맵), restart, topologyConstraint |
| Dynamo v1.5.0 | DynamoComponentDeployment, DynamoGraphDeploymentRequest, DynamoGraphDeploymentScalingAdapter, DynamoModel |
컴포넌트 배포, SLA 기반 요청(프로파일링 후 DGD 생성), HPA용 scale, 모델 등록 |
KServe 문서는 InferenceService 를 전통적 예측 모델용으로 두고, 프리필/디코드 분리와 다중 노드, 접두사 인식 라우팅은 LLMInferenceService 가 맡는다고 밝힙니다(0.20 페이지를 열었습니다). Dynamo의 CRD 이름과 필드는 v1.5.0 태그의 CRD YAML에서 직접 읽었습니다.
DynamoGraphDeployment (dgd) ← 사용자가 선언: 컴포넌트 목록, 복제 수, 재시작 id
│ 오퍼레이터가 만듦
▼
DynamoComponentDeployment (dcd) ← 컴포넌트마다, 이름에 worker hash 접미사
│ 배포 모드에 따라
├─▶ Deployment (단일 노드)
└─▶ Grove PodCliqueSet 또는 LWS (다중 노드, 갱 스케줄링)
3. 오퍼레이터의 핵심 패턴
API Server ─watch▶ Informer(로컬 캐시) ─▶ WorkQueue(키 중복 제거, 백오프) ─▶ Reconcile(키)
▲ │
└────── 변경(자식 생성, status 기록) ◀── 관측 → 비교 → 행동 → 보고 ◀────┘
레벨 기반 조정. controller-runtime 소스(v0.25.2)의 주석은 조정이 이벤트가 아니라 apiserver나 캐시에서 읽은 실제 상태로 구동된다고 적습니다. Request 에는 이름과 네임스페이스만 있고 무엇이 바뀌었는지는 없어서, 파드 삭제 이벤트를 받아도 조정 함수는 「파드가 없다」를 읽어서 알아냅니다. kubebuilder 책의 Good practices 장도 이벤트별로 로직을 쓰면 리소스가 막혀 사람 손이 필요해진다고 경고합니다. kopf는 핸들러가 생성, 변경, 삭제라는 원인별로 호출되는 모델이어서 감시하지 않는 자식의 변화는 타이머로 보완해야 합니다(9절). 다만 가동 중단 중의 변경은 시작할 때 모든 객체를 나열해 마지막 상태만 처리하는 레벨 기반으로 다룹니다.
멱등성. 「생성하라」가 아니라 「원하는 모습으로 존재하게 하라」로 작성합니다. 이미 맞으면 아무것도 쓰지 않아야 하위 오브젝트의 resourceVersion 이 그대로이고 불필요한 감시 이벤트가 생기지 않습니다. 리더 선출이 있어도 순간적으로 리더가 둘일 수 있어 필수입니다.
소유 참조와 finalizer. 쿠버네티스 문서에 따르면 자식의 ownerReferences 는 같은 네임스페이스의 소유자 이름과 UID를 가리키며, 네임스페이스를 넘는 참조는 설계상 금지되고 가비지 컬렉터가 OwnerRefInvalidNamespace 경고 이벤트를 남깁니다. finalizer 가 있으면 삭제 요청은 deletionTimestamp 만 찍고, 컨트롤러가 정리를 끝내고 finalizer 를 빼야 실제 삭제됩니다. kubebuilder 책은 삭제가 갱신으로 바뀌므로 finalizer 추가와 정리 로직을 모두 멱등하게 쓰라고 합니다.
상태와 조건. status.conditions 와 observedGeneration 으로 상태를 표준 형식으로 노출합니다. Dynamo의 DGD도 conditions, observedGeneration, state, rollingUpdate 를 status 에 둡니다. Argo CD 문서는 CRD의 status 형식이 제각각이라 사용자 정의 건강 검사가 필요하고, observedGeneration 을 쓰면 조정이 끝나기 전의 오판을 막는다고 안내합니다.
재시도와 백오프. 조정 함수가 오류를 반환하면 워크큐가 지수 백오프로 다시 넣고, RequeueAfter 는 지정한 시간 뒤에 다시 넣으며, Requeue 필드는 deprecated 입니다. TerminalError 는 재큐하지 않습니다. 「아직 준비 안 됨」은 오류가 아니라 지연 재큐로 표현하는 것이 맞습니다.
리더 선출. controller-runtime 매니저는 LeaderElection, LeaderElectionID, LeaderElectionNamespace 옵션으로 Lease 를 잡습니다(Dynamo의 main.go 에서 확인했고, LMCache 오퍼레이터 설치 YAML에도 leader-election Role 이 있습니다). 쿠버네티스 문서는 Lease가 노드 하트비트와 컨트롤 플레인 리더 선출에 쓰인다고 설명합니다.
웹훅 검증. 단순 규칙은 CRD 스키마의 CEL(x-kubernetes-validations)로 충분하고, 복잡한 규칙과 주입은 웹훅입니다. Dynamo는 검증 웹훅에서 field.ErrorList 로 독립 오류를 모두 모아 반환하는 규칙을 문서화했고, LMCache 오퍼레이터는 변경 웹훅으로 vLLM 파드에 연결 설정을 주입하며 웹훅 인증서를 cert-manager 로 발급합니다.
4. Go(controller-runtime/kubebuilder) 대 Python(kopf)
| 항목 | Go: controller-runtime v0.25.2, kubebuilder v4.16.0 | Python: kopf 1.44.6 (2026-06-03) |
|---|---|---|
| 모델 | 타입 있는 클라이언트, 인포머 캐시, 워크큐, 매니저 | 데코레이터 핸들러, 상태를 객체의 어노테이션이나 status에 저장 |
| 코드 생성 | controller-gen 으로 CRD 생성(Dynamo는 v0.17.3) |
CRD YAML을 직접 작성 |
| 중복 방지 | Lease 리더 선출 | KopfPeering과 우선순위 |
| 생태계 | Dynamo 오퍼레이터는 Kubebuilder 로 만들었다고 README가 밝히고, 변환은 conversion.Convertible 인터페이스로 구현. LWS, KServe, LMCache CRD도 controller-gen 으로 생성됨 |
상태를 객체에 두고 kopf.adopt 같은 도구 제공, kopf 스스로 Kubernetes 클라이언트 라이브러리가 아니라고 밝힘 |
제 판단입니다. 변환 웹훅과 대규모 감시가 필요한 업스트림급 CRD는 Go 생태계가 기본값이고, 한두 팀이 쓰는 내부 자동화는 팀이 가장 잘 쓰는 언어가 득입니다. 저는 Go 오퍼레이터를 운영한 경험이 없어, 위 비교는 문서와 코드를 읽은 결과입니다.
5. CRD 스키마 설계 원칙
- 스펙은 작게, 일회성 명령은 상태로.
restartNow: true같은 명령형 필드는 실행 뒤 의미가 모호해집니다. Dynamo의 DGD는restart.id를 바꾸면 재시작하며, CEL 전이 규칙oldSelf.hasValue() || !has(self.restart)로 생성 시점에는 restart 를 비워 두게 합니다. - 기본값은 스키마에. LWS는
size기본 1,restartPolicy기본RecreateGroupOnPodRestart를 CRD에 둡니다. 반면 LMCacheLMCacheEngine의isolatedIPC는 GPU 벤더에 따라 달라 정적 기본값이 없고 오퍼레이터가 계산하므로, 오퍼레이터 업그레이드가 기본 동작을 바꿔 엔진 파드를 재시작시킬 수 있다고 문서가 경고합니다. - CEL로 검증. Dynamo의 DCD는
minAvailable <= replicas와minAvailable,type변경 불가를 CEL로 걸고, KServeLLMInferenceService는replicas와scaling동시 지정을 금지합니다. 기존 오브젝트를 깨지 않는 검증 래칫팅(ratcheting)은 쿠버네티스 1.33부터 stable 입니다. - 버전과 변환.
served(요청을 받는가)와storage(etcd에 쓰는 표현, 정확히 하나)는 다른 축입니다. 스키마가 같으면 변환 전략None으로 충분하고, 모양이 바뀌면 변환 웹훅이 필요합니다. Dynamo는 v1alpha1(deprecated)에서 v1beta1(저장)로 가며services맵이components목록으로 바뀌었고, 변환 웹훅과 허브-스포크 규칙, 표현할 수 없는 필드를 희소 어노테이션에 보존하는 규칙을CONVERSION.md에 문서화했습니다. KServe의LLMInferenceService는 CRD에 변환 설정이 없어 기본 전략None이고kvCacheOffloading이 v1alpha2에만 있습니다(스키마를 비교해 확인했습니다). 저장 버전 이전은StorageVersionMigrationAPI가 있으나, 쿠버네티스 문서가 1.35부터 Beta로 표기하면서 기본 활성 여부는 문서 안에서 엇갈려 확인하지 못했습니다. Dynamo는 Cluster API의 migrator 를 가져다 씁니다.
6. 롤아웃 전략
- LWS: 롤링 업데이트가 그룹 단위이고
maxUnavailable,maxSurge는 그룹 수 기준입니다(둘 다 0일 수 없음).partition으로 일부 서수만 새 템플릿에 올리는 카나리가 되며 Hash 모드에서는 지원되지 않습니다.maxSurge에는 추가 GPU 그룹이 필요합니다. DisaggregatedSet 은 역할 사이 용량 비율을 유지하며 함께 롤아웃하고 partition 은 지원하지 않습니다. - Dynamo: 워커 입력의 해시를 DGD 어노테이션(
nvidia.com/current-worker-hash-v2)에 두고, 해시가 바뀌면 새 접미사의 DCD를 만들어 롤아웃하며 완료한 뒤에야 어노테이션을 커밋합니다. 오퍼레이터 업그레이드가 롤아웃을 일으키지 않도록 v1에서 v2로 이전하는 규칙도 있습니다.status.rollingUpdate는 단일 노드, 비 Grove 배포만 지원합니다. - KServe:
canaryTrafficPercent로 새 리비전에 트래픽 일부를 보내고 마지막 정상 리비전을 기억해 롤백하며, 정상이 아닌 리비전으로는 보내지 않습니다. 서버리스 배포 모드에서만 지원합니다. - 모델 버전 전환과 블루그린: 새 버전을 별도 그룹으로 띄워 모델 적재와 readiness 통과를 확인한 뒤 트래픽을 옮기는 순서입니다. 위 도구들의 공통 원칙을 제가 정리한 표현이며 단일 문서의 인용이 아닙니다.
7. GitOps(Argo CD)와의 충돌
충돌은 Git에 적힌 필드를 오퍼레이터나 HPA가 바꿀 때 생깁니다. replicas 를 Git에 적으면 HPA와 싸웁니다(HPA가 늘리면 Argo가 되돌리고 다시 HPA가 늘림). 해결 수단은 다음과 같습니다.
ignoreDifferences:jsonPointers,jqPathExpressions,managedFieldsManagers로 비교에서 뺍니다. 시스템 전체는argocd-cm의resource.customizations.ignoreDifferences.<그룹>_<종류>로 지정합니다.RespectIgnoreDifferences=true: 기본은 비교에만 적용되고 동기화 때는 원하는 상태가 그대로 적용되므로, 동기화에서도 제외하려면 이 옵션이 필요합니다. 이미 존재하는 리소스에만 효과가 있습니다.- 서버사이드 diff(v3.1.0부터 stable): 서버사이드 적용 드라이런 결과와 비교하므로 어드미션 컨트롤러가 diff 계산에 참여합니다. 리소스를 처음 만들 때는 수행하지 않습니다.
- 사용자 정의 건강 검사(Lua): 오퍼레이터 CR의 status를 앱 건강에 반영합니다.
흔히 겪는 함정은 건강 검사입니다. 앱의 건강이 하위 리소스의 상태로 정해지고 동기화가 그 건강을 기다리는 구조에서는, 내장 검사가 어떤 리소스(예: 한 번 실패한 CronJob)를 Degraded 로 판정하면 동기화가 건강을 기다리다 실패해 새 변경이 반영되지 못할 수 있습니다. 검사 규칙은 Lua 로 재정의해 풀 수 있습니다(이 동작이 어느 버전에서 나타나는지는 확인하지 않았습니다). ApplicationSet 에서도 자식 애플리케이션의 자동화를 꺼도 부모 템플릿이 다시 켜는, 두 조정기의 충돌이 생기며 해법은 필드 하나만 이름과 경로로 좁혀 예외를 두는 것입니다.
8. 격리된 환경에서 확인한 동작
아래는 격리된 실습 환경(kwok 클러스터나 개인 VM)에서 실제 쿠버네티스 컨트롤러로 확인한 내용이며, 제 프로덕션 장애 기록이 아닙니다.
- 소유 참조 UID 오류. uid가 틀린 자식은 가비지 컬렉터가 부모가 이미 지워졌다고 보고 몇 초 안에 지웁니다. 컨트롤러가 부모 uid를 캐시한 채 부모가 같은 이름으로 다시 만들어지면, 옛 uid로 만든 자식이 만들자마자 사라집니다. 네임스페이스를 넘는 참조는 apply가 통과하고 경고 이벤트만 남으며 그 이벤트는 기본 한 시간이면 사라집니다. 오퍼레이터를 먼저 지우고 CR을 지우면 Terminating에서 멈추는데, finalizer를 손으로 떼면 외부 정리를 건너뛰므로 CR을 먼저 지우고 오퍼레이터를 걷어 내야 합니다.
- 저장 버전 이전. 전략
None에서는 새 필드의 기본값이 저장할 때 채워지므로 옛 표현의 오브젝트에는 다시 쓰기 전까지 보이지 않아, 컨트롤러가 같은 종류를 두 가지로 취급할 수 있습니다.storedVersions에 남은 버전을spec.versions에서 빼면 API 서버가 요청을 거절하는 것은 실습 파드에서 확인했습니다. 웹훅 서버를 띄울 수 없어 변환 웹훅은 시험하지 못했습니다. - 조정 루프. Go 컨트롤러를 실행할 수단이 없어 셸 스크립트 조정기로 관측, 비교, 행동, 보고와 멱등성(
resourceVersion불변)을 채점하며 informer 캐시와 워크큐는 모사하지 않습니다. 리더 선출 실습은 두 인스턴스가 같은 필드를 쓰려 하면 서버사이드 적용이 충돌을 알려 주는 것으로 리더가 둘인 구간의 위험을 보입니다.
한계. 위는 실습이 확인한 쿠버네티스 자체의 동작이며, 특정 오퍼레이터 구현이나 Go 프로덕션 오퍼레이터의 장애 대응이 아닙니다.
9. 직접 해 보기
로컬 kind 클러스터 하나에서만 진행합니다. 모든 명령이 kind-op-lab 컨텍스트를 명시하므로 다른 클러스터에는 닿지 않습니다. 끝나면 마지막 줄로 클러스터째 지웁니다.
kind create cluster --name op-lab
kubectl --context kind-op-lab create ns op-lab
cat <<'EOF' | kubectl --context kind-op-lab apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: modelcaches.labs.example.dev}
spec:
group: labs.example.dev
scope: Namespaced
names: {kind: ModelCache, plural: modelcaches, singular: modelcache, shortNames: [mc]}
versions:
- name: v1
served: true
storage: true
subresources: {status: {}}
additionalPrinterColumns:
- {name: Phase, type: string, jsonPath: .status.phase}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [model]
properties:
model: {type: string}
replicas: {type: integer, default: 1, minimum: 0, maximum: 8}
x-kubernetes-validations:
- {rule: "self.model.size() > 0", message: "model must not be empty"}
status:
type: object
x-kubernetes-preserve-unknown-fields: true
EOF
최소 조정 루프(op.py)는 CR 하나에 ConfigMap 하나를 소유 참조로 묶어 둡니다. kopf는 pip install kopf kubernetes 로 설치합니다.
import kopf, kubernetes as k8s
@kopf.on.resume("labs.example.dev", "v1", "modelcaches")
@kopf.on.create("labs.example.dev", "v1", "modelcaches")
@kopf.on.update("labs.example.dev", "v1", "modelcaches")
@kopf.timer("labs.example.dev", "v1", "modelcaches", interval=30, idle=10)
def reconcile(spec, name, namespace, patch, **_):
cm = {"apiVersion": "v1", "kind": "ConfigMap",
"metadata": {"name": f"{name}-cfg"},
"data": {"model": spec["model"], "replicas": str(spec.get("replicas", 1))}}
kopf.adopt(cm) # ownerReferences: 이 CR의 이름과 uid
api = k8s.client.CoreV1Api()
try:
api.create_namespaced_config_map(namespace, cm)
except k8s.client.ApiException as e:
if e.status != 409:
raise
api.patch_namespaced_config_map(f"{name}-cfg", namespace, cm) # 멱등
patch.status["phase"] = "Ready"
@kopf.on.delete("labs.example.dev", "v1", "modelcaches")
def cleanup(name, logger, **_):
logger.info("%s: 외부 자원 정리 자리", name) # 삭제 핸들러가 있으면 kopf 가 finalizer 를 붙인다
kubectl config current-context # kind-op-lab 인지 확인(kind 가 만들면서 바꿉니다)
kopf run --standalone --namespace op-lab op.py &
kubectl --context kind-op-lab -n op-lab apply -f - <<'EOF'
apiVersion: labs.example.dev/v1
kind: ModelCache
metadata: {name: demo}
spec: {model: tiny-llm}
EOF
kubectl --context kind-op-lab -n op-lab get mc,cm -o wide
kubectl --context kind-op-lab -n op-lab get cm demo-cfg -o jsonpath='{.metadata.ownerReferences}'
# 에지 대 레벨 관찰: 자식을 지워도 CR 이벤트가 없으므로 타이머(30초 주기)가 되살립니다
kubectl --context kind-op-lab -n op-lab delete cm demo-cfg && sleep 40 && kubectl --context kind-op-lab -n op-lab get cm
# CEL 검증: spec.model 을 빈 문자열로 넣은 CR 을 적용하면 "model must not be empty" 로 거절됩니다
# 정리
kill %1; kind delete cluster --name op-lab
이어서 해 볼 것: ownerReferences 의 uid를 일부러 틀리게 쓴 ConfigMap이 지워지는지 보고, CR 삭제 중 deletionTimestamp 와 finalizer 목록을 확인합니다. 이 스켈레톤은 자식 변경을 감시하지 않으므로 타이머로 보완한 학습용입니다.
10. 면접에서 나올 만한 질문
- LLM 서빙에 Deployment 대신 LWS 를 쓰는 이유는? 복제본이 리더와 워커 묶음이어서 스케일, 롤링 업데이트, 장애 복구, 토폴로지 배치를 그룹 단위로 해야 하기 때문입니다. 한 파드가 실패하면 집합 통신이 깨지므로 그룹 전체를 재생성하는 정책이 기본입니다.
- 조정 루프가 이벤트를 놓쳐도 괜찮은 이유는? 요청에 이벤트 내용이 없고 매번 현재 상태를 읽어 차이를 메우기 때문입니다. 컨트롤러가 죽었다 살아나도 처음부터 다시 돌려 끝나지 않은 부분만 마저 하므로 모든 쓰기가 멱등해야 합니다.
- ownerReference 와 finalizer 의 차이는? 전자는 자식이 부모를 가리켜 가비지 컬렉터가 정리를 맡기는 장치이며 uid가 틀리면 자식이 지워집니다. 후자는 외부 자원 정리를 위해 삭제를 늦추는 장치이며 컨트롤러 없이 떼면 정리를 건너뜁니다.
- CRD를 v1alpha1 에서 v1beta1 으로 올리는 절차는? 새 버전을 served 로 추가하고 필요하면 변환 웹훅을 붙이며, storage 를 옮기고 기존 객체를 모두 다시 쓴 뒤
storedVersions를 정리하고 옛 버전을 끕니다. 전략None은 필드 모양이 같을 때만 안전합니다. - Argo CD 가 오퍼레이터가 바꾼 필드를 되돌리면? Git에서 그 필드를 빼고, 어렵다면
ignoreDifferences로 비교에서,RespectIgnoreDifferences로 동기화에서 제외합니다. 건강은 CR 용 Lua 검사로observedGeneration을 반영합니다.
11. 흔한 오해
- 조정 함수는 이벤트 핸들러다: 키만 받고 현재 상태를 다시 읽는 함수입니다.
- 리더 선출이 있으면 동시에 두 곳에서 돌지 않는다: 순간적으로 둘일 수 있습니다.
- finalizer 를 떼면 Terminating 이 안전하게 해결된다: 외부 정리를 건너뜁니다.
- 필드 추가는 항상 호환된다: 전략
None에서 옛 표현은 새 기본값을 갖지 못합니다. - Argo CD가 초록이면 모델이 서빙 중이다: CR의 건강은 사용자 정의 검사가 있어야 반영됩니다.
외부 근거
| 주장 | 자료 | 확인한 내용 |
|---|---|---|
| 조정은 이벤트가 아니라 현재 상태로 구동된다 | controller-runtime pkg/reconcile/reconcile.go 의 Request·Reconciler 주석 |
Request 는 이름과 네임스페이스만 담고 이벤트 내용이 없음. 조정은 레벨 기반이며 apiserver 나 캐시에서 읽은 실제 상태로 구동된다고 적고, 파드가 지워졌다는 사실도 삭제 이벤트가 아니라 상태를 읽다가 알게 된다는 예를 듦. Requeue 필드는 deprecated, RequeueAfter 를 쓰라고 적음 |
| 이벤트별 로직은 안티패턴 | kubebuilder 책, Good practices | 이벤트별로 조정 로직을 쓰면 연산자 패턴에 어긋나고 리소스가 막혀 수작업이 필요해질 수 있다고 경고, 재조정이 몇 번 돌아도 같은 결과를 내도록 쓰라고 함 |
| 컨트롤러는 꺼져 있던 시간을 모른다 | 쿠버네티스 커뮤니티 문서(sig-api-machinery, controllers.md) | 전이가 아닌 현재 상태를 관찰해야 하고, 인포머가 주기적으로 재동기화하며, 작업 큐에는 키만 넣어 중복을 제거하고 오류는 지수 백오프로 재큐 |
| kopf 의 감시 범위와 타이머 | kopf 문서(handlers, timers, continuity, errors) | 핸들러는 diff 로 감지한 본질적 변화에만 반응하고 감시하지 않는 자식의 변화에 대한 서술은 없음. 타이머는 객체가 존재하는 동안 주기로 실행되고, 꺼진 동안의 변경은 재시작 때 나열해 마지막 상태만 처리하며, 일반 오류의 재시도 간격 기본값은 60초 |
| 「level-triggered」 용어의 출처 | 위 controller-runtime 주석과 커뮤니티 문서 | kubernetes.io 의 컨트롤러 개념 페이지는 제어 루프와 「현재 상태를 원하는 상태에 가깝게」는 서술하지만 이 용어 자체는 쓰지 않음. 이 글은 용어를 controller-runtime 과 kubebuilder 책에 귀속함 |
참고 자료 (실제로 열어 본 것)
- https://lws.sigs.k8s.io/docs/overview/ , /concepts/leaderworkerset/ , /concepts/leaderworkerset/failure-handling/ , /concepts/leaderworkerset/rollout-strategy/ , /concepts/disaggregatedset/ , /reference/disaggregatedset.v1/ , 그리고 https://github.com/kubernetes-sigs/lws (v0.11.1 CRD YAML)
- https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview , /concepts/architecture/control-plane-llmisvc , /model-serving/predictive-inference/rollout-strategies/canary , 그리고 https://github.com/kserve/kserve (v0.21.0 CRD YAML)
- https://docs.nvidia.com/dynamo/latest/ , https://github.com/ai-dynamo/dynamo (v1.5.0: 오퍼레이터 디렉터리의
README.md,api/CONVERSION.md,internal/dynamo/worker-hash.md, CRD YAML,docs/fern/pages/kubernetes/installation/multinode-orchestration.md) - https://docs.lmcache.ai/mp/operator.html
- https://book.kubebuilder.io/reference/good-practices.html , /reference/using-finalizers.html , /multiversion-tutorial/tutorial.html
- https://github.com/kubernetes-sigs/controller-runtime (v0.25.2
pkg/reconcile/reconcile.go) - https://kopf.readthedocs.io/en/stable/ , /peering/ , /errors/ , /continuity/ , /configuration/
- https://kubernetes.io/docs/concepts/extend-kubernetes/operator/ , /concepts/overview/working-with-objects/owners-dependents/ , /finalizers/ , /concepts/architecture/leases/ , /tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ , /custom-resource-definition-versioning/ , /tasks/manage-kubernetes-objects/storage-version-migration/ , /reference/command-line-tools-reference/feature-gates/
- https://argo-cd.readthedocs.io/en/stable/user-guide/diffing/ , /sync-options/ , /diff-strategies/ , /operator-manual/health/
kopf 오퍼레이터 직접 시험
9절의 최소 조정 루프(op.py, 고친 것 없이 그대로)를 일회용 단일 노드 k3s(v1.36.5, 4 vCPU 가상 머신)에서 kopf 1.44.6 으로 실행해 3절의 패턴을 확인했습니다. 호출 횟수를 세려고 로그 한 줄만 더한 판(op2.py)을 중복 실행과 오류 시험에 썼습니다.
| 확인한 것 | 결과 |
|---|---|
CR 생성 후 자식 ConfigMap 이 생기기까지, status.phase=Ready 까지 |
각각 0.1초 |
| 자식의 소유 참조 | CR 의 이름과 UID, controller: true, blockOwnerDeletion: true |
| 스키마 기본값 | replicas 를 안 쓰면 1 이 채워져 ConfigMap 에 "replicas":"1" |
| CEL 검증 | 빈 model 은 model must not be empty, model 누락은 Required value, replicas: 99 는 8 이하 규칙으로 거절됨(API 서버 단계) |
| finalizer | kopf.zalando.org/KopfFinalizerMarker 가 붙음 |
| 자식 ConfigMap 을 지웠을 때 복구 시간(5회, 시작 위상을 흩뜨림) | 2.5, 15.9, 24.9, 17.8, 26.9초 |
| 자식 내용을 바꿨을 때 원래 값으로 복구 | 29.9초 |
| CR 스펙 변경이 ConfigMap 에 반영되기까지 | 0.1초 |
조정이 세 번 돌 동안(95초) 자식과 CR 의 resourceVersion |
둘 다 불변(멱등) |
| 오퍼레이터가 꺼진 동안 스펙 변경과 자식 삭제, 재기동 후 | 0.6초 만에 ConfigMap 복원(replicas=5 반영) |
| 오퍼레이터 없이 CR 삭제 | CR 이 deletionTimestamp 만 달린 채 남고 자식 ConfigMap 도 그대로. 오퍼레이터를 켜자 0.6초 뒤 CR 이 사라지고 자식은 가비지 컬렉션으로 0.1초 뒤 사라짐 |
- 복구 지연의 크기는 구현 방식이 정합니다. 자식을 지웠을 때 복구가 2.5~26.9초로 흩어진 것은 kopf 의 CR 핸들러가 감시하지 않는 자식의 삭제로는 깨어나지 않고 타이머(이 시험에서
interval=30,idle=10)로만 복구하기 때문입니다. controller-runtime 의Owns()는 자식 삭제 이벤트로 소유자를 곧바로 다시 조정하므로 지연이 감시 전파 시간에 가깝습니다(공식 문서 서술, 직접 측정하지 않음). 두 방식 모두 현재 상태를 읽어 차이를 메우는 레벨 기반 조정이고, 이벤트를 놓쳐도 복구된다는 성질이 시험으로 확인됐습니다. - 오퍼레이터가 없으면 finalizer 가 삭제를 막습니다. CR 삭제가 끝나지 않는 것은 정상 동작이며, 오퍼레이터를 제거하기 전에 CR 을 먼저 지우거나 finalizer 를 의도적으로 정리해야 합니다. 이 시험도 정리 단계에서 같은 이유로 한 번 멈췄습니다(오퍼레이터가 꺼진 채 전체 CR 삭제를 실행).
- 중복 실행(리더 선출 없음). 같은 CR 을 오퍼레이터 두 개가 동시에 조정했습니다. 50초 동안 인스턴스 A 6회, B 6회로 같은 일을 같은 시각에 했습니다. kopf 의 피어링(
KopfPeering)을 켜고 우선순위를 100 과 50 으로 주자 A 가 6회, B 가 2회였고 B 는Pausing operations in favour of로그를 남기며 멈췄습니다. 우선순위가 높은 A 를 정상 종료하자 B 가 0.2초 안에 다시 일하기 시작했습니다(비정상 종료 시의 인계 지연은 재지 않았고 피어링lifetime60초가 상한일 것으로 추정합니다).
오류 처리와 재시도 간격. 조정 함수가 항상 실패하도록 한 CR 을 100초 동안 두고 호출 시각을 기록했습니다(kopf 기본값).
| 던진 예외 | 100초 동안 호출 | 간격 |
|---|---|---|
일반 예외(RuntimeError) |
4회 | 생성 핸들러가 실패하고 60초 뒤 재시도, 타이머도 따로 실패해 60초 뒤 재시도(호출 시각 +0, +10, +60, +70초) |
kopf.TemporaryError(delay=5) |
20회 | 5초마다 |
kopf.PermanentError |
4회 | 생성 핸들러는 재시도하지 않음(0 succeeded; 1 failed). 그러나 타이머는 계속 돌아 30초마다 같은 오류를 냄(+10, +40, +70초) |
- 일반 예외의 재시도 간격 60초는 kopf 문서의 기본값과 같습니다. 「아직 준비 안 됨」은
TemporaryError로 표현해 간격을 짧게 주는 것이 맞습니다. PermanentError는 그 핸들러만 멈춥니다. 같은 CR 에 타이머가 붙어 있으면 스펙이 잘못된 채로 30초마다 계속 다시 평가되므로, 영구 오류를status의 조건으로 남기고 타이머에서도 건너뛰게 설계해야 합니다.- 한계. 규모 시험(CR 100개)은 이 VM 의 한계(루트 디스크 2.4GiB,
kubectl apply100개 동시 실행이 20개만 만들어진 채 정체)로 무효였습니다(미실측). 호출 시각은 로그의 시각이며 Go 오퍼레이터와의 직접 비교는 하지 않았습니다.
LWS 직접 시험
일회용 단일 노드 k3s(v1.36.5, 4 vCPU, 4GiB 가상 머신)에 LWS v0.11.1 을 설치하고, busybox sleep 파드로 그룹의 구조와 복구, 롤링 업데이트, 스케일을 확인했습니다. GPU 와 모델은 쓰지 않았으므로 모델 적재 시간은 들어 있지 않고, 시간은 0.5초 간격으로 쟀습니다.
| 확인한 것 | 결과 |
|---|---|
| 설치 | 매니페스트 적용부터 컨트롤러 준비까지 17초. CRD 는 LeaderWorkerSet 외에 DisaggregatedSet, DisaggregatedSetRoleScaler 도 함께 설치됨 |
| 그룹 만들기 | replicas: 2, size: 3 이면 파드 6개가 4.8초 만에 모두 Running. 이름은 리더 demo-0, 워커 demo-0-1, demo-0-2, 두 번째 그룹 demo-1, demo-1-1, demo-1-2 |
| 만들어진 하위 리소스 | 리더 StatefulSet 하나(demo, 그룹 수만큼), 그룹마다 워커 StatefulSet 하나(demo-0, demo-1), 모든 그룹이 공유하는 헤드리스 서비스 하나(demo). 파드의 소유자는 리더가 StatefulSet/demo, 워커가 StatefulSet/demo-0 |
| 파드 라벨 | leaderworkerset.sigs.k8s.io/ 아래 name, group-index, worker-index, group-key(그룹마다 고유한 해시), template-revision-hash |
| 주입된 환경 변수 | LWS_LEADER_ADDRESS(예: demo-1.demo.default, 워커도 자기 그룹 리더의 주소), LWS_GROUP_SIZE=3, LWS_WORKER_INDEX(리더 0, 워커 1부터) |
| 기본값 | rolloutStrategy: RollingUpdate, maxSurge: 0, maxUnavailable: 1, partition: 0(6절 서술과 일치). startupPolicy: LeaderCreated, networkConfig.subdomainPolicy: Shared, restartPolicy: RecreateGroupOnPodRestart |
-
워커 하나가 죽으면 그룹 전체가 다시 만들어집니다(기본 정책). 그룹 0 의 워커를 강제로 지우자 약 2.0초 뒤 같은 그룹의 파드 3개(리더 1개와 워커 2개) 모두 새 UID 가 되었고, 그룹 1 의 파드 3개는 UID 가 그대로였습니다. 다중 노드 추론에서 랭크 하나가 사라지면 나머지가 쓸모없어지는 구조를 그룹 단위 재시작이 반영한 것입니다.
-
restartPolicy: None이면 지워진 파드만 다시 생깁니다(약 1.9초). 같은 시험에서 새 UID 는 그 워커 하나뿐이었습니다. 그룹을 자동으로 되살리는 것이 이득인지 부담인지는 모델 적재 시간에 달려 있으며, 이 시험은 적재 시간이 없어서 그 비용을 보여 주지 못합니다(미실측). -
롤링 업데이트는 서수가 큰 그룹부터 한 그룹씩 진행했습니다. 템플릿 값을 바꾸자 1초 시점에
demo-1그룹이 새 개정으로 Pending, 3초에demo-1이 Running 이고demo-0이 새 개정으로 교체 중, 4초에 모두 새 개정으로 Running 이었습니다(관찰 루프 종료까지 8초). 모델 적재가 없어서 한 그룹이 몇 초 만에 끝났고, 실제 서빙에서는 그룹 하나가 준비될 때까지 다음 그룹을 기다리므로 훨씬 길어질 것입니다(추정). -
스케일 서브리소스가 동작합니다. replicas 를 3 으로 올리면 파드 9개가 2.5초 만에 Running 이 되었고, 1 로 내리면
demo-0,demo-0-1,demo-0-2만 남았습니다(번호가 큰 그룹부터 제거). -
LWS 는 갱 스케줄링을 하지 않습니다. 파드마다 1 CPU 를 요청하는 크기 4 의 그룹 둘(8개)을 CPU 여유가 0.8 인 노드에 만들자, 리더 하나만 Running 이고 나머지 7개는
Insufficient cpu로 Pending 이었습니다. 그룹 수를 1 로 줄여도 리더만 Running 이고 워커 3개는 계속 Pending 이었습니다. 리더가 자원을 쥔 채 워커를 기다리는 모양이며, 갱 스케줄링 도구(Kueue, Volcano)가 필요한 이유가 같은 곳에서 나옵니다. 10편의 시험에서는 두 도구가 한 작업만 받아들여 이런 교착을 피했습니다. -
partition으로 카나리가 됩니다. 그룹 4개(size: 2)에서partition: 2로 템플릿 값을 바꾸자 서수 2 이상의 그룹(can-2,can-3)만 5초 안에 새 개정이 되었고, 서수 0 과 1 은 옛 개정(해시 7596)으로 남았습니다. 이어서partition: 0으로 내리자 나머지 두 그룹도 4초 안에 새 개정으로 갔습니다. 6절의 서술(서수가 큰 그룹부터, partition 이상만 갱신)과 일치합니다. -
maxSurge: 1이면 그룹이 임시로 하나 늘었다가 줄어듭니다. 그룹 4개에서 템플릿을 바꾸자can-4(파드 2개)가 추가로 생겨 파드 수가 8개에서 최대 10개가 되었고, 교체가 끝나자 다시 8개가 되었습니다(전체 약 9초).maxUnavailable: 0이어도 같은 모양이었고 교체 중에도 Running 그룹이 매번 4개 이상이었습니다. 1~2초 간격의 관찰로는maxUnavailable이 1 일 때와 0 일 때의 차이를 가려내지 못했습니다. 추가 그룹이 생긴다는 것은 실제 GPU 환경에서 그룹 하나만큼의 여분 GPU 가 있어야 한다는 뜻입니다(여기서는sleep파드라 비용이 없었습니다). -
둘 다 0 이면 어드미션 웹훅이 거절합니다.
maxSurge: 0에서maxUnavailable: 0으로 바꾸려 하자spec.rolloutStrategy.rollingUpdateConfiguration.maxUnavailable: Invalid value: 0: must not be 0 when maxSurge is 0오류로 막혔습니다.
한계: 가상 머신 한 대, 파드가 하는 일은 sleep, 이미지는 이미 받아 둔 busybox이고 준비 상태 검사가 없습니다. 노드 장애로 인한 그룹 복구와 DisaggregatedSet 은 시험하지 않았습니다(미실측).
확인한 버전과 날짜 (2026-10-05)
LWS v0.11.1(2026-10-01), KServe v0.21.0(2026-09-25, 문서는 0.20 페이지), Dynamo v1.5.0(2026-09-21), controller-runtime v0.25.2(2026-10-01), kubebuilder v4.16.0(2026-09-10), kopf 1.44.6(2026-06-03), Argo CD v3.5.3(2026-09-14), LMCache v0.5.5, Kubernetes v1.37.1(2026-09-23).
확인하지 못한 것. StorageVersionMigration 의 기본 활성 여부(쿠버네티스 문서 안에서 서술이 엇갈림), kopf의 대규모 클러스터 성능 한계, 컨트롤러 단위 시험 도구(envtest) 세부, ValidatingAdmissionPolicy 와 웹훅의 득실, KServe 최신 문서와 0.21.0 사이의 차이, Dynamo의 DGD 배포 모드별 정확한 하위 리소스 생성 경로(CRD 설명과 문서로 도식을 그렸으며 코드 경로는 추적하지 않음). 9절의 kopf 스켈레톤은 제가 문서를 보고 쓴 것으로 실제 클러스터에서 실행해 검증하지 않았습니다.