Sep 21, 20247 min read

Setting Guardrails in a K8s Cluster in a Multi-Tenant Environment ๐Ÿ”

Multi-tenant Kubernetes clusters cut infrastructure cost by sharing one cluster across teams โ€” but without guardrails, that shared blast radius means one tenant can leak into another. Here's how to lock it down with Kyverno policies and NetworkPolicies.

KubernetesKyvernoMulti-TenancyNetwork PolicySecurity
Isometric illustration of isolated, color-coded tenant blocks on a shared platform, ringed by shields and locks

Multi-tenant clusters are essentially the way to go if you want to save costs for your organization by running multiple applications โ€” different environments, different customers, different specs โ€” isolated through namespaces in a single cluster. But how does that actually save money?

Mostly by not giving every team their own cluster. Onboarding everyone onto a single shared cluster means sharing resources, which saves real money that separate clusters per team wouldn't. That said, multi-tenancy comes with real downsides too โ€” which is why I prefer it for non-production environments, and stick to a dedicated per-team cluster in production. A good rule of thumb worth reading up on before committing to either model.

To me, the biggest concern with a multi-tenant cluster is data leakage between tenants โ€” a real problem once you're running production workloads on it. That's exactly why setting guardrails is critical for these environments: get it wrong, and it can lead to a major blow-up ๐Ÿ’ฃ

Isometric illustration of isolated, color-coded tenant blocks on a shared platform, ringed by shields and locks
Isolating tenants on a shared cluster โ€” the goal guardrails exist to enforce.

This post digs into how to prevent that kind of data leakage between tenants, using two tools: Kyverno and Kubernetes NetworkPolicies.

Kyverno

Kyverno is an open-source, Kubernetes-native policy engine built to manage, validate, and audit or enforce policies inside the cluster. Its best features:

  • Policy enforcement โ€” enforces cluster policies so workloads adhere to organizational security and operational standards.
  • Generate resources โ€” can generate additional resources like ConfigMaps or RoleBindings based on policy rules.
  • Auditing โ€” checks whether existing resources comply with new or updated policies.

Install Kyverno on an EKS cluster via Helm:

bash
helm repo add kyverno https://kyverno.github.io/kyverno/
kubectl create namespace kyverno
helm install kyverno --namespace kyverno kyverno/kyverno

Once that's done, you should see Kyverno's core component pods running in the cluster:

  • Admission controller โ€” evaluates policies and enforces them against every request as it's made.
  • Background controller โ€” periodically scans existing resources to make sure they still comply with defined policies.
  • Cleanup controller โ€” Kyverno can generate resources as part of its policies (e.g. policy reports); this controller deletes them once they're no longer needed or no longer match the source policy.
  • Reports controller โ€” generates policy reports (ClusterPolicyReport or PolicyReport) showing how policies are being enforced and where violations are happening.
Diagram of Kyverno's architecture: an API request flowing through admission review into the Kyverno admission controller, which coordinates the webhook controller, cert renewer, engine, report controllers, and background controller against policies and policy exceptions
Kyverno's architecture โ€” how an API request flows through the admission controller and its supporting controllers. Source: Kyverno docs.

The cluster policies

With Kyverno running, here are the policies a multi-tenant environment actually needs โ€” and why each one exists.

First, a check-namespace-labels ClusterPolicy that requires every namespace in the cluster to carry a label. This is what lets every team's namespaces be identified as theirs in a multi-tenant environment:

check-namespace-labels.yaml
yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: check-namespace-labels
  annotations:
    policies.kyverno.io/title: Check Namespace Labels
    policies.kyverno.io/category: Other
    policies.kyverno.io/subject: Namespace
    kyverno.io/kyverno-version: <KYVERNO-VERSION>
    kyverno.io/kubernetes-version: <CLUSTER-VERSION>
    policies.kyverno.io/description: >-
      Making sure that every Namespace in the cluster has a label attached.
      The label should be of the form team-name:<YOUR-TEAM-NAME>
spec:
  validationFailureAction: <Enforce or Audit>
  background: true
  failurePolicy: Fail
  rules:
  - name: check-namespace-labels
    match:
      any:
      - resources:
          kinds:
            - Namespace
    validate:
      message: This Namespace is missing a project label.
      pattern:
        metadata:
          labels:
            team-name: "?*"

Next, either taints/tolerations on the nodes and pods, or a node selector on every pod, so workloads only ever get scheduled onto the nodes dedicated to their team โ€” never onto some random node in the shared cluster. Here's require-node-selector.yaml:

require-node-selector.yaml
yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-node-selector
  annotations:
    policies.kyverno.io/title: Enforce nodeSelector
    policies.kyverno.io/category: Pod Best Practices (Baseline)
    policies.kyverno.io/subject: Pods
    kyverno.io/kyverno-version: <KYVERNO-VERSION>
    kyverno.io/kubernetes-version: <CLUSTER-VERSION>
    policies.kyverno.io/description: >-
      Node selector must be defined.
spec:
  validationFailureAction: <Enforce or Audit>
  background: true
  failurePolicy: Fail
  rules:
  - name: check-nodeselector
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      message: >-
        Pods must define a node selector block. Either add
        spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[*]
        or spec.nodeSelector[*]
      anyPattern:
      - spec:
          affinity:
            nodeAffinity:
              requiredDuringSchedulingIgnoredDuringExecution:
                nodeSelectorTerms: "*"
      - spec:
          affinity:
            nodeAffinity:
              preferredDuringSchedulingIgnoredDuringExecution:
                nodeSelectorTerms: "*"
      - spec:
          affinity:
            nodeAntiAffinity:
              requiredDuringSchedulingIgnoredDuringExecution:
                nodeSelectorTerms: "*"
      - spec:
          affinity:
            nodeAntiAffinity:
              preferredDuringSchedulingIgnoredDuringExecution:
                nodeSelectorTerms: "*"
      - spec:
          nodeSelector: "*"

Finally, a policy making sure every namespace in the cluster actually has a NetworkPolicy attached to it โ€” check-namespace-networkpolicies.yaml. We'll cover what a NetworkPolicy actually does right after:

check-namespace-networkpolicies.yaml
yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: check-namespace-networkpolicies
  annotations:
    policies.kyverno.io/title: Check Namespace NetworkPolicies
    policies.kyverno.io/category: Other
    policies.kyverno.io/subject: Namespace
    kyverno.io/kyverno-version: <KYVERNO-VERSION>
    kyverno.io/kubernetes-version: <CLUSTER-VERSION>
    policies.kyverno.io/description: >-
      Making sure that every Namespace in the cluster has a network policy attached.
spec:
  validationFailureAction: <Enforce or Audit>
  background: true
  failurePolicy: Fail
  rules:
  - name: networkpolicies
    match:
      any:
      - resources:
          kinds:
          - Namespace
    exclude:
      any:
      - resources:
          namespaces: <namespaces to skip checking>
      apiCall:
        urlPath: "/apis/networking.k8s.io/v1/namespaces/{{request.object.metadata.name}}/networkpolicies"
        method: "GET"
        jmesPath: "items[] | length(@)"
    validate:
      message: "Every Namespace must have at least one NetworkPolicy."
      deny:
        conditions:
          all:
          - key: "{{ netpols }}"
            operator: Equals
            value: 0

Network Policy

A NetworkPolicy in Kubernetes controls communication between pods, and between pods and other network endpoints โ€” it's effectively a firewall for cluster-internal traffic, letting you define exactly which traffic is allowed or denied.

namespace-access-networkpolicy.yaml
yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: namespace-access
  namespace: team-a-namespace
spec:
  podSelector:
    matchLabels: {}
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
                team-name: A

This policy only allows ingress traffic from namespaces carrying a specific label to reach the resources or serve requests inside the namespace allocated to that team.

That's the combination: Kyverno cluster policies to require labels, scheduling isolation, and a NetworkPolicy per namespace, plus the NetworkPolicy itself to actually enforce traffic isolation. Together, they're the guardrails that keep a security breach between tenants from ever happening in a multi-tenant cluster ๐Ÿ”

Key takeaways

  • Multi-tenancy saves real infrastructure cost by sharing one cluster across teams โ€” but it also shares blast radius, so treat it as production-unsafe until guardrails are in place.
  • Kyverno turns 'every namespace must be labeled / scheduled correctly / network-isolated' from a convention into an enforced, audited policy โ€” new resources that don't comply simply get rejected or flagged.
  • A per-namespace NetworkPolicy is what actually blocks cross-tenant traffic at the network layer; the Kyverno policy just guarantees one always exists.
  • This combination โ€” label policy, scheduling policy, and mandatory NetworkPolicy โ€” is the minimum bar for running a multi-tenant cluster without one tenant being able to reach another's data.

Written by Shubham Jain, Cloud Engineer at Newspresso Tech.

Want this kind of engineering on your infrastructure?