ABOUT ME

-

Total
-
  • Kubernetes Operator 개발할 때 Server-Side Apply 써봤더니
    컴퓨터/Kubernetes 2025. 10. 18. 21:32
    728x90
    반응형

    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/op

    Grouped 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/op

    Small → 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/op

    Small → 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/op

    SSA는 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/min

    SSA 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: OK

    SSA는 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 요청을 만든다. 이 과정에서:

    1. GET 요청이 필요 없다 - 현재 상태를 읽지 않고 바로 PATCH
    2. 필드 소유권만 추적 - managedFields로 누가 어떤 필드를 소유하는지만 기록
    3. 선언적 업데이트 - 내가 관리하는 필드만 명시하면 나머지는 건드리지 않음

    이게 전통적인 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)이다:

    1. GET으로 현재 상태 읽기
    2. DeepCopy로 복사본 만들기
    3. 원하는 필드 수정
    4. DeepEqual로 변경 여부 확인
    5. 변경됐으면 UPDATE

    SSA는 선언형(Declarative)이다:

    1. 원하는 필드만 선언
    2. 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를 쓰면 좋은 경우

    1. Status 업데이트가 잦은 경우
      • Reconciliation이 자주 일어나면 reflection overhead가 누적됨
      • SSA로 바꾸면 즉시 효과를 본다
    2. Controller를 수평 확장할 계획이 있는 경우
      • Replica 3개 이상이면 conflict 문제가 심각해짐
      • SSA는 conflict-free라 확장이 자유롭다
    3. 객체가 큰 경우
      • Nested struct가 많거나 큰 객체는 DeepCopy/DeepEqual overhead가 큼
      • SSA는 필요한 필드만 patch하니까 효율적
    4. 여러 controller가 같은 객체를 관리하는 경우
      • Field ownership으로 각자 필드를 명확히 구분할 수 있음

    전통적인 Update를 써도 되는 경우

    1. Reconciliation이 드문 경우
      • 하루에 몇 번 안 일어나면 overhead가 미미함
    2. 객체가 작은 경우
      • 필드가 몇 개 안 되면 DeepEqual도 빠름
    3. 단일 replica로 충분한 경우
      • 1개 replica면 conflict 거의 안 남
    4. 레거시 시스템 (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를 적용하면서 느낀 건, "왜 진작 안 썼을까" 싶을 정도로 좋았다는 거다.

    핵심 포인트 정리:

    1. Reflection overhead 제거 - DeepCopy, DeepEqual 안 써도 됨
    2. Conflict 제거 - Field ownership으로 충돌 없음
    3. API 호출 감소 - GET 없이 PATCH만
    4. 수평 확장 가능 - Replica 늘려도 성능 선형 증가
    5. 코드 단순화 - 비교 로직, retry 로직 필요 없음
    6. 안전한 필드 관리 - 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가 어떻게 구현되어 있는지 이해하니까, 왜 이런 제약사항들이 있는지도 납득이 됐다. 예를 들어:

    • ApplyConfigurationDeepCopyObject()를 지원 안 하는 건 → DeepCopy overhead를 아예 제거하려는 의도
    • deletionTimestamp를 silently ignore하는 건 → 삭제는 별도 메커니즘으로 처리해야 한다는 설계 철학
    • FieldManager 필수인 건 → 필드 소유권 추적이 SSA의 핵심이니까

    기존에 Update 패턴으로 짜여진 Operator가 있다면, 시간 날 때 SSA로 마이그레이션해보는 걸 강력히 추천한다. 성능 개선이 눈에 보이고, 코드도 더 깔끔해진다.

    참고 자료

    728x90

    댓글