🏢 Tenant Models

Multi-tenancy in Kubernetes spans a spectrum from loose namespace separation to fully isolated virtual clusters. Choose your model based on the trust level between tenants.

🟢 Soft Multi-tenancy (Namespace per Team)

  • Tenants are internal teams — trusted
  • Isolation via RBAC + NetworkPolicy + Quotas
  • Shared control plane, shared nodes
  • Low overhead, easy to operate
  • Risk: privileged pod can escape namespace
  • Use: most enterprise internal clusters

🟡 Medium (Namespace + Policy Enforcement)

  • Stricter: PodSecurity restricted + OPA policies
  • No host network, no privileged containers
  • Dedicated node pools per tenant (node taints)
  • Audit logging per namespace
  • Use: regulated industries, semi-trusted tenants

🔴 Hard Multi-tenancy (Virtual Clusters)

  • Tenants are external customers — untrusted
  • Each tenant gets a virtual cluster (vcluster)
  • Tenant has full cluster-admin in their vcluster
  • Host cluster nodes are shared but isolated
  • Use: SaaS platforms, CaaS products
Kubernetes Cluster (shared control plane) ns: team-alpha Pods A Services A RBAC A Quota A ns: team-beta Pods B Services B RBAC B Quota B vcluster: customer-c Virtual apiserver Virtual etcd Synced to host namespace

🔐 Namespace Isolation & RBAC Patterns

Namespace Bootstrap Template

Automate namespace creation with a consistent set of objects: RBAC, ResourceQuota, LimitRange, and default NetworkPolicy. Use a Helm chart or Kyverno policy to ensure every namespace gets the same baseline.

# Namespace + standard labels
apiVersion: v1
kind: Namespace
metadata:
  name: team-alpha
  labels:
    team: alpha
    environment: production
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/warn: restricted
---
# ResourceQuota — hard cap on resource consumption
apiVersion: v1
kind: ResourceQuota
metadata:
  name: default-quota
  namespace: team-alpha
spec:
  hard:
    requests.cpu: "16"
    requests.memory: "32Gi"
    limits.cpu: "32"
    limits.memory: "64Gi"
    count/pods: "100"
    count/services: "20"
    count/services.loadbalancers: "2"
    persistentvolumeclaims: "20"
    requests.storage: "500Gi"
---
# LimitRange — default requests/limits for pods with no spec
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: team-alpha
spec:
  limits:
    - type: Container
      default:
        cpu: "500m"
        memory: "256Mi"
      defaultRequest:
        cpu: "100m"
        memory: "128Mi"
      max:
        cpu: "4"
        memory: "8Gi"

RBAC Roles for Tenants

RoleCan doCannot do
namespace-viewerget/list/watch all resources in namespaceCreate, update, delete anything
namespace-developerFull CRUD on Deployments, Services, ConfigMaps, Secrets (in namespace)Modify RBAC, LimitRange, Quota, NetworkPolicy
namespace-adminFull CRUD on all namespace-scoped resourcesCluster-scoped resources (Nodes, PVs, CRDs)
cluster-adminEverythingNothing
# Developer role — scoped to one namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: developer
  namespace: team-alpha
