쿠버네티스 Admission Control #1

지난 블로그 포스트에서는 쿠버네티스의 전반적인 Authentication에 대해서 살펴 보았습니다. 이번에는 쿠버네티스의 Admission Control에 대한 내용을 다뤄볼까 합니다. 이번 포스트에서는 Admission Controller가 무엇인지 알아보고 직접 custom webhook을 구현해 보는 시간을 가져보겠습니다.

쿠버네티스 Admission Control 시리즈

  1. 사용자 Webhook: 사용자가 직접 webhook을 구현함으로써 세밀한 접근관리를 수행합니다.
  2. Open Policy Agent: OPA의 개념과 그 Query 언어인 Rego에 대해서 살펴 봅니다.
  3. Open Policy Agent 적용: OPA를 직접 쿠버네티스에 적용해 봅니다.

Admission Control란?

쿠버네티스 접근제어에는 크게 3단계가 있습니다.

  • Authentication: 접속한 사람의 신분을 시스템이 인증하는 단계입니다. (신분증 확인)
  • Authorization: 누가 어떤 권한을 가지고 어떤 행동을 할 수 있는지 확인하는 단계입니다. (view권한, create권한 등)
  • Admission Control: 인증과 권한확인 이후에 추가적으로 요청 내용에 대한 검증이나 요청 내용을 강제로 변경할 때 사용합니다.

언뜻 살펴보면 Authorization과 Admission Control 단계가 크게 다르지 않게 보입니다. 쿠버네티스에 Authorization 단계가 존재한다면 굳이 Admission Control을 수행하는 이유는 무엇일까요?

Authorization 단계는 말그대로 사용자가 특정 작업을 수행할 때 어떤 권한이 있는지를 확인하는 단계입니다. 반대로 Admission Control은 권한이 있는 사용자에 한해서 관리자(Admin)가 추가로 특정 행동을 제한(validate) 혹은 변경(mutate)하는 작업입니다. 예를 들어, 어떤 사람이 미술 전시관을 관람한다고 생각해 봅시다. 그 사람은 전시관 입구에서 신원확인(Authentication)을 받은 후 표 검사를 통해 입장할 수 있는 허가(Authorization)를 받습니다. 그렇다고 해서 그 사람이 전시품을 마음대로 만지거나 훼손할 수 있는 것은 아니기에 전시장에서 전시품을 만지는 행동을 제한(validating admission control) 받거나 목소리를 낮추도록(mutating admission control) 요구 받습니다. 이것이 Admission Control입니다. 관리자의 정책에 따라 세부적인 작업을 제한하거나 변경 시키는 것입니다. 쉽게 생각해서 Authorization은 큰 단위의 권한 체계, Admission Control은 그 안에서의 세부 지침 사항이라고 이해하면 좋겠습니다.

Admission Controller Plugins

Admission Controller란 관리자의 특정 정책을 수행하는 주체, 다시 말해 Admission Control을 수행하는 주체입니다. 쿠버네티스에는 다양한 Admission Controller들이 존재합니다. 쿠버네티스 버전마다 조금씩 default로 설정된 Admission Controller들이 다릅니다. 대표적인 것이 LimitRangeResourceQuota 등이 있습니다. 관리자에 의해 정해진 리소스를 초과하는 경우(관리자의 특정 정책) Pod 생성을 막아버리는 기능(작업 제한)이라 볼 수 있습니다. 그 외에 다양한 Admission Controller들을 다음 페이지에서 확인해 보시기 바랍니다.

Dynamic Admission Controller

쿠버네티스의 많은 컴포넌트들이 그렇듯이 Admission Control도 Webhook으로 사용자에게 API가 열려 있습니다. 쿠버네티스가 정의한 인터페이스만 잘 맞춘다면 사용자는 자신만의 Admission Controller를 구현할 수 있습니다. 쿠버네티스에서는 이것을 Dynamic Admission Controller 라고 부르고 크게 MutatingWebhookValidatingWebhook으로 나뉩니다.

