상세 컨텐츠

본문 제목

Dynamic Admission Controller - ValidatingWebhook

카테고리 없음

by yiaw 2021. 11. 4. 21:42

본문

앞선 포스팅에서 Admission Controller에 대해 설명하는 포스팅을 진행했었습니다. 

이번 포스팅에서는 ValidatingWebhook의 동작방식을 확인해보겠습니다.

전체적인 흐름을 설명 하자면

사용자의 k8s resource(pod , deployments, service... 등) 생성 요청을  kube-apiserver가 admissionController로 AdmissionReview라는 메시지 형식을 통해 검토를 요청하게 됩니다.

해당 AdmissionController에서는 메시지의 내용을 보고 resource 생성 요청을 허용할지 거절할지에 대해 결정 후 메시지를 응답하게 됩니다.

AdmissionReview 정보

아래 메시지는 간략하게 축약해놓은것이니 실제 구현을 통해 메시지를 확인하는 것이 좋습니다.

{
	"kind":"AdmissionReview",
	"apiVersion":"admission.k8s.io/v1beta1",
	"request":{
		"object": {
			... Resource 정보 .... 
 		}
	}
}

obejct 필드를 확인하게 되면 사용자가 요청한 k8s resource 정보가 json 형식으로 기록되어있습니다. 

obejct 필드 내에서 사용자가 잘못 요청한것이 있거나 반드시 필요한 데이터가 없을 경우 생성을 거절할 수 있습니다.

결과를 전송 시에는 AdmissionReview 메시지에서 response 필드에 데이터를 담아 응답 할 수있습니다.

# 요청 거절 메시지
{
	"response":{
		"allowed":false,
		"status":{
			"message":"Valid Check, Not Allowed Pod Create"
		}
	}
}
=========================================================================
# 요청 허용 메시지 
{
	"response":{
		"allowed":true,
	}
}

kube-apiserver로 부터 요청 메시지를 수신 할 수있는 https server를 구현 해보도록 하겠습니다.


HTTPS Server

구현은 Golang에서 제공 하는 기본 net/http pkg를 이용하여 개발 하였습니다. 

package main

