Skip to content
Published on

Operator 프로덕션 업그레이드와 마이그레이션 — 무중단으로 진화시키기

Authors

들어가며

Operator는 한 번 배포하고 끝나는 컴포넌트가 아닙니다. CRD 스키마는 진화하고, 컨트롤러 로직은 버그 수정과 기능 추가로 끊임없이 바뀌며, 관리하는 워크로드의 수는 수백에서 수천 개로 늘어납니다. 문제는 이 모든 변화가 "이미 돌아가고 있는 프로덕션 위에서" 일어난다는 점입니다. 데이터베이스 Operator가 잘못 업그레이드되면 수백 개의 DB 인스턴스가 동시에 흔들릴 수 있고, CRD 스토리지 버전을 잘못 바꾸면 기존 CR을 더 이상 읽지 못하게 될 수도 있습니다.

이 글에서는 2026년 기준 Kubebuilder 생태계(Kubernetes 1.36 / Go 1.26 지원, controller-runtime v0.24.x, controller-tools v0.21.x)를 전제로, Operator를 무중단에 가깝게 업그레이드하고 마이그레이션하는 실전 패턴을 다룹니다. 단순히 kubectl apply를 다시 하는 수준이 아니라, 컨트롤러 자체의 롤링, CRD 스키마 진화, 관리 워크로드의 단계적 전환, 롤백 가능성, 그리고 장애가 났을 때의 대응까지 하나의 흐름으로 엮어 봅니다.

이 글이 다루는 범위를 먼저 정리하면 다음과 같습니다.

1. Operator 자체 업그레이드      - 컨트롤러 Deployment 롤링, 리더 선출, graceful shutdown
2. CRD 스키마 진화               - 다중 버전 CRD, conversion webhook, storage version
3. 관리 워크로드 안전 롤아웃     - 단계적/파티션 롤아웃, observedGeneration, status conditions
4. 롤백 전략                     - 다운그레이드 함정, 비가역 변환, 필드 손실
5. 다중 버전 운영                - 여러 API 버전 동시 서빙, deprecation 정책
6. 카나리 Operator              - 일부 네임스페이스에만 신규 버전 배포
7. 버전 호환성                  - K8s 1.36 / Go 1.26, controller-runtime v0.24.x, version skew
8. 대규모 CR 마이그레이션        - 일회성 배치 잡, storage version migrator, rate limit
9. 장애 대응                    - 롤아웃 중간에 문제가 났을 때
10. 체크리스트                  - 배포 전/후 점검 항목

Operator 업그레이드가 일반 앱 업그레이드와 다른 이유

일반적인 stateless 웹 애플리케이션은 Deployment를 롤링 업데이트하면 끝입니다. 이전 Pod가 새 Pod로 교체되고, 트래픽이 새 버전으로 흘러가면 됩니다. 하지만 Operator는 다릅니다.

첫째, Operator는 상태를 외부에 둡니다. 진짜 상태는 etcd에 저장된 CR(Custom Resource)과 그것이 관리하는 실제 워크로드입니다. 컨트롤러 Pod 자체는 거의 stateless에 가깝지만, 컨트롤러가 해석하는 데이터(CRD 스키마)는 바뀝니다.

둘째, Operator는 지속적으로 reconcile합니다. 새 버전 컨트롤러가 올라오면 기존 CR 전체를 다시 reconcile하기 시작합니다. 이때 로직이 바뀌었다면 모든 워크로드가 동시에 영향을 받을 수 있습니다.

셋째, CRD는 클러스터 전역 리소스입니다. CRD를 바꾸면 그 CRD를 쓰는 모든 네임스페이스, 모든 CR에 영향을 줍니다. 네임스페이스 단위로 격리되지 않습니다.

다음 표는 둘의 차이를 정리한 것입니다.

항목일반 stateless 앱Operator
상태 위치외부 DBetcd의 CR + 관리 워크로드
업그레이드 단위Deployment 롤링컨트롤러 + CRD + 관리 워크로드
스키마 변경 영향앱 내부 한정클러스터 전역 CRD
동시 실행여러 replica 동시 처리리더 1개만 reconcile
롤백 난이도이미지 되돌리면 끝스토리지 버전, 데이터 손실 고려

이 차이 때문에 Operator 업그레이드는 "컨트롤러", "스키마", "관리 워크로드"라는 세 축을 따로따로 그러나 정합성 있게 굴려야 합니다.

