-
백엔드 마인드로 Kubernetes 컨트롤러 개발하면서 깨달은 것들컴퓨터/Kubernetes 2025. 10. 25. 21:45728x90반응형
"아, 이거 그냥 요청당 핸들러 돌리는 거 아니었구나..."
며칠 전부터 Kubernetes Operator/Controller를 개발하기 시작했다.
백엔드 개발만 주로 하다가 K8s 생태계로 넘어오니까 솔직히 개념이 머리에 잘 안 들어왔다.
특히 이런 질문들이 머릿속을 떠나지 않았다:
- 컨트롤러가 뭐지? 고루틴 하나인가?
- 유저가 UI 클릭하면 내 백엔드 API가 컨트롤러를 실행시키는 건가?
- 요청마다 컨트롤러가 새로 뜨는 건가?
- Owner Reference가 뭔데 "sole controller"라는 말이 나오지?
백엔드 개발자라면 공감할 것이다:
// 내가 아는 세상 (Echo/Gin 같은 백엔드) func CreateRedis(c echo.Context) error { // 요청 들어옴 → 핸들러 실행 → 응답 반환 // 요청마다 독립적으로 실행됨 }근데 K8s 컨트롤러는 완전히 다른 세상이었다.
첫 번째 착각: "컨트롤러 = 요청당 실행되는 핸들러"
내가 처음에 상상한 것
Redis Operator를 만든다고 치자. 내 머릿속 시나리오는 이랬다:
- 유저가 UI에서 "Redis 생성" 버튼 클릭
- 내 백엔드 API (
POST /create-redis) 호출됨 - 각 유저 요청마다 컨트롤러 인스턴스가 실행됨
- 컨트롤러가 뭔가 처리하고 응답 반환
// ❌ 내가 상상한 잘못된 모델 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할 수 있지만 소유하지는 않음
실용적인 효과:
- Garbage Collection: Redis CR이 삭제되면 StatefulSet도 자동 삭제
- 명확한 책임: 누가 이 리소스를 관리하는지 명확함
- 충돌 방지: 여러 컨트롤러가 동시에 수정하는 것 방지
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: {}핵심 개념:
- Field Manager: 각 주체(kubectl, controller 등)가 자기 이름으로 필드 소유
- Partial Object: 전체 오브젝트가 아니라 내가 관심 있는 필드만 제출
- 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?
- Finalizer는 단순 배열 추가/제거: SSA의 복잡한 field management 불필요
- 하나의 컨트롤러만 자기 finalizer 관리: 충돌 가능성 낮음
- 코드가 더 간단: DeepCopy + Merge가 직관적
- controller-runtime 기본 패턴: 대부분의 컨트롤러가 이 방식 사용
SSA는 언제 유용한가?
- 여러 주체가 같은 리소스의 다른 필드를 관리할 때
- 예: Deployment를 여러 컨트롤러가 수정
- Auto-scaler:
replicas관리 - Image updater:
image관리 - Security controller:
securityContext관리
- Auto-scaler:
실전 패턴: 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로 연결된 리소스 자동 watchWatches(): 커스텀 watch - 특정 리소스 변경 시 다른 리소스 reconcileWithEventFilter(): 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"
해결책:
- Konnectivity Service (권장)
- SSH 터널 (deprecated)
- 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든, 결국 같은 패턴:
- Watch 리소스
- Reconcile로 desired state와 비교
- 차이를 메움
- Requeue하거나 완료
다음 단계:
- Webhook 개발 (validation, mutation, conversion)
- Leader Election 이해하기
- Metrics & Observability
- Testing strategies (envtest, kind)
참고 자료
728x90'컴퓨터 > Kubernetes' 카테고리의 다른 글
Kubernetes Operator 개발할 때 Server-Side Apply 써봤더니 (1) 2025.10.18