import (
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {

    w.Write([]byte("hello handler call\n"))
    w.WriteHeader(http.StatusOK)
}

func main() {
    http.HandleFunc("/hello", HelloHandler)

    log.Println("Start HTTPS Server")
    // server.crt와 server.key는 실제 위치해 있는 경로로 작성하면 된다. 
    // /home/key/server.crt, /home/key/server.key에 존재 할 경우에는 
    // go log.Fatal(http.ListenAndServeTLS(":8443", "/home/key/server.crt", "/home/key/server.key", nil))
    go log.Fatal(http.ListenAndServeTLS(":8443", "server.crt", "server.key", nil))

    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
    <-signalChan

    log.Println("shutdown signal, shutting down webhook server")

}

인증 파일 생성

openssl을 통해 server.crt 파일과 server.key 파일을 생성해 보겠습니다.

:> mkdir ~/key & cd key

:> openssl genrsa -out ca.key 2048openssl req -x509 -new -nodes -key ca.key -days 100000 -out ca.crt -subj "/CN=admission_ca"

:> cat >server.conf <<EOF
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[req_distinguished_name]
#### k8s Service 명을 입력 webhook이라는 service k8s reousrce를 yiaw이라는 NameSpace에 생성
CN = webhook.yiaw.svc    
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
subjectAltName = @alt_names
[alt_names]
#### k8s Service 명을 입력 webhook이라는 service k8s reousrce를 yiaw이라는 NameSpace에 생성
DNS.1 = webhook.yiaw.svc
EOF

:> openssl genrsa -out server.key 2048
:> openssl req -new -key server.key -out server.csr -config server.conf
:> openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 100000 -extensions v3_req -extfile server.conf

이제 생성된 server.key 파일과 server.crt 파일을 구현한 Code로 이동 시키겠습니다. 

Go로 구현된 Server에서 ListenAndServerTLS 함수의 파리미터로 들어가는 server.crt와 server.key가 별도 경로 설정이 없어 바이너리 수행 위치와 동일한 위치에 있어야지 https server가 정상적으로 동작 할수 있다.
:> pwd
:> ~/$GOPATH/src/webhook-example/
:> go build 
:> cp ~/key/server.crt ./
:> cp ~/key/server.key ./
:> ls 
main.go  server.crt  server.key webhook-example

HTTP Server 테스트

위에서 server.conf에 기입한 DNS 명을 /etc/hosts 정보에 등록 해주어야 local 환경에서 https 테스를 해볼 수 있다.

:> vi /etc/hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4 webhook.yiaw.svc
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6

이제 https server를 기동시켜 확인 해 보겠습니다.

:> ./webhook-example
2021/11/05 11:04:33 Start HTTPS Server

curl을 통해서 구현한 server로 request를 요청 해보겠습니다.

:> curl https://webhook.yiaw.svc:8443/hello --cacert ./ca.crt
hello handler call

ValidatingWebhook Handler

먼저 HTTPS Server에 /validate URI를 요청 시 호출 할 핸들러를 등록해줍니다. 

package main

import (
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {

    w.Write([]byte("hello handler call\n"))
    w.WriteHeader(http.StatusOK)
}

func main() {
    http.HandleFunc("/hello", HelloHandler)
    http.HandleFunc("/validate", ValidatingWebHook)

    log.Println("Start HTTPS Server")

    go log.Fatal(http.ListenAndServeTLS(":8443", "server.crt", "server.key", nil))

    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
    <-signalChan

    log.Println("shutdown signal, shutting down webhook server")

}

그리고 아래 소스코드는 Validate 핸들러 구현 부 입니다. 

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "strings"

    "k8s.io/api/admission/v1beta1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/serializer"
)

const (
    ValidationAnnotations = "yiaw.webhook/validation"
)

var (
    runtimeScheme = runtime.NewScheme()
    codecs        = serializer.NewCodecFactory(runtimeScheme)
    deserializer  = codecs.UniversalDeserializer()
    defaulter     = runtime.ObjectDefaulter(runtimeScheme)
)

func Validating(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionResponse {
    req := ar.Request
    var pod corev1.Pod
    if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
        log.Printf("Could not unmarshal raw object: %v", err)
        return &v1beta1.AdmissionResponse{
            Result: &metav1.Status{
                Message: err.Error(),
            },
        }
    }

    annotations := pod.ObjectMeta.GetAnnotations()
    if annotations == nil {
        return &v1beta1.AdmissionResponse{
            Allowed: true,
        }
    }

    var resp *v1beta1.AdmissionResponse
    switch strings.ToLower(annotations[ValidationAnnotations]) {
    case "yes", "true", "ok":
        resp = &v1beta1.AdmissionResponse{
            Allowed: false,
            Result: &metav1.Status{
                Message: "Valid Check, Not Allowed Pod Create",
            },
        }
    default:
        resp = &v1beta1.AdmissionResponse{
            Allowed: true,
        }
    }

    return resp
}

func ValidatingWebHook(w http.ResponseWriter, r *http.Request) {
    var body []byte
    if r.Body != nil {
        if data, err := ioutil.ReadAll(r.Body); err == nil {
            body = data
        }
    }
    if len(body) == 0 {
        log.Println("empty body")
        http.Error(w, "empty body", http.StatusBadRequest)
        return
    }

    log.Printf("Recv Message : %s\n", string(body))

    contentType := r.Header.Get("Content-Type")
    if contentType != "application/json" {
        log.Printf("Content-Type=%s, expect application/json", contentType)
        http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType)
        return
    }

    var admissionResponse *v1beta1.AdmissionResponse
    ar := v1beta1.AdmissionReview{}
    if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
        admissionResponse = &v1beta1.AdmissionResponse{
            Result: &metav1.Status{
                Message: err.Error(),
            },
        }
    } else {
        admissionResponse = Validating(&ar)
    }

    responseReview := v1beta1.AdmissionReview{}
    responseReview.Response = admissionResponse

    resp, err := json.Marshal(responseReview)
    if err != nil {
        http.Error(w, fmt.Sprintf("json Marshal Fail.. err=%v", err), http.StatusInternalServerError)
        return
    }

    log.Printf("Send Message : %s\n", string(resp))
    w.Write(resp)
}

Pod 생성 요청에서 annotations항목에 해당 "yiaw.webhook/validation": "ture"라는 값이 존재한다면 Pod 생성 요청을 반려시킵니다.

이미지 생성 및 배포

자 이제 코드는 완성하였고 실제로 해당 바이너리를 이미지로 생성하여 k8s cluster에서 기동 시켜 보겠습니다.

## Docker Image 생성 및 Docker Hub Push
:> docker build -t yiaw/webhook:0.0.1 .
:> docker push yiaw/webhook:0.0.1
... 생략 ... 

## Kubernetes Namespace 생성
:> kubectl create namespace yiaw

:> cat > deploy.yaml << EOF
apiVersion: v1
kind: Service
metadata:
  name: webhook
  namespace: yiaw
spec:
  selector:
    app: webhook
  ports:
  - name: https
    protocol: TCP
    port:  443
    targetPort: 8443
---
apiVersion: v1
kind: Pod
metadata:
  name: webhook
  namespace: yiaw
  labels:
    app: webhook