1. 컨트롤러 자체를 안전하게 롤링하기

리더 선출: 단 하나의 reconciler만 active

여러 replica를 띄우더라도 동시에 두 컨트롤러가 같은 CR을 reconcile하면 충돌이 납니다. controller-runtime은 리더 선출(leader election)을 기본 제공합니다. HA를 위해 replica는 2~3개 띄우되, 리더 한 개만 실제로 reconcile하고 나머지는 standby로 대기합니다.

package main

import (
	"crypto/tls"
	"flag"
	"os"

	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/healthz"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
	"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
)

func main() {
	var enableLeaderElection bool
	var probeAddr string
	flag.BoolVar(&enableLeaderElection, "leader-elect", true,
		"Enable leader election for controller manager.")
	flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081",
		"The address the probe endpoint binds to.")
	flag.Parse()

	ctrl.SetLogger(zap.New(zap.UseDevMode(false)))

	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
		Scheme: scheme,
		Metrics: metricsserver.Options{
			BindAddress:   ":8443",
			SecureServing: true,
			// 2026: kube-rbac-proxy 제거됨. 인증/인가를 매니저에 내장.
			FilterProvider: filters.WithAuthenticationAndAuthorization,
			TLSOpts: []func(*tls.Config){
				func(c *tls.Config) { c.MinVersion = tls.VersionTLS13 },
			},
		},
		HealthProbeBindAddress: probeAddr,
		LeaderElection:         enableLeaderElection,
		LeaderElectionID:       "db-operator.example.com",
		// 리더가 죽었을 때 standby가 빠르게 인계받도록 lease를 조정.
		LeaseDuration: durationPtr(15 * time.Second),
		RenewDeadline: durationPtr(10 * time.Second),
		RetryPeriod:   durationPtr(2 * time.Second),
		// 종료 시 리더십을 즉시 반납해 다운타임을 줄인다.
		LeaderElectionReleaseOnCancel: true,
	})
	if err != nil {
		os.Exit(1)
	}

	_ = mgr.AddHealthzCheck("healthz", healthz.Ping)
	_ = mgr.AddReadyzCheck("readyz", healthz.Ping)

	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		os.Exit(1)
	}
}

여기서 핵심은 LeaderElectionReleaseOnCancel: true입니다. 이 옵션이 켜져 있으면 컨트롤러가 SIGTERM을 받고 종료될 때 리더 lease를 즉시 반납합니다. 그러면 standby replica가 LeaseDuration을 기다리지 않고 바로 리더가 되어 reconcile을 이어받습니다. 이 한 줄이 롤링 업데이트 중 reconcile 공백을 수 초 단위로 줄여 줍니다.

Deployment 롤링 전략

컨트롤러는 단일 active 리더만 일하기 때문에, replica를 무작정 늘려도 처리량이 올라가지는 않습니다. 대신 가용성을 위해 다음과 같이 설정합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: db-operator-controller-manager
  namespace: db-operator-system
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: manager
          image: registry.example.com/db-operator:v1.4.0
          args:
            - --leader-elect=true
            - --health-probe-bind-address=:8081
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8081
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8081
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 512Mi
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]

maxUnavailable: 0maxSurge: 1을 함께 쓰면 새 Pod가 Ready가 된 다음에야 기존 Pod가 종료됩니다. terminationGracePeriodSeconds: 30은 SIGTERM 이후 진행 중이던 reconcile이 정리될 시간을 줍니다. preStop의 짧은 sleep은 엔드포인트 전파 지연으로 인한 경쟁 조건을 완화합니다.

Graceful shutdown

controller-runtime의 ctrl.SetupSignalHandler()는 SIGTERM/SIGINT을 받으면 매니저 컨텍스트를 취소합니다. 이때 진행 중인 reconcile은 컨텍스트 취소를 감지하고 깔끔하게 빠져나가야 합니다. reconcile 함수 안에서 외부 호출(예: DB API, 클라우드 SDK)을 할 때는 항상 ctx를 전달해 종료 신호가 전파되도록 합니다.

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := ctrl.LoggerFrom(ctx)

	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 외부 호출에 ctx를 전달하면 종료 시 즉시 취소되어 graceful shutdown이 보장된다.
	if err := r.provisioner.EnsureReady(ctx, &db); err != nil {
		if ctx.Err() != nil {
			// 종료 중이면 다음 리더가 다시 처리하도록 조용히 반환.
			return ctrl.Result{}, nil
		}
		log.Error(err, "failed to ensure database ready")
		return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
	}

	return ctrl.Result{}, nil
}

