ABOUT ME

-

Total
-
  • 백엔드 마인드로 Kubernetes 컨트롤러 개발하면서 깨달은 것들
    컴퓨터/Kubernetes 2025. 10. 25. 21:45
    728x90
    반응형
    "아, 이거 그냥 요청당 핸들러 돌리는 거 아니었구나..."

     

    며칠 전부터 Kubernetes Operator/Controller를 개발하기 시작했다.

    백엔드 개발만 주로 하다가 K8s 생태계로 넘어오니까 솔직히 개념이 머리에 잘 안 들어왔다.

    특히 이런 질문들이 머릿속을 떠나지 않았다:

    • 컨트롤러가 뭐지? 고루틴 하나인가?
    • 유저가 UI 클릭하면 내 백엔드 API가 컨트롤러를 실행시키는 건가?
    • 요청마다 컨트롤러가 새로 뜨는 건가?
    • Owner Reference가 뭔데 "sole controller"라는 말이 나오지?

     

    백엔드 개발자라면 공감할 것이다:

    // 내가 아는 세상 (Echo/Gin 같은 백엔드)
    func CreateRedis(c echo.Context) error {
        // 요청 들어옴 → 핸들러 실행 → 응답 반환
        // 요청마다 독립적으로 실행됨
    }

    근데 K8s 컨트롤러는 완전히 다른 세상이었다.


     

    첫 번째 착각: "컨트롤러 = 요청당 실행되는 핸들러"

    내가 처음에 상상한 것

    Redis Operator를 만든다고 치자. 내 머릿속 시나리오는 이랬다:

    1. 유저가 UI에서 "Redis 생성" 버튼 클릭
    2. 내 백엔드 API (POST /create-redis) 호출됨
    3. 각 유저 요청마다 컨트롤러 인스턴스가 실행됨
    4. 컨트롤러가 뭔가 처리하고 응답 반환
    // ❌ 내가 상상한 잘못된 모델
    func CreateRedis(c echo.Context) {
        // 요청당 새로운 컨트롤러가 실행?
        controller := NewRedisController()
        controller.Run()
    }

    결론부터 말하자면, 이건 완전히 틀렸다.

     

    실제로는 어떻게 동작하는가

    컨트롤러는 단 하나의 long-running 프로세스다.

    요청당 실행되는 게 아니라, 처음부터 끝까지 계속 돌면서 모든 리소스의 변경사항을 감시(watch)한다.

    ┌─────────────────────────────────────────────────────┐
    │ Your Operator Pod (K8s에 배포됨)                    │
    │                                                     │
    │  ┌───────────────────────────────────────────────┐ │
    │  │ Manager (단 한 개!)                           │ │
    │  │                                               │ │
    │  │ Shared 컴포넌트:                              │ │
    │  │  ├─ API Client (K8s API와 통신)              │ │
    │  │  ├─ Cache/Informers (리소스 watch)           │ │
    │  │  └─ Scheme (CRD 타입 인식)                   │ │
    │  │                                               │ │
    │  │ ┌──────────────────────────────────────────┐ │ │
    │  │ │ Redis Controller (하나!)                 │ │ │
    │  │ │  ├─ WorkQueue [이벤트들...]              │ │ │
    │  │ │  ├─ Workers (고루틴 1~10개)              │ │ │
    │  │ │  │   └─ Reconcile() 호출                │ │ │
    │  │ │  └─ Reconcile 로직                       │ │ │
    │  │ └──────────────────────────────────────────┘ │ │
    │  └───────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────┘
            ↑ watches
            │
    ┌───────┴─────────────────────────────────────────────┐
    │ Kubernetes API Server                               │
    │                                                     │
    │  ├─ Redis CR (default/my-redis-1)                  │
    │  ├─ Redis CR (default/my-redis-2)                  │
    │  ├─ Redis CR (prod/redis-cache)                    │
    │  └─ ...                                            │
    └─────────────────────────────────────────────────────┘

    핵심은:

    • 하나의 컨트롤러모든 Redis 리소스를 관리
    • Informer가 K8s API를 지켜보다가 변경사항을 감지
    • ✅ 변경사항이 있으면 WorkQueue에 이벤트 추가
    • Worker 고루틴들이 큐에서 꺼내서 Reconcile() 호출

     

    실제 흐름을 따라가 보자

    사용자 행동:
    ===========
    1. 유저가 UI에서 "Redis Cluster 생성" 클릭
       └─> 내 백엔드 API (Echo/Gin) 호출됨
    
    2. 백엔드 API가 K8s API 호출
       └─> kubectl apply 또는 client-go로 Redis CR 생성
    
    3. K8s API Server가 Redis CR 저장
       (여기까지는 백엔드 API 관점에서 끝!)
    
    4. Informer가 새 Redis CR 감지
       └─> WorkQueue에 이벤트 추가: "default/my-redis created"
    
    5. Worker 고루틴이 큐에서 이벤트 꺼냄
       └─> Reconcile(ctx, Request{Name: "my-redis"}) 호출
    
    6. Reconcile() 실행:
       ├─> Redis CR 읽기
       ├─> StatefulSet 존재 확인
       ├─> 없으면 StatefulSet 생성
       ├─> Service 생성
       └─> return
    
    7. 나중에 유저가 Redis CR 업데이트 (replicas 변경):
       └─> 다시 이벤트가 큐에 추가됨
       └─> Worker가 다시 Reconcile() 호출

    중요한 깨달음: 내 백엔드 API는 컨트롤러를 "직접 호출"하지 않는다!

    백엔드는 그냥 K8s API에 CR(Custom Resource)만 생성하고 끝.

    컨트롤러는 독립적으로 돌면서 리소스 변경을 감지하고 반응한다.


     

    Manager, Reconciler, WorkQueue... 도대체 뭐가 뭐야?

    처음에 코드 봤을 때 이런 것들이 나와서 당황했다:

    // main.go
    func main() {
        // Manager: 전체 operator의 허브
        mgr, _ := ctrl.NewManager(cfg, ctrl.Options{})
    
        // Shared 컴포넌트들 (모든 컨트롤러가 공유)
        client := mgr.GetClient()    // K8s API와 통신
        cache := mgr.GetCache()       // 리소스 캐싱 & watching
    
        // Redis Controller 생성
        redisReconciler := &RedisReconciler{
            Client: client,  // Shared!
            Scheme: mgr.GetScheme(),
        }
    
        // Manager에 등록
        redisReconciler.SetupWithManager(mgr)
    
        // Manager 시작 (여기서 블로킹됨 - 계속 실행됨)
        mgr.Start(ctx)
    }

     

    Shared vs Isolated

    Shared (Manager 레벨, 모든 컨트롤러가 공유):

    • ✅ API Client: K8s API와 통신하는 클라이언트
    • ✅ Cache/Informers: 리소스를 watch하고 캐싱
    • ✅ Scheme: CRD 타입 정보

    Isolated (컨트롤러별로 독립):

    • ✅ WorkQueue: 해당 컨트롤러의 이벤트 큐
    • ✅ Workers: 큐에서 꺼내서 처리하는 고루틴들
    • ✅ Reconcile 로직: 각 컨트롤러만의 비즈니스 로직

    왜 이렇게 나뉘어 있을까? 효율성 때문이다.

    K8s API는 하나만 watch하면서 여러 컨트롤러가 공유할 수 있고, 각 컨트롤러는 자기가 관심 있는 리소스의 이벤트만 큐로 받아서 처리한다.


     

    Reconcile: 컨트롤러의 핵심

    Reconcile 함수가 컨트롤러의 실제 비즈니스 로직이다.

    func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        log := r.Log.WithValues("redis", req.NamespacedName)
    
        // 1. Redis CR 가져오기
        redis := &myapi.Redis{}
        if err := r.Get(ctx, req.NamespacedName, redis); err != nil {
            // 리소스가 삭제된 경우 무시
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // 2. 현재 상태 확인
        // 3. Desired State와 비교
        // 4. 필요하면 리소스 생성/업데이트/삭제
    
        return ctrl.Result{}, nil
    }

     

    Level-triggered vs Edge-triggered

    백엔드 개발할 때는 보통 "이벤트 발생 → 처리" 방식이다 (edge-triggered).

    하지만 Reconcile은 level-triggered다:

    • "무엇이 변경되었는지"가 아니라 "현재 상태가 무엇인지" 확인
    • Desired State와 Current State를 비교해서 차이를 메움
    • 멱등성(idempotent): 같은 입력으로 여러 번 호출해도 안전
    func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        redis := &myapi.Redis{}
        r.Get(ctx, req.NamespacedName, redis)
    
        // "replicas를 3으로 변경했다"가 아니라
        // "현재 replicas가 3이어야 한다"를 확인
        desiredReplicas := redis.Spec.Replicas
    
        sts := &appsv1.StatefulSet{}
        err := r.Get(ctx, types.NamespacedName{Name: redis.Name}, sts)
    
        if err != nil {
            // StatefulSet이 없으면 생성
            r.Create(ctx, newStatefulSet(redis))
        } else if *sts.Spec.Replicas != desiredReplicas {
            // 있는데 replicas가 다르면 업데이트
            sts.Spec.Replicas = &desiredReplicas
            r.Update(ctx, sts)
        }
    
        return ctrl.Result{}, nil
    }

    이 방식이 왜 좋은가?

    • 네트워크 장애로 중간 이벤트를 놓쳐도 괜찮음
    • 언제든 다시 Reconcile 돌리면 최종 상태로 수렴
    • Eventually Consistent: 시간이 지나면 결국 원하는 상태가 됨

     

    Owner Reference와 "Sole Controller"의 비밀

    처음에 이 개념이 제일 헷갈렸다.

    시나리오

    Redis Controller가 StatefulSet을 생성한다고 치자:

    func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        redis := &myapi.Redis{}
        r.Get(ctx, req.NamespacedName, redis)
    
        // StatefulSet 생성 시 Owner Reference 설정
        sts := &appsv1.StatefulSet{
            ObjectMeta: metav1.ObjectMeta{
                Name: redis.Name + "-sts",
                OwnerReferences: []metav1.OwnerReference{
                    {
                        APIVersion: "myapi/v1",
                        Kind:       "Redis",
                        Name:       redis.Name,
                        UID:        redis.UID,
                        Controller: pointer.Bool(true),  // ← 이게 핵심!
                    },
                },
            },
            Spec: appsv1.StatefulSetSpec{...},
        }
    
        r.Create(ctx, sts)
    }

     

    Owner Reference가 하는 일

    Redis CR (name: my-redis)
        │
        │ owns (controller: true)
        ↓
    StatefulSet (name: my-redis-sts)
        ├─ OwnerReference: Redis/my-redis (controller: true)
        │
        │ 여러 컨트롤러가 watch 가능:
        ├─ Redis Controller (OWNER, controller:true)
        ├─ Monitoring Controller (watcher)
        └─ Metrics Controller (watcher)

    "Sole Controller"의 의미:

    • controller: true단 하나의 controller만 가질 수 있음
    • 이게 "책임자"라는 뜻
    • Redis Controller가 이 StatefulSet의 lifecycle을 책임짐
    • 다른 컨트롤러들도 이 StatefulSet을 watch할 수 있지만 소유하지는 않음

    실용적인 효과:

    1. Garbage Collection: Redis CR이 삭제되면 StatefulSet도 자동 삭제
    2. 명확한 책임: 누가 이 리소스를 관리하는지 명확함
    3. 충돌 방지: 여러 컨트롤러가 동시에 수정하는 것 방지

     

    Finalizer: 삭제 전에 청소하기

    개발하면서 "아, 이거 진짜 이상한(?) 패턴이다" 싶었던 게 Finalizer다.

    문제 상황

    Redis CR을 삭제(kubectl delete redis my-redis)하면:

    • K8s가 바로 CR을 삭제하려고 함
    • 근데 우리는 삭제 전에 뭔가 해야 할 수도 있음:
      • GitHub Actions runner를 service에서 제거
      • 외부 데이터베이스 정리
      • 백업 생성
      • 등등...

     

    Finalizer 패턴

    const finalizerName = "redis.myapi.io/finalizer"
    
    func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        redis := &myapi.Redis{}
        if err := r.Get(ctx, req.NamespacedName, redis); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // === 1. 삭제 중인가? ===
        if !redis.DeletionTimestamp.IsZero() {
            // Finalizer가 있으면 청소 작업 수행
            if controllerutil.ContainsFinalizer(redis, finalizerName) {
                log.Info("Cleaning up before deletion...")
    
                // 청소 작업 (예: 외부 서비스에서 제거)
                if err := r.cleanupExternalResources(ctx, redis); err != nil {
                    return ctrl.Result{}, err
                }
    
                // Finalizer 제거 → 이제 K8s가 실제로 삭제 가능
                controllerutil.RemoveFinalizer(redis, finalizerName)
                r.Update(ctx, redis)
    
                log.Info("Cleanup done, finalizer removed")
            }
            return ctrl.Result{}, nil
        }
    
        // === 2. 생성/업데이트 중 - Finalizer 추가 ===
        if !controllerutil.ContainsFinalizer(redis, finalizerName) {
            controllerutil.AddFinalizer(redis, finalizerName)
            r.Update(ctx, redis)
            return ctrl.Result{}, nil
        }
    
        // === 3. 일반 Reconcile 로직 ===
        // ...
    
        return ctrl.Result{}, nil
    }

     

    흐름

    1. Redis CR 생성됨
       └─> Reconcile 호출
           └─> Finalizer 추가: redis.myapi.io/finalizer
    
    2. 정상 동작 중...
    
    3. kubectl delete redis my-redis
       └─> K8s가 DeletionTimestamp 설정 (아직 삭제 안 함!)
       └─> Reconcile 호출
           └─> DeletionTimestamp가 있음 감지
           └─> Finalizer 확인
           └─> 청소 작업 수행 (external cleanup)
           └─> Finalizer 제거
       └─> K8s가 실제로 CR 삭제

     

    actions-runner-controller의 멀티 Finalizer

    actions-runner-controller 코드를 보니까 더 똑똑하게 쓰고 있었다:

    const (
        ephemeralRunnerFinalizerName        = "ephemeralrunner.actions.github.com/finalizer"
        ephemeralRunnerActionsFinalizerName = "ephemeralrunner.actions.github.com/runner-registration-finalizer"
    )
    
    // 삭제 시 여러 단계로 청소
    if !ephemeralRunner.DeletionTimestamp.IsZero() {
        // 1단계: GitHub service에서 runner 제거
        if controllerutil.ContainsFinalizer(ephemeralRunner, ephemeralRunnerActionsFinalizerName) {
            ok, err := r.cleanupRunnerFromService(ctx, ephemeralRunner)
            if !ok {
                // 아직 runner가 작업 중이면 30초 후 재시도
                return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
            }
            controllerutil.RemoveFinalizer(ephemeralRunner, ephemeralRunnerActionsFinalizerName)
            r.Update(ctx, ephemeralRunner)
        }
    
        // 2단계: K8s 리소스 청소
        if controllerutil.ContainsFinalizer(ephemeralRunner, ephemeralRunnerFinalizerName) {
            r.cleanupKubernetesResources(ctx, ephemeralRunner)
            controllerutil.RemoveFinalizer(ephemeralRunner, ephemeralRunnerFinalizerName)
            r.Update(ctx, ephemeralRunner)
        }
    }

    멀티 Finalizer의 장점:

    • 청소 작업을 단계별로 분리
    • 각 단계가 실패해도 독립적으로 재시도 가능
    • 명확한 순서 보장

     

    Server-Side Apply (SSA): 협업의 기술

    K8s 공식 문서 읽다가 "아 이래서 Finalizer를 merge patch로 추가하는구나" 싶었던 부분이 Server-Side Apply다.

    문제 상황

    K8s 리소스를 여러 주체가 동시에 수정할 수 있다:

    • kubectl로 수동 수정
    • Terraform으로 인프라 관리
    • 여러 개의 컨트롤러가 같은 리소스 watch
    • CI/CD 파이프라인에서 업데이트

    이럴 때 누가 어떤 필드를 소유하는지 명확하지 않으면 충돌 발생.

     

    Server-Side Apply의 해법

    SSA는 필드 단위로 소유권을 추적한다:

    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: my-config
      managedFields:  # ← SSA가 자동으로 관리
      - manager: kubectl
        operation: Apply
        fieldsV1:
          f:data:
            f:key1: {}
      - manager: my-controller
        operation: Apply
        fieldsV1:
          f:data:
            f:key2: {}

    핵심 개념:

    1. Field Manager: 각 주체(kubectl, controller 등)가 자기 이름으로 필드 소유
    2. Partial Object: 전체 오브젝트가 아니라 내가 관심 있는 필드만 제출
    3. Conflict Resolution: 같은 필드를 두 manager가 소유하려 하면:
      • --force-conflicts로 강제로 빼앗기
      • 또는 그 필드를 포기하기
      • 또는 공유 소유권 허용

     

    예시

    // Kubectl이 먼저 생성
    // kubectl apply -f configmap.yaml
    data:
      key1: "value1"
    
    // Controller가 나중에 추가
    patch := map[string]interface{}{
        "data": map[string]string{
            "key2": "value2",  // 새 필드 추가
        },
    }
    
    // SSA 사용
    r.Patch(ctx, configMap, client.Apply,
        client.ForceOwnership,  // 필드 매니저 명시
        client.FieldOwner("my-controller"))

    결과:

    • kubectl은 key1 소유
    • my-controller는 key2 소유
    • 서로 간섭 없이 각자 필드 관리

     

    왜 Finalizer에는 SSA 안 쓰고 Merge Patch?

    actions-runner-controller 코드를 보면:

    // Generic patch helper - Merge Patch 사용
    func patch[T object[T]](ctx context.Context, client patcher, obj T, update func(obj T)) error {
        original := obj.DeepCopy()  // Deep copy
        update(obj)
        return client.Patch(ctx, obj, kclient.MergeFrom(original))  // Merge patch
    }
    
    // Finalizer 추가
    patch(ctx, r.Client, redis, func(obj *myapi.Redis) {
        controllerutil.AddFinalizer(obj, finalizerName)
    })

    왜 SSA가 아니라 Merge Patch?

    1. Finalizer는 단순 배열 추가/제거: SSA의 복잡한 field management 불필요
    2. 하나의 컨트롤러만 자기 finalizer 관리: 충돌 가능성 낮음
    3. 코드가 더 간단: DeepCopy + Merge가 직관적
    4. controller-runtime 기본 패턴: 대부분의 컨트롤러가 이 방식 사용

    SSA는 언제 유용한가?

    • 여러 주체가 같은 리소스의 다른 필드를 관리할 때
    • 예: Deployment를 여러 컨트롤러가 수정
      • Auto-scaler: replicas 관리
      • Image updater: image 관리
      • Security controller: securityContext 관리

     

    실전 패턴: actions-runner-controller에서 배운 것들

    실제 프로덕션 코드를 보면서 "아 이렇게 하는구나" 싶었던 패턴들.

    1. ResourceBuilder 패턴

    리소스 생성 로직을 한 곳에 모으기:

    type ResourceBuilder struct {
        ExcludeLabelPropagationPrefixes []string
    }
    
    func (b *ResourceBuilder) newEphemeralRunner(
        ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet,
    ) *v1alpha1.EphemeralRunner {
        // 레이블 복사
        labels := make(map[string]string)
        for k, v := range ephemeralRunnerSet.Labels {
            labels[k] = v
        }
    
        return &v1alpha1.EphemeralRunner{
            ObjectMeta: metav1.ObjectMeta{
                GenerateName: ephemeralRunnerSet.Name + "-runner-",
                Namespace:    ephemeralRunnerSet.Namespace,
                Labels:       labels,
                OwnerReferences: []metav1.OwnerReference{
                    {
                        APIVersion: ephemeralRunnerSet.APIVersion,
                        Kind:       ephemeralRunnerSet.Kind,
                        UID:        ephemeralRunnerSet.UID,
                        Name:       ephemeralRunnerSet.Name,
                        Controller: boolPtr(true),
                    },
                },
            },
            Spec: ephemeralRunnerSet.Spec.EphemeralRunnerSpec,
        }
    }

    장점:

    • 일관된 레이블/어노테이션 관리
    • Owner reference 자동 설정
    • 중복 코드 제거
    • 테스트 용이

     

    2. Generic Patch Utilities

    Go 제네릭으로 타입 안전한 patch 헬퍼:

    type object[T kclient.Object] interface {
        kclient.Object
        DeepCopy() T
    }
    
    func patch[T object[T]](ctx context.Context, client patcher, obj T, update func(obj T)) error {
        original := obj.DeepCopy()
        update(obj)
        return client.Patch(ctx, obj, kclient.MergeFrom(original))
    }
    
    // 사용
    patch(ctx, r.Client, redis, func(obj *myapi.Redis) {
        controllerutil.AddFinalizer(obj, finalizerName)
    })

    장점:

    • 타입 안전성
    • 보일러플레이트 제거
    • 일관된 패턴

     

    3. Field Indexer로 효율적인 쿼리

    Owner로 리소스 찾기를 자주 하니까 indexer 설정:

    // 설정 (main.go에서 한 번)
    func SetupIndexers(mgr ctrl.Manager) error {
        return mgr.GetFieldIndexer().IndexField(
            context.Background(),
            &v1alpha1.EphemeralRunner{},
            "spec.owner",  // Index key
            func(obj client.Object) []string {
                runner := obj.(*v1alpha1.EphemeralRunner)
                if len(runner.OwnerReferences) == 0 {
                    return nil
                }
                return []string{runner.OwnerReferences[0].Name}
            },
        )
    }
    
    // 사용 (reconcile에서)
    func (r *Reconciler) listRunners(ctx context.Context, ownerName string) ([]v1alpha1.EphemeralRunner, error) {
        list := &v1alpha1.EphemeralRunnerList{}
        err := r.List(ctx, list,
            client.InNamespace("default"),
            client.MatchingFields{"spec.owner": ownerName},  // Indexed query!
        )
        return list.Items, err
    }

    효과:

    • O(n) 전체 순회 → O(1) indexed lookup
    • API server 부하 감소

     

    4. SetupWithManager의 Watch 패턴

    컨트롤러가 어떤 리소스를 watch할지 선언:

    func (r *AutoscalingRunnerSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&v1alpha1.AutoscalingRunnerSet{}).  // 주 리소스
            Owns(&v1alpha1.EphemeralRunnerSet{}).   // 소유한 리소스
            Watches(  // 커스텀 watch
                &v1alpha1.AutoscalingListener{},
                handler.EnqueueRequestsFromMapFunc(
                    func(_ context.Context, o client.Object) []reconcile.Request {
                        listener := o.(*v1alpha1.AutoscalingListener)
                        return []reconcile.Request{
                            {
                                NamespacedName: types.NamespacedName{
                                    Namespace: listener.Spec.AutoscalingRunnerSetNamespace,
                                    Name:      listener.Spec.AutoscalingRunnerSetName,
                                },
                            },
                        }
                    },
                ),
            ).
            WithEventFilter(predicate.ResourceVersionChangedPredicate{}).  // 불필요한 reconcile 제거
            Complete(r)
    }

    패턴 설명:

    • For(): 이 컨트롤러의 메인 리소스
    • Owns(): Owner reference로 연결된 리소스 자동 watch
    • Watches(): 커스텀 watch - 특정 리소스 변경 시 다른 리소스 reconcile
    • WithEventFilter(): ResourceVersion만 바뀐 경우 (실제 변경 없음) 무시

     

    5. Status Subresource 사용

    Spec과 Status를 분리해서 업데이트:

    func (r *EphemeralRunnerReconciler) markAsFailed(
        ctx context.Context,
        runner *v1alpha1.EphemeralRunner,
    ) error {
        // Status만 업데이트 (Spec은 건드리지 않음)
        return patchSubResource(ctx, r.Status(), runner, func(obj *v1alpha1.EphemeralRunner) {
            obj.Status.Phase = corev1.PodFailed
            obj.Status.Reason = "InitializationFailed"
            obj.Status.Message = "Failed to start runner"
        })
    }

    왜 분리?

    • Status는 컨트롤러가 설정 (observed state)
    • Spec은 유저가 설정 (desired state)
    • 동시 업데이트 충돌 방지
    • RBAC 권한 분리 가능

     

    6. Reconcile Early Returns

    불필요한 처리 건너뛰기:

    func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        runner := new(v1alpha1.EphemeralRunner)
        if err := r.Get(ctx, req.NamespacedName, runner); err != nil {
            // 리소스 없음 - 이미 삭제됨
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // 삭제 중이면 finalizer 처리만
        if !runner.DeletionTimestamp.IsZero() {
            return r.handleDeletion(ctx, runner)
        }
    
        // 이미 완료된 상태면 멈춤
        if runner.Status.Phase == corev1.PodSucceeded {
            log.Info("Runner already finished, stopping reconciliation")
            return ctrl.Result{}, nil
        }
    
        // 일반 reconcile 로직...
    }

    효과:

    • 불필요한 API 호출 감소
    • 명확한 상태 관리
    • 성능 향상

     

    K8s 아키텍처: Control Plane ↔ Node 통신

    컨트롤러 개발하면서 "내 컨트롤러는 어디서 실행되고, 어떻게 통신하지?" 궁금했다.

    Hub-and-Spoke 패턴

    K8s는 모든 통신이 API Server를 거친다:

                      ┌─────────────────────┐
                      │   API Server        │
                      │   (Control Plane)   │
                      └──────────┬──────────┘
                                 │
              ┌──────────────────┼──────────────────┐
              │                  │                  │
              ↓                  ↓                  ↓
         ┌─────────┐        ┌─────────┐       ┌─────────┐
         │ Node 1  │        │ Node 2  │       │ Node 3  │
         │ kubelet │        │ kubelet │       │ kubelet │
         └─────────┘        └─────────┘       └─────────┘

     

    Node → Control Plane (안전!)

    노드들이 API Server로 연결:

    • HTTPS (443 포트)
    • 인증서 자동 주입
    • Service Account 사용

    내 컨트롤러도 이 방식:

    // Controller Pod 안에서
    client := mgr.GetClient()
    client.Get(ctx, namespacedName, &myResource)  // → API Server로 HTTPS 요청

    기본적으로 안전하게 설정됨!

     

    Control Plane → Node (주의!)

    API Server가 kubelet으로 연결:

    • Pod logs 가져오기
    • kubectl exec 실행
    • Port forwarding

    문제: 기본적으로 API Server는 kubelet 인증서를 검증 안 함!

    문서에서 경고:

    "unsafe to run over untrusted and/or public networks"

    해결책:

    1. Konnectivity Service (권장)
    2. SSH 터널 (deprecated)
    3. Kubelet 인증/인가 활성화

     

    정리: 백엔드 개발자가 알아야 할 핵심

    사고방식 전환

    백엔드 API K8s 컨트롤러
    요청당 핸들러 하나의 long-running 프로세스
    Edge-triggered Level-triggered
    동기 처리 비동기 eventual consistency
    요청-응답 Watch-Reconcile 루프
    Stateless (보통) Stateful (리소스 상태 추적)

     

    핵심 패턴 체크리스트

    Reconcile 함수:

    • Early return (not found, deleted, done)
    • Finalizer 체크 먼저
    • 멱등성 보장
    • Level-triggered 사고

    Finalizer:

    • 리소스 생성 시 추가
    • 삭제 시 cleanup 후 제거
    • 여러 단계면 여러 finalizer

    Owner Reference:

    • controller: true는 하나만
    • Garbage collection 활용
    • 명확한 책임 소유

    Status 관리:

    • Status subresource 사용
    • Spec과 분리
    • Observed state 반영

    효율성:

    • Field indexer로 빠른 쿼리
    • Event filter로 불필요한 reconcile 제거
    • Status 체크로 early return

     

    실수하기 쉬운 것들

    하지 말 것:

    • Reconcile에서 blocking 작업 (외부 API 동기 호출 등)
    • Finalizer 빼먹기
    • Status 업데이트를 Spec 업데이트와 섞기
    • 모든 이벤트에 반응 (filtering 없이)
    • Error 무시하기

    대신 이렇게:

    • 긴 작업은 status 체크 + requeue
    • Finalizer 패턴 일관되게 사용
    • Status subresource 사용
    • Event filter 적극 활용
    • Error는 return해서 재시도

     

    마무리: 새로운 세계

    백엔드에서 K8s 컨트롤러 개발로 넘어오면서 정말 많이 배웠다.

    처음엔 "이게 뭐야, 요청당 실행되는 거 아니었어?" 하면서 멘붕 왔지만, 이제는 "아, 이 패턴이 분산 시스템에서 훨씬 강력하구나" 싶다.

    핵심은:

    • 하나의 컨트롤러가 모든 리소스를 watch
    • Level-triggered로 desired state로 수렴
    • Eventually consistent
    • Declarative configuration

    이 사고방식에 익숙해지면, K8s의 모든 것들이 (Deployment, ReplicaSet, StatefulSet...) 그냥 "컨트롤러의 Reconcile 로직"으로 보인다.

    Redis Operator든, GitHub Actions Runner Controller든, 결국 같은 패턴:

    1. Watch 리소스
    2. Reconcile로 desired state와 비교
    3. 차이를 메움
    4. Requeue하거나 완료

    다음 단계:

    • Webhook 개발 (validation, mutation, conversion)
    • Leader Election 이해하기
    • Metrics & Observability
    • Testing strategies (envtest, kind)

     

    참고 자료

    728x90

    댓글