spec:
  containers:
  - name: webhook
    image: "yiaw/webhook:0.0.1"
    imagePullPolicy: Always
EOF

:> kubectl apply -f deploy.yaml
service/webhook created
pod/webhook created

:> kubectl get all
NAME          READY   STATUS    RESTARTS   AGE
pod/webhook   1/1     Running   0          58s

NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/webhook   ClusterIP   10.233.27.138   <none>        8443/TCP   58s

이렇게 Server를 Deloy 하는 데 성공했습니다. 

ValidatingWebhookConfiguration

이제는 추가적으로 ValidatingWebhookConfiguration 만 작성하면 테스트 환경이 구축됩니다. 

:> cat > yalidate.yaml << EOF
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: yiaw-validator
webhooks:
  - name: rev.validation.yiaw.io
    namespaceSelector:
      matchExpressions:
      - key: yiaw-org-webhook
        operator: In
        values:
        - "true"
    admissionReviewVersions:
    - v1beta1
    - v1
    clientConfig:
      caBundle: $(cat ca.crt | base64 | tr -d '\n') ## 앞서 생성한 ca.crt를 경로를 작성
                                                    ## ex) /home/key/ca.crt 에 있으면 
                                                    ## caBundle: $(cat /home/key/ca.crt | base64 | tr -d '\n')
      service:
        name: webhook
        namespace: yiaw
        path: /validate
        port: 443
    rules:
    - apiGroups:
      - '*'
      apiVersions:
      - v1
      operations:
      - CREATE
      resources:
      - pods
    sideEffects: None
EOF

Namspace의 Label 설정이"yiaw-org-webhook" 이 true로 설정된 Namespace들에 대해서만 ResponseReview를 전송하여 검토를 요청 받게 됩니다.

 

    namespaceSelector:
      matchExpressions:
      - key: yiaw-org-webhook
        operator: In
        values:
        - "true"

요청을 Review 받기 위해 전송하는 Webhook 서버에 대한 설정입니다. 

인증은 caBundle에 적혀있는 데이터로 tls 인증을 수행하며 전송 목적지 주소는 webhook.yiaw:443/validate가 되도록 설정하였습니다. (server.key  , server.crt 생성 시 CN 설정을 참조)

      service:
        name: webhook
        namespace: yiaw
        path: /validate
        port: 443

자 이제 해당 ValidatingWebhookConfiguration  Resource를 생성하도록 하겠습니다.

:> kubectl apply -f validate.yaml
validatingwebhookconfiguration.admissionregistration.k8s.io/yiaw-validator created

테스트 진행

다음 명령어를 수행하여 Namespace에 yiaw-org-webhook Label을 설정합니다. 

:> kubectl label ns yiaw yiaw-org-webhook=true
namespace/yiaw labeled

:> kubectl get ns -L yiaw-org-webhook
NAME              STATUS   AGE    YIAW-ORG-WEBHOOK
default           Active   39d
kube-system       Active   39d
yiaw              Active   23m    true

이제 yiaw이라는 Namespace에 Pod를 생성 해보겠습니다.

:> cat > busybox.yaml << EOF
apiVersion: v1
kind: Pod
metadata:
  name: validate-pod
  namespace: yiaw
  labels:
    app: busybox
  annotations:
    yiaw.webhook/validation: "true" ## Pod 생성 거부
spec:
  containers:
  - name: busybox
    image: busybox
    command: ["sh", "-c", "echo I am running as user $(id -u)"]
EOF
:> kubectl apply -f busybox.yaml
Error from server: error when creating "busybox.yaml": admission webhook "rev.validation.yiaw.io" denied the request: Valid Check, Not Allowed Pod Create

해당 에러 메시지를 확인해보니 내가 생성한 admission controller에 의해 Pod의 생성이 반려된 것을 확인할 수 있었습니다. 


마치며

이번 포스팅에서는 validatingwebhook을 구현 해보 았습니다.

실제 사용자가 원하는 형태로 개발 하게 된다면 내가 구축한 클러스터에 대해 좀더 세밀하게 리소스 생성을 관리 할 수 있다는 장점이 있습니다. 

다만 사용자가 Admission Controller에 대한 이해가 부족하다면 클러스터에 필요한 리소스 생성에 제약이 생길 수 있다는 것이 단점이 되겠습니다. 

다음 포스팅을 통해서는 mutatingwebhook을 구현하여 Pod를 Injection하는 기능을 구현 해보겠습니다. 

 

구현된 코드및 예제는 아래 Github에서 확인 하실 수 있습니다.

https://github.com/yiaw/k8s-example/tree/main/AdmissionController

 

GitHub - yiaw/k8s-example: k8s-example

k8s-example. Contribute to yiaw/k8s-example development by creating an account on GitHub.

github.com

 

댓글 영역