2. CRD 스키마 진화 — 다중 버전과 conversion webhook

가장 까다로운 부분이 CRD 스키마 변경입니다. 필드를 추가하는 정도는 호환되지만, 필드 이름을 바꾸거나 구조를 재편하면 기존 CR과 호환되지 않습니다. 이때 다중 버전 CRD(multi-version CRD)와 conversion webhook을 씁니다.

served version과 storage version

CRD의 각 버전은 두 가지 속성을 가집니다.

  • served: 이 버전으로 API 요청을 받을 수 있는가 (kubectl, 클라이언트가 사용 가능)
  • storage: etcd에 실제로 저장되는 버전 (오직 하나만 true)

핵심 규칙은 이렇습니다. etcd에는 항상 단 하나의 storage version으로만 저장되고, 다른 버전으로 들어온 요청은 conversion webhook을 거쳐 storage version으로 변환되어 저장됩니다. 읽을 때는 반대 방향으로 변환됩니다.

              ┌──────────────────────────────────────────────┐
              │                 API Server                    │
  v1alpha1 ──▶│  served:true   ─┐                             │
  v1beta1  ──▶│  served:true    ├─▶ conversion webhook ─▶ etcd│ (storage: v1)
  v1       ──▶│  served:true,   │                             │
              │  storage:true ──┘                             │
              └──────────────────────────────────────────────┘

hub-and-spoke 변환 모델

여러 버전이 서로서로 변환되면 변환 함수가 N×N으로 폭발합니다. Kubebuilder는 hub-and-spoke 모델을 권장합니다. 하나의 버전을 "hub"로 정하고, 나머지 모든 버전(spoke)은 hub와의 변환만 구현합니다. 그러면 v1alpha1에서 v1beta1로 변환할 때도 v1alpha1 → hub → v1beta1 경로를 거칩니다.

   v1alpha1 (spoke)              v1 (hub, storage)              v1beta1 (spoke)
        │                            ▲   │                            │
        │  ConvertTo(hub) ───────────┘   └──── ConvertFrom(hub) ◀─────┤
        └────────── ConvertFrom(hub) ◀──── ConvertTo(hub) ────────────┘

hub 버전(여기서는 v1)에는 Hub() 마커 메서드만 둡니다.

package v1

// Hub은 이 타입이 변환 hub임을 표시한다. 별도 구현은 필요 없다.
func (*Database) Hub() {}

spoke 버전(v1beta1)은 hub와의 양방향 변환을 구현합니다.

package v1beta1

import (
	"sigs.k8s.io/controller-runtime/pkg/conversion"
	dbv1 "github.com/example/db-operator/api/v1"
)

// ConvertTo는 이 spoke(v1beta1)를 hub(v1)로 변환한다.
func (src *Database) ConvertTo(dstRaw conversion.Hub) error {
	dst := dstRaw.(*dbv1.Database)

	dst.ObjectMeta = src.ObjectMeta

	// 단순 필드는 그대로 복사
	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Replicas = src.Spec.Replicas

	// v1beta1의 StorageGB(int) → v1의 Storage(구조체)로 재편
	dst.Spec.Storage = dbv1.StorageSpec{
		SizeGB:    src.Spec.StorageGB,
		ClassName: "standard", // 신규 필드의 기본값
	}

	// status도 변환
	dst.Status.Phase = src.Status.Phase
	dst.Status.ObservedGeneration = src.Status.ObservedGeneration
	return nil
}

// ConvertFrom은 hub(v1)를 이 spoke(v1beta1)로 변환한다.
func (dst *Database) ConvertFrom(srcRaw conversion.Hub) error {
	src := srcRaw.(*dbv1.Database)

	dst.ObjectMeta = src.ObjectMeta

	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Replicas = src.Spec.Replicas

	// v1의 Storage 구조체 → v1beta1의 StorageGB(int)로 축소
	// 주의: ClassName 정보는 v1beta1에 표현할 수 없어 손실된다.
	dst.Spec.StorageGB = src.Spec.Storage.SizeGB

	dst.Status.Phase = src.Status.Phase
	dst.Status.ObservedGeneration = src.Status.ObservedGeneration
	return nil
}

