쿠버네티스 Admission Control #3 - Open Policy Agent 적용

쿠버네티스 Admission Control #2에서는 General Policy Controller인 Open Policy Agent(OPA)에 대해서 알아보고 Policy를 만드는 언어 Rego에 대해서 살펴 보았습니다. 이번 포스트에서는 직접 OPA를 쿠버네티스 Dynamic Admission Controller로 연결하여 Rego를 이용한 세밀한 관리자 정책을 설정해 보겠습니다.

쿠버네티스 Admission Control 시리즈

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

OPA 인터페이스

OPA가 쿠버네티스와 인터페이스하기 위해서 사용하는 특별한 두가지 객체가 있습니다.

첫번째는 이전 포스트에서도 설명 드린 input이라는 객체입니다. 이 객체는 쿠버네티스로부터 전달되는 AdmissionReview 리소스에 대한 전체 정보를 담고 있습니다. 정책 관리자는 이 AdmissionReview 객체에 들어 있는 정보를 참조하여 정책을 판단합니다.

두번째는 system.main 객체에 대한 내용입니다. OPA는 system.main에 정의된 내용을 평가의 결과로 인식합니다. 이 뜻은 system.main 객체의 response 결과에 따라 사용자의 요청이 허용될지, 반려될지 결정이 됩니다. 다음 Rego 스크립트를 살펴 봅시다. 쿠버네티스에 전달할 수 있는 가장 간단한 Admission Control 결과입니다. 보시다시피 system.main(system package에 정의된 main 객체) 객체가 AdmissionReview 형식으로 정의가 되어 있고 response property가 true로 정의되어 있습니다. OPA는 이것을 보고 모든 사용자 요청에 대해서 허용하게 됩니다.

package system

main = {
  "apiVersion": "admission.k8s.io/v1beta1",
  "kind": "AdmissionReview",
  "response": "true"
}

간단한 Deny Rule

이번에는 Deny Rule을 추가해 보겠습니다. 정책은 간단합니다. 모든 Pod의 생성을 막는 정책입니다.

package system

main = {
  "apiVersion": "admission.k8s.io/v1beta1",
  "kind": "AdmissionReview",
  "response": response
}

아까와는 다르게 response property의 값이 변수로 정의되어 있습니다.

default response = { "allowed": true }

response = {
    "allowed": false,
    "status": {
        "reason": reason
    }
} {
    reason = concat(", ", deny)
    reason != ""
}

response 변수의 기본값은 { "allowed": true } 입니다. 그리고 Rule Body의 평가 결과에 따라 response의 값이 "allowed": false가 될 수도 있습니다. 조금 헷갈릴 수도 있는데 하나씩 나눠서 설명해 보겠습니다.

response = {
  "allowed": false,
    "status": {
        "reason": reason
    }
}

먼저 첫번째 중괄호 부분은 response 객체에 특정값을 부여하는 표현식입니다. 지난 포스트에서는 주로 scalar나 string 값을 부여했지만 여기서는 단지 dictionary 자료형을 부여한다는 차이점 외에는 동일합니다.

{
    reason = concat(", ", deny)
    reason != ""
}

두번째 중괄호 부분은 Rule Body 부분입니다. 해당 부분의 평가 결과가 true로 평가되어야지만 앞서 정의한 dictionary가 response에 부여됩니다.

imperative 언어로 표현하지면 다음과 같습니다.

response = { "allowed": true };
reason = concat(", ", deny);

if (reason != "") {
    response = {
      "allowed": false,
        "status": {
            "reason": reason
        }
    }
}

Rule Body에서는 deny라는 이름을 가진 set을 전부 concatenate 시켜 그 결과가 빈 스트링("")이 아닌 경우에 true가 평가됩니다. 그럼 이제 deny set을 정의해 보도록 하겠습니다.

deny[msg] {
  input.request.operation == "CREATE"
  input.request.kind.kind == "Pod"
  msg := sprintf("Pod is not allowed to be created by %s", [input.request.userInfo.username])
}

deny라는 set을 하나 정의합니다. 이때 operationCREATE이고 kindPod인 경우에 deny에 아이템 하나를 정의하고 다음 스트링 값을 부여합니다: Pod is not allowed... 만약에 deny set의 Rule Body에 해당하는 요청 사항이 없는 경우에는 deny set이 비게 되어(empty set) response의 기본값인 { "allowed": true }가 정의됩니다.

전체 Rego 스크립트를 합치면 다음과 같습니다.

# main.rego
package system

main = {
  "apiVersion": "admission.k8s.io/v1beta1",
  "kind": "AdmissionReview",
  "response": response
}

default response = { "allowed": true }

response = {
    "allowed": false,
    "status": {
        "reason": reason
    }
} {
    reason = concat(", ", deny)
    reason != ""
}

deny[msg] {
  input.request.operation == "CREATE"
  input.request.kind.kind == "Pod"
  msg := sprintf("Pod is not allowed to be created by %s", [input.request.userInfo.username])
}

imperative 언어와 다른 점은 Rego는 선언형 문법이기 때문에 expression 순서가 상관 없습니다. 보통 imperative 언어에서는 먼저 변수를 선언하고 뒤에서 참조하지만 Rego에서는 먼저 변수를 참조하고 나중에 변수를 정의해도 됩니다. 쿠버네티스의 Pod 리소스를 생각해보면 이해하기 쉽습니다. 먼저 volumeMount에서 volume을 참조하고 뒤에서 volume을 정의해도 되는 것처럼 Rego에서는 먼저 response를 참조하고 뒤에서 response를 정의할 수 있습니다.

다시 deny set으로 돌아와서 이번에는 Pod 생성 뿐만 아니라 Service 리소스 생성까지도 막아보겠습니다.

deny[msg] {
  input.request.operation == "CREATE"
  input.request.kind.kind == "Pod  "    # Pod 리소스이거나
  msg := sprintf("Pod is not allowed to be created by %s", [input.request.userInfo.username])
}

deny[msg] {
  input.request.operation == "CREATE"
  input.request.kind.kind == "Service"  # Service 리소스인 경우 deny
  msg := sprintf("Service is not allowed to be created by %s", [input.request.userInfo.username])
}

이렇게 동일한 이름의 deny set을 여러번 정의하면 OR 연산이 적용되어 또 다른 deny 정책을 정의할 수 있습니다. 반대로 하나의 set에 두가지 조건을 모두 적으면 AND 연산이 적용됩니다.

deny[msg] {
  input.request.operation == "CREATE"
  input.request.kind.kind == "Pod"        # Pod 리소스이면서
  input.request.kind.kind == "Service"    # Service 리소스인 경우 (존재하지 않음)
  msg := sprintf("Service is not allowed to be created by %s", [input.request.userInfo.username])
}

만약 두개 Rule을 하나로 합치고 싶다면 앞에서 배운 _ 키워드를 사용하면 됩니다.

deny[msg] {
  input.request.operation == "CREATE"
  kinds = ["Pod", "service"]
  input.request.kind.kind == kinds[_]   # for each Resource에 대해서 (Pod, Service)
  msg := sprintf("%s is not allowed to be created by %s", [input.request.kind.kind, input.request.userInfo.username])
}

OPA install on K8s

이제 OPA를 쿠버네티스에 설치하고 정책을 적용해 보겠습니다.. 대부분의 내용은 OPA 공식 홈페이지에 나와 있는 Ingress Validation Tutorial을 참고하였습니다.

먼저 OPA 네임스페이스를 만듭니다.

kubectl create namespace opa

OPA에서도 Custom Webhook과 마찬가지로 Self-signed 인증서를 만들어 줍니다. Part1에서 만든 인증서를 그대로 사용해도 됩니다.

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

Custom Webhook에서는 인증서를 Flask 앱에 직접 넣어줬지만 OPA에서는 Secret에 저장합니다. OPA에서 이 Secret을 참조하여 TLS 설정을 합니다.

kubectl create secret tls opa-server --cert=server.crt --key=server.key -nopa

OPA Deployment를 정의합니다.

# admission-controller.yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: opa-viewer
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: Group
  name: system:serviceaccounts:opa
  apiGroup: rbac.authorization.k8s.io
---
# Define role for OPA/kube-mgmt to update configmaps with policy status.
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: opa
  name: configmap-modifier
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["update", "patch"]
---
# Grant OPA/kube-mgmt role defined above.
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: opa
  name: opa-configmap-modifier
roleRef:
  kind: Role
  name: configmap-modifier
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: Group
  name: system:serviceaccounts:opa
  apiGroup: rbac.authorization.k8s.io
---
kind: Service
apiVersion: v1
metadata:
  name: opa
  namespace: opa
spec:
  selector:
    app: opa
  ports:
  - name: https
    protocol: TCP
    port: 443
    targetPort: 8443
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: opa
  namespace: opa
  name: opa
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opa
  template:
    metadata:
      labels:
        app: opa
      name: opa
    spec:
      containers:
        # WARNING: OPA is NOT running with an authorization policy configured. This
        # means that clients can read and write policies in OPA. If you are
        # deploying OPA in an insecure environment, be sure to configure
        # authentication and authorization on the daemon. See the Security page for
        # details: https://www.openpolicyagent.org/docs/security.html.
        - name: opa
          image: openpolicyagent/opa:0.28.0-rootless
          args:
            - "run"
            - "--server"
            - "--tls-cert-file=/certs/tls.crt"
            - "--tls-private-key-file=/certs/tls.key"
            - "--addr=0.0.0.0:8443"
            - "--addr=http://127.0.0.1:8181"
            - "--log-format=json-pretty"
            - "--set=decision_logs.console=true"
          volumeMounts:
            - readOnly: true
              mountPath: /certs
              name: opa-server
          readinessProbe:
            httpGet:
              path: /health?plugins&bundle
              scheme: HTTPS
              port: 8443
            initialDelaySeconds: 3
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              scheme: HTTPS
              port: 8443
            initialDelaySeconds: 3
            periodSeconds: 5
        - name: kube-mgmt
          image: openpolicyagent/kube-mgmt:0.11
          args:
            - "--replicate-cluster=v1/namespaces"
            - "--replicate=extensions/v1beta1/ingresses"
      volumes:
        - name: opa-server
          secret:
            secretName: opa-server

OPA로 request를 보내기 위해 Webhook Configuration을 정의합니다.

cat > webhook-configuration.yaml <<EOF
kind: ValidatingWebhookConfiguration
apiVersion: admissionregistration.k8s.io/v1beta1
metadata:
  name: opa-validating-webhook
webhooks:
  - name: validating-webhook.openpolicyagent.org
    namespaceSelector:
      matchExpressions:
      - key: openpolicyagent.org/webhook
        operator: NotIn
        values:
        - ignore
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: ["*"]
        apiVersions: ["*"]
        resources: ["*"]
    clientConfig:
      caBundle: $(cat ca.crt | base64 | tr -d '\n')
      service:
        namespace: opa
        name: opa
EOF
kubectl apply -f admission-controller.yaml
kubectl label ns kube-system openpolicyagent.org/webhook=ignore
kubectl label ns opa openpolicyagent.org/webhook=ignore

main Rego 스크립트를 ConfigMap 형식으로 배포합니다.

kubectl create cm main-rego --from-file main.rego -n opa

앞에서 정의한 ValidatingWebhookConfiguration를 반영합니다.

kubectl apply -f webhook-configuration.yaml

이제 쿠버네티스로 들어오는 요청들이 OPA를 거쳐가게 됩니다. 그럼 한번 Pod를 생성해 볼까요?

kubectl run mynginx --image nginx --restart Never
# Pod is not allowed to be created by kubernetes-admin

예상한대로 Pod 생성 요청이 반려되었습니다. 이제 관리자는 Rego 코드만 수정하여 재배포하면 자유롭고 간편하게 사용자 요청을 제어할 수 있게 됩니다. 어떤가요? 멋지지 않나요?

Clean up

kubectl delete -f webhook-configuration.yaml
kubectl delete -f admission-controller.yaml
kubectl delete secret opa-server -nopa
kubectl delete ns opa

마치며

총 3편에 걸쳐 쿠버네티스의 Admission Control에 대해서 알아보고 OPA를 이용하여 관리자의 입맛대로 세밀하게 사용자 접근을 제어하는 방법을 살펴 보았습니다.

쿠버네티스가 점점 더 다양한 도메인에서 근간이 되는 플랫폼이 되어 가고 있는 만큼 다양한 사용자가 쿠버네티스를 직간접적으로 사용하게 됩니다. 이런 경우 쿠버네티스의 기본 RBAC 설정만으로는 모든 사용자의 요청사항을 다 수용하기 힘든 경우가 생기게 됩니다. 이럴 때 Open Policy Agent의 뛰어난 정책 정의 기능을 이용하여 편리하게 쿠버네티스 Administration을 수행하시길 바랍니다.