rules:
  - apiGroups: ["", "apps", "batch", "autoscaling"]
    resources:
      - pods
      - deployments
      - replicasets
      - services
      - configmaps
      - horizontalpodautoscalers
      - jobs
      - cronjobs
    verbs: ["get","list","watch","create","update","patch","delete"]
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get","list","watch"]   # read secrets but not create/delete
  - apiGroups: [""]
    resources: ["pods/log", "pods/exec", "pods/portforward"]
    verbs: ["get","create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: team-alpha-developers
  namespace: team-alpha
subjects:
  - kind: Group
    name: team-alpha               # OIDC group claim
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: developer
  apiGroup: rbac.authorization.k8s.io
💡 Use Groups not individual Users Bind RBAC to OIDC groups, not individual usernames. When someone joins or leaves a team, you only update the IdP group membership — no Kubernetes changes needed.

🌐 Network, Admission & Node Isolation

Default-Deny NetworkPolicy per Namespace

Apply this to every tenant namespace immediately after creation. Tenants must explicitly declare what traffic they allow.

# Deny all ingress and egress by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: team-alpha
spec:
  podSelector: {}       # matches all pods in namespace
  policyTypes:
    - Ingress
    - Egress
---
# Allow DNS egress (CoreDNS) — required for service discovery
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: team-alpha
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - ports:
        - port: 53
          protocol: UDP
        - port: 53
          protocol: TCP
---
# Allow intra-namespace communication
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-same-namespace
  namespace: team-alpha
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - podSelector: {}    # any pod in same namespace
  egress:
    - to:
        - podSelector: {}

PodSecurity Standards Enforcement

# Enforce 'restricted' profile on namespace via label
kubectl label namespace team-alpha \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

# Restricted profile blocks:
# - privileged containers
# - hostNetwork, hostPID, hostIPC
# - hostPath volumes
# - runAsRoot
# - allowPrivilegeEscalation
# - dropping less than ALL capabilities

Kyverno Policies for Tenant Guardrails

# Require all pods to have resource requests
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-requests
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-requests
      match:
        resources: { kinds: [Pod] }
      validate:
        message: "CPU and memory requests are required"
        pattern:
          spec:
            containers:
              - resources:
                  requests:
                    cpu: "?*"
                    memory: "?*"
---
# Disallow latest image tag in production namespaces
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-image-tag
      match:
        resources:
          kinds: [Pod]
          namespaceSelector:
            matchLabels:
              environment: production
      validate:
        message: "Image tag ':latest' is not allowed in production"
        pattern:
          spec:
            containers:
              - image: "!*:latest"

Dedicated Node Pools per Tenant

# Taint dedicated nodes for a tenant
kubectl taint nodes -l tenant=alpha \
  tenant=alpha:NoSchedule

# Team Alpha pods add toleration to use their nodes
spec:
  tolerations:
    - key: tenant
      operator: Equal
      value: alpha
      effect: NoSchedule
  nodeSelector:
    tenant: alpha      # or use nodeAffinity for required/preferred
⚠️ Namespace isolation is not kernel-level isolation A container that escapes its namespace (e.g. via a kernel exploit or misconfigured privileged pod) can access other tenants' data. For truly untrusted tenants use virtual clusters (vcluster) or sandboxed runtimes (gVisor, Kata Containers).

🏗️ Hard Multi-tenancy with vcluster

vcluster runs a full Kubernetes control plane (apiserver + etcd + scheduler + controller-manager) as a StatefulSet inside a namespace of the host cluster. From the tenant's perspective they have a real cluster with full admin access. From the host's perspective it's just pods in a namespace.

Full Admin Access

Tenants can create CRDs, ClusterRoles, custom admission webhooks — without touching the host cluster's RBAC.

Workloads on Host Nodes

Pods are synced from the virtual cluster to the host namespace. They run on host nodes but appear in the virtual cluster's API.

Lightweight

A vcluster uses ~300 MB RAM for the control plane. You can run dozens per host cluster — far cheaper than separate clusters.

Namespace Isolation

Each vcluster gets its own host namespace. Host-level NetworkPolicy and ResourceQuota still apply — the tenant can't break out.

Create a vcluster

# Install vcluster CLI
curl -L -o vcluster https://github.com/loft-sh/vcluster/releases/latest/download/vcluster-linux-amd64
chmod +x vcluster && mv vcluster /usr/local/bin/

# Create a virtual cluster for customer-c
vcluster create customer-c \
  --namespace vc-customer-c \
  --set "sync.ingresses.enabled=true" \
  --set "isolation.enabled=true" \
  --set "isolation.resourceQuota.enabled=true" \
  --set "isolation.resourceQuota.requests.cpu=8" \
  --set "isolation.resourceQuota.requests.memory=16Gi"

# Connect to the virtual cluster
vcluster connect customer-c --namespace vc-customer-c
# Switches kubeconfig to point at the virtual cluster's apiserver

kubectl get nodes     # sees virtual "fake" nodes
kubectl get pods -A   # only sees pods in this vcluster

vcluster Architecture

# What vcluster creates in the host cluster (vc-customer-c namespace):
kubectl get pods -n vc-customer-c
# NAME                                    READY   STATUS
# customer-c-0                            2/2     Running   ← virtual apiserver + etcd
# customer-c-coredns-abc                  1/1     Running   ← virtual CoreDNS
# customer-c-vcluster-workload-abc        1/1     Running   ← synced pod (tenant workload)

# Tenant pods appear in host namespace as:
# -x--x-
# e.g. nginx-x-default-x-customer-c

Isolation Model Comparison

Isolation dimensionNamespace onlyNamespace + PSS + NetworkPolicyvcluster
RBAC blast radiusNamespace-scopedNamespace-scoped + KyvernoFull cluster-admin in vcluster
Network isolationNetworkPolicy (L3/L4)L3–L7 with CiliumHost NetworkPolicy around vcluster ns
Kernel isolationNone (shared)seccomp + AppArmorNone (shared host kernel)
CRD namespaceShared cluster CRDsShared cluster CRDsPrivate CRDs per vcluster
Tenant trust levelInternal teamsSemi-trustedUntrusted / external customers
OverheadNoneLow (webhooks)~300 MB per tenant

📝 Knowledge Check

Q1. A team's pod in namespace team-alpha is trying to call a service in namespace team-beta. A default-deny NetworkPolicy is applied to both namespaces. The call fails. How do you allow only this specific cross-namespace traffic?
  • A) Delete the NetworkPolicy in team-beta
  • B) Add an egress rule to team-alpha allowing traffic to team-beta, and an ingress rule to team-beta allowing traffic from team-alpha
  • C) Add a ClusterRoleBinding to allow cross-namespace pod communication
  • D) Set spec.hostNetwork: true on the calling pod