여기서 주목할 점은 ConvertFrom의 주석입니다. v1의 ClassName은 v1beta1에 대응되는 필드가 없으므로 변환 과정에서 손실됩니다. 이런 비가역성은 나중에 롤백 전략에서 결정적인 함정이 되므로 변환 코드를 작성할 때부터 명시적으로 기록해 둬야 합니다.

conversion webhook 등록과 CRD 매니페스트

webhook을 활성화하려면 매니저에 webhook 서버를 띄우고 타입에 등록합니다.

func (r *Database) SetupWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).
		For(r).
		Complete()
}

CRD 매니페스트에는 conversion 전략을 webhook으로 지정합니다.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  names:
    kind: Database
    plural: databases
  scope: Namespaced
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          namespace: db-operator-system
          name: db-operator-webhook-service
          path: /convert
  versions:
    - name: v1alpha1
      served: true
      storage: false
      deprecated: true
      deprecationWarning: "example.com/v1alpha1 Database is deprecated; use v1"
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
    - name: v1beta1
      served: true
      storage: false
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object

CRD 진화의 안전한 순서

스키마를 진화시킬 때 순서가 중요합니다. 새 버전을 추가하는 흐름은 다음과 같습니다.

단계 1: v1을 served:true, storage:false로 추가 (아직 저장은 v1beta1)
        conversion webhook 배포 + 변환 함수 검증
단계 2: 컨트롤러 신규 버전 배포 (v1을 인식)
단계 3: storage version을 v1로 전환 (v1beta1 storage:false, v1 storage:true)
단계 4: storage version migrator로 기존 CR을 v1로 재저장
단계 5: v1alpha1/v1beta1을 deprecated 표기 후, 충분한 유예 기간 뒤 served:false
단계 6: 더 이상 참조가 없으면 구버전 제거

storage version을 바꾸기 전에 반드시 conversion webhook이 안정적으로 동작하는지 확인해야 합니다. webhook이 실패하면 해당 CRD에 대한 모든 읽기/쓰기가 막혀 클러스터 운영에 직접적인 장애가 됩니다.

3. 관리 워크로드의 안전한 단계적 롤아웃

컨트롤러가 새 로직으로 바뀌면, 모든 관리 워크로드를 한꺼번에 바꾸고 싶은 유혹이 있습니다. 하지만 그렇게 하면 새 로직에 버그가 있을 때 전체가 동시에 무너집니다. Operator가 직접 단계적(파티션) 롤아웃을 주도하도록 만드는 것이 안전합니다.

observedGeneration으로 진행 추적

metadata.generation은 spec이 바뀔 때마다 증가합니다. 컨트롤러는 처리를 끝낸 generation을 status.observedGeneration에 기록합니다. 이 둘을 비교하면 "내가 본 spec을 다 처리했는가"를 알 수 있습니다.

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 이미 최신 generation을 처리했다면 추가 작업 없음
	if db.Status.ObservedGeneration == db.Generation &&
		meta.IsStatusConditionTrue(db.Status.Conditions, "Ready") {
		return ctrl.Result{}, nil
	}

	// ... 실제 reconcile 로직 ...

	db.Status.ObservedGeneration = db.Generation
	meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
		Type:               "Ready",
		Status:             metav1.ConditionTrue,
		ObservedGeneration: db.Generation,
		Reason:             "Reconciled",
		Message:            "database is ready",
	})
	if err := r.Status().Update(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}
	return ctrl.Result{}, nil
}

파티션 기반 점진 롤아웃

Operator가 새 워크로드 버전을 적용할 때, 한 번에 N%만 바꾸고 결과를 확인한 뒤 다음 단계로 넘어가는 패턴입니다. CR spec에 롤아웃 정책을 두고 컨트롤러가 이를 해석합니다.

// RolloutPolicy는 CR spec에 정의되어 단계적 전환을 제어한다.
type RolloutPolicy struct {
	// MaxUnavailable: 동시에 교체 가능한 인스턴스 비율(%)
	MaxUnavailable int `json:"maxUnavailable"`
	// Partition: 이 인덱스 미만은 새 버전으로 바꾸지 않는다(점진 확대)
	Partition int `json:"partition"`
	// Paused: true면 롤아웃을 멈추고 현 상태를 유지한다
	Paused bool `json:"paused"`
}

