들어가며
Operator를 처음 작성할 때는 "reconcile 루프가 잘 도는가"에만 집중하게 됩니다. 그러나 운영에 들어가면 질문이 달라집니다. "지금 reconcile가 얼마나 자주 실패하는가?", "큐가 밀리고 있는가?", "사용자가 만든 리소스가 왜 반영되지 않는가?", "이 Operator가 약속한 신뢰 수준(SLO)을 지키고 있는가?" 같은 질문들입니다.
이 글은 Kubebuilder(2026년 기준 Kubernetes 1.36 / Go 1.26, controller-runtime v0.24.x, controller-tools v0.21.x)로 만든 Operator의 관측성(observability)을 다룹니다. 관측성은 단순히 대시보드를 띄우는 일이 아니라, 시스템 내부 상태를 외부에서 추론할 수 있게 만드는 모든 신호(signal)를 설계하는 일입니다. 우리가 다룰 신호는 크게 다섯 가지입니다.
관측성의 다섯 신호
┌──────────────┬───────────────────────────────────────────┐
│ 신호 │ 누구를 위한 것인가 │
├──────────────┼───────────────────────────────────────────┤
│ 메트릭 │ SRE / 운영자 — 추세, 알림, SLO │
│ 이벤트 │ 최종 사용자 — kubectl describe 로 보는 맥락 │
│ 로깅 │ 개발자 / 운영자 — 단일 reconcile의 상세 흐름 │
│ 트레이싱 │ 개발자 — 여러 단계에 걸친 지연 분석 │
│ status/조건 │ 최종 사용자 / 컨트롤러 — 선언적 현재 상태 │
└──────────────┴───────────────────────────────────────────┘
각 신호는 청중과 목적이 다릅니다. 메트릭은 집계된 추세를, 이벤트는 사용자가 `kubectl describe`로 보는 맥락을, 로그는 단일 reconcile의 상세 흐름을, 트레이싱은 분산된 지연을, status/conditions는 선언적 현재 상태를 담습니다. 이 다섯을 균형 있게 설계해야 운영 가능한 Operator가 됩니다.
controller-runtime 기본 메트릭
controller-runtime은 별도 설정 없이도 풍부한 기본 메트릭을 메트릭 서버를 통해 노출합니다. 이 메트릭들은 Operator 관측성의 출발점입니다. 가장 중요한 것들을 살펴보겠습니다.
reconcile 계열 메트릭
controller_runtime_reconcile_total{controller, result}
reconcile 호출 횟수. result 라벨은 success / error / requeue /
requeue_after 로 구분됩니다. 성공률 계산의 분모/분자가 됩니다.
controller_runtime_reconcile_errors_total{controller}
Reconcile가 에러를 반환한 횟수. 이 값이 꾸준히 오르면
무언가가 반복적으로 실패하고 있다는 뜻입니다.
controller_runtime_reconcile_time_seconds{controller}
reconcile 1회 처리 시간의 히스토그램. _bucket / _sum / _count
시계열을 만들어내며 분위수(p50/p95/p99) 계산에 사용합니다.
controller_runtime_active_workers{controller}
현재 reconcile를 동시에 수행 중인 워커 수.
controller_runtime_max_concurrent_reconciles{controller}
설정된 최대 동시 reconcile 수(MaxConcurrentReconciles).
`reconcile_total`의 `result` 라벨은 운영에서 매우 중요합니다. `requeue`/`requeue_after`가 비정상적으로 많다면, reconcile가 desired state에 수렴하지 못하고 계속 재시도하고 있다는 신호일 수 있습니다.
workqueue 계열 메트릭
reconcile를 트리거하는 작업 큐(workqueue)의 상태는 Operator의 부하와 건강을 직접적으로 보여줍니다.
workqueue_depth{name}
큐에 대기 중인 항목 수. 지속적으로 높으면 컨트롤러가
들어오는 이벤트 속도를 따라잡지 못하고 있다는 뜻입니다.
workqueue_adds_total{name}
큐에 추가된 누적 항목 수. rate() 로 유입 속도를 봅니다.
workqueue_queue_duration_seconds{name}
항목이 큐에 머문 시간(히스토그램). 처리가 시작되기까지의 대기.
workqueue_work_duration_seconds{name}
항목 처리에 걸린 시간(히스토그램). reconcile 실제 작업 시간.
workqueue_retries_total{name}
큐에서 재시도된 누적 횟수. 에러로 인한 재시도가 많으면 증가.
workqueue_unfinished_work_seconds{name}
아직 처리되지 않은 작업이 큐에 쌓여 있는 시간.
workqueue_longest_running_processor_seconds{name}
가장 오래 실행 중인 처리 작업의 시간. 멈춘 reconcile 탐지에 유용.
이 메트릭들의 관계를 그림으로 보면 다음과 같습니다.
이벤트 발생 → predicate 통과 → workqueue.Add()
│
[workqueue_adds_total]
│
▼
┌──────────────┐
│ workqueue │ ← [workqueue_depth]
│ (대기 항목) │ ← [queue_duration]
└──────┬───────┘
│ Get()
▼
Reconcile() 실행
│ ← [active_workers]
│ ← [work_duration / reconcile_time]
▼
성공 → Forget() 에러 → AddRateLimited()
[reconcile_total [reconcile_errors_total
result=success] workqueue_retries_total]
메트릭 서버와 보안
controller-runtime v0.24.x에서는 메트릭 서버가 `manager.Options`의 `Metrics` 필드로 설정됩니다. 과거에 흔히 쓰던 kube-rbac-proxy 사이드카는 제거되었고, 메트릭 엔드포인트 자체가 인증/인가를 내장하는 방식으로 바뀌었습니다.
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
)
func newManager(cfg *rest.Config) (manager.Manager, error) {
return ctrl.NewManager(cfg, ctrl.Options{
Metrics: metricsserver.Options{
BindAddress: ":8443",
SecureServing: true,
// 메트릭 엔드포인트에 인증/인가를 적용합니다.
// 과거 kube-rbac-proxy 사이드카 역할을 대체합니다.
FilterProvider: filters.WithAuthenticationAndAuthorization,
},
})
}
`WithAuthenticationAndAuthorization`는 들어오는 요청의 ServiceAccount 토큰을 검증하고, `nonResourceURLs`에 대한 RBAC 권한을 확인합니다. 즉 Prometheus가 메트릭을 긁어가려면 해당 경로에 대한 권한을 가진 ServiceAccount가 필요합니다.
커스텀 메트릭 추가하기
기본 메트릭은 컨트롤러의 동작을 보여주지만, 도메인 고유의 신호는 직접 정의해야 합니다. 예를 들어 "현재 관리 중인 워크로드 수", "마지막 동기화 이후 경과 시간", "외부 API 호출 실패 횟수" 같은 것들입니다.
controller-runtime은 자체 Prometheus 레지스트리(`metrics.Registry`)를 노출하므로, 여기에 커스텀 컬렉터를 등록하면 같은 `/metrics` 엔드포인트로 함께 노출됩니다.
package controller
"github.com/prometheus/client_golang/prometheus"
"sigs.k8s.io/controller-runtime/pkg/metrics"
)
var (
// 관리 중인 리소스의 현재 상태별 개수 (게이지)
managedResources = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "myoperator_managed_resources",
Help: "현재 관리 중인 리소스 수 (phase 라벨별)",
},
[]string{"namespace", "phase"},
)
// 외부 API 호출 결과 카운터
externalAPICalls = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "myoperator_external_api_calls_total",
Help: "외부 API 호출 횟수 (결과 라벨별)",
},
[]string{"endpoint", "result"},
)
// 마지막 성공적 동기화 이후 경과 시간을 추론하기 위한 타임스탬프 게이지
lastSyncTimestamp = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "myoperator_last_successful_sync_timestamp_seconds",
Help: "리소스별 마지막 성공 동기화의 유닉스 타임스탬프",
},
[]string{"namespace", "name"},
)
// reconcile 단계별 소요 시간 히스토그램
reconcileStageDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "myoperator_reconcile_stage_duration_seconds",
Help: "reconcile 내부 단계별 소요 시간",
Buckets: prometheus.DefBuckets,
},
[]string{"stage"},
)
)
func init() {
// controller-runtime의 글로벌 레지스트리에 등록합니다.
// 이렇게 하면 기본 메트릭과 같은 /metrics 로 노출됩니다.
metrics.Registry.MustRegister(
managedResources,
externalAPICalls,
lastSyncTimestamp,
reconcileStageDuration,
)
}
이제 reconcile 안에서 이 메트릭들을 갱신합니다.
func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var obj appsv1alpha1.MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 단계별 타이머 시작
stageTimer := prometheus.NewTimer(
reconcileStageDuration.WithLabelValues("fetch_external"),
)
state, err := r.External.FetchState(ctx, obj.Spec.ID)
stageTimer.ObserveDuration()
if err != nil {
externalAPICalls.WithLabelValues("fetch", "error").Inc()
log.Error(err, "외부 상태 조회 실패")
return ctrl.Result{}, err
}
externalAPICalls.WithLabelValues("fetch", "success").Inc()
// desired state로 수렴시키는 적용 단계
applyTimer := prometheus.NewTimer(
reconcileStageDuration.WithLabelValues("apply"),
)
phase, err := r.applyDesiredState(ctx, &obj, state)
applyTimer.ObserveDuration()
if err != nil {
return ctrl.Result{}, err
}
// 게이지 갱신
managedResources.WithLabelValues(obj.Namespace, phase).Set(1)
lastSyncTimestamp.WithLabelValues(obj.Namespace, obj.Name).
SetToCurrentTime()
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
게이지 메트릭과 삭제 처리의 함정
게이지(Gauge)를 사용할 때 가장 흔한 실수는, 리소스가 삭제된 뒤에도 게이지가 마지막 값을 계속 보고하는 것입니다. `managed_resources`처럼 라벨 카디널리티가 리소스 개수에 비례하는 게이지는 삭제 시 반드시 `DeleteLabelValues`로 제거해야 합니다.
// finalizer 또는 NotFound 처리 시 해당 라벨 시계열을 정리합니다.
managedResources.DeleteLabelValues(obj.Namespace, oldPhase)
lastSyncTimestamp.DeleteLabelValues(obj.Namespace, obj.Name)
이를 빠뜨리면 삭제된 리소스에 대한 "유령" 시계열이 남아 카디널리티가 무한히 증가하고, Prometheus 메모리를 잠식합니다. 라벨에 `name` 같은 고카디널리티 값을 넣는 것 자체를 신중히 결정해야 하는 이유이기도 합니다.
Prometheus 스크레이프와 ServiceMonitor
메트릭을 노출했다면 Prometheus가 이를 수집해야 합니다. Prometheus Operator를 쓰는 환경이라면 `ServiceMonitor` 커스텀 리소스로 스크레이프 대상을 선언합니다.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: myoperator-controller-manager-metrics
namespace: myoperator-system
labels:
app.kubernetes.io/name: myoperator
release: kube-prometheus-stack
spec:
selector:
matchLabels:
control-plane: controller-manager
endpoints:
- port: https
scheme: https
path: /metrics
interval: 30s
bearerTokenSecret:
name: metrics-reader-token
key: token
tlsConfig:
insecureSkipVerify: true
namespaceSelector:
matchNames:
- myoperator-system
메트릭 엔드포인트가 인증을 요구하므로, Prometheus가 사용할 ServiceAccount에 다음과 같은 권한을 부여해야 합니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: myoperator-metrics-reader
rules:
- nonResourceURLs:
- /metrics
verbs:
- get
ServiceMonitor를 쓰지 않는다면, Prometheus의 정적 스크레이프 설정으로도 같은 일을 할 수 있습니다.
scrape_configs:
- job_name: myoperator
scheme: https
metrics_path: /metrics
tls_config:
insecure_skip_verify: true
authorization:
type: Bearer
credentials_file: /var/run/secrets/tokens/metrics-token
kubernetes_sd_configs:
- role: endpoints
namespaces:
names:
- myoperator-system
relabel_configs:
- source_labels: [__meta_kubernetes_endpoint_port_name]
action: keep
regex: https
Grafana 대시보드와 핵심 PromQL
수집한 메트릭은 PromQL로 질의하여 대시보드를 구성합니다. Operator 운영에서 반드시 패널로 두어야 하는 질의들을 정리합니다.
reconcile 성공률
sum(rate(controller_runtime_reconcile_total{controller="myresource", result!="error"}[5m]))
/
sum(rate(controller_runtime_reconcile_total{controller="myresource"}[5m]))
이 값이 SLO의 핵심 지표가 됩니다. 1에 가까울수록 건강합니다. 0.99 미만으로 떨어지면 경고를 고려합니다.
reconcile 에러율
sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[5m]))
에러 자체의 절대 속도를 봅니다. 0에서 갑자기 튀어 오르면 신규 배포나 외부 의존성 장애를 의심합니다.
reconcile 지연 분위수 (p95 / p99)
histogram_quantile(
0.95,
sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller="myresource"}[5m])) by (le)
)
p99까지 보려면 `0.95`를 `0.99`로 바꿉니다. 지연이 길어지면 외부 API나 apply 단계의 병목을 의심합니다.
workqueue 깊이와 대기 시간
workqueue_depth{name="myresource"}
histogram_quantile(
0.95,
sum(rate(workqueue_queue_duration_seconds_bucket{name="myresource"}[5m])) by (le)
)
depth가 꾸준히 우상향하면 컨트롤러가 부하를 따라잡지 못하는 것입니다. `MaxConcurrentReconciles`를 늘리거나, predicate로 불필요한 이벤트를 줄이거나, reconcile 자체를 가볍게 만들어야 합니다.
데이터 신선도 (freshness)
커스텀 타임스탬프 게이지를 활용하면 "마지막 동기화 이후 얼마나 지났는가"를 직접 알림으로 만들 수 있습니다.
time() - max by (namespace, name) (myoperator_last_successful_sync_timestamp_seconds)
이 값이 예컨대 900초(15분)를 넘으면, 해당 리소스가 한동안 reconcile되지 않은 것입니다.
Kubernetes 이벤트 기록하기
메트릭이 SRE를 위한 것이라면, 이벤트(Event)는 최종 사용자를 위한 것입니다. 사용자는 `kubectl describe`로 자신의 리소스에 무슨 일이 일어났는지 봅니다. 잘 만든 이벤트는 "왜 내 리소스가 Ready가 아닌가"라는 질문에 즉시 답을 줍니다.
controller-runtime에서는 `mgr.GetEventRecorderFor`로 EventRecorder를 얻습니다.
type MyResourceReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
}
// SetupWithManager에서 Recorder를 주입합니다.
func (r *MyResourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
r.Recorder = mgr.GetEventRecorderFor("myresource-controller")
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.MyResource{}).
Complete(r)
}
reconcile 안에서 Normal/Warning 이벤트를 기록합니다.
func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var obj appsv1alpha1.MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
deployment, err := r.ensureDeployment(ctx, &obj)
if err != nil {
// 사용자에게 보이는 Warning 이벤트: 무엇이, 왜 실패했는지
r.Recorder.Eventf(&obj, corev1.EventTypeWarning, "DeploymentFailed",
"하위 Deployment 생성 실패: %v", err)
return ctrl.Result{}, err
}
if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas {
// 정상 상태를 알리는 Normal 이벤트
r.Recorder.Eventf(&obj, corev1.EventTypeNormal, "Ready",
"모든 레플리카(%d개)가 준비되었습니다", deployment.Status.ReadyReplicas)
}
return ctrl.Result{}, nil
}
이벤트 사용 시 주의점
이벤트는 강력하지만 남용하면 해롭습니다. 핵심 원칙은 다음과 같습니다.
이벤트 기록 원칙
- 상태 "전이"에만 기록하라. 매 reconcile마다 같은 이벤트를
찍으면 etcd와 사용자 모두에게 소음이 된다.
- Reason은 PascalCase 단어로, 기계가 grep 가능하게 하라.
(예: DeploymentFailed, Ready, Scaling, QuotaExceeded)
- 메시지는 사람을 위한 문장으로. 다음 행동을 암시하면 더 좋다.
- 이벤트는 기본 1시간(이벤트 TTL) 뒤 사라진다. 영속적인 상태는
이벤트가 아니라 status.conditions에 둬라.
- Warning은 진짜 주의가 필요할 때만. 남발하면 무뎌진다.
이벤트는 기본적으로 약 1시간 후 가비지 컬렉션됩니다. 따라서 "현재 상태"의 영속적 표현은 이벤트가 아니라 status에 담아야 합니다.
logr를 사용한 구조화 로깅
Kubebuilder Operator는 `logr` 인터페이스를 사용합니다. 핵심은 문자열 포매팅이 아니라 키-값 쌍의 구조화 로깅이라는 점입니다. 이렇게 하면 로그를 JSON으로 출력하고, 필드 단위로 검색/집계할 수 있습니다.
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 컨텍스트에서 로거를 얻습니다. controller-runtime이 이미
// reconcileID, controller, namespace, name 등을 주입해 둡니다.
log := logf.FromContext(ctx)
var obj appsv1alpha1.MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 이 reconcile 동안 계속 따라다닐 공통 필드를 묶습니다.
log = log.WithValues("generation", obj.Generation, "phase", obj.Status.Phase)
log.Info("reconcile 시작")
if err := r.doWork(ctx, &obj); err != nil {
// 에러는 Error 레벨로. err를 첫 인자로 넘기면 구조화됩니다.
log.Error(err, "작업 수행 실패", "step", "doWork")
return ctrl.Result{}, err
}
// 상세한 디버그 정보는 V-level을 활용해 평소엔 끕니다.
log.V(1).Info("desired state 계산 완료", "replicas", obj.Spec.Replicas)
log.Info("reconcile 완료")
return ctrl.Result{}, nil
}
V-level과 로깅 정책
`logr`의 V-level은 "상세도"를 의미합니다. `V(0)`(=`Info`)는 항상 보이고, 숫자가 커질수록 더 상세하며 평소에는 꺼두는 것이 일반적입니다.
로깅 레벨 가이드
Error : reconcile가 실패해 재시도될 상황. 항상 출력.
Info / V(0) : 상태 전이, reconcile 시작/완료 같은 거시적 흐름.
V(1) : 결정 근거 — 왜 이 경로를 택했는가.
V(2) 이상 : 루프 내부, 개별 항목 단위의 매우 상세한 흐름.
무엇을 로깅하지 말 것인가
- Secret 값, 토큰, 비밀번호 (절대 금지)
- 매 reconcile마다 동일한 "변화 없음" 로그 (소음)
- 거대한 객체 전체 덤프 (필요한 필드만 WithValues 로)
좋은 로깅의 핵심은 "한 줄의 reconcile 흐름을 reconcileID로 추적할 수 있는가"입니다. controller-runtime은 컨텍스트 로거에 `reconcileID`를 자동으로 넣어주므로, 같은 ID로 필터링하면 단일 reconcile의 전체 이야기를 재구성할 수 있습니다.
OpenTelemetry로 분산 트레이싱
reconcile가 여러 외부 시스템(클라우드 API, 데이터베이스, 다른 컨트롤러)에 걸쳐 작동할 때, 지연이 어디서 발생하는지 알려면 트레이싱이 필요합니다. OpenTelemetry로 reconcile를 계측하고 컨텍스트를 전파합니다.
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("myoperator/controller")
func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// reconcile 전체를 감싸는 루트 스팬을 시작합니다.
ctx, span := tracer.Start(ctx, "Reconcile",
trace.WithAttributes(
attribute.String("resource.namespace", req.Namespace),
attribute.String("resource.name", req.Name),
),
)
defer span.End()
var obj appsv1alpha1.MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
span.RecordError(err)
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 외부 호출을 자식 스팬으로 감쌉니다. ctx를 그대로 넘겨
// 부모-자식 관계가 전파되게 합니다.
if err := r.fetchExternal(ctx, &obj); err != nil {
span.SetStatus(codes.Error, "external fetch failed")
span.RecordError(err)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *MyResourceReconciler) fetchExternal(ctx context.Context, obj *appsv1alpha1.MyResource) error {
// 같은 ctx에서 자식 스팬을 시작하면 자동으로 부모에 연결됩니다.
ctx, span := tracer.Start(ctx, "fetchExternal")
defer span.End()
span.SetAttributes(attribute.String("external.id", obj.Spec.ID))
return r.External.Call(ctx, obj.Spec.ID)
}
트레이싱의 핵심은 컨텍스트 전파입니다. `ctx`를 모든 호출에 일관되게 넘기면, 하나의 reconcile가 만들어내는 모든 스팬이 하나의 트레이스로 묶입니다. 이를 통해 "p99 지연의 80%가 외부 API 호출에서 발생한다" 같은 결론을 데이터로 내릴 수 있습니다.
하나의 reconcile 트레이스
Reconcile [120ms] ──────────────────────────────────────────
├─ Get(MyResource) [3ms] ─
├─ fetchExternal [95ms] ────────────────────────────
│ └─ HTTP GET cloud-api [92ms] ──────────────────
└─ applyDesiredState [20ms] ───────
└─ Patch(Deployment) [18ms] ──────
status와 conditions로 사용자에게 상태 보여주기
이벤트가 일시적 사건이라면, `status.conditions`는 영속적이고 선언적인 현재 상태입니다. Kubernetes 생태계의 표준은 `metav1.Condition` 배열을 status에 두는 것입니다. 사용자는 `kubectl get`의 출력이나 다른 컨트롤러가 이 조건을 읽어 동작합니다.
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/api/meta"
)
func (r *MyResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var obj appsv1alpha1.MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
ready, reason, msg := r.evaluateReadiness(ctx, &obj)
// meta.SetStatusCondition은 같은 Type의 조건을 갱신하거나
// 없으면 추가합니다. LastTransitionTime을 알아서 관리합니다.
meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: conditionStatus(ready),
Reason: reason, // PascalCase, 기계 판독 가능
Message: msg, // 사람을 위한 설명
ObservedGeneration: obj.Generation, // 어느 세대를 평가했는지
})
// status 서브리소스를 업데이트합니다.
if err := r.Status().Update(ctx, &obj); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func conditionStatus(ok bool) metav1.ConditionStatus {
if ok {
return metav1.ConditionTrue
}
return metav1.ConditionFalse
}
`ObservedGeneration`은 특히 중요합니다. 사용자가 spec을 바꿔 `generation`이 올라갔는데 `conditions[].observedGeneration`이 그보다 작다면, 컨트롤러가 아직 최신 spec을 반영하지 못한 것입니다. 이는 "내 변경이 적용되었는가"를 판단하는 표준 신호입니다.
status.conditions 설계 원칙
- Type 은 명사적 능력/상태로: Ready, Available, Progressing,
Degraded 등. 표준 의미를 따르면 도구 호환성이 좋다.
- Status 는 True/False/Unknown 세 가지만.
- Reason 은 PascalCase 단어. Message 는 사람을 위한 문장.
- ObservedGeneration 을 반드시 채워라. 적용 여부 판단의 핵심.
- conditions 는 멱등적으로 set 하라. 매번 append 하면 안 된다.
Operator SLO 정의하기
이제 신호들을 모았으니, 이를 "약속"으로 바꿀 차례입니다. SLO(Service Level Objective)는 Operator가 지켜야 할 신뢰 수준을 수치로 정의합니다. Operator에 적합한 세 가지 SLI(지표)를 제안합니다.
Operator를 위한 세 가지 SLI / SLO 예시
┌────────────────┬─────────────────────────────┬──────────────┐
│ SLI │ 정의 │ SLO 목표(예) │
├────────────────┼─────────────────────────────┼──────────────┤
│ reconcile │ 에러 아닌 reconcile / │ 30일 99.5% │
│ 성공률 │ 전체 reconcile │ │
├────────────────┼─────────────────────────────┼──────────────┤
│ reconcile │ reconcile p99 처리 시간 │ p99 < 2s │
│ 지연 │ │ (5분 윈도) │
├────────────────┼─────────────────────────────┼──────────────┤
│ 신선도 │ 마지막 성공 동기화 이후 경과 │ 95%가 │
│ (freshness) │ 시간 │ < 10분 │
└────────────────┴─────────────────────────────┴──────────────┘
성공률 SLO를 PromQL의 에러 예산(error budget) 관점으로 표현하면 다음과 같습니다.
1 - (
sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[30d]))
/
sum(rate(controller_runtime_reconcile_total{controller="myresource"}[30d]))
)
PrometheusRule로 알림 정의하기
SLO를 위반에 다가갈 때 알림을 보내려면 `PrometheusRule`을 정의합니다.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: myoperator-slo-alerts
namespace: myoperator-system
labels:
release: kube-prometheus-stack
spec:
groups:
- name: myoperator.slo
rules:
- alert: ReconcileErrorRateHigh
expr: |
sum(rate(controller_runtime_reconcile_errors_total{controller="myresource"}[5m]))
/
sum(rate(controller_runtime_reconcile_total{controller="myresource"}[5m]))
> 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "MyResource reconcile 에러율이 5%를 초과했습니다"
description: "최근 5분 동안 reconcile 에러율이 SLO 임계치를 넘었습니다."
- alert: ReconcileLatencyHigh
expr: |
histogram_quantile(0.99,
sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller="myresource"}[5m])) by (le)
) > 2
for: 15m
labels:
severity: warning
annotations:
summary: "MyResource reconcile p99 지연이 2초를 초과했습니다"
- alert: ResourceStaleness
expr: |
time() - max by (namespace, name) (
myoperator_last_successful_sync_timestamp_seconds
) > 900
for: 5m
labels:
severity: critical
annotations:
summary: "리소스가 15분 이상 동기화되지 않았습니다"
description: "특정 MyResource가 SLO 신선도 목표를 위반했습니다."
- alert: WorkqueueBacklog
expr: workqueue_depth{name="myresource"} > 50
for: 10m
labels:
severity: warning
annotations:
summary: "workqueue 적체가 발생했습니다"
description: "컨트롤러가 들어오는 이벤트를 따라잡지 못하고 있습니다."
알림 설계에서 `for` 절은 중요합니다. 일시적 스파이크에 알림이 울리지 않도록 지속 시간을 충분히 두되, SLO 위반을 너무 늦게 알리지 않도록 균형을 맞춰야 합니다.
디버깅 워크플로 — "reconcile가 동작하지 않아요"
Operator 운영에서 가장 흔하고 당황스러운 상황은 "리소스를 만들었는데 아무 일도 일어나지 않는다"입니다. reconcile 자체가 호출되지 않는 경우인데, 원인이 여러 곳에 흩어져 있어 추적이 까다롭습니다. 다음은 단계별 진단 런북입니다.
런북: "reconcile가 트리거되지 않음" 진단
1) 메트릭으로 reconcile가 도는지부터 확인
- controller_runtime_reconcile_total{controller="..."} 가
증가하는가?
- 전혀 안 늘면 → 컨트롤러가 이벤트를 받지 못하는 것.
아래 2~6으로.
- 늘긴 느는데 errors_total 도 같이 는다면 → reconcile는
호출되나 실패. 로그/이벤트로 원인 추적(이 런북 대상 아님).
2) 컨트롤러 매니저가 살아 있고 리더인가
- 파드가 Running 인가? 크래시 루프는 아닌가?
- 리더 선출(leader election) 사용 시: 리더가 아닌 복제본은
reconcile를 돌리지 않는다. 리더 락 Lease 를 확인.
kubectl get lease -n myoperator-system
- 리더 파드의 로그를 보고 있는지 확인(다른 파드 로그를 보면
"조용"해 보일 수 있다).
3) 정보가 캐시에 들어오는가 (informer / RBAC)
- 컨트롤러 SA 가 대상 리소스를 watch/list/get 할 RBAC 이
있는가? 없으면 informer 가 조용히 실패하거나 권한 에러.
kubectl auth can-i list myresources \
--as=system:serviceaccount:myoperator-system:controller-manager
- 로그에 "failed to list" / "forbidden" 류 에러가 없는가.
4) predicate 가 이벤트를 걸러내고 있는가
- SetupWithManager 의 WithEventFilter / Owns / Watches 에
붙은 predicate 가 해당 변경을 통과시키는가?
- 예: GenerationChangedPredicate 는 metadata/label 만 바뀐
업데이트를 무시한다 → status/annotation 변경만으로는 reconcile
가 안 돈다.
- 의심되면 predicate 를 일시 제거하고 재현해 본다.
5) 이벤트 소스(watch)가 실제로 등록됐는가
- For()/Owns()/Watches() 에 대상 타입이 빠지지 않았는가.
- 다른 네임스페이스/클러스터 스코프 리소스를 보는데
매니저가 namespace 로 캐시를 제한하고 있지 않은가
(Cache.DefaultNamespaces 설정 확인).
6) 객체가 실제로 변하긴 했는가
- kubectl get myresource -o yaml 로 generation 과
status.observedGeneration 비교.
- resourceVersion 이 그대로면 apiserver 입장에서 변경이
없는 것 → 이벤트 자체가 없다.
요약 결정 트리
reconcile_total 안 늚?
├─ 파드/리더 문제 → 2)
├─ RBAC 부족 → 3)
├─ predicate 필터 → 4)
└─ watch 누락 → 5)
이 런북의 핵심은 "메트릭(1번)으로 먼저 reconcile 호출 여부를 가르는 것"입니다. reconcile가 한 번도 안 도는 문제와, 돌긴 도는데 실패하는 문제는 원인 영역이 완전히 다르기 때문입니다. 관측성을 잘 설계해두면 이 첫 분기를 추측이 아니라 데이터로 내릴 수 있습니다.
운영 런북
마지막으로, 평상시와 장애 시에 참고할 운영 런북을 정리합니다.
일상 점검 (대시보드에서 매일/주간)
- reconcile 성공률이 SLO(예: 99.5%) 위인가
- reconcile p99 지연이 목표(예: 2s) 아래인가
- workqueue_depth 가 0 근처로 수렴하는가
- 신선도 알림이 조용한가
배포 후 확인
- reconcile_errors_total 가 배포 직후 튀지 않는가
- active_workers 가 max_concurrent_reconciles 에 계속
붙어 있지 않은가 (포화 신호)
- 새 버전의 로그에 신규 Error 가 없는가
장애 대응
- 에러율 급증 → 최근 배포 롤백 후보 1순위. 외부 의존성
상태도 동시에 확인.
- 지연 급증 → 트레이스로 어느 스팬이 느린지 특정.
외부 API rate limit / 타임아웃 의심.
- workqueue 적체 → MaxConcurrentReconciles 상향,
predicate 로 불필요 이벤트 차단, reconcile 경량화.
- 특정 리소스만 stale → 해당 객체 kubectl describe 로
Warning 이벤트와 conditions 확인. observedGeneration 비교.
용량 계획
- 관리 리소스 수 증가에 따른 reconcile_total rate 추세
- 메트릭 카디널리티(특히 name 라벨) 증가 추세 모니터링
- Prometheus TSDB 메모리/디스크 사용량
마치며
관측성은 Operator를 "동작하는 코드"에서 "운영 가능한 시스템"으로 끌어올리는 마지막 한 걸음입니다. 핵심을 다시 정리하면 다음과 같습니다.
- controller-runtime의 기본 메트릭(reconcile total/errors/duration, workqueue depth/adds/latency)은 공짜로 얻는 강력한 출발점입니다. 각 라벨의 의미를 이해하는 것이 먼저입니다.
- 도메인 고유 신호는 커스텀 메트릭으로 직접 정의하되, 게이지 삭제와 라벨 카디널리티에 주의해야 합니다.
- 이벤트는 사용자를 위한 맥락, 로그는 개발자를 위한 흐름, 트레이싱은 지연 분석, status/conditions는 선언적 현재 상태 — 청중을 의식해 신호를 나누어 설계하세요.
- SLO를 수치로 정의하고 PrometheusRule로 알림을 걸면, Operator의 신뢰성을 추측이 아니라 약속으로 관리할 수 있습니다.
- "reconcile가 안 돈다"는 문제는 메트릭으로 먼저 호출 여부를 가르고, predicate/RBAC/watch/leader election을 체계적으로 짚어 나가면 빠르게 좁혀집니다.
좋은 Operator는 조용히 일하지만, 좋은 관측성은 그 조용함이 "건강한 침묵"인지 "장애의 침묵"인지를 늘 구별해 줍니다.
참고 자료
- [Kubebuilder Book](https://book.kubebuilder.io/)
- [Kubebuilder Book — Metrics](https://book.kubebuilder.io/reference/metrics)
- [controller-runtime (pkg.go.dev)](https://pkg.go.dev/sigs.k8s.io/controller-runtime)
- [kubernetes-sigs/controller-runtime (GitHub)](https://github.com/kubernetes-sigs/controller-runtime)
- [kubernetes-sigs/kubebuilder (GitHub)](https://github.com/kubernetes-sigs/kubebuilder)
- [Operator pattern — Kubernetes Docs](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
- [Prometheus Documentation](https://prometheus.io/docs/)
- [Grafana Documentation](https://grafana.com/docs/)
- [kube-state-metrics (GitHub)](https://github.com/kubernetes/kube-state-metrics)
- [OpenTelemetry Documentation](https://opentelemetry.io/docs/)
- [go-logr/logr (GitHub)](https://github.com/go-logr/logr)
- [Kubernetes API Reference — Events](https://kubernetes.io/docs/reference/kubernetes-api/)
현재 단락 (1/571)
Operator를 처음 작성할 때는 "reconcile 루프가 잘 도는가"에만 집중하게 됩니다. 그러나 운영에 들어가면 질문이 달라집니다. "지금 reconcile가 얼마나 자주 ...