K8s policy with Kyverno
Kubernetes is a complex beast and any best practice or security guide you read will hit you with dozens of best-practice rules your clusters should adhere to in order to make them manageable and secure. In most situations the reality is that the rules are only worth their salt if they are at least audited and, ideally, enforced. Kubernetes policy allows you to define your policy as code, then audit and enforce the rules as you see fit.
Kyverno, a Kubernetes policy engine
Kyverno is a K8s policy engine that we like a lot. From their website:
"Kyverno is a policy engine designed for Kubernetes. With Kyverno, policies are managed as Kubernetes resources and no new language is required to write policies. This allows using familiar tools such as kubectl, git, and kustomize to manage policies. Kyverno policies can validate, mutate, and generate Kubernetes resources."
Kyverno is a CNCF sandbox project and one of the initial questions might be "why would you use it over the graduated Open Policy Agent (OPA) engine"? While the OPA is great, it is complex with a high barrier to entry, requiring adopters to know the rego policy language. For example, a simple policy that requires a specific label to be applied to resources is defined in a constraint template something like this:
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
listKind: K8sRequiredLabelsList
plural: k8srequiredlabels
singular: k8srequiredlabels
validation:
openAPIV3Schema:
properties:
labels:
type: array
items: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg, "details": {"missing_labels": missing}}] {
provided := {label | input.review.object.metadata.labels[label]}
required := {label | label := input.parameters.labels[_]}
missing := required - provided
count(missing) > 0
msg := sprintf("you must provide labels: %v", [missing])
}
...and then the constraint applied with:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: ns-must-have-cc
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Namespace"]
parameters:
labels: ["app.kubernetes.io/costcentre"]
While with Kyverno I can define my policy like this:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-labels
annotations:
policies.kyverno.io/title: Require labels
policies.kyverno.io/category: Best practice
policies.kyverno.io/severity: medium
policies.kyverno.io/subject: Namespace
policies.kyverno.io/description: >-
Require a app.kubernetes.io/costcentre label
spec:
validationFailureAction: enforce
background: false
rules:
- name: check-for-labels
match:
resources:
kinds:
- Namespace
validate:
message: "The label `app.kubernetes.io/costcentre` is required."
pattern:
metadata:
labels:
app.kubernetes.io/costcentre: "?*"
Even in a trivial example there is huge difference in how easy the latter is to understand over the former and the OPA rego can get pretty hairy as complexity increases. The OPA is undoubtedly more flexible than Kyverno but, for a lot of teams managing Kubernetes clusters, the complexity is too much of a maintenance and management issue. Horses for courses and all that.
Installing Kyverno
Kyverno's documentation is good here so won't dwell on this. To install the Kyverno and the policy reporter GUI:
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno --namespace kyverno --create-namespace
Install policy reporter GUI:
helm repo add policy-reporter https://kyverno.github.io/policy-reporter
helm repo update
helm install policy-reporter policy-reporter/policy-reporter --set kyvernoPlugin.enabled=true --set ui.enabled=true --set ui.plugins.kyverno=true -n policy-reporter --create-namespace
Port forward to GUI:
kubectl port-forward service/policy-reporter-ui 8082:8080 -n policy-reporter
Writing Kyverno policies
The basics of how you write a Kyverno policy are:
We will use our require-labels
policy as our example:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-labels
annotations:
policies.kyverno.io/title: Require labels
policies.kyverno.io/category: Best practice
policies.kyverno.io/severity: medium
policies.kyverno.io/subject: Namespace
policies.kyverno.io/description: >-
Require a app.kubernetes.io/costcentre label
spec:
validationFailureAction: enforce
background: false
rules:
- name: check-for-labels
match:
resources:
kinds:
- Namespace
validate:
message: "The label `app.kubernetes.io/costcentre` is required."
pattern:
metadata:
labels:
app.kubernetes.io/costcentre: "?*"
Select resources
Select the resources that this policy will apply to. So in our require labels
example:
...
match:
resources:
kinds:
- Namespace
...
The policy will apply to all of my namespaces. I'm using kind
here but you can of course select on things like names and labels.
Validate resources
Validate that the resources match my standard. In the example this was ensuring the app.kubernetes.io/costcentre
label had something in it:
...
validate:
message: "The label `app.kubernetes.io/costcentre` is required."
pattern:
metadata:
labels:
app.kubernetes.io/costcentre: "?*"
...
What happens when validation fails? The line:
validationFailureAction: enforce
...in the example will not allow the resource to deploy if it does not have the label as we are enforcing the rule. It can also be set to audit
so that we would allow the resource to deploy but report the failure.
Mutate resources
You can modify or mutate
a resource to match your standards rather than rejecting it. For example, if you wanted to make sure all pods had a label type=user
then we can mutate the resource as it enters the system:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-label
annotations:
policies.kyverno.io/title: Add default label
policies.kyverno.io/category: Best practice
policies.kyverno.io/severity: low
policies.kyverno.io/subject: Label
policies.kyverno.io/description: >-
This policy performs a simple mutation which adds a label
`type=user` to Pods, Services, ConfigMaps, and Secrets.
spec:
rules:
- name: add-label
match:
resources:
kinds:
- Pod
mutate:
patchStrategicMerge:
metadata:
labels:
type: user
Generate resources
Generate rules can create additional resources. For example, It is good practice to set a network policy in K8s to deny all network traffic to and from a namespace/service and only allow traffic with explicit rules. This policy will apply a default deny rule to any namespace created or amended that is not in the exclusion list of (kube-system, default, kube-public, kyverno):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: default
spec:
rules:
- name: deny-all-traffic
match:
resources:
kinds:
- Namespace
exclude:
resources:
namespaces:
- kube-system
- default
- kube-public
- kyverno
generate:
kind: NetworkPolicy
name: deny-all-traffic
namespace: "{{request.object.metadata.name}}"
data:
spec:
# select all pods in the namespace
podSelector: {}
policyTypes:
- Ingress
- Egress
Testing
Example 1 - labels
In a file called require_label_ns.yaml
in my policies folder, I have defined this namespace label rule to ensure the app.kubernetes.io/costcentre
label is on all new namespaces:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-labels
annotations:
policies.kyverno.io/title: Require labels
policies.kyverno.io/category: Best practice
policies.kyverno.io/severity: medium
policies.kyverno.io/subject: Namespace
policies.kyverno.io/description: >-
Require a app.kubernetes.io/costcentre label
spec:
validationFailureAction: enforce
background: false
rules:
- name: check-for-labels
match:
resources:
kinds:
- Namespace
validate:
message: "The label `app.kubernetes.io/costcentre` is required."
pattern:
metadata:
labels:
app.kubernetes.io/costcentre: "?*"
Now apply it to the cluster:
$ kubectl apply -f policies/require_label_ns.yaml
clusterpolicy.kyverno.io/require-labels created
Now try to add a namespace that does not have the app.kubernetes.io/costcentre
label:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
labels:
name: kyverno-testing
name: kyverno-testing
EOF
resource Namespace//kyverno-testing was blocked due to the following policies
require-labels:
check-for-labels: 'validation error: The label `app.kubernetes.io/costcentre` is
required. Rule check-for-labels failed at path /metadata/labels/app.kubernetes.io/costcentre/'
As we are enforcing the policy the namespace creation is rightly denied.
Now try to add a namespace that does have the app.kubernetes.io/costcentre
label:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
labels:
app.kubernetes.io/costcentre: "engineering"
name: kyverno-testing
name: kyverno-testing
EOF
namespace/kyverno-testing created
Example 2 - requests and limits
In a file called require_pod_requests_limits.yaml
in my policies folder, I have defined this pod rule to ensure all containers have CPU & Memory requests & limits:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-requests-limits
annotations:
policies.kyverno.io/title: Require Limits and Requests
policies.kyverno.io/category: Multi-Tenancy
policies.kyverno.io/severity: medium
policies.kyverno.io/subject: Pod
policies.kyverno.io/description: >-
This policy validates that all containers have specified memory and CPU requests and limits.
spec:
validationFailureAction: enforce
background: true
rules:
- name: validate-resources
match:
resources:
kinds:
- Pod
validate:
message: "CPU and memory resource requests and limits are required."
pattern:
spec:
containers:
- resources:
requests:
memory: "?*"
cpu: "?*"
limits:
memory: "?*"
cpu: "?*"
Now apply it to the cluster:
$ kubectl apply -f policies/require_pod_requests_limits.yaml
clusterpolicy.kyverno.io/require-requests-limits created
Now try to add a pod that does not set any requests or limits:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: busybox1
labels:
app: busybox1
spec:
containers:
- image: busybox:latest
command:
- sleep
- "3600"
imagePullPolicy: IfNotPresent
name: busybox
restartPolicy: Always
EOF
The creation is denied:
resource Pod/default/busybox1 was blocked due to the following policies
require-requests-limits:
validate-resources: 'validation error: CPU and memory resource requests and limits
are required. Rule validate-resources failed at path /spec/containers/0/resources/limits/'
Now try again but with requests and limits set:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: busybox1
labels:
app: busybox1
spec:
containers:
- image: busybox:latest
command:
- sleep
- "3600"
imagePullPolicy: IfNotPresent
name: busybox
resources:
requests:
memory: "50Mi"
cpu: "100m"
limits:
memory: "50Mi"
cpu: "100m"
restartPolicy: Always
EOF
pod/busybox1 created
Example 3 - Default namespace quotas
Namespace resource quotas are a good way to govern fair usage of a cluster shared by multiple teams. Ideally application teams will be able to help define their overall resource requirements for a namespace and you can set these limits in a resource quota. In reality it is not always practical to do this. In the scenario where resource quotas are not set it is helpful to set defaults to ensure that one namespace doesn't overwhelm cluster resources and cause issues for it's neighbors.
In a file called add_ns_quota.yaml
in my policies folder, I have defined a namespace rule to generate resource quotas:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-ns-quota
annotations:
policies.kyverno.io/title: Add Quota
policies.kyverno.io/category: Multi-Tenancy
policies.kyverno.io/subject: ResourceQuota
policies.kyverno.io/description: >-
This policy will generate ResourceQuota resources
when a new Namespace is created.
spec:
rules:
- name: generate-resourcequota
match:
resources:
kinds:
- Namespace
generate:
kind: ResourceQuota
name: default-resourcequota
synchronize: true
namespace: "{{request.object.metadata.name}}"
data:
spec:
hard:
requests.cpu: '4'
requests.memory: '16Gi'
limits.cpu: '4'
limits.memory: '16Gi'
requests.storage: '100Gi'
persistentvolumeclaims: 5
Now apply it to the cluster:
$ kubectl apply -f policies/add_ns_quota.yaml
clusterpolicy.kyverno.io/add_ns_quota created
And apply my namespace again from the label test:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
labels:
app.kubernetes.io/costcentre: "engineering"
name: kyverno-testing
name: kyverno-testing
EOF
namespace/kyverno-testing created
The namespace created ok, what about my resource quota:
kubectl get resourceQuotas -n kyverno-testing -o yaml
Some output removed for brevity:
apiVersion: v1
items:
- apiVersion: v1
kind: ResourceQuota
metadata:
creationTimestamp: "2021-10-07T14:14:48Z"
labels:
app.kubernetes.io/managed-by: kyverno
kyverno.io/generated-by-kind: Namespace
kyverno.io/generated-by-name: kyverno-testing
kyverno.io/generated-by-namespace: ""
policy.kyverno.io/gr-name: gr-xnvgw
policy.kyverno.io/policy-name: add-ns-quota
policy.kyverno.io/synchronize: enable
name: default-resourcequota
namespace: kyverno-testing
spec:
hard:
limits.cpu: "4"
limits.memory: 16Gi
persistentvolumeclaims: "5"
requests.cpu: "4"
requests.memory: 16Gi
requests.storage: 100Gi
We see that Kyvero generated the resource quota as per our policy definition.
Policy definition
I've glossed over some of the yaml in the policy definitions, so let's go through one of the policy definitions line-by-line to be clear about what is being done. We'll go through the 'require requests and limits' policy:
Lines 1 and 2 set the Kyverno api version and the kind ClusterPolicy
:
The policies can be either cluster scoped with ClusterPolicy
or namespace scoped with Policy
.
Lines 3-11 are the meta data for organising and describing your polices. Important to organise properly when you have a lot of policies:
From line 12 on is the specification of the policy.
Line 13 determines the action you take when the policy is violated. When we enforce
, the resource creation is denied when the policy is violated. When we audit
, the resource creation succeeds but the violation is recorded in the log:
The background
option on line 14 tells Kyverno what to do with existing cluster resources. They will be audited if set to true
:
From line 15 are the actual rule definitions, of which there can be multiple. Line 16 starts our first (and only) rule and names it validate-resources
.
Lines 17-20 defines the resources we are going to match
and thus apply this policy to. Every Pod
in our case:
Line 21 defines the type of policy rule. validate
in our case but can also be mutate
or generate
:
Line 22 is the policy rule violation message:
Lines 23-32 define the pattern we need to match for the policy rule to be passed:
So for a pod to pass the rule it must have:
- Something in the pod's
spec.containers.resources.requests.memory
field - Something in the pod's
spec.containers.resources.requests.cpu
field - Something in the pod's
spec.containers.limits.requests.memory
field - Something in the pod's
spec.containers.limits.requests.cpu
field
The 'something' in our example is the ?*
wildcard:
- ? - matches a single alphanumeric character
- * - matches zero or more alphanumeric characters
Kyverno policy reports
If we are enforcing policy you can see the impact when resources are denied or mutated but what about audit? For our namespace label cluster policy we will have clusterpolicyreports
that report the current state of the policies:
kubectl get clusterpolicyreports
Gives me:
NAME PASS FAIL WARN ERROR SKIP AGE
clusterpolicyreport 0 2 0 0 0 8m9s
...and digging into the output of the report:
kubectl get clusterpolicyreports clusterpolicyreport -o yaml
Let's me see the resources which have failed auditing (some output omitted for brevity):
apiVersion: wgpolicyk8s.io/v1alpha2
kind: ClusterPolicyReport
metadata:
name: clusterpolicyreport
results:
- category: Best practice
message: 'validation error: The label `app.kubernetes.io/costcentre` is required.
Rule check-for-labels failed at path /metadata/labels/app.kubernetes.io/costcentre/'
policy: require-labels
resources:
- apiVersion: v1
kind: Namespace
name: default
uid: 905f857f-8c89-4f76-9001-5cf5ddc7a5ea
result: fail
rule: check-for-labels
scored: true
severity: medium
source: Kyverno
- category: Best practice
message: 'validation error: The label `app.kubernetes.io/costcentre` is required.
Rule check-for-labels failed at path /metadata/labels/app.kubernetes.io/costcentre/'
policy: require-labels
resources:
- apiVersion: v1
kind: Namespace
name: kyverno-gui
uid: 60a4165f-6c13-416b-92af-61acdcf67757
result: fail
rule: check-for-labels
scored: true
severity: medium
source: Kyverno
From my output I can see that the kyverno-gui
and default
namespaces fail validation as they don’t have the costcentre label defined.
So the reports are Kubernetes resources and here we have the cluster level policy clusterpolicyreport
. You also have namespace scoped policyreport
for policies scoped at that level.
The policy-reporter GUI surfaces all of this in a clean, simple front-end.
Conclusion
Defining and maintaining Kubernetes policy is hugely simplified with Kyverno and it is a great alternative to OPA.