func (r *DatabaseReconciler) rolloutManagedPods(
	ctx context.Context, db *examplev1.Database, desiredImage string,
) (bool, error) {
	pods, err := r.listManagedPods(ctx, db)
	if err != nil {
		return false, err
	}

	// 일시정지 상태면 아무 것도 바꾸지 않는다
	if db.Spec.Rollout.Paused {
		return false, nil
	}

	// 인덱스 내림차순으로, partition 이상인 것만 대상으로 삼는다
	updating := 0
	maxConcurrent := percentToCount(db.Spec.Rollout.MaxUnavailable, len(pods))

	for i := len(pods) - 1; i >= db.Spec.Rollout.Partition; i-- {
		p := pods[i]
		if podImage(p) == desiredImage {
			continue // 이미 새 버전
		}
		if !isHealthy(p) {
			// 직전에 바꾼 Pod가 아직 건강하지 않으면 더 진행하지 않는다
			return true, nil
		}
		if updating >= maxConcurrent {
			return true, nil // 동시 교체 한도 도달, 다음 reconcile에서 계속
		}
		if err := r.recreatePodWithImage(ctx, p, desiredImage); err != nil {
			return false, err
		}
		updating++
	}

	// 모든 대상이 새 버전이면 완료
	return updating > 0, nil
}

이 패턴의 핵심은 두 가지입니다. 첫째, 건강하지 않은 Pod가 있으면 진행을 멈춘다. 새 버전에 문제가 있으면 첫 번째 Pod에서 멈추므로 피해가 국소화됩니다. 둘째, Partition을 운영자가 조정하면서 점진적으로 확대할 수 있습니다. 처음에는 Partition을 높게 둬 소수만 바꾸고, 안정성이 확인되면 0까지 낮춰 전체를 전환합니다.

reconcile 일시정지

문제가 감지되면 즉시 reconcile을 멈춰야 할 때가 있습니다. annotation으로 일시정지 스위치를 두는 패턴이 흔합니다.

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 운영자가 긴급 정지를 걸면 reconcile을 건너뛴다
	if db.Annotations["example.com/reconcile-paused"] == "true" {
		ctrl.LoggerFrom(ctx).Info("reconcile paused by annotation")
		return ctrl.Result{}, nil
	}
	// ...
	return ctrl.Result{}, nil
}

kubectl annotate database mydb example.com/reconcile-paused=true 한 번이면 해당 CR의 자동 조정이 멈춥니다. 장애 상황에서 컨트롤러가 잘못된 상태를 계속 밀어붙이는 것을 막는 안전판입니다.

4. 롤백 전략 — 되돌릴 수 없는 것들을 인지하기

업그레이드가 잘못되면 되돌려야 합니다. 그런데 Operator의 롤백은 단순히 컨트롤러 이미지를 이전 태그로 바꾸는 것으로 끝나지 않습니다. 되돌릴 수 없는 것들이 곳곳에 숨어 있습니다.

함정 1: storage version을 올린 뒤의 다운그레이드

CRD storage version을 v1beta1에서 v1로 올린 다음, 일부 CR이 이미 v1로 저장되었다고 합시다. 이제 컨트롤러를 v1을 모르는 구버전으로 되돌리면, 구버전 컨트롤러는 etcd에 v1로 저장된 객체를 읽지 못할 수 있습니다. conversion webhook이 여전히 살아 있어 v1을 v1beta1로 변환해 준다면 읽기는 되지만, webhook까지 함께 롤백했다면 데이터에 접근하지 못합니다.

[올바른 롤백 순서 — 스토리지 버전을 올린 경우]

  1. conversion webhook은 그대로 유지 (절대 먼저 내리지 않는다)
  2. 컨트롤러만 이전 버전으로 롤백
  3. 구버전이 인식하는 버전으로 storage version을 되돌릴지 신중히 판단
  4. 이미 v1로 저장된 CR이 있다면 migrator로 다시 구버전으로 재저장 필요

함정 2: 제거된 필드의 데이터 손실

새 스키마에서 어떤 필드를 제거했다면, 그 필드는 conversion 과정에서 영영 사라집니다. 앞의 ConvertFrom에서 ClassName이 손실되던 것을 떠올려 봅시다. v1 → v1beta1로 한 번 변환되어 저장되고 나면, 다시 v1로 올려도 ClassName은 복구되지 않습니다(기본값으로 채워질 뿐입니다).

함정 3: 변환의 비가역성