B) Egress rule on source + ingress rule on destination. NetworkPolicy requires both ends: an egress rule on the calling namespace (team-alpha) allowing traffic to team-beta pods, AND an ingress rule on team-beta allowing traffic from team-alpha pods. Use namespaceSelector with the namespace's labels to target the correct namespace.
Q2. A developer's namespace is labelled pod-security.kubernetes.io/enforce: restricted. Their Deployment mounts /etc/host-certs as a hostPath volume. What happens when they apply it?
  • A) The Deployment is created but the pod fails with an OOMKilled error
  • B) The pod is rejected by the PodSecurity admission controller — hostPath volumes are forbidden under the restricted profile
  • C) The hostPath volume is silently ignored and an emptyDir is used instead
  • D) The Deployment is created but the pod runs without the volume mount
B) Pod rejected by PodSecurity admission. The restricted Pod Security Standard forbids hostPath volumes (along with hostNetwork, hostPID, privileged containers, and running as root). With enforce mode, the pod creation request is rejected at admission with a descriptive error message.
Q3. You're building a SaaS platform where each customer needs full cluster-admin access to their own Kubernetes environment, but you want to share underlying node capacity. Which solution best fits?
  • A) Give each customer a separate namespace with a ClusterRoleBinding to cluster-admin
  • B) Create a dedicated Kubernetes cluster per customer
  • C) Use vcluster — each customer gets a virtual cluster with full admin, running on shared host nodes
  • D) Use PodSecurity restricted profile to limit what cluster-admin can do
C) vcluster. Giving namespace-scoped cluster-admin (A) is a security anti-pattern. Separate clusters (B) are expensive to operate at scale. vcluster gives each customer a real virtual cluster with full admin access, isolated CRDs, and their own control plane — while physically running on your shared host nodes for cost efficiency.