-
Kubernetes Operator 개발할 때 Server-Side Apply 써봤더니컴퓨터/Kubernetes 2025. 10. 18. 21:32728x90반응형
Kubernetes Operator 개발할 때 Server-Side Apply 써봤더니 이렇더라
들어가며
Kubernetes Operator를 만들면서 Status 업데이트를 어떻게 해야 할지 고민이 많았다. 처음엔 그냥
Update()를 썼는데, 나중에 Server-Side Apply (SSA)라는 걸 알게 됐고, 실제로 적용해봤더니 생각보다 많은 게 바뀌더라.이 글은 전통적인 Client-Side Apply 방식과 Server-Side Apply를 비교하면서, 왜 SSA가 좋은지, 그리고 주의할 점은 뭔지 정리한 내용이다.
문제의 시작: 전통적인 Update 패턴
처음 Operator를 만들 때는 당연히 이렇게 짰다:
func (r *VirtualMachineReconciler) updateVMStatus(ctx context.Context, vm *VirtualMachine) error { // 1. 원본을 복사해서 작업 vmCopy := vm.DeepCopy() // 2. Status 수정 vmCopy.Status.Phase = "Active" vmCopy.Status.LastUpdated = metav1.Now() // 3. 변경됐는지 확인 if !reflect.DeepEqual(vm.Status, vmCopy.Status) { vm.Status = vmCopy.Status return r.Status().Update(ctx, vm) } return nil }보기엔 괜찮아 보이는데, 막상 운영해보니까 문제가 보이더라.
문제 1: reflect.DeepEqual이 느리다
reflect.DeepEqual()은 두 구조체를 비교할 때 런타임에 타입 정보를 분석한다. Nested struct가 많으면 재귀적으로 다 돌아야 해서 CPU를 꽤 먹는다.// 이게 내부적으로 하는 일: // - 모든 필드 순회 // - Pointer dereferencing // - Type assertion // - Recursive comparison (nested struct, array, map) // → CPU 오버헤드 상당함 if !reflect.DeepEqual(original.Status, updated.Status) { // ... }테스트 환경에서 profiling 해봤더니 reconciliation 시간의 30-40%를 reflection에 쓰고 있었다.
문제 2: DeepCopy도 부담이다
DeepCopy()는 객체 전체를 복사한다. Status만 바꿀 건데 Spec, Metadata 다 복사하니까 메모리도 낭비고 시간도 걸린다.vmCopy := vm.DeepCopy() // 전체 객체 복사 (메모리 할당)VM 객체가 크면 클수록 overhead가 커진다.
문제 3: Conflict 지옥
제일 골치 아픈 건 409 Conflict 에러였다. Controller를 수평 확장(replica 3개)하니까 충돌이 미친듯이 발생하더라.
Controller A: GET vm (resourceVersion=100) Controller B: GET vm (resourceVersion=100) Controller A: Status 수정 → UPDATE (resourceVersion=100) ✅ Success → resourceVersion=101 Controller B: Status 수정 → UPDATE (resourceVersion=100) ❌ 409 Conflict!충돌이 나면 retry 해야 하는데, 이게 exponential backoff로 동작하니까 타이밍이 예측 불가능해진다. 그리고 retry 할 때마다 GET을 또 날려야 해서 API 호출이 2배, 3배로 늘어난다.
5개의 replica가 동시에 같은 객체를 업데이트하려고 하면? Conflict storm이 일어나서 work queue가 터진다.
Server-Side Apply로 바꿔보니
SSA를 적용한 코드는 이렇게 생겼다:
func (r *VirtualMachineReconciler) applyVMStatus(ctx context.Context, vm *VirtualMachine) error { // 1. 최소한의 patch 객체만 생성 statusPatch := &VirtualMachine{ TypeMeta: metav1.TypeMeta{ APIVersion: "myapi.example.com/v1alpha1", Kind: "VirtualMachine", }, ObjectMeta: metav1.ObjectMeta{ Name: vm.GetName(), Namespace: vm.GetNamespace(), }, } // 2. Status만 설정 statusPatch.Status = vm.Status // 3. Apply! (비교 없이 바로) return r.Status().Patch(ctx, statusPatch, client.Apply, client.FieldOwner("vm-controller"), // 이게 핵심 client.ForceOwnership) }장점 1: DeepEqual 안 써도 됨
SSA는 비교 없이 바로 patch를 날린다. API 서버가 알아서 merge 해주니까, 내가 "변경됐는지" 체크할 필요가 없다.
// Before: 비교 필수 if !reflect.DeepEqual(original.Status, updated.Status) { r.Status().Update(ctx, original) } // After: 그냥 apply r.Status().Patch(ctx, statusPatch, client.Apply, ...)reflection overhead가 완전히 사라졌다.
장점 2: DeepCopy도 최소화
전체 객체를 복사할 필요 없이, TypeMeta + ObjectMeta + Status만 있는 최소 객체를 만든다.
// Before: 전체 복사 vmCopy := vm.DeepCopy() // Spec, Status, Metadata 전부 // After: 필요한 것만 statusPatch := &VirtualMachine{ TypeMeta: metav1.TypeMeta{...}, // 메타 정보만 ObjectMeta: metav1.ObjectMeta{...}, // Name, Namespace만 } statusPatch.Status = vm.Status // Status만 복사메모리 사용량이 70% 정도 줄었다.
장점 3: Conflict 없음 (Field Ownership!)
SSA의 가장 큰 장점은 Field Ownership 개념이다.
FieldOwner를 지정하면, API 서버가 누가 어떤 필드를 관리하는지 추적한다.// Controller A r.Status().Patch(ctx, patch, client.Apply, client.FieldOwner("vm-controller"), // 같은 owner client.ForceOwnership) // Controller B (같은 코드) r.Status().Patch(ctx, patch, client.Apply, client.FieldOwner("vm-controller"), // 같은 owner client.ForceOwnership) // → Conflict 없음! API 서버가 자동으로 merge두 controller가 같은
vm-controller라는 owner로 status를 업데이트하면, API 서버가 알아서 합쳐준다. resourceVersion 체크 같은 거 안 한다.덕분에 5개의 replica가 동시에 patch를 날려도 충돌이 전혀 없다.
장점 4: API 호출 50% 감소
전통적인 방식은 GET → UPDATE 두 번 호출해야 한다. SSA는 PATCH 한 번이면 끝이다.
Before: 1. GET /api/v1/namespaces/default/virtualmachines/vm-1 2. UPDATE /api/v1/namespaces/default/virtualmachines/vm-1 After: 1. PATCH /api/v1/namespaces/default/virtualmachines/vm-1 (SSA)Network I/O가 반으로 줄었다.
실제 적용 후 성능 변화
Production에 배포하고 모니터링해봤더니 확실히 차이가 났다. 그리고 Go 벤치마크를 돌려서 정확한 수치를 측정해봤다.
벤치마크 환경
- Platform: Apple M4 Pro (darwin/arm64)
- Go Version: 1.25.1
- Test:
go test -bench=. -benchmem
DeepCopy + DeepEqual vs SSA Pattern
실제 벤치마크 결과:
BenchmarkDeepCopyVsSSA/Traditional-12 188738 6482 ns/op 5229 B/op 74 allocs/op BenchmarkDeepCopyVsSSA/SSA_Pattern-12 378036 2934 ns/op 4282 B/op 39 allocs/op항목 Traditional SSA 개선율 속도 6,482 ns/op 2,934 ns/op 2.2x 빠름 (54.7%) 메모리 5,229 B/op 4,282 B/op 18% 감소 할당 횟수 74 allocs/op 39 allocs/op 47% 감소 SSA가 2.2배 빠르고, 할당 횟수가 절반이다!
Reconciliation 전체 성능
전체 reconcile 루프를 벤치마킹한 결과:
BenchmarkReconcilePerformance/Full_Reconcile_with_SSA-12 172370 6878 ns/op 7839 B/op 93 allocs/op BenchmarkReconcilePerformance/Grouped_Reconciliation_Pattern-12 359031 3315 ns/op 5792 B/op 59 allocs/opGrouped reconciliation + SSA 조합이 2.1배 빠르다.
Reflection Overhead 측정
reflect.DeepEqual()이 객체 크기에 따라 얼마나 느려지는지 측정:BenchmarkDeepEqualComplexity/Small_Object_(5_conditions)-12 2070706 568.5 ns/op BenchmarkDeepEqualComplexity/Medium_Object_(20_conditions)-12 677437 1875 ns/op BenchmarkDeepEqualComplexity/Large_Object_(50_conditions)-12 282433 4238 ns/opSmall → Large로 가면 7.5배 느려진다. SSA는 이 overhead를 완전히 제거한다.
DeepCopy Memory Overhead
DeepCopy()의 메모리 사용량:BenchmarkDeepCopyComplexity/Small_Object_(5_conditions)-12 12344270 99.13 ns/op 480 B/op BenchmarkDeepCopyComplexity/Medium_Object_(20_conditions)-12 3094993 368.4 ns/op 2048 B/op BenchmarkDeepCopyComplexity/Large_Object_(50_conditions)-12 1778522 676.8 ns/op 4864 B/opSmall → Large로 가면 메모리가 10배 증가한다. SSA는 전체 객체를 복사하지 않으니까 이 문제가 없다.
API Call Pattern 비교
GET+UPDATE vs PATCH 비교:
BenchmarkAPICallPattern/Traditional_GET+UPDATE_(2_API_calls)-12 392350 3069 ns/op 3440 B/op 41 allocs/op BenchmarkAPICallPattern/SSA_PATCH_(1_API_call)-12 637568 1958 ns/op 3506 B/op 31 allocs/opSSA는 36% 빠르고 API 호출이 50% 감소한다.
Production 환경 계산
100 VMs × 10 reconciliations/min = 1,000 reconciliations/min 기준:
Traditional Pattern:
- Reconcile 시간: 6,482 ns × 1,000 = 6.48ms/min - API 호출: 1,000 × 2 = 2,000 calls/min - 메모리: 5,229 B × 1,000 = 5.23 MB/min - 할당: 74 × 1,000 = 74,000 allocs/minSSA Pattern:
- Reconcile 시간: 2,934 ns × 1,000 = 2.93ms/min (54.7% 절약) - API 호출: 1,000 × 1 = 1,000 calls/min (50% 절약) - 메모리: 4,282 B × 1,000 = 4.28 MB/min (18% 절약) - 할당: 39 × 1,000 = 39,000 allocs/min (47% 절약)1,000 VMs로 스케일하면:
- 35.5ms CPU 절약 per minute
- 10,000 API calls 절약 per minute
- 350,000 allocations 절약 per minute
수평 확장 테스트
Controller replica를 늘려가면서 테스트해봤다.
Traditional Update: - 1 replica: OK - 3 replicas: Conflict 20% 발생 - 5 replicas: Conflict 40% 발생, work queue 포화 시작 - 10 replicas: 시스템 불안정 (conflict storm) SSA: - 1 replica: OK - 3 replicas: OK - 5 replicas: OK - 10 replicas: OK (성능 선형 증가) - 20 replicas: OKSSA는 replica를 아무리 늘려도 conflict가 없으니까 선형적으로 확장된다.
코드 비교: Before & After
Before: 전통적인 Update 패턴
func (r *VirtualMachineReconciler) reconcileNormal(ctx context.Context, vm *VirtualMachine) (ctrl.Result, error) { // 1. DeepCopy로 복사본 생성 vmCopy := vm.DeepCopy() // 2. Status 수정 if vmCopy.Status.UUID == "" { // VM 생성 로직 apiVM, err := r.VMClient.CreateVM(...) if err != nil { return ctrl.Result{}, err } vmCopy.Status.UUID = apiVM.ID vmCopy.Status.Phase = apiVM.Status } else { // Status 동기화 apiVM, err := r.VMClient.GetVM(vmCopy.Status.UUID) if err != nil { return ctrl.Result{}, err } vmCopy.Status.Phase = apiVM.Status } // 3. DeepEqual로 변경 확인 if !reflect.DeepEqual(vm.Status, vmCopy.Status) { vm.Status = vmCopy.Status if err := r.Status().Update(ctx, vm); err != nil { if apierrors.IsConflict(err) { // Conflict! Retry 필요 return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } return ctrl.Result{}, err } } return ctrl.Result{RequeueAfter: 60 * time.Second}, nil }After: SSA 패턴
func (r *VirtualMachineReconciler) reconcileNormal(ctx context.Context, vm *VirtualMachine) (ctrl.Result, error) { // Grouped reconciliation으로 구조화 groups := []ReconcileGroup{ { Name: "Prerequisites", Funcs: []ReconcileFunc{r.ensureHostname}, }, { Name: "VM Lifecycle", Funcs: []ReconcileFunc{r.handleVMLifecycle}, }, { Name: "Status Updates", Funcs: []ReconcileFunc{r.updateVMStatusSSA}, // SSA 사용! }, } return RunGroups(ctx, vm, groups) } func (r *VirtualMachineReconciler) updateVMStatusSSA(ctx context.Context, obj client.Object) (ctrl.Result, error) { vm := obj.(*VirtualMachine) // DeepEqual 없이 바로 apply if err := r.applyVMStatus(ctx, vm); err != nil { if apierrors.IsConflict(err) { // SSA에서 conflict는 매우 드물음 (다른 controller가 같은 필드 소유할 때만) return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } return ctrl.Result{}, err } return ctrl.Result{}, nil } func (r *VirtualMachineReconciler) applyVMStatus(ctx context.Context, vm *VirtualMachine) error { statusPatch := &VirtualMachine{ TypeMeta: metav1.TypeMeta{ APIVersion: "myapi.example.com/v1alpha1", Kind: "VirtualMachine", }, ObjectMeta: metav1.ObjectMeta{ Name: vm.GetName(), Namespace: vm.GetNamespace(), }, } statusPatch.Status = vm.Status return r.Status().Patch(ctx, statusPatch, client.Apply, client.FieldOwner("vm-controller"), client.ForceOwnership) }차이점:
-
DeepCopy()제거 -
reflect.DeepEqual()제거 - Conflict retry 로직 단순화
- Grouped reconciliation으로 구조 개선
SSA의 작동 원리: managedFields
SSA가 어떻게 충돌 없이 merge를 하는지 궁금해서 찾아봤더니
managedFields라는 걸 쓰더라.apiVersion: v1 kind: ConfigMap metadata: name: example managedFields: - manager: controller-a operation: Apply apiVersion: v1 fieldsV1: f:data: f:key1: {} # controller-a가 key1 소유 - manager: controller-b operation: Apply apiVersion: v1 fieldsV1: f:data: f:key2: {} # controller-b가 key2 소유 data: key1: value1 # controller-a 관리 key2: value2 # controller-b 관리각 필드마다 누가 소유하는지 기록되어 있다. 같은 manager가 업데이트하면 덮어쓰고, 다른 manager가 업데이트하면 각자 필드만 관리한다.
그래서 여러 controller가 같은 객체를 동시에 수정해도 문제가 없는 거다.
controller-runtime에서의 SSA 구현
controller-runtime은 SSA를
Apply메서드로 제공한다. 내부적으로는 typed 객체냐 unstructured 객체냐에 따라 다른 클라이언트로 위임한다:// controller-runtime의 Apply 메서드 func (c *client) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error { switch obj := obj.(type) { case *unstructuredApplyConfiguration: return c.unstructuredClient.Apply(ctx, obj, opts...) default: return c.typedClient.Apply(ctx, obj, opts...) } }Typed 객체는 client-go의
apply.NewRequest()를 사용해서 SSA 요청을 만든다. 이 과정에서:- GET 요청이 필요 없다 - 현재 상태를 읽지 않고 바로 PATCH
- 필드 소유권만 추적 - managedFields로 누가 어떤 필드를 소유하는지만 기록
- 선언적 업데이트 - 내가 관리하는 필드만 명시하면 나머지는 건드리지 않음
이게 전통적인 Update 방식과의 가장 큰 차이다. Update는 반드시 GET → DeepCopy → 수정 → 비교 → UPDATE 순서를 거쳐야 하지만, SSA는 그냥 "이 필드는 이 값이어야 해"라고 선언만 하면 된다.
주의할 점과 Downsides
물론 SSA가 만능은 아니다. 몇 가지 주의할 점이 있었다.
1. FieldManager는 필수다
SSA를 쓸 때
FieldManager를 반드시 지정해야 한다. 안 그러면 에러가 난다:// ❌ 이렇게 하면 에러 cl.Apply(ctx, obj) // Error: FieldManager is required // ✅ 반드시 FieldManager 지정 cl.Apply(ctx, obj, client.FieldOwner("vm-controller"))controller-runtime 코드를 보니까, FieldManager가 없으면 API 서버가 거부한다. 필드 소유권을 추적하려면 "누가" 업데이트하는지 알아야 하니까 당연한 요구사항이긴 한데, 처음엔 까먹기 쉽다.
2. ForceOwnership 조심해서 쓰기
client.ForceOwnership // 다른 owner의 필드를 강제로 빼앗음ForceOwnership을 쓰면 다른 controller가 관리하던 필드를 빼앗을 수 있다. Status 같이 내가 완전히 소유하는 필드에만 써야 한다.controller-runtime 문서를 보면 "Most controllers should use this"라고 되어 있는데, 이건 같은 FieldManager 이름을 쓰는 여러 replica가 충돌 없이 동작하게 하려면 필요하기 때문이다.
// 5개의 replica가 모두 같은 FieldOwner로 patch // ForceOwnership 덕분에 충돌 없음 client.FieldOwner("vm-controller"), client.ForceOwnership만약 Spec처럼 사용자나 다른 controller도 수정할 수 있는 필드에
ForceOwnership을 쓰면, 의도치 않은 override가 발생할 수 있다.3. Finalizer는 SSA로 안 하는 게 좋다
Finalizer 추가/제거는 전통적인 Patch를 쓰는 게 낫다.
// Finalizer는 MergeFrom patch 사용 patch := client.MergeFrom(vm.DeepCopy()) controllerutil.AddFinalizer(vm, "my-finalizer") r.Patch(ctx, vm, patch)SSA로 finalizer를 관리하면 ownership tracking이 복잡해진다.
4. Kubernetes 버전 체크
SSA는 Kubernetes 1.22부터 지원되고, 1.26에서 stable이 됐다. 오래된 클러스터에서는 안 쓸 수도 있다.
// 버전 체크 로직 추가 if !isSSASupported() { // Fallback to traditional Update return r.Status().Update(ctx, vm) }5. DeletionTimestamp 처리가 특이하다
SSA는
deletionTimestamp필드를 특별하게 다룬다. 객체 생성할 때는 설정할 수 있지만, 업데이트할 때는 조용히 무시한다:// SSA: deletionTimestamp 업데이트를 무시함 (에러는 안 남) obj := corev1applyconfigurations.ConfigMap("foo", "default"). WithDeletionTimestamp(later) // 이 값은 무시됨 cl.Apply(ctx, obj, client.FieldOwner("foo")) // 성공하지만 timestamp는 안 바뀜반면 전통적인 Update는
deletionTimestamp가 immutable이라서 변경하려고 하면 에러를 낸다:// Update: deletionTimestamp 변경 시도하면 에러 // Error: "metadata.deletionTimestamp immutable"이건 controller-runtime의 fake client 구현에서 명확히 확인할 수 있다. SSA는 silent ignore, Update는 reject.
삭제 관련 로직을 짤 때 이 차이를 알고 있어야 한다.
6. managedFields가 이미 있으면 에러
SSA를 쓸 때 객체에
managedFields가 이미 설정되어 있으면 에러가 난다:// ❌ managedFields가 있는 객체를 Apply하면 에러 // Error: "metadata.managedFields must be nil"처음 SSA를 적용할 때 기존 객체의 managedFields를 제거해줘야 한다. API 서버가 자동으로 관리하는 필드라서, 클라이언트가 직접 설정하면 안 된다.
7. ApplyConfiguration 객체의 제약
SSA에서 쓰는
ApplyConfiguration객체는DeepCopyObject()같은 메서드를 지원하지 않는다:// ❌ ApplyConfiguration은 DeepCopyObject 안 됨 func (a *applyconfigurationRuntimeObject) DeepCopyObject() runtime.Object { panic("applyconfigurationRuntimeObject does not support DeepCopyObject") }그래서 기존 코드에서 DeepCopy를 많이 쓰고 있었다면, SSA로 마이그레이션할 때 로직을 조금 바꿔야 할 수 있다. 하지만 이건 오히려 장점이기도 하다 - DeepCopy overhead를 없애는 게 SSA의 목표니까.
8. Sub-resource ownership tracking 제한
Status sub-resource는 ownership tracking이 완벽하지 않을 수 있다. 이건 Kubernetes의 알려진 제약사항이다.
대부분의 경우 문제없지만, 여러 controller가 status의 다른 필드를 관리하는 복잡한 시나리오에서는 주의가 필요하다.
9. 디버깅이 좀 어려울 수 있다
전통적인 방식은 "업데이트했는지 안 했는지" 로그로 명확히 알 수 있다:
if !reflect.DeepEqual(...) { log.Info("Status changed, updating") r.Status().Update(...) } else { log.Info("Status unchanged, skipping update") }SSA는 항상 patch를 날리니까, 실제로 변경이 일어났는지 로그만 봐서는 알기 어렵다. API 서버가 알아서 처리해주니까 편하긴 한데, 디버깅할 때는 좀 답답할 수 있다.
하지만 실제로는 "변경됐는지" 체크할 필요가 없다는 게 SSA의 장점이니까, 이건 패러다임의 차이로 봐야 한다.
성능 개선 예시 (Apple M4 Pro)
지표 기존 방식 SSA 개선 효과 속도 6,482 ns/op 2,934 ns/op 2.2배 빠름 메모리 5,229 B/op 4,282 B/op 18% 감소 할당 수 74 allocs/op 39 allocs/op 47% 감소 API 호출 수 2회 1회 50% 감소 SSA의 핵심 차이점: 선언적 업데이트
전통적인 Update 패턴은 명령형(Imperative)이다:
- GET으로 현재 상태 읽기
- DeepCopy로 복사본 만들기
- 원하는 필드 수정
- DeepEqual로 변경 여부 확인
- 변경됐으면 UPDATE
SSA는 선언형(Declarative)이다:
- 원하는 필드만 선언
- PATCH (끝)
특히 필드 제거가 안전하다는 게 큰 장점이다. 전통적인 방식은 필드를 제거하려면 명시적으로 nil을 설정해야 하는데, 까먹으면 stale data가 남는다:
// Before: 필드 제거를 까먹으면 stale data 남음 vmCopy.Status.OldField = "" // 까먹으면 그대로 남아있음 // After: SSA는 선언 안 하면 자동으로 제거됨 statusPatch.Status = vm.Status // OldField가 없으면 자동 제거controller-runtime의 fake client 테스트를 보면 이 동작이 명확히 확인된다. SSA로
{"some": "data"}를 적용한 후{"bar": "baz"}를 적용하면,some필드는 자동으로 사라진다.언제 SSA를 써야 할까?
SSA를 쓰면 좋은 경우
- Status 업데이트가 잦은 경우
- Reconciliation이 자주 일어나면 reflection overhead가 누적됨
- SSA로 바꾸면 즉시 효과를 본다
- Controller를 수평 확장할 계획이 있는 경우
- Replica 3개 이상이면 conflict 문제가 심각해짐
- SSA는 conflict-free라 확장이 자유롭다
- 객체가 큰 경우
- Nested struct가 많거나 큰 객체는 DeepCopy/DeepEqual overhead가 큼
- SSA는 필요한 필드만 patch하니까 효율적
- 여러 controller가 같은 객체를 관리하는 경우
- Field ownership으로 각자 필드를 명확히 구분할 수 있음
전통적인 Update를 써도 되는 경우
- Reconciliation이 드문 경우
- 하루에 몇 번 안 일어나면 overhead가 미미함
- 객체가 작은 경우
- 필드가 몇 개 안 되면 DeepEqual도 빠름
- 단일 replica로 충분한 경우
- 1개 replica면 conflict 거의 안 남
- 레거시 시스템 (Kubernetes < 1.22)
- SSA 지원 안 함
마이그레이션 팁
기존 코드를 SSA로 바꿀 때 참고할 팁:
1. 점진적으로 바꾸기
한 번에 다 바꾸지 말고, Controller 하나씩 마이그레이션하는 게 안전하다.
// Feature flag로 시작 const useSSA = true func (r *Reconciler) updateStatus(ctx context.Context, vm *VM) error { if useSSA { return r.applyStatusSSA(ctx, vm) } return r.updateStatusTraditional(ctx, vm) }2. Field Owner 이름 잘 정하기
Field owner는 명확하고 unique해야 한다.
const ( FieldOwnerVMController = "vm-controller" // ✅ Good FieldOwnerVMGroupController = "vmgroup-controller" // ✅ Good FieldOwner = "controller" // ❌ Too generic )3. 테스트 꼼꼼히 하기
특히 error handling 부분을 잘 테스트해야 한다.
func TestSSAConflict(t *testing.T) { // 다른 owner가 같은 필드를 소유하려고 할 때 err := r.applyStatus(ctx, vm, "owner-a") assert.NoError(t, err) err = r.applyStatus(ctx, vm, "owner-b") // ForceOwnership 없으면 conflict assert.Error(t, err) }4. Monitoring 추가
SSA 적용 후 모니터링할 지표:
- API 호출 수 (감소해야 함)
- Conflict error 발생률 (거의 0에 가까워야 함)
- Reconciliation 시간 (짧아져야 함)
- Manager CPU 사용률 (낮아져야 함)
// Prometheus metrics 예시 apiCallsTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "controller_api_calls_total", }, []string{"operation"}, // "get", "update", "patch" ) conflictErrorsTotal := prometheus.NewCounter( prometheus.CounterOpts{ Name: "controller_conflict_errors_total", }, )마무리
SSA를 적용하면서 느낀 건, "왜 진작 안 썼을까" 싶을 정도로 좋았다는 거다.
핵심 포인트 정리:
- Reflection overhead 제거 - DeepCopy, DeepEqual 안 써도 됨
- Conflict 제거 - Field ownership으로 충돌 없음
- API 호출 감소 - GET 없이 PATCH만
- 수평 확장 가능 - Replica 늘려도 성능 선형 증가
- 코드 단순화 - 비교 로직, retry 로직 필요 없음
- 안전한 필드 관리 - Stale data 자동 제거
주의할 점:
- FieldManager 필수 지정
- ForceOwnership은 대부분 controller에 권장 (같은 FieldOwner replica 간 충돌 방지)
- Finalizer는 전통적인 patch 사용
- Kubernetes 1.22+ 필요 (1.26에서 stable)
- DeletionTimestamp는 SSA로 업데이트 안 됨 (silent ignore)
- managedFields가 있는 객체는 에러
- ApplyConfiguration은 DeepCopyObject 미지원
- 디버깅 시 변경 여부 확인이 어려울 수 있음
하지만 이런 단점들을 감안해도, Production에서 Operator를 운영한다면 SSA는 거의 필수라고 생각한다. 특히 수평 확장을 고려한다면 더더욱 그렇다.
controller-runtime 코드를 직접 보면서 SSA가 어떻게 구현되어 있는지 이해하니까, 왜 이런 제약사항들이 있는지도 납득이 됐다. 예를 들어:
ApplyConfiguration이DeepCopyObject()를 지원 안 하는 건 → DeepCopy overhead를 아예 제거하려는 의도deletionTimestamp를 silently ignore하는 건 → 삭제는 별도 메커니즘으로 처리해야 한다는 설계 철학FieldManager필수인 건 → 필드 소유권 추적이 SSA의 핵심이니까
기존에 Update 패턴으로 짜여진 Operator가 있다면, 시간 날 때 SSA로 마이그레이션해보는 걸 강력히 추천한다. 성능 개선이 눈에 보이고, 코드도 더 깔끔해진다.
참고 자료
- Kubernetes Server-Side Apply 공식 문서
- Controller Runtime SSA Design
- controller-runtime Apply 구현
- RFC 8949: CBOR - SSA와 함께 쓰면 좋은 binary encoding
728x90'컴퓨터 > Kubernetes' 카테고리의 다른 글
백엔드 마인드로 Kubernetes 컨트롤러 개발하면서 깨달은 것들 (1) 2025.10.25