conversion이 양방향이라고 해서 왕복(round-trip)이 항상 무손실인 것은 아닙니다. 변환 함수를 작성할 때 round-trip 테스트를 반드시 넣어 무손실 여부를 검증해야 합니다.

func TestConversionRoundTrip(t *testing.T) {
	original := &v1beta1.Database{
		Spec: v1beta1.DatabaseSpec{
			Engine:    "postgres",
			Replicas:  3,
			StorageGB: 100,
		},
	}

	// v1beta1 → v1 → v1beta1
	hub := &v1.Database{}
	if err := original.ConvertTo(hub); err != nil {
		t.Fatal(err)
	}
	roundTripped := &v1beta1.Database{}
	if err := roundTripped.ConvertFrom(hub); err != nil {
		t.Fatal(err)
	}

	// v1beta1이 표현 가능한 필드는 무손실이어야 한다
	if roundTripped.Spec.StorageGB != original.Spec.StorageGB {
		t.Errorf("StorageGB lost: got %d want %d",
			roundTripped.Spec.StorageGB, original.Spec.StorageGB)
	}
}

롤백 가능성을 보장하는 가장 현실적인 방법은 스토리지 버전 전환과 컨트롤러 업그레이드를 분리하는 것입니다. 컨트롤러를 먼저 충분히 운영해 안정성을 확인한 뒤, 한참 지나서 스토리지 버전을 올립니다. 그래야 컨트롤러 롤백이 storage version과 얽히지 않습니다.

5. 다중 버전 동시 운영과 deprecation

현실에서는 여러 팀이 서로 다른 API 버전으로 CR을 만들어 둡니다. v1alpha1로 만든 CR도 있고, v1beta1로 만든 것도 있습니다. served version을 여러 개 유지하면 이들을 동시에 지원할 수 있습니다.

deprecation은 갑작스럽게 하면 안 됩니다. Kubernetes의 API deprecation 정책을 본떠 단계를 둡니다.

단계상태동작
정상served, 미표기자유롭게 사용
사용 자제served + deprecated 표기사용 시 경고 메시지
서빙 중단served:falseAPI 요청 거부, etcd 데이터는 유지
제거versions에서 삭제더 이상 존재하지 않음

deprecated: truedeprecationWarning을 CRD에 설정하면 해당 버전을 사용하는 kubectl 명령마다 경고가 출력됩니다. 사용자에게 이전을 독려하는 가장 부드러운 방법입니다. served:false로 바꾸기 전에는 반드시 그 버전을 참조하는 CR이 더 이상 없는지(또는 storage version migrator로 모두 최신 버전으로 재저장되었는지) 확인합니다.

6. 카나리 Operator — 일부에만 신규 버전 적용

CRD는 클러스터 전역이라 카나리가 어렵지만, 컨트롤러의 처리 범위는 네임스페이스로 좁힐 수 있습니다. 신규 버전 컨트롤러를 특정 네임스페이스만 보도록 띄우고, 기존 컨트롤러는 나머지를 담당하게 하는 방식입니다.

controller-runtime의 매니저 캐시를 네임스페이스로 한정합니다.

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
	Scheme: scheme,
	Cache: cache.Options{
		// 이 컨트롤러는 canary 네임스페이스만 감시한다
		DefaultNamespaces: map[string]cache.Config{
			"team-canary": {},
		},
	},
	// 카나리는 별도의 리더 선출 ID를 써 기존 컨트롤러와 충돌하지 않게 한다
	LeaderElection:   true,
	LeaderElectionID: "db-operator-canary.example.com",
})

다만 주의할 점이 있습니다. 두 버전의 컨트롤러가 같은 CRD를 공유하므로, 신규 버전이 CRD 스키마 변경을 동반한다면 구버전 컨트롤러도 그 스키마를 견딜 수 있어야 합니다. 따라서 카나리는 "스키마는 그대로 두고 로직만 바뀌는" 업그레이드에 특히 적합합니다. 스키마까지 바뀐다면, 신규 스키마가 구버전과 호환되는(필드 추가 같은) 경우로 한정하는 것이 안전합니다.

7. 버전 호환성 — K8s, Go, controller-runtime

2026년 기준 버전 매트릭스를 정리하면 다음과 같습니다.

구성요소권장 버전비고
Kubernetes1.36최신 지원 대상
Go1.26Kubebuilder 스캐폴딩 기준
controller-runtimev0.24.x매니저/클라이언트 API
controller-toolsv0.21.xCRD/RBAC 마커 생성

업그레이드 시 가장 흔한 실수는 controller-runtime 버전을 올리면서 client-go/apimachinery 버전을 맞추지 않는 것입니다. controller-runtime의 각 마이너 버전은 특정 client-go 버전을 전제로 하므로, go.mod에서 이들을 함께 맞춰야 합니다. 또한 Kubernetes의 version skew 정책상, 컨트롤 플레인과 클라이언트 라이브러리의 버전 차이는 제한됩니다. 너무 오래된 client-go로 빌드된 Operator를 최신 API 서버에 붙이면 일부 API가 동작하지 않을 수 있습니다.

[호환성 점검 순서]

  1. go.mod에서 controller-runtime, client-go, apimachinery 버전 정렬
  2. 대상 클러스터의 K8s 버전이 지원 범위 안인지 확인
  3. 제거 예정(deprecated) API를 사용 중인지 점검 (예: 구 admissionregistration 버전)
  4. controller-tools를 올린 뒤 CRD를 재생성해 스키마 diff 확인
  5. envtest로 통합 테스트를 돌려 회귀 확인

업그레이드 후에는 반드시 CRD 매니페스트를 재생성해 기존과 diff를 떠 봐야 합니다. controller-tools 버전이 바뀌면 생성되는 OpenAPI 스키마가 미묘하게 달라질 수 있고, 이것이 의도치 않은 검증 변경을 일으킬 수 있기 때문입니다.

8. 대규모 CR 마이그레이션 — 배치로 안전하게

storage version을 올린 뒤에는 기존 CR을 새 storage version으로 다시 저장해야 합니다. 그냥 두면 etcd에는 여전히 옛 버전으로 저장된 객체가 남아, 나중에 구버전을 제거할 때 막힙니다. 객체를 한 번 읽어 다시 쓰면(no-op update) API 서버가 storage version으로 재저장합니다.

수천 개의 CR을 한꺼번에 건드리면 API 서버와 conversion webhook에 부하가 몰립니다. rate limit을 둔 일회성 마이그레이션 잡으로 처리합니다.

package main

import (
	"context"
	"time"

	"golang.org/x/time/rate"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

// migrateStorageVersion은 모든 Database CR을 한 번씩 no-op 업데이트해
// 현재 storage version으로 재저장한다.
func migrateStorageVersion(ctx context.Context, c client.Client) error {
	// 초당 10건으로 제한 (API 서버/webhook 보호)
	limiter := rate.NewLimiter(rate.Limit(10), 1)

	var continueToken string
	for {
		var list examplev1.DatabaseList
		opts := []client.ListOption{client.Limit(100)}
		if continueToken != "" {
			opts = append(opts, client.Continue(continueToken))
		}
		if err := c.List(ctx, &list, opts...); err != nil {
			return err
		}

		for i := range list.Items {
			if err := limiter.Wait(ctx); err != nil {
				return err
			}
			db := &list.Items[i]

			// no-op annotation 토글로 update를 유발해 재저장
			if db.Annotations == nil {
				db.Annotations = map[string]string{}
			}
			db.Annotations["example.com/storage-migrated-at"] =
				time.Now().UTC().Format(time.RFC3339)

			if err := c.Update(ctx, db); err != nil {
				// 충돌은 다음 라운드에서 재시도되도록 로그만 남긴다
				continue
			}
		}

		if list.Continue == "" {
			break
		}
		continueToken = list.Continue
	}
	return nil
}

var _ = metav1.ObjectMeta{} // import 사용 표시

Kubernetes는 이런 작업을 자동화하는 storage version migrator도 제공합니다. StorageVersionMigration 리소스를 만들면 컨트롤 플레인 측에서 해당 리소스의 모든 객체를 storage version으로 재저장해 줍니다. 다만 마이그레이션 중에는 conversion webhook이 안정적으로 떠 있어야 하며, 대규모 클러스터에서는 부하를 모니터링하면서 진행해야 합니다.

마이그레이션 잡은 다음 원칙을 지킵니다.

1. rate limit 필수            - API 서버/webhook 과부하 방지 (초당 N건)
2. 페이지네이션              - List에 Limit + Continue 사용, 메모리 폭발 방지
3. 멱등성                    - 중간에 죽어도 다시 돌리면 되도록 설계
4. 충돌 허용                 - 동시 업데이트 충돌은 다음 라운드에서 재시도
5. 진행 가시성              - 처리 건수/실패 건수를 메트릭으로 노출
6. webhook 헬스 확인        - 변환이 실패하면 즉시 중단

9. 장애 대응 — 롤아웃 중간에 문제가 났다면

업그레이드 도중 문제가 감지되었을 때의 표준 대응 절차를 정리합니다. 핵심은 "더 이상의 확산을 막고, 가역적인 것부터 되돌린다"입니다.

[장애 대응 플레이북]

증상 감지
1) 롤아웃 즉시 정지
   - CR에 reconcile-paused=true annotation (확산 차단)
   - 또는 Rollout.Paused=true로 단계적 전환 중단
2) 영향 범위 파악
   - status conditions / observedGeneration으로 어디까지 진행됐는지 확인
   - 새 버전으로 바뀐 워크로드 수 집계
3) 가역성 판단
   - 스토리지 버전을 아직 안 올렸나? → 컨트롤러 이미지 롤백으로 충분
   - 이미 올렸나? → conversion webhook 유지하며 신중히 롤백
4) 컨트롤러 롤백
   - Deployment 이미지를 직전 정상 태그로
   - 단, CRD/webhook은 함부로 같이 내리지 않는다
5) 데이터 정합성 확인
   - 손실 가능 필드(ClassName 등)가 영향받았는지 점검
   - 필요 시 백업에서 복구
6) 사후 분석
   - round-trip 테스트 누락, webhook 헬스 미점검 등 원인 기록

가장 중요한 원칙은 CRD와 conversion webhook을 컨트롤러와 함께 무작정 롤백하지 않는다는 것입니다. 컨트롤러는 stateless에 가까워 롤백이 안전하지만, CRD/webhook은 etcd 데이터 접근성에 직접 관여하므로 잘못 내리면 모든 CR 읽기가 막힙니다.

10. 배포 전후 체크리스트

[업그레이드 전]
□ go.mod의 controller-runtime/client-go/apimachinery 버전 정렬 확인
□ 대상 K8s 버전이 지원 범위(1.36) 안인지 확인
□ CRD 재생성 후 기존과 schema diff 확인
□ conversion 함수 round-trip 테스트 통과
□ envtest 통합 테스트 통과
□ 리더 선출 + LeaderElectionReleaseOnCancel 설정 확인
□ Deployment maxUnavailable=0 / maxSurge=1 확인
□ 롤백 절차 문서화 + 백업 확인
□ 신규 스키마가 구버전 컨트롤러와 호환되는지(카나리 가능 여부) 판단

[업그레이드 중]
□ 새 버전 served:true, storage:false로 먼저 추가
□ conversion webhook 헬스 확인
□ 컨트롤러 롤링 후 리더 인계 정상 동작 확인
□ 관리 워크로드는 partition으로 점진 확대
□ status conditions / observedGeneration으로 진행 추적

[업그레이드 후]
□ storage version 전환은 컨트롤러 안정화 이후 별도로
□ storage version migrator로 기존 CR 재저장 (rate limit)
□ 구버전 deprecated 표기 후 유예 기간 부여
□ 메트릭(reconcile 에러율, webhook 지연) 정상 범위 확인
□ 더 이상 참조 없는 구버전을 served:false → 제거

마치며

Operator 업그레이드의 본질은 "세 가지 시간 축을 분리하는 것"입니다. 컨트롤러 로직, CRD 스키마, 관리 워크로드는 서로 다른 속도로, 서로 다른 가역성을 가지고 변합니다. 이들을 한꺼번에 바꾸려는 유혹을 이겨내고, 가역적인 것(컨트롤러)부터 먼저 굴려 안정성을 확인한 뒤, 비가역적인 것(스토리지 버전, 필드 제거)을 가장 마지막에 신중히 적용하는 것이 무중단 진화의 핵심입니다.

특히 기억할 것은 세 가지입니다. 첫째, 리더 선출과 LeaderElectionReleaseOnCancel로 컨트롤러 롤링의 공백을 최소화하세요. 둘째, conversion webhook은 etcd 데이터 접근성의 생명줄이므로 storage version 전환 전후로 절대 함부로 내리지 마세요. 셋째, 모든 변환 함수에 round-trip 테스트를 넣어 비가역적 손실을 빌드 단계에서 잡으세요. 이 세 가지만 지켜도 대부분의 프로덕션 업그레이드 사고는 예방됩니다.

참고 자료