- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — Operator는 클러스터에서 가장 위험한 워크로드일 수 있다
- Operator 권한이 위험한 이유 — 위협 모델부터
- RBAC 마커로 최소권한 생성하기
- Namespace-scoped vs Cluster-scoped — Role과 ClusterRole 선택
- 메트릭 엔드포인트 보안 — kube-rbac-proxy 제거 이후 (2026)
- 웹훅 보안 — TLS와 cert-manager
- CR을 통한 권한 상승 방지 — 가장 미묘한 위험
- 멀티테넌시 격리
- 공급망 보안 — 이미지 서명과 SBOM
- Secret 처리
- 감사(Audit)
- reconcile 멱등성과 보안의 관계
- 보안 체크리스트
- 마치며
- 참고 자료
들어가며 — Operator는 클러스터에서 가장 위험한 워크로드일 수 있다
쿠버네티스 클러스터 안에서 돌아가는 대부분의 워크로드는 자기 네임스페이스 안의 일을 합니다. 웹 애플리케이션은 자기 Pod와 Service를 가지고 트래픽을 처리하고, 배치 잡은 데이터를 읽고 쓰고 끝납니다. 이런 워크로드가 침해되면 피해는 보통 그 워크로드와 그 데이터에 한정됩니다.
Operator는 다릅니다. Operator의 본질은 클러스터 상태를 대신 조작하는 자동화된 관리자입니다. CRD(Custom Resource Definition)로 정의한 도메인 객체를 감시하고, 그것을 현실로 만들기 위해 Deployment를 만들고, Service를 만들고, Secret을 읽고, 때로는 ClusterRole까지 생성합니다. 이 일을 하려면 강력한 권한이 필요합니다. 그리고 많은 Operator가 클러스터 전역(cluster-scoped)에서 동작합니다.
여기서 보안의 핵심 명제가 나옵니다. Operator의 ServiceAccount가 탈취되면, 공격자는 그 Operator가 가진 모든 권한을 그대로 손에 넣습니다. Operator가 모든 네임스페이스의 Secret을 읽을 수 있다면 공격자도 그렇게 할 수 있습니다. Operator가 ClusterRoleBinding을 만들 수 있다면 공격자는 cluster-admin으로 자신을 승격시킬 수 있습니다. Operator의 컨트롤러 Pod 하나에서 발견된 RCE 취약점이 곧 클러스터 전체의 장악으로 이어지는 것입니다.
이 글은 Kubebuilder(2026년 기준 Kubernetes 1.36 / Go 1.26, controller-runtime v0.24.x, controller-tools v0.21.x)로 Operator를 만들 때 적용해야 할 보안 강화(hardening) 전체를 다룹니다. RBAC 최소권한 생성부터 메트릭 엔드포인트 보안, 웹훅 TLS, 권한 상승 방지, 멀티테넌시 격리, 공급망 보안까지 실제로 동작하는 코드와 매니페스트 중심으로 정리했습니다.
Operator 권한이 위험한 이유 — 위협 모델부터
보안 설계는 항상 위협 모델에서 출발해야 합니다. Operator를 공격자의 관점에서 보면 다음과 같은 공격 경로가 보입니다.
공격 경로 1: 컨트롤러 Pod 침해
애플리케이션 취약점(RCE) → 컨트롤러 Pod 셸 획득
→ ServiceAccount 토큰 탈취(/var/run/secrets/...)
→ Operator의 모든 RBAC 권한 행사
→ 예: 전 네임스페이스 Secret 읽기, ClusterRoleBinding 생성
공격 경로 2: CR을 통한 권한 상승
악의적 사용자가 CR을 생성/수정
→ Operator가 그 CR을 신뢰하고 RBAC 객체를 생성
→ 사용자가 직접 가질 수 없던 권한을 우회 획득
공격 경로 3: 공급망(supply chain)
Operator 이미지 빌드 파이프라인 또는 베이스 이미지 침해
→ 악성 코드가 포함된 이미지가 클러스터에 배포
→ 정상 Operator처럼 보이지만 백도어 포함
공격 경로 4: 메트릭/웹훅 엔드포인트
인증 없는 메트릭 엔드포인트 노출 → 내부 정보 유출
TLS 미적용 웹훅 → 중간자 공격으로 admission 우회
이 네 가지 경로를 각각 막는 것이 Operator 보안의 실무입니다. 핵심 원칙은 하나로 수렴합니다. 최소권한(least privilege). Operator가 실제로 필요한 동사(verb)와 리소스에만 권한을 주고, 그 외에는 전부 거부하는 것입니다.
RBAC 마커로 최소권한 생성하기
권한은 코드 옆에 선언한다
Kubebuilder 기반 Operator의 가장 좋은 점 중 하나는 RBAC를 컨트롤러 코드 바로 옆에 마커(marker)로 선언한다는 것입니다. controller-tools가 이 마커를 읽어 Role/ClusterRole 매니페스트를 생성합니다. 이렇게 하면 "코드는 Secret을 읽는데 RBAC에는 그 권한이 없어서 런타임에 터지는" 흔한 사고를 줄이고, 반대로 "코드는 안 쓰는데 RBAC만 과도하게 넓은" 권한 누수도 코드 리뷰에서 잡을 수 있습니다.
다음은 전형적인 Reconciler의 RBAC 마커입니다.
// WidgetReconciler는 Widget 커스텀 리소스를 reconcile합니다.
type WidgetReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func (r *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var widget appsv1alpha1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
// NotFound는 정상 — 삭제된 객체의 재큐잉일 수 있음
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 원하는 상태(desired state)를 계산하고 Deployment를 보장
desired := r.buildDeployment(&widget)
if err := r.ensureDeployment(ctx, desired); err != nil {
log.Error(err, "deployment 보장 실패")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
make manifests를 실행하면 controller-tools가 위 마커를 읽어 다음과 같은 ClusterRole을 config/rbac/role.yaml에 생성합니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: widget-manager-role
rules:
- apiGroups: ["apps.example.com"]
resources: ["widgets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps.example.com"]
resources: ["widgets/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["apps.example.com"]
resources: ["widgets/finalizers"]
verbs: ["update"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
동사를 줄이는 것이 최소권한의 핵심
초보 Operator 개발자가 가장 흔히 저지르는 실수는 모든 리소스에 verbs=*(또는 get;list;watch;create;update;patch;delete 전체)를 부여하는 것입니다. 실제로는 동사를 신중하게 골라야 합니다.
| 컨트롤러가 하는 일 | 필요한 동사 | 불필요하게 넓은 권한 |
|---|---|---|
| CR을 읽고 감시만 함 | get, list, watch | create, delete |
| 자식 리소스를 생성/갱신 | get, list, watch, create, update, patch | delete (가비지 컬렉션을 ownerReference에 맡기면 불필요) |
| 상태 보고 | get, update, patch (status 서브리소스) | 메인 리소스 update |
| 이벤트 기록 | create, patch | get, list |
특히 delete 동사는 신중하게 다뤄야 합니다. 자식 리소스 정리는 대부분 ownerReference와 가비지 컬렉터에 맡길 수 있으므로 컨트롤러가 직접 delete 권한을 가질 필요가 없는 경우가 많습니다. delete 권한이 없으면 침해된 컨트롤러가 리소스를 대량 삭제하는 시나리오를 원천 차단할 수 있습니다.
리소스 이름으로 권한 범위 좁히기
RBAC는 resourceNames로 특정 객체 이름에만 권한을 한정할 수 있습니다. 예를 들어 Operator가 자신의 리더 선출(leader election)에 쓰는 ConfigMap이나 Lease 하나만 수정하면 된다면, 모든 ConfigMap에 권한을 주는 대신 그 이름에만 한정합니다. 다만 controller-tools 마커는 resourceNames를 직접 지원하지 않으므로, 생성된 매니페스트를 kustomize patch로 좁히거나 별도의 Role을 수동 관리하는 방식을 씁니다.
# 리더 선출 전용 Role — 이름이 명시된 Lease 하나만 수정 가능
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: widget-leader-election-role
namespace: widget-system
rules:
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
resourceNames: ["widget-operator-leader"]
Namespace-scoped vs Cluster-scoped — Role과 ClusterRole 선택
Operator를 설계할 때 가장 먼저 결정해야 할 보안 사항은 권한의 범위입니다. 쿠버네티스 RBAC에는 두 쌍의 객체가 있습니다.
Role + RoleBinding → 특정 네임스페이스 안에서만 유효
ClusterRole + ClusterRoleBinding → 클러스터 전역에서 유효
ClusterRole + RoleBinding → ClusterRole 정의를 특정 네임스페이스에 한정 적용
마지막 조합이 중요합니다. ClusterRole로 권한 규칙을 정의하되, RoleBinding으로 묶으면 그 권한은 RoleBinding이 있는 네임스페이스에만 적용됩니다. 이를 활용하면 동일한 권한 정의를 재사용하면서도 범위를 좁힐 수 있습니다.
| 상황 | 권장 범위 | 이유 |
|---|---|---|
| CR이 cluster-scoped 리소스(예: Namespace, StorageClass)를 다룸 | ClusterRole + ClusterRoleBinding | 네임스페이스 경계를 넘어야 함 |
| Operator가 모든 네임스페이스의 CR을 감시 | ClusterRole + ClusterRoleBinding | watch 대상이 전 네임스페이스 |
| Operator가 특정 네임스페이스의 CR만 관리(멀티테넌시) | ClusterRole + 네임스페이스별 RoleBinding | 권한 정의 재사용 + 범위 한정 |
| 단일 네임스페이스 전용 Operator | Role + RoleBinding | 가장 좁은 범위, 가장 안전 |
단일 네임스페이스 매니저 설정
controller-runtime의 매니저는 캐시 범위를 네임스페이스로 한정할 수 있습니다. 이렇게 하면 Operator가 다른 네임스페이스의 객체를 읽지도 않고, RBAC도 그에 맞춰 좁힐 수 있습니다.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
// 캐시를 특정 네임스페이스로 한정 — 전 클러스터 watch를 하지 않음
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{
"widget-system": {},
"tenant-a": {},
},
},
Metrics: server.Options{
BindAddress: ":8443",
},
})
if err != nil {
setupLog.Error(err, "매니저 생성 실패")
os.Exit(1)
}
네임스페이스를 한정하면 ClusterRole 대신 각 네임스페이스의 Role + RoleBinding으로 권한을 줄 수 있습니다. 권한 범위가 좁아지면 침해 시 폭발 반경(blast radius)도 작아집니다. "이 Operator는 정말 모든 네임스페이스를 봐야 하는가?"라는 질문을 설계 단계에서 반드시 던져야 합니다.
메트릭 엔드포인트 보안 — kube-rbac-proxy 제거 이후 (2026)
무엇이 바뀌었나
과거 Kubebuilder 스캐폴드는 메트릭 엔드포인트를 보호하기 위해 사이드카로 kube-rbac-proxy를 띄웠습니다. 메트릭 포트 앞에 프록시를 두고, 들어오는 요청의 토큰을 SubjectAccessReview로 검증하는 구조였습니다. 하지만 이 사이드카는 추가 이미지 의존성, 추가 권한, 추가 운영 부담을 만들었습니다.
2026년 현재 Kubebuilder는 kube-rbac-proxy를 제거했습니다. 대신 controller-runtime의 메트릭 서버가 자체적으로 인증/인가를 수행하는 WithAuthenticationAndAuthorization 필터를 제공합니다. 메트릭은 HTTPS로 제공되고, 들어오는 요청은 TokenReview와 SubjectAccessReview를 통해 검증됩니다.
import (
"crypto/tls"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
)
func main() {
// ... scheme 등록 등 생략 ...
metricsServerOptions := metricsserver.Options{
BindAddress: ":8443",
SecureServing: true,
// 들어오는 메트릭 스크레이프 요청을 인증/인가
// SubjectAccessReview로 nonResourceURL /metrics 접근 권한을 확인
FilterProvider: filters.WithAuthenticationAndAuthorization,
TLSOpts: []func(*tls.Config){
func(c *tls.Config) {
c.MinVersion = tls.VersionTLS13
},
},
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
HealthProbeBindAddress: ":8081",
LeaderElection: true,
LeaderElectionID: "widget-operator-leader",
})
if err != nil {
setupLog.Error(err, "매니저 생성 실패")
os.Exit(1)
}
// ... 컨트롤러 등록, mgr.Start(...) ...
}
스크레이퍼에게 권한을 부여하기
이제 메트릭을 긁어가려는 측(예: Prometheus의 ServiceAccount)은 /metrics 비리소스 URL에 대한 get 권한을 가져야 합니다. 다음 ClusterRole이 그 권한을 정의합니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: widget-metrics-reader
rules:
- nonResourceURLs: ["/metrics"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: widget-metrics-reader-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: widget-metrics-reader
subjects:
- kind: ServiceAccount
name: prometheus
namespace: monitoring
이 방식의 이점은 명확합니다. 사이드카가 사라져 공격 표면이 줄고, 인증/인가가 메인 바이너리 안에서 controller-runtime이 검증한 코드 경로로 처리되며, TLS 1.3을 강제할 수 있습니다. 인증되지 않은 요청은 401, 권한 없는 요청은 403으로 거부됩니다.
웹훅 보안 — TLS와 cert-manager
웹훅은 API 서버가 직접 호출한다
Operator가 ValidatingWebhook이나 MutatingWebhook을 제공하면, API 서버가 admission 요청을 HTTPS로 컨트롤러에 보냅니다. 이 통신은 반드시 TLS로 보호되어야 하며, API 서버는 웹훅 서버 인증서를 CA로 검증합니다. 인증서가 만료되거나 CA 번들이 어긋나면 admission이 실패하고, 실패 정책에 따라 전체 객체 생성이 막힐 수 있습니다.
실무에서는 cert-manager로 인증서를 자동 발급/회전합니다. cert-manager가 발급한 인증서를 웹훅 서버가 사용하고, CA 번들은 cert-manager의 ca-injector가 ValidatingWebhookConfiguration에 자동 주입합니다.
# cert-manager Certificate — 웹훅 서버 TLS 인증서
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: widget-serving-cert
namespace: widget-system
spec:
dnsNames:
- widget-webhook-service.widget-system.svc
- widget-webhook-service.widget-system.svc.cluster.local
issuerRef:
kind: Issuer
name: widget-selfsigned-issuer
secretName: widget-webhook-server-cert
duration: 2160h # 90일
renewBefore: 360h # 만료 15일 전 갱신
# ca-injector가 caBundle을 자동 주입하도록 어노테이션 부여
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: widget-validating-webhook
annotations:
cert-manager.io/inject-ca-from: widget-system/widget-serving-cert
webhooks:
- name: vwidget.kb.io
failurePolicy: Fail
sideEffects: None
admissionReviewVersions: ["v1"]
clientConfig:
service:
name: widget-webhook-service
namespace: widget-system
path: /validate-apps-example-com-v1alpha1-widget
rules:
- apiGroups: ["apps.example.com"]
apiVersions: ["v1alpha1"]
operations: ["CREATE", "UPDATE"]
resources: ["widgets"]
검증 웹훅 코드와 실패 정책
검증 웹훅은 객체가 보안 정책을 어기지 않는지 확인하는 강력한 통제 지점입니다. 예를 들어 사용자가 위험한 필드 조합을 넣지 못하게 막을 수 있습니다.
func (v *WidgetValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
widget, ok := obj.(*appsv1alpha1.Widget)
if !ok {
return nil, fmt.Errorf("Widget 타입이 아닙니다")
}
// 보안 정책: 특권 모드 요청을 거부
if widget.Spec.Privileged {
return nil, field.Forbidden(
field.NewPath("spec", "privileged"),
"특권 모드는 이 클러스터에서 허용되지 않습니다",
)
}
// 보안 정책: 신뢰된 레지스트리의 이미지만 허용
if !strings.HasPrefix(widget.Spec.Image, "registry.internal.example.com/") {
return nil, field.Invalid(
field.NewPath("spec", "image"),
widget.Spec.Image,
"내부 레지스트리 이미지만 허용됩니다",
)
}
return nil, nil
}
failurePolicy는 보안 관점에서 신중하게 선택해야 합니다. Fail은 웹훅이 응답하지 못하면 요청을 거부하므로 보안 통제를 우회당하지 않지만, 웹훅 가용성이 떨어지면 클러스터 운영이 막힐 수 있습니다. 보안 통제 목적의 웹훅은 Fail을 쓰되, 웹훅 자체의 고가용성(복제본, PDB)을 함께 확보해야 합니다.
CR을 통한 권한 상승 방지 — 가장 미묘한 위험
문제의 구조
Operator가 RBAC 객체를 생성하는 경우, 미묘하지만 심각한 권한 상승 경로가 생깁니다. 예를 들어 "테넌트마다 ServiceAccount와 Role을 자동으로 만들어주는" Operator를 생각해 봅시다. 사용자가 CR에 원하는 권한을 적으면 Operator가 그 권한대로 Role을 만들어줍니다.
여기서 문제가 생깁니다. 쿠버네티스 RBAC에는 권한 상승 방지(privilege escalation prevention) 규칙이 있습니다. 어떤 주체가 RBAC 객체를 만들거나 수정할 때, 자기 자신이 가지지 않은 권한을 다른 주체에게 부여할 수 없습니다. 이를 우회하려면 rbac.authorization.k8s.io 그룹의 escalate 또는 bind 동사가 필요합니다.
문제는 Operator의 ServiceAccount가 강력한 권한을 가진 경우입니다. 사용자가 직접 만들면 권한 상승 규칙에 걸려 막히는 Role을, Operator를 통해 우회하여 만들 수 있습니다. 사용자는 CR만 만들 수 있으면 되고, 실제 RBAC 생성은 강력한 Operator가 대신 해주기 때문입니다.
정상 경로(막힘):
사용자 → 직접 RoleBinding 생성 시도(cluster-admin 부여)
→ API 서버: "당신은 cluster-admin이 없으므로 부여 불가" → 거부
우회 경로(위험):
사용자 → CR 생성(spec에 cluster-admin 요청)
→ Operator(강력한 SA) → CR 신뢰하고 RoleBinding 생성
→ 사용자가 우회로 cluster-admin 획득
방어 전략
이 위험을 막는 방법은 여러 겹입니다.
첫째, Operator가 RBAC를 생성하는 권한 자체를 최소화합니다. 정말 RBAC 객체를 만들어야 하는지 다시 묻고, 가능하면 미리 정의된 고정 Role만 바인딩하게 설계합니다. Operator가 임의의 규칙을 가진 Role을 동적으로 생성하는 설계는 가능한 한 피합니다.
둘째, 검증 웹훅으로 CR이 요청할 수 있는 권한을 화이트리스트로 제한합니다. CR이 위험한 동사(escalate, bind, impersonate)나 위험한 리소스(clusterrolebindings, secrets 전체)를 요청하면 웹훅이 거부합니다.
var forbiddenVerbs = map[string]bool{
"escalate": true,
"bind": true,
"impersonate": true,
}
func (v *TenantRoleValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
tr, ok := obj.(*appsv1alpha1.TenantRole)
if !ok {
return nil, fmt.Errorf("TenantRole 타입이 아닙니다")
}
for _, rule := range tr.Spec.Rules {
for _, verb := range rule.Verbs {
if forbiddenVerbs[verb] {
return nil, field.Forbidden(
field.NewPath("spec", "rules"),
fmt.Sprintf("동사 %q 는 테넌트 Role에서 허용되지 않습니다", verb),
)
}
}
// cluster-scoped RBAC 리소스 요청 차단
for _, res := range rule.Resources {
if res == "clusterroles" || res == "clusterrolebindings" {
return nil, field.Forbidden(
field.NewPath("spec", "rules"),
"cluster-scoped RBAC 리소스는 테넌트 Role에서 허용되지 않습니다",
)
}
}
}
return nil, nil
}
셋째, 집계(aggregated) ClusterRole을 활용해 동적 생성 자체를 없앱니다. 미리 라벨로 묶이는 작은 ClusterRole들을 정의해 두고, Operator는 새 규칙을 만드는 대신 이 집계 그룹에 합류시키는 정도만 합니다. 이렇게 하면 운영자가 검토한 권한 조각만 조합되므로 임의 권한 주입을 방지할 수 있습니다.
넷째, 컨트롤러 ServiceAccount 자체의 최소권한을 다시 강조합니다. Operator가 rbac.authorization.k8s.io/roles에 대해 가질 수 있는 동사를 get;list;watch;create;update;patch로 제한하고, delete나 cluster-scoped RBAC 권한은 정말 필요할 때만 부여합니다.
멀티테넌시 격리
테넌트 사이의 경계를 어떻게 긋는가
여러 팀이 하나의 클러스터를 공유하고 각자 Operator의 CR을 사용하는 멀티테넌트 환경에서는, 한 테넌트가 다른 테넌트의 리소스에 영향을 주지 못하게 격리해야 합니다. 격리 전략은 크게 두 갈래입니다.
전략 A: 단일 Operator, 다중 테넌트 (공유 Operator)
하나의 Operator 인스턴스가 모든 테넌트 네임스페이스의 CR을 처리
장점: 운영 단순, 리소스 절약
단점: Operator 침해 시 모든 테넌트 영향, 격리 약함
전략 B: 테넌트별 Operator (전용 Operator)
테넌트 네임스페이스마다 독립적인 Operator 인스턴스
장점: 강한 격리, 폭발 반경 최소화
단점: 운영 복잡, 리소스 오버헤드
규제가 강하거나 테넌트 간 신뢰가 낮은 환경에서는 전략 B(테넌트별 Operator)가 권장됩니다. 각 Operator는 자기 테넌트 네임스페이스로 캐시와 RBAC가 한정되므로, 한 테넌트의 Operator가 침해되어도 다른 테넌트에는 영향이 없습니다.
OperatorGroup으로 범위 한정 (OLM)
Operator Lifecycle Manager(OLM)를 쓰는 경우, OperatorGroup이 Operator의 감시 범위를 결정합니다. OperatorGroup의 targetNamespaces를 지정하면 Operator는 그 네임스페이스의 CR만 처리합니다.
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
name: tenant-a-operatorgroup
namespace: tenant-a
spec:
# 이 Operator는 tenant-a 네임스페이스만 감시
targetNamespaces:
- tenant-a
이와 함께 네트워크 정책으로 테넌트 간 트래픽을 차단하고, ResourceQuota와 LimitRange로 한 테넌트가 자원을 독점하지 못하게 막습니다.
# 테넌트 네임스페이스로의 인바운드를 같은 테넌트로만 제한
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-a-isolation
namespace: tenant-a
spec:
podSelector: {}
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
tenant: tenant-a
# 테넌트별 자원 상한
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-a-quota
namespace: tenant-a
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
count/widgets.apps.example.com: "50"
마지막 줄에 주목할 만합니다. 커스텀 리소스 개수도 ResourceQuota로 제한할 수 있어, 한 테넌트가 CR을 무한정 만들어 Operator를 마비시키는 자원 고갈 공격을 막을 수 있습니다.
공급망 보안 — 이미지 서명과 SBOM
Operator 이미지를 신뢰할 수 있는가
위협 모델의 세 번째 경로는 공급망이었습니다. 아무리 RBAC를 좁혀도, 배포되는 Operator 이미지 자체에 백도어가 있다면 소용없습니다. 2026년 현재 공급망 보안의 표준 도구는 sigstore의 cosign(서명), SBOM(Software Bill of Materials, 구성 명세), SLSA(공급망 무결성 수준) provenance입니다.
빌드 파이프라인에서 이미지를 서명합니다.
# cosign으로 이미지 서명 (키리스 — OIDC 신원 사용)
cosign sign --yes \
registry.internal.example.com/widget-operator:v1.4.0
# SBOM 생성 후 이미지에 첨부
syft registry.internal.example.com/widget-operator:v1.4.0 \
-o spdx-json > sbom.spdx.json
cosign attach sbom --sbom sbom.spdx.json \
registry.internal.example.com/widget-operator:v1.4.0
# SBOM 자체도 서명
cosign attest --yes \
--predicate sbom.spdx.json --type spdxjson \
registry.internal.example.com/widget-operator:v1.4.0
admission 정책으로 서명 검증 강제
이미지를 서명하는 것만으로는 부족합니다. 클러스터가 서명되지 않은 이미지를 거부해야 의미가 있습니다. sigstore policy-controller(또는 Kyverno, OPA Gatekeeper)를 admission 단계에 두어 서명을 검증합니다.
# sigstore policy-controller — 신뢰된 신원으로 서명된 이미지만 허용
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-signed-operator-images
spec:
images:
- glob: "registry.internal.example.com/widget-operator*"
authorities:
- keyless:
identities:
- issuer: https://token.actions.githubusercontent.com
subject: https://github.com/example/widget-operator/.github/workflows/release.yml@refs/tags/v1.4.0
이 정책이 활성화된 네임스페이스에서는 서명 검증을 통과하지 못한 이미지로 Pod를 만들려 하면 admission이 거부합니다. SLSA provenance까지 검증하면 "이 이미지가 신뢰된 빌드 시스템에서, 특정 소스 커밋으로부터 만들어졌다"는 사실까지 보장할 수 있습니다.
베이스 이미지와 의존성
Operator 바이너리 자체는 정적 링크된 Go 바이너리이므로 distroless나 scratch 베이스를 쓰는 것이 좋습니다. 공격 표면을 최소화하고, 셸이 없어 침해 후 측면 이동(lateral movement)을 어렵게 만듭니다.
# 멀티스테이지 빌드 — 최종 이미지는 distroless
FROM golang:1.26 AS builder
WORKDIR /workspace
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o manager cmd/main.go
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532
ENTRYPOINT ["/manager"]
nonroot 사용자로 실행하고 셸을 포함하지 않는 distroless 이미지는 공급망과 런타임 보안 양쪽에 기여합니다.
Secret 처리
Secret을 다루는 Operator의 원칙
많은 Operator가 Secret을 읽습니다. 데이터베이스 비밀번호, API 키, TLS 인증서 등을 다루기 때문입니다. Secret 처리에는 몇 가지 철칙이 있습니다.
첫째, Secret을 로그에 남기지 않습니다. 디버깅을 위해 객체 전체를 로깅하다가 Secret 값이 평문으로 로그에 남는 사고가 흔합니다. 구조화 로깅에서 민감 필드를 명시적으로 제외해야 합니다.
// 나쁜 예: Secret 전체를 로깅 — 값이 노출됨
// log.Info("secret 조회됨", "secret", secret)
// 좋은 예: 메타데이터만 로깅, 값은 절대 출력하지 않음
log.Info("secret 조회됨",
"name", secret.Name,
"namespace", secret.Namespace,
"keys", maps.Keys(secret.Data), // 키 이름만, 값은 제외
)
둘째, Secret에 대한 RBAC를 최대한 좁힙니다. Operator가 모든 네임스페이스의 모든 Secret을 읽을 수 있게 하지 말고, 가능하면 특정 네임스페이스 또는 특정 이름의 Secret에만 권한을 한정합니다. 앞서 본 resourceNames를 적극 활용합니다.
셋째, **저장 시 암호화(encryption at rest)**를 켭니다. 이는 클러스터 운영자의 책임이지만, Operator 개발자도 이 전제를 문서화해야 합니다. etcd에 저장되는 Secret이 평문이면, etcd 백업 하나가 모든 Secret의 유출로 이어집니다. KMS provider를 통한 봉투 암호화(envelope encryption)를 권장합니다.
# API 서버의 EncryptionConfiguration — KMS provider로 Secret 암호화
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- kms:
apiVersion: v2
name: cluster-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
- identity: {}
넷째, 가능하면 외부 Secret 관리자(Vault, 클라우드 KMS)와 연동해 Secret을 클러스터 밖에 두고, External Secrets Operator 같은 도구로 필요할 때만 동기화합니다. Operator가 직접 장기 자격증명을 들고 있지 않게 하는 것이 이상적입니다.
감사(Audit)
무엇을 기록해야 하는가
Operator가 클러스터 상태를 바꾸는 강력한 주체인 만큼, 그 행동은 감사 로그에 남아야 합니다. 쿠버네티스 감사 정책(audit policy)에서 Operator의 ServiceAccount가 수행하는 요청을 적절한 수준으로 기록하도록 설정합니다.
# API 서버 audit policy 발췌 — Operator의 쓰기 작업을 상세 기록
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Operator SA의 RBAC 객체 변경은 RequestResponse 수준으로 전체 기록
- level: RequestResponse
users: ["system:serviceaccount:widget-system:widget-controller-manager"]
verbs: ["create", "update", "patch", "delete"]
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
# Secret 접근은 Metadata 수준 (값은 로그에 남기지 않음)
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
# 나머지 Operator 쓰기 작업은 Request 수준
- level: Request
users: ["system:serviceaccount:widget-system:widget-controller-manager"]
verbs: ["create", "update", "patch", "delete"]
특히 Operator가 RBAC 객체를 변경하는 행위는 권한 상승의 신호일 수 있으므로 RequestResponse 수준으로 전체를 기록하는 것이 좋습니다. 반면 Secret 접근은 Metadata 수준으로만 기록해 값이 감사 로그에 평문으로 남지 않게 합니다. Operator 자체도 의미 있는 도메인 이벤트(예: "Widget X reconcile 실패")를 쿠버네티스 Event로 기록해 운영 가시성을 높입니다.
reconcile 멱등성과 보안의 관계
조금 다른 각도지만 중요한 이야기입니다. controller-runtime의 reconcile는 멱등(idempotent)이고 원하는 상태(desired state) 중심으로 설계되어야 합니다. 이것은 보안과도 연결됩니다.
reconcile가 "현재 상태를 보고 원하는 상태로 수렴"하는 방식이면, 누군가 악의적으로 또는 실수로 리소스를 변조해도 Operator가 다음 reconcile에서 이를 원래대로 되돌립니다. 즉 reconcile 루프 자체가 일종의 드리프트 교정이자 보안 통제가 됩니다. 반대로 reconcile가 명령형(이벤트가 오면 한 번 실행)으로 작성되면, 변조가 교정되지 않고 남아 보안 구멍이 됩니다.
또한 predicate로 이벤트를 필터링하면 불필요한 reconcile를 줄여 부하 기반 공격(많은 CR 변경으로 Operator를 마비시키는 시도)에 대한 내성도 높일 수 있습니다.
// predicate로 spec 변경에만 반응 — status-only 업데이트로 인한 reconcile 폭주 방지
func (r *WidgetReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.Widget{}).
WithEventFilter(predicate.GenerationChangedPredicate{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}
finalizer는 삭제 시 정리(cleanup)를 보장하는데, 이것도 보안과 관련됩니다. Operator가 외부 시스템에 자격증명이나 리소스를 생성했다면, CR 삭제 시 finalizer로 그것을 확실히 회수해야 고아 자원(orphaned resource)으로 인한 권한 누수를 막을 수 있습니다.
보안 체크리스트
운영에 들어가기 전 점검할 항목을 한눈에 정리합니다.
[ RBAC 최소권한 ]
□ 모든 RBAC 마커가 실제 코드가 쓰는 동사/리소스만 부여하는가
□ verbs=* 또는 resources=* 와일드카드를 제거했는가
□ delete 권한이 정말 필요한가 (ownerReference로 대체 가능한가)
□ 리더 선출 Lease는 resourceNames로 한정했는가
□ ClusterRole이 정말 필요한가, Role로 충분하지 않은가
[ 범위 / 멀티테넌시 ]
□ 매니저 캐시를 필요한 네임스페이스로 한정했는가
□ 테넌트 격리가 필요하면 테넌트별 Operator를 검토했는가
□ NetworkPolicy / ResourceQuota / LimitRange를 적용했는가
□ CR 개수에도 quota를 걸었는가
[ 메트릭 / 웹훅 ]
□ 메트릭 엔드포인트에 WithAuthenticationAndAuthorization 적용
□ kube-rbac-proxy 사이드카를 제거했는가 (2026)
□ TLS 1.3 최소 버전을 강제했는가
□ 웹훅 인증서를 cert-manager로 자동 회전하는가
□ 보안 웹훅의 failurePolicy=Fail + 고가용성 확보
[ 권한 상승 방지 ]
□ Operator가 RBAC를 동적 생성하지 않게 설계했는가
□ CR이 요청 가능한 권한을 웹훅으로 화이트리스트했는가
□ escalate/bind/impersonate 동사를 차단했는가
[ 공급망 ]
□ 이미지를 cosign으로 서명하는가
□ SBOM을 생성/첨부/서명하는가
□ admission 정책으로 서명 검증을 강제하는가
□ distroless/nonroot 베이스 이미지를 쓰는가
[ Secret / 감사 ]
□ Secret을 로그에 평문으로 남기지 않는가
□ Secret RBAC를 네임스페이스/이름으로 좁혔는가
□ etcd encryption at rest (KMS)를 켰는가
□ audit policy로 Operator의 RBAC 변경을 RequestResponse로 기록하는가
마치며
Operator는 클러스터를 운영하는 자동화의 정점이지만, 동시에 강력한 권한 때문에 가장 매력적인 공격 대상입니다. 이 글에서 다룬 보안 강화는 결국 하나의 원칙으로 수렴합니다. Operator에게 필요한 최소한의 권한만 주고, 그 권한이 오남용되지 않도록 다층으로 방어하라.
RBAC 마커로 권한을 코드 옆에 선언하고, 동사를 신중하게 좁히고, 범위를 네임스페이스로 한정하고, 메트릭과 웹훅을 인증/TLS로 보호하고, CR을 통한 권한 상승을 웹훅으로 차단하고, 이미지를 서명하고 검증하고, Secret을 신중히 다루고, 모든 행동을 감사 로그에 남기는 것. 이 각각은 어렵지 않지만, 합쳐졌을 때 비로소 침해 한 번이 클러스터 전체의 장악으로 이어지지 않는 방어선이 됩니다.
새 Operator를 설계할 때마다 위협 모델을 먼저 그리고, 위 체크리스트를 처음부터 적용하시길 권합니다. 보안은 나중에 덧붙이는 것이 아니라 설계의 일부일 때 가장 견고합니다.
참고 자료
- Kubebuilder Book: https://kubebuilder.io/
- Operator SDK: https://sdk.operatorframework.io/
- controller-runtime (pkg.go.dev): https://pkg.go.dev/sigs.k8s.io/controller-runtime
- Kubernetes Operator 패턴: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
- Kubernetes RBAC 문서: https://kubernetes.io/docs/reference/access-authn-authz/rbac/
- Admission Controllers: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
- controller-tools (RBAC 마커): https://github.com/kubernetes-sigs/controller-tools
- Operator Lifecycle Manager (OLM): https://olm.operatorframework.io/
- sigstore / cosign: https://www.sigstore.dev/
- SLSA 공급망 무결성: https://slsa.dev/
- Kubebuilder GitHub: https://github.com/kubernetes-sigs/kubebuilder
- controller-runtime GitHub: https://github.com/kubernetes-sigs/controller-runtime