쿠버네티스 Admission Control #3 - Open Policy Agent 적용
11 min read
쿠버네티스 Admission Control #2에서는 General Policy Controller인 Open Policy Agent(OPA)에 대해서 알아보고 Policy를 만드는 언어 Rego에 대해서 살펴 보았습니다. 이번 포스트에서는 직접 OPA를 쿠버네티스 Dynamic Admission Controller로 연결하여 Rego를 이용한 세밀한 관리자 정책을 설정해 보겠습니다.
쿠버네티스 Admission Control 시리즈
- 사용자 Webhook: 사용자가 직접 webhook을 구현함으로써 세밀한 접근관리를 수행합니다.
- Open Policy Agent: OPA의 개념과 그 Query 언어인 Rego에 대해서 살펴 봅니다.
- 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을 하나 정의합니다. 이때 operation
이 CREATE
이고 kind
가 Pod
인 경우에 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을 수행하시길 바랍니다.