출처: https://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers

  • MutatingWebhook은 사용자가 요청한 request에 대해서 관리자가 임의로 값을 변경하는 작업입니다. 예를 들어, 사용자가 아무리 다른 ServiceAccount를 사용하더라도 무조건 default ServiceAccount를 사용하게끔 강제하는 경우 사용할 수 있습니다. resource 설정도 마찬가지입니다. 사용자의 요청과는 무관하게 관리자가 임의로 정의해 놓은 사용량으로 resource를 강제할 수 있습니다.
  • ValidatingWebhook은 사용자가 요청한 request에 대해서 관리자기 허용을 막는 작업입니다. 예를 들어, 사용자가 default ServiceAccount를 사용하지 않는 경우 에러 메세지와 함께 요청을 반려하고 싶은 경우 사용할 수 있습니다. 혹은 관리자가 제공하는 도커 이미지 레포지토리 이외에 외부 저장소의 이미지를 사용하는 경우 관리자가 임의로 요청을 반려할 수도 있습니다.

Dynamic Admission Controller의 순서는 위와 같이 AuthN & AuthZ - MutatingWebhook - Object schema validation - ValidatingWebhook으로 진행됩니다. 먼저 사용자 인증과 권한을 허가 받은 요청에 대해서 관리자의 정책대로 요청을 변경한 이후 변경한 스키마의 정합성을 체크합니다. 그 이후에 validating admission 과정에서 최종적으로 요청한 request가 관리자 정책에 부합(valid)한지 검증합니다. validating admission까지 전부 통과한 request에 대해서는 etcd로 요청이 저장되어 이후에 쿠버네티스 controller에 의해 후속 작업이 진행됩니다.

Dynamic Admission Controller을 직접 구현하기 전에 webhook 인터페이스를 잠깐 살펴 보겠습니다.

Input 인터페이스

MutatingWebhookValidatingWebhook의 Input은 다음과 같이 동일합니다.

사용자가 새로운 Pod를 하나 생성하게 되면 아래와 같은 AdmissionReview라는 json 객체가 Dynamic Admission Controller로 전달이 됩니다.

{
  "kind": "AdmissionReview",
  "apiVersion": "admission.k8s.io/v1beta1",
  "request": {
    "kind": {
      "group": "",
      "version": "v1",
      "kind": "Pod"
    },
    "resource": {
      "group": "",
      "version": "v1",
      "resource": "pods"
    },
    "namespace": "default",
    "operation": "CREATE",
    "userInfo": {
      "username": "system:serviceaccount:default:default",
      "uid": "439dea65-3e4e-4fa8-b5f8-8fdc4bc7cf53",
      "groups": [
        "system:serviceaccounts",
        "system:serviceaccounts:default",
        "system:authenticated"
      ]
    },
    "object": {
      "apiVersion": "v1",
      "kind": "Pod",
      "metadata": {
        "creationTimestamp": "2019-08-13T16:01:54Z",
        "generateName": "nginx-7bb7cd8db5-",
        "name": "nginx-7bb7cd8db5-dbplk",
        "namespace": "default",
      },
      "spec": {
        "containers": [
          {
            "image": "nginx",
            "imagePullPolicy": "Always",
            "name": "nginx",
          }
        ]
      },
      "status": {
        "phase": "Pending",
        "qosClass": "BestEffort"
      }
    },
    "oldObject": null
  }
}

여기서 눈여겨 볼 property는 다음과 같습니다.

  • request: 사용자가 요청한 전체 스펙
    • kind: 요청한 쿠버네티스 리소스 종류 (예시에서는 Pod를 요청함)
    • operation: Create / Read / Update / Delete 등 연산 내용
    • userInfo: 요청한 사용자의 정보
    • object: 실제 리소스 스펙 (Pod 상세 스펙)
    • oldObject: 리소스 변경(Update)이 발생하는 경우 이전 리소스 스펙이 전달됨

사용자는 Webhook의 POST body로 전달되는 AdmissionReview 객체의 전체 요청사항을 파악하여 적절하게 판단(mutate or validate) 합니다. 판단이 완료되면 다시 쿠버네티스쪽으로 Admission Control의 결과를 반환해야 합니다. 리턴값도 동일하게 AdmissionReview 객체를 전달하게 됩니다.

Validating Admission Output

Validating admission의 리턴값은 간단합니다. AdmissionReview 객체를 리턴할 때, 최종적으로 허가를 할지, 반려할지 response.allowed 값을 설정하기만 하면 됩니다.

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "allowed": true,
  }
}

allowed값이 true로 반환이 되면 정상적으로 허용이 되는 것이고 반대로 false면 반려가 됩니다.

Mutating Admission Output

Mutating admission에서는 한가지만 더 추가하면 되는데요. 변경(mutating)하려는 정보를 JSONPath 형식의 json을 base64로 인코딩하여 patch라는 property에 추가하면 됩니다.

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "allowed": true,
    "patchType": "JSONPatch",
    "patch": "W3tvcDogYWRkLCBwYXRoOiAvbWV0YWRhdGEvYW5ub3RhdGlvbnMvZm9vLCB2YWx1ZTogYmFyfV0="
  }
}

위의 리턴값은 metadata.annotations.foo라는 위치에 bar이라는 값으로 변경(mutate)하게 만듭니다. 실제로 위의 patch값을 base64로 디코딩하면 다음과 같은 정보가 출력됩니다.

echo -ne W3tvcDogYWRkLCBwYXRoOiAvbWV0YWRhdGEvYW5ub3RhdGlvbnMvZm9vLCB2YWx1ZTogYmFyfV0= | base64 -d
# {"op": "add", "path": "/metadata/annotations/foo", "value": "bar"}

Example

그럼 직접 Webhook을 구현해 봅시다. Dynamic Admission Controller는 서비스 형태로 존재합니다. 그래서 간단한 웹서버를 만들어야 합니다. 한가지 특징은 통신이 HTTPS로 동작해야 합니다. 이를 위해 자체적인 Certificate을 생성해 봅시다.

Self-signed Certificate

다음과 같은 명령을 이용하여 Self-signed Certificate을 만듭니다. 여기서 중요한 것은 Server Certificate의 CN의 값이 webhook.default.svc라는 것입니다. 나중에 쿠버네티스 Servie를 생성할 때 도메인 이름을 이와 동일하게 맞춰줘야 합니다. (default 네임스페이스의 webhook이라는 이름의 Service 생성)

openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -days 100000 -out ca.crt -subj "/CN=admission_ca"  # 이것은 Certificate Authority의 CN입니다.

# Server Certificate을 만들기 위한 설정 파일을 생성합니다. 여기서 CN이 webhook.default.svc인 것을 확인할 수 있습니다.
cat >server.conf <<EOF
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[req_distinguished_name]
CN = webhook.default.svc
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = webhook.default.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

Webhook handler

파이썬 flask를 이용하여 간단한 웹 서비스를 만듭니다.

  • URI: /
  • Method: POST

input값으로 위에서 살펴본 AdmissionReview json 객체가 POST의 body로 들어옵니다. 예시에서는 동작 여부를 빠르게 확인해 보기 위해 모든 Pod 생성을 반려하도록 구성하였습니다. HTTPS 통신을 위한 TLS 설정도 추가하였습니다.

from flask import Flask, request
import ssl


app = Flask(__name__)

@app.route('/', methods=['POST'])
def webhook():    
    # validate from INPUT data
    result = validate(request.json)
 
    return {
      "kind": "AdmissionReview",
      "apiVersion": "admission.k8s.io/v1beta1",
      "response": {
        "allowed": result,
        "status": {
          "reason": "Pod create not allowed"
        }
      }
}


def validate(review):
    # denying all Pod creating
    if (review['request']['object']['kind'] == 'Pod') and \
        (review['request']['operation'] == 'CREATE'):
        return False  # Deny
    return True       # Accept


##################################
# Webhook needs to serve TLS
##################################
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
context.load_verify_locations('./ca.crt')
context.load_cert_chain('./server.crt', './server.key')

app.run(host='0.0.0.0', debug=True, ssl_context=context)

이제 쿠버네티스 위에서 Service 형태로 만들기 위해 도커 이미지를 생성합니다. 간단하게 flask를 설치하고 flask 스크립트와 필요한 X.509 파일들을 추가합니다.

FROM python:3.7

RUN pip install flask

ADD main.py .
ADD server.key .
ADD server.crt .
ADD ca.crt .


CMD ["python", "-u", "main.py"]
docker build . -t <YOUR_WEBHOOK_IMAGE>
docker push <YOUR_WEBHOOK_IMAGE>

생성한 webhook 이미지를 쿠버네티스에 배포합니다. 이때 중요한 것은 Service의 정보입니다. 앞에서 정의한 CN과 동일하게 default 네임스페이스의 webhook 이름으로 생성합니다.

# webhook.yaml
kind: Service
apiVersion: v1
metadata:
  name: webhook
  namespace: default
spec:
  selector:
    app: webhook
  ports:
  - name: https
    protocol: TCP
    port: 443
    targetPort: 5000
---
apiVersion: v1
kind: Pod
metadata:
  name: webhook
  labels:
    app: webhook
spec:
  containers:
  - name: webhook
    image: <YOUR_WEBHOOK_IMAGE>
kubectl apply -f webhook.yaml

ValidatingWebhookConfiguration

쿠버네티스로 들어오는 요청들을 방금 생성한 Service로 전달해주기 위해 WebhookConfiguration을 설정합니다. 크게 4가지 정보를 합니다.

  • name: webhook의 이름을 정의합니다.
  • namespaceSelector: 특정 네임스페이스에 대해서는 validation을 수행하지 않게 만듭니다.
  • rules: 어떤 리소스에 대해서 validation할지 정의합니다.
  • clientConfig: 어떤 webhook 서버로 보낼지 지정합니다. 자체 인증한(self-signed) 인증서이기 때문에 CA(Certificate Authority) certificate을 지정해 줍니다.
cat > validatingwebhook.yaml <<EOF
# validatingwebhook.yaml
kind: ValidatingWebhookConfiguration
apiVersion: admissionregistration.k8s.io/v1beta1
metadata:
  name: val-webhook
webhooks:
  - name: val-webhook.coffeewhale.com
    namespaceSelector:
      matchExpressions:
      - key: openpolicyagent.org/webhook
        operator: NotIn
        values:
        - ignore
    rules:
      - operations: ["CREATE"]
        apiGroups: ["*"]
        apiVersions: ["*"]
        resources: ["*"]
    clientConfig:
      caBundle: $(cat ca.crt | base64 | tr -d '\n')
      service:
        namespace: default
        name: webhook
EOF
kubectl apply -f validatingwebhook.yaml

kube-system에서 발생하는 요청에 대해서는 validation을 수행하지 않게 적절한 라벨을 네임스페이스에 부여합니다.

kubectl label ns kube-system openpolicyagent.org/webhook=ignore

ValidatingWebhook을 테스트해 봅니다. 간단한 nginx Pod를 생성하였을 때 어떻게 되는지 확인해 봅시다.

kubectl run mynginx --image nginx --restart Never
# Pod create not allowed

예상대로 모든 Pod 생성에 대한 요청이 반려되는 것을 확인할 수 있습니다. 이번에는 kube-system에 동일한 Pod를 생성해 봅니다.

kubectl run mynginx --image nginx --restart Never -n kube-system
# pod/mynginx created

이전과는 다르게 Pod가 정상적으로 실행되는 것을 확인할 수 있습니다. kube-system에서는 validation이 동작하지 않도록 설정하였기 때문입니다.

Cleanup

kubectl delete -f validatingwebhook.yaml
kubectl delete -f webhook.yaml
kubectl delete pod mynginx -n kube-system

마치며

지금까지 직접 Validating Webhook 서버를 구현해 보면서 쿠버네티스의 Dynamic Admission Control이 어떤 방식으로 동작하는지 살펴 보았습니다. 사용자가 직접 Webhook을 구현하면 장단점이 있습니다. 장점으로는 사용자가 원하는대로 세밀하게 validation을 체크할 수 있습니다. 내부 DB를 사용해도 되고 외부 리소스를 호출하여 활용할 수도 있습니다. 반대로 단점은 간단한 validation 체크를 위해 직접 웹 서버를 구축해야하고 매번 빌드하여 적용해야 합니다. Open Policy Agent(이하 OPA)는 이러한 단점을 보완하고 체계적으로 admission을 관리하고자 만들어진 CNCF graduated 프로젝트입니다. 다음 포스트에서는 Open Policy Agent를 통해서 효율적으로 Admission Control을 수행하는 방법에 대해서 살펴보도록 하겠습니다.