k8s

Kubernetes Admission Controller

Luuuuu 2025. 6. 1. 17:18

쿠버네티스를 운영하다 보면 "개발자들이 자꾸 리소스 제한을 빼먹네", "모든 Pod에 특정 라벨을 강제하고 싶은데"와 같은 고민을 하게 됩니다. 이런 상황에서 우리에게 필요한 것이 바로 Admission Controller입니다.

Admission Controller를 쉽게 이해하기 위해 아파트 경비실을 생각해보세요. 방문자가 들어올 때 경비원은 두 가지 일을 합니다. 먼저 방문증을 발급해주고 필요한 안내를 해준 다음(변조), 출입이 가능한 사람인지 최종 확인합니다(검증). 쿠버네티스의 Admission Controller도 정확히 이런 역할을 합니다.

 

이 글에서는 https://malwareanalysis.tistory.com/704 를 참조하여 작성했습니다.

핵심 개념: 두 단계로 이루어진 관문

1단계: 변조(Mutation) - "필요한 것을 추가해주기"

Admission Controller의 첫 번째 역할은 들어오는 요청을 개선하는 것입니다. 마치 호텔 컨시어지가 손님의 예약을 확인하면서 룸 업그레이드나 웰컴 서비스를 추가해주는 것처럼, 변조 단계에서는 Pod에 필요한 설정들을 자동으로 추가할 수 있습니다.

예를 들어, 개발자가 다음과 같은 간단한 Pod를 생성한다고 해봅시다:

apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    image: nginx

변조 단계를 거치면 자동으로 다음과 같이 변신할 수 있습니다:

apiVersion: v1
kind: Pod
metadata:
  name: my-app
  labels:
    managed-by: "admission-controller"
    environment: "production"
spec:
  containers:
  - name: app
    image: nginx
    resources:
      limits:
        memory: "512Mi"
        cpu: "500m"
  - name: monitoring-sidecar  # 자동으로 추가된 모니터링 컨테이너
    image: datadog/agent

2단계: 검증(Validation) - "규칙에 맞는지 확인하기"

변조가 끝나면 이제 최종 결과물이 우리가 정한 규칙에 맞는지 확인합니다. 이 단계에서는 보안 정책, 리소스 제한, 네이밍 규칙 등을 검사해서 문제가 있으면 요청을 거부할 수 있습니다.

왜 변조를 먼저 하고 검증을 나중에 할까요? 이는 매우 논리적인 순서입니다. 요리를 완성한 후에 맛을 보는 것처럼, 모든 필요한 재료(설정)를 추가한 다음에 최종 결과물이 올바른지 확인하는 것이 합리적이기 때문입니다.

간단한 구현: Go로 만드는 Admission Controller

실제로 Admission Controller를 구현하는 것은 생각보다 어렵지 않습니다. 핵심은 Kubernetes가 보내주는 AdmissionReview 객체를 처리하는 웹서버를 만드는 것입니다.

기본 웹서버 구조

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    
    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func main() {
    // 변조와 검증을 담당하는 핸들러 등록
    http.HandleFunc("/mutate", mutateHandler)
    http.HandleFunc("/validate", validateHandler)
    
    log.Println("Admission Controller 서버 시작 중...")
    
    // HTTPS로 서버 시작 (보안을 위해 필수)
    log.Fatal(http.ListenAndServeTLS(":443", 
        "/etc/certs/tls.crt", "/etc/certs/tls.key", nil))
}

여기서 중요한 점은 반드시 HTTPS를 사용해야 한다는 것입니다. Admission Controller는 모든 API 요청을 가로채는 매우 민감한 위치에 있기 때문에 보안이 필수입니다.

실용적인 변조 로직 구현

실제 현업에서 유용한 변조 로직을 구현해보겠습니다:

