들어가며
Operator를 한 번 작성해 본 사람과, 그 Operator를 수십 개의 클러스터에 안전하게 배포하고 무중단으로 업그레이드해 본 사람 사이에는 큰 간극이 존재합니다. 컨트롤러의 reconcile 로직을 작성하는 것은 시작에 불과합니다. 진짜 어려움은 "이 컨트롤러가 정말 의도대로 동작하는가", "새 버전을 배포해도 기존 워크로드가 깨지지 않는가", "사용자가 OperatorHub에서 클릭 한 번으로 설치하고 업그레이드할 수 있는가" 같은 질문에 답하는 과정에서 드러납니다.
이 글에서는 Operator의 신뢰성을 보장하는 두 축, 즉 테스트와 배포를 깊이 있게 다룹니다. 테스트 측면에서는 unit reconcile 테스트, envtest 기반 통합 테스트, kind 위에서의 e2e 테스트로 이어지는 테스트 피라미드를 살펴봅니다. 배포 측면에서는 OLM(Operator Lifecycle Manager)의 ClusterServiceVersion, 번들, 카탈로그 개념과 채널·업그레이드 그래프, 그리고 OperatorHub를 통한 배포까지 다룹니다.
2026년 현재 Kubebuilder는 Kubernetes 1.36과 Go 1.26을 지원하며, controller-runtime v0.24.x, controller-tools v0.21.x 위에서 동작합니다. 과거 메트릭 보호에 쓰이던 kube-rbac-proxy는 제거되었고, 이제는 controller-runtime이 제공하는 인증·인가 필터를 직접 사용하는 방식으로 전환되었습니다. 이 글의 예제는 이 최신 스택을 기준으로 작성했습니다.
테스트 피라미드
Operator 테스트는 전통적인 테스트 피라미드를 Kubernetes 환경에 맞게 변형한 형태로 구성하는 것이 효율적입니다. 아래쪽은 빠르고 저렴하지만 현실성이 낮고, 위쪽은 느리고 비싸지만 실제 운영 환경에 가깝습니다.
/\
/ \ e2e (kind/실클러스터)
/ \ - 적은 수, 느림, 비용 높음
/------\ - 전체 흐름·실제 kubelet·CNI 검증
/ \
/ \ envtest 통합 테스트
/ \ - 중간 수, 실제 apiserver+etcd
/ \ - reconcile ↔ API 상호작용 검증
/----------------\
/ \ unit reconcile 테스트
/ \- 많은 수, 빠름, 비용 낮음
/ \- fake client로 로직 단위 검증
/________________________\
세 계층의 역할은 명확히 구분됩니다.
| 계층 | 실행 대상 | 속도 | kubelet/CNI | 주 검증 대상 |
| --- | --- | --- | --- | --- |
| unit reconcile | fake client | 매우 빠름 | 없음 | reconcile 분기 로직, 상태 전이 |
| envtest 통합 | 실제 apiserver + etcd | 빠름 | 없음 | CRD 검증, 워치, 소유 참조, 상태 갱신 |
| e2e | 실제 클러스터(kind) | 느림 | 있음 | Pod 기동, 네트워킹, 실제 워크로드 |
핵심 원칙은 "아래 계층에서 잡을 수 있는 버그는 아래 계층에서 잡는다"입니다. e2e에서만 실패가 재현된다면 그 비용은 매우 큽니다. 따라서 가능한 한 많은 로직을 unit과 envtest 수준으로 끌어내려 검증하고, e2e는 정말로 실제 클러스터가 필요한 통합 시나리오에만 집중하는 것이 바람직합니다.
envtest 설정
envtest는 controller-runtime이 제공하는 테스트 도구로, 실제 kube-apiserver와 etcd 바이너리를 로컬에서 띄워 그 위에 컨트롤러를 붙입니다. kubelet, 스케줄러, 컨트롤러 매니저는 실행되지 않기 때문에 Pod가 실제로 스케줄링되거나 기동되지는 않지만, API 서버와의 모든 상호작용은 진짜와 동일하게 동작합니다. 즉 CRD 검증, admission, 워치 이벤트, 소유 참조 기반 가비지 컬렉션 트리거 같은 동작을 실제 API 의미론으로 검증할 수 있습니다.
envtest용 바이너리는 setup-envtest 도구로 내려받습니다.
특정 쿠버네티스 버전의 envtest 바이너리 설치
go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use 1.36.x
설치 경로를 환경변수로 노출
export KUBEBUILDER_ASSETS=$(setup-envtest use 1.36.x -p path)
Kubebuilder로 스캐폴딩하면 `suite_test.go`가 생성되며, Ginkgo와 Gomega를 기반으로 테스트 스위트를 구성합니다.
package controller
"context"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
webappv1 "example.com/guestbook-operator/api/v1"
)
var (
cfg *rest.Config
k8sClient client.Client
testEnv *envtest.Environment
ctx context.Context
cancel context.CancelFunc
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
ctx, cancel = context.WithCancel(context.TODO())
By("부트스트랩 테스트 환경 기동")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
var err error
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
Expect(webappv1.AddToScheme(scheme.Scheme)).To(Succeed())
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
err = (&GuestbookReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
go func() {
defer GinkgoRecover()
Expect(mgr.Start(ctx)).To(Succeed())
}()
})
var _ = AfterSuite(func() {
cancel()
By("테스트 환경 정리")
Expect(testEnv.Stop()).To(Succeed())
})
이 스위트가 핵심적으로 보여 주는 것은 `testEnv.Start()`가 실제 apiserver와 etcd를 띄우고 그 접속 정보를 `cfg`로 돌려준다는 점입니다. 이후 매니저를 띄워 실제 컨트롤러를 등록하면, 통합 테스트는 진짜 reconcile 루프가 돌아가는 환경에서 객체를 만들고 그 결과를 관찰하는 방식으로 작성할 수 있습니다.
다음은 envtest 위에서 도는 통합 테스트 예시입니다. Guestbook 리소스를 만들면 컨트롤러가 Deployment를 생성하고 상태를 갱신하는지 검증합니다.
var _ = Describe("Guestbook 컨트롤러", func() {
const (
resourceName = "test-guestbook"
namespace = "default"
)
Context("Guestbook 리소스를 reconcile 할 때", func() {
It("Deployment를 생성하고 Ready 상태로 표시해야 한다", func() {
By("Guestbook 객체 생성")
gb := &webappv1.Guestbook{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: namespace,
},
Spec: webappv1.GuestbookSpec{
Replicas: 3,
Image: "nginx:1.27",
},
}
Expect(k8sClient.Create(ctx, gb)).To(Succeed())
By("컨트롤러가 Deployment를 만드는지 확인")
deployKey := types.NamespacedName{Name: resourceName, Namespace: namespace}
createdDeploy := &appsv1.Deployment{}
Eventually(func() error {
return k8sClient.Get(ctx, deployKey, createdDeploy)
}, time.Second*10, time.Millisecond*250).Should(Succeed())
Expect(*createdDeploy.Spec.Replicas).To(Equal(int32(3)))
By("소유 참조가 올바르게 설정되었는지 확인")
Expect(createdDeploy.OwnerReferences).To(HaveLen(1))
Expect(createdDeploy.OwnerReferences[0].Kind).To(Equal("Guestbook"))
})
})
})
`Eventually` 블록은 컨트롤러가 비동기로 동작한다는 점을 반영합니다. 객체를 만든 직후에는 아직 Deployment가 없을 수 있으므로, 일정 시간 동안 반복적으로 조회하며 조건이 만족되기를 기다립니다. 이 패턴은 envtest 통합 테스트의 거의 모든 곳에서 등장합니다.
unit reconcile 테스트
모든 분기를 envtest로 검증하면 테스트가 느려집니다. reconcile 함수 내부의 순수 로직 분기는 fake client로 빠르게 검증하는 것이 좋습니다. controller-runtime의 fake client는 인메모리 객체 저장소를 흉내 내므로, apiserver를 띄우지 않고도 Get/Create/Update/List를 시험할 수 있습니다.
package controller
"context"
"testing"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
webappv1 "example.com/guestbook-operator/api/v1"
)
func TestReconcile_CreatesDeployment(t *testing.T) {
s := scheme.Scheme
_ = webappv1.AddToScheme(s)
_ = appsv1.AddToScheme(s)
gb := &webappv1.Guestbook{
ObjectMeta: metav1.ObjectMeta{Name: "gb", Namespace: "ns"},
Spec: webappv1.GuestbookSpec{Replicas: 2, Image: "nginx:1.27"},
}
cl := fake.NewClientBuilder().
WithScheme(s).
WithObjects(gb).
Build()
r := &GuestbookReconciler{Client: cl, Scheme: s}
_, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Name: "gb", Namespace: "ns"},
})
if err != nil {
t.Fatalf("reconcile 실패: %v", err)
}
got := &appsv1.Deployment{}
if err := cl.Get(context.Background(),
types.NamespacedName{Name: "gb", Namespace: "ns"}, got); err != nil {
t.Fatalf("Deployment가 생성되지 않음: %v", err)
}
if *got.Spec.Replicas != 2 {
t.Errorf("replicas 기대값 2, 실제 %d", *got.Spec.Replicas)
}
}
이런 단위 테스트는 밀리초 단위로 실행되므로, 엣지 케이스(예: 이미 존재하는 Deployment, replicas 불일치, 삭제 진행 중 상태)를 수십 개 작성해도 부담이 없습니다. fake client는 실제 API 서버의 admission이나 검증 웹훅을 수행하지 않으므로, CRD 검증 같은 동작은 반드시 envtest에서 확인해야 한다는 점만 기억하면 됩니다.
OLM 개념: CSV, 번들, 카탈로그
테스트가 끝났다면 이제 배포 차례입니다. OLM(Operator Lifecycle Manager)은 클러스터에서 Operator의 설치·업그레이드·의존성·권한을 선언적으로 관리하는 컴포넌트입니다. OLM을 이해하려면 네 가지 핵심 개념을 알아야 합니다.
| 개념 | 역할 |
| --- | --- |
| ClusterServiceVersion (CSV) | Operator의 한 버전을 기술하는 매니페스트. 배포, RBAC, CRD 소유, 설치 모드, 버전 메타데이터 포함 |
| 번들 (bundle) | 하나의 CSV와 그에 딸린 CRD·메타데이터를 묶은 패키징 단위 |
| 번들 이미지 (bundle image) | 번들 콘텐츠를 담은 OCI 컨테이너 이미지 |
| 카탈로그 (catalog) | 여러 번들과 채널 정보를 인덱싱한 이미지. OLM이 설치 가능한 Operator 목록을 여기서 읽음 |
CSV는 OLM 세계의 중심 문서입니다. 다음은 핵심 필드만 추린 예시입니다.
apiVersion: operators.coreos.com/v1alpha1
kind: ClusterServiceVersion
metadata:
name: guestbook-operator.v0.2.0
namespace: placeholder
spec:
displayName: Guestbook Operator
version: 0.2.0
replaces: guestbook-operator.v0.1.0
maturity: stable
installModes:
- type: OwnNamespace
supported: true
- type: SingleNamespace
supported: true
- type: MultiNamespace
supported: false
- type: AllNamespaces
supported: true
customresourcedefinitions:
owned:
- name: guestbooks.webapp.example.com
version: v1
kind: Guestbook
install:
strategy: deployment
spec:
deployments:
- name: guestbook-controller-manager
spec:
replicas: 1
번들은 보통 다음과 같은 디렉터리 구조를 가집니다.
bundle/
├── manifests/
│ ├── guestbook-operator.clusterserviceversion.yaml
│ └── webapp.example.com_guestbooks.yaml
├── metadata/
│ └── annotations.yaml
└── bundle.Dockerfile
이 번들을 OCI 이미지로 빌드해 레지스트리에 푸시하면 번들 이미지가 되고, 여러 번들 이미지를 묶어 인덱싱하면 카탈로그 이미지가 됩니다. operator-sdk와 opm 도구가 이 과정을 자동화합니다.
CSV 스캐폴딩 및 번들 매니페스트 생성
operator-sdk generate kustomize manifests
make bundle VERSION=0.2.0
번들 이미지 빌드 및 푸시
make bundle-build bundle-push BUNDLE_IMG=quay.io/example/guestbook-bundle:v0.2.0
카탈로그 이미지 빌드(번들들을 인덱싱)
opm index add \
--bundles quay.io/example/guestbook-bundle:v0.2.0 \
--tag quay.io/example/guestbook-catalog:latest
채널과 업그레이드 그래프
OLM에서 업그레이드는 채널(channel)과 업그레이드 그래프로 표현됩니다. 채널은 stable, alpha, fast 같은 릴리스 스트림이며, 각 채널 안에서 버전들이 어떻게 이어지는지를 그래프로 정의합니다. 이 그래프를 정의하는 세 가지 메커니즘이 있습니다.
| 필드 | 의미 |
| --- | --- |
| replaces | 이 버전이 직전 어떤 버전을 대체하는지 명시. 선형 업그레이드 경로 형성 |
| skips | 건너뛸 수 있는 버전들의 목록. 결함 있는 중간 버전을 우회 |
| skipRange | semver 범위로 한꺼번에 건너뛸 버전 지정. 예: 0.1.0 이상 0.5.0 미만 |
다음은 stable 채널의 업그레이드 그래프를 시각화한 것입니다.
stable 채널 업그레이드 그래프
v0.1.0 ──replaces── v0.2.0 ──replaces── v0.3.0
│
skips: v0.2.1 (결함 버전)
│
▼
v0.4.0 ──skipRange: ">=0.1.0 <0.4.0"── v0.5.0
설치 시: OLM은 채널의 head(최신)를 찾아
replaces 체인을 따라 단계적으로 업그레이드하거나
skipRange로 한 번에 head까지 점프
skipRange는 특히 유용합니다. replaces 체인만 사용하면 v0.1.0 사용자가 v0.5.0으로 가려면 모든 중간 버전을 순차적으로 거쳐야 하지만, skipRange를 설정하면 한 번에 최신 버전으로 점프할 수 있습니다. 다만 그만큼 중간 버전의 마이그레이션 로직이 모두 호환되어야 하므로 신중하게 설계해야 합니다.
CSV의 어노테이션으로 skipRange 지정
metadata:
name: guestbook-operator.v0.5.0
annotations:
olm.skipRange: '>=0.1.0 <0.5.0'
spec:
replaces: guestbook-operator.v0.4.0
skips:
- guestbook-operator.v0.2.1
OperatorHub 배포
OperatorHub.io는 커뮤니티 Operator의 공개 카탈로그입니다. 자신의 Operator를 여기에 등록하면 OLM이 설치된 모든 클러스터에서 검색·설치할 수 있게 됩니다. 등록 절차는 community-operators 저장소에 번들 매니페스트를 PR로 제출하는 방식입니다.
OperatorHub 배포 흐름
개발자 ──PR──> community-operators 저장소
│
│ CI: 번들 검증, CSV lint,
│ 설치 모드 검사, 업그레이드 경로 확인
▼
머지 ──> 카탈로그 이미지에 반영
│
▼
사용자 클러스터의 OLM ──> OperatorHub UI에 노출
│
▼
Subscription 생성으로 설치
사용자 입장에서는 Subscription 리소스 하나로 설치와 자동 업그레이드를 선언합니다.
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: guestbook-operator
namespace: operators
spec:
channel: stable
name: guestbook-operator
source: operatorhubio-catalog
sourceNamespace: olm
installPlanApproval: Automatic
`installPlanApproval`을 Manual로 두면 새 버전이 채널에 올라와도 관리자가 명시적으로 승인할 때까지 업그레이드가 보류됩니다. 프로덕션에서는 Manual을 선호하는 경우가 많습니다.
버전 업그레이드 안전성
업그레이드는 Operator 운영에서 가장 위험한 순간입니다. 새 컨트롤러 버전이 기존 CRD 객체를 잘못 reconcile하거나, CRD 스키마가 비호환적으로 바뀌면 운영 중인 워크로드가 손상될 수 있습니다. 안전한 업그레이드를 위한 원칙은 다음과 같습니다.
- CRD 스키마는 항상 하위 호환되게 변경합니다. 필드 추가는 안전하지만, 필드 삭제나 타입 변경은 새 API 버전과 변환 웹훅을 통해 처리합니다.
- 여러 API 버전을 동시에 제공할 때는 conversion webhook으로 저장 버전과 서빙 버전 간 변환을 보장합니다.
- 업그레이드 경로마다 e2e 테스트를 둡니다. 즉 이전 버전으로 객체를 만들어 둔 상태에서 새 버전 컨트롤러로 교체하고, 객체가 정상적으로 reconcile되는지 검증합니다.
- skipRange를 사용할 경우, 건너뛰는 모든 중간 버전의 마이그레이션이 누적적으로 호환되는지 확인합니다.
// 변환 웹훅: v1beta1 ↔ v1 변환 예시
func (src *GuestbookV1Beta1) ConvertTo(dstRaw conversion.Hub) error {
dst := dstRaw.(*GuestbookV1)
dst.ObjectMeta = src.ObjectMeta
dst.Spec.Replicas = src.Spec.Count // 필드명 변경 흡수
dst.Spec.Image = src.Spec.Image
return nil
}
func (dst *GuestbookV1Beta1) ConvertFrom(srcRaw conversion.Hub) error {
src := srcRaw.(*GuestbookV1)
dst.ObjectMeta = src.ObjectMeta
dst.Spec.Count = src.Spec.Replicas
dst.Spec.Image = src.Spec.Image
return nil
}
최소권한 RBAC
Operator는 종종 클러스터 전역에 걸쳐 광범위한 권한을 요구하기 쉽습니다. 그러나 보안 관점에서 컨트롤러는 실제로 필요한 리소스에 대해서만, 필요한 동사(verb)만 가져야 합니다. Kubebuilder에서는 RBAC 마커 주석으로 권한을 선언하고, controller-tools가 이를 읽어 Role과 ClusterRole 매니페스트를 생성합니다.
// reconcile 함수 바로 위에 RBAC 마커를 선언
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// reconcile 로직
return ctrl.Result{}, nil
}
이 마커에서 `make manifests`를 실행하면 다음과 같은 Role이 생성됩니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: manager-role
rules:
- apiGroups: ['webapp.example.com']
resources: ['guestbooks']
verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
- apiGroups: ['webapp.example.com']
resources: ['guestbooks/status']
verbs: ['get', 'update', 'patch']
- apiGroups: ['apps']
resources: ['deployments']
verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
최소권한 설계의 실천 지침은 다음과 같습니다.
- 와일드카드 verb(`*`)와 와일드카드 resource를 피합니다. 명시적으로 필요한 동사만 나열합니다.
- 네임스페이스 범위로 충분하다면 ClusterRole 대신 Role을 사용합니다.
- 메트릭 엔드포인트 보호는 과거 kube-rbac-proxy 사이드카로 처리했지만, 2026년 현재 이 사이드카는 제거되었고 controller-runtime의 `WithAuthenticationAndAuthorization` 필터로 직접 보호합니다.
// 메트릭 서버를 인증·인가 필터로 보호
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{
BindAddress: ":8443",
SecureServing: true,
FilterProvider: filters.WithAuthenticationAndAuthorization,
},
})
이렇게 하면 별도 사이드카 컨테이너 없이도 메트릭 엔드포인트가 TokenReview와 SubjectAccessReview를 통해 인증·인가됩니다. 결과적으로 Pod 구조가 단순해지고 관리할 컴포넌트가 줄어듭니다.
멀티네임스페이스 / 멀티테넌트 운영
Operator를 어느 범위에서 동작시킬지는 OLM의 설치 모드(install mode)로 결정합니다. CSV의 `installModes` 필드가 어떤 모드를 지원하는지 선언하고, 사용자는 Subscription을 만들 때 그중 하나를 선택합니다.
| 설치 모드 | 감시 범위 | 사용 시나리오 |
| --- | --- | --- |
| OwnNamespace | Operator가 설치된 네임스페이스만 | 단일 팀 전용, 가장 격리적 |
| SingleNamespace | 지정한 단일 네임스페이스 | Operator와 워크로드를 분리 배치 |
| MultiNamespace | 명시한 여러 네임스페이스 | 일부 테넌트만 선택적으로 관리 |
| AllNamespaces | 클러스터 전체 | 플랫폼 수준 공유 Operator |
멀티테넌트 환경에서는 이 선택이 보안 경계와 직결됩니다. AllNamespaces 모드는 편리하지만 컨트롤러가 클러스터 전역 권한을 가지므로, 한 테넌트의 CR이 다른 테넌트에 영향을 주지 않도록 reconcile 로직에서 네임스페이스 격리를 철저히 지켜야 합니다.
설치 모드별 감시 범위
OwnNamespace SingleNamespace AllNamespaces
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ ns-a │ │ operator │ │ 클러스터 전체 │
│ [op][cr] │ └────┬─────┘ │ ┌────┐┌────┐┌───┐ │
└──────────┘ │ watch │ │ns-a││ns-b││...│ │
▼ │ └────┘└────┘└───┘ │
┌──────────┐ └────────┬─────────┘
│ ns-b │ op watch all
│ [cr] │
└──────────┘
컨트롤러 코드에서는 매니저의 캐시 범위를 설치 모드에 맞게 제한하는 것이 좋습니다. 예를 들어 SingleNamespace 모드라면 매니저가 해당 네임스페이스만 캐시하도록 설정합니다.
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Scheme: scheme,
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{
"team-a-workloads": {},
},
},
})
이렇게 하면 컨트롤러가 불필요하게 클러스터 전역을 워치하지 않아 메모리 사용량과 API 부하가 줄어들고, 권한 경계도 명확해집니다.
프로덕션 체크리스트
배포 직전에 점검할 항목을 정리합니다.
- 테스트: unit reconcile 테스트가 주요 분기를 모두 커버하는가. envtest 통합 테스트가 CRD 검증과 소유 참조를 검증하는가. e2e가 실제 워크로드 기동까지 확인하는가.
- 업그레이드: 이전 버전에서 새 버전으로의 e2e 업그레이드 테스트가 통과하는가. conversion webhook이 모든 저장 버전을 처리하는가. skipRange 범위 내 모든 마이그레이션이 호환되는가.
- RBAC: 와일드카드 권한이 없는가. ClusterRole이 정말 필요한가, 아니면 Role로 충분한가. 메트릭이 인증·인가 필터로 보호되는가.
- OLM: CSV의 replaces/skips/skipRange가 의도한 업그레이드 그래프를 형성하는가. installModes가 실제 지원 범위와 일치하는가.
- 관측성: 컨트롤러가 reconcile 에러율, 큐 깊이, reconcile 지연 같은 메트릭을 노출하는가. 적절한 이벤트와 컨디션을 상태에 기록하는가.
- 복원력: 리더 선출이 활성화되어 있는가. 컨트롤러가 재시작되어도 상태가 일관되게 수렴하는가(멱등성).
- 리소스: 컨트롤러 Pod에 적절한 requests/limits가 설정되어 있는가. 대규모 클러스터에서 캐시 메모리가 폭증하지 않는가.
마치며
Operator의 가치는 단순히 동작하는 reconcile 루프를 작성하는 데 있지 않습니다. 그 컨트롤러를 신뢰할 수 있게 검증하고, 사용자가 안전하게 설치·업그레이드할 수 있도록 패키징하는 전체 라이프사이클을 책임지는 데 있습니다. 테스트 피라미드는 버그를 가능한 한 빠르고 저렴한 계층에서 잡게 해 주고, OLM과 번들 패키징은 배포와 업그레이드를 선언적이고 재현 가능하게 만들어 줍니다.
특히 2026년 현재의 스택은 과거보다 단순해졌습니다. kube-rbac-proxy 사이드카가 사라지면서 메트릭 보호가 controller-runtime 안으로 통합되었고, envtest와 operator-sdk 도구 체인은 성숙해 테스트와 번들링을 자동화하기 쉬워졌습니다. 이 글에서 다룬 원칙들을 체크리스트로 삼아, 작성한 Operator가 실험실을 넘어 프로덕션에서 신뢰받는 컴포넌트가 되기를 바랍니다.
References
- Kubebuilder Book: https://kubebuilder.io/
- Operator SDK Documentation: https://sdk.operatorframework.io/
- controller-runtime (Go reference): https://pkg.go.dev/sigs.k8s.io/controller-runtime
- Kubernetes Operator pattern: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
- Kubebuilder source: https://github.com/kubernetes-sigs/kubebuilder
- controller-runtime source: https://github.com/kubernetes-sigs/controller-runtime
- Operator Lifecycle Manager: https://olm.operatorframework.io/
- OperatorHub.io: https://operatorhub.io/
현재 단락 (1/375)
Operator를 한 번 작성해 본 사람과, 그 Operator를 수십 개의 클러스터에 안전하게 배포하고 무중단으로 업그레이드해 본 사람 사이에는 큰 간극이 존재합니다. 컨트롤러의...