func mutateHandler(w http.ResponseWriter, r *http.Request) {
    // 요청 파싱
    admissionReview, err := parseAdmissionReview(r)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Pod 객체 추출
    pod := corev1.Pod{}
    json.Unmarshal(admissionReview.Request.Object.Raw, &pod)
    
    var patches []map[string]interface{}
    
    // 1. 리소스 제한이 없으면 기본값 설정
    if pod.Spec.Containers[0].Resources.Limits == nil {
        patches = append(patches, map[string]interface{}{
            "op":    "add",
            "path":  "/spec/containers/0/resources",
            "value": map[string]interface{}{
                "limits": map[string]string{
                    "memory": "512Mi",
                    "cpu":    "500m",
                },
            },
        })
        log.Println("기본 리소스 제한을 추가했습니다")
    }
    
    // 2. 프로덕션 환경이면 모니터링 라벨 추가
    if pod.Namespace == "production" {
        if pod.Labels == nil {
            patches = append(patches, map[string]interface{}{
                "op":   "add", 
                "path": "/metadata/labels",
                "value": map[string]string{},
            })
        }
        patches = append(patches, map[string]interface{}{
            "op":    "add",
            "path":  "/metadata/labels/monitoring",
            "value": "enabled",
        })
        log.Println("프로덕션 모니터링 라벨을 추가했습니다")
    }
    
    // 응답 생성
    response := createMutationResponse(admissionReview.Request.UID, patches)
    respondWithJSON(w, response)
}

이 코드의 좋은 점은 개발자가 의식하지 못하는 사이에 모든 Pod가 안전하고 일관된 설정을 갖게 된다는 것입니다. 개발자는 여전히 간단한 YAML을 작성하지만, 실제로는 운영에 필요한 모든 설정이 자동으로 적용됩니다.

실용적인 검증 로직 구현

검증 단계에서는 보안과 정책 준수를 확인합니다:

func validateHandler(w http.ResponseWriter, r *http.Request) {
    admissionReview, _ := parseAdmissionReview(r)
    pod := corev1.Pod{}
    json.Unmarshal(admissionReview.Request.Object.Raw, &pod)
    
    var violations []string
    
    // 1. 보안 검사: root 사용자 금지
    if pod.Spec.SecurityContext != nil && 
       pod.Spec.SecurityContext.RunAsUser != nil && 
       *pod.Spec.SecurityContext.RunAsUser == 0 {
        violations = append(violations, "보안상 root 사용자로 실행할 수 없습니다")
    }
    
    // 2. 네이밍 규칙 검사
    if !strings.Contains(pod.Name, pod.Namespace) {
        violations = append(violations, 
            "Pod 이름에 네임스페이스가 포함되어야 합니다")
    }
    
    // 3. 이미지 정책 검사
    for _, container := range pod.Spec.Containers {
        if strings.HasSuffix(container.Image, ":latest") {
            violations = append(violations, 
                "latest 태그 사용은 금지되어 있습니다")
        }
    }
    
    // 검증 결과에 따른 응답
    if len(violations) > 0 {
        response := createDenialResponse(admissionReview.Request.UID, violations)
        respondWithJSON(w, response)
        log.Printf("요청 거부: %v", violations)
    } else {
        response := createApprovalResponse(admissionReview.Request.UID)
        respondWithJSON(w, response)
        log.Println("검증 통과")
    }
}

실제 배포와 테스트

인증서 준비

Admission Controller를 배포하기 전에 HTTPS 통신을 위한 인증서가 필요합니다:

# 간단한 self-signed 인증서 생성
openssl req -x509 -newkey rsa:2048 -nodes \
    -out webhook.crt -keyout webhook.key \
    -days 365 -subj "/CN=webhook-service.default.svc"

# Kubernetes Secret으로 저장
kubectl create secret tls webhook-certs \
    --cert=webhook.crt --key=webhook.key

Webhook 등록

이제 Kubernetes에게 우리의 웹서버를 Admission Controller로 사용하겠다고 알려야 합니다:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: my-webhook
webhooks:
- name: mutate.example.com
  clientConfig:
    service:
      name: webhook-service
      namespace: default
      path: /mutate
    caBundle: <base64-encoded-cert>
  rules:
  - operations: ["CREATE"]
    apiGroups: [""]
    apiVersions: ["v1"] 
    resources: ["pods"]

동작 확인

이제 간단한 Pod를 생성해서 우리의 Admission Controller가 제대로 동작하는지 확인해봅시다:

kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
  - name: nginx
    image: nginx:1.21
EOF

성공적으로 배포되었다면, 생성된 Pod를 확인해보세요:

kubectl get pod test-pod -o yaml

우리가 설정한 리소스 제한과 라벨들이 자동으로 추가되어 있을 것입니다. 


참고 자료: