Learn what separates Platform Engineers from DevOps Engineers - multi-tenancy, admission controllers, policy as code with Kyverno, CRDs, the operator pattern, and Cluster API for declarative cluster lifecycle management.
A DevOps Engineer knows how to deploy applications on Kubernetes. A Platform Engineer builds and operates the Kubernetes platform that other teams deploy onto. This distinction sounds subtle. In practice it is the difference between: ``` DevOps Engineer: "How do I deploy this service to the cluster?" → writes Deployment YAML, applies it, done Platform Engineer: "How do I build a platform that 50 teams can deploy to without stepping on each other, without misconfiguring security, without consuming unbounded resources, and without requiring manual intervention for each request?" → builds multi-tenancy, policies, self-service, guardrails ``` This step covers the technical foundations of that second role. Every concept here maps directly to what Platform Engineering teams at Razorpay, Hotstar, Zerodha, and PhonePe operate daily. ### What You Will Learn * How to build a multi-tenant Kubernetes platform where teams are isolated from each other * How admission controllers intercept and modify requests before they reach the cluster * How to enforce security baselines automatically with Kyverno policy as code * How to extend the Kubernetes API with Custom Resource Definitions * How to build Kubernetes operators that automate complex operational workflows * How Cluster API enables declarative cluster lifecycle management ---
When 10 teams share one Kubernetes cluster, you need isolation. Without it: * Team A's memory leak exhausts nodes, crashing Team B's pods * Team C accidentally deploys to Team D's namespace * A misconfigured pod in the payments namespace can reach the user-service database * One team's workload consumes all available CPU, starving everyone else Multi-tenancy is the practice of safely sharing a cluster across multiple teams or customers. ### The Two Models of Multi-Tenancy **Namespace per team** — Each team owns one or more namespaces. Policies, RBAC, quotas, and network policies enforce isolation between namespaces. Teams share the same Kubernetes control plane. This is the most common approach and what we build in this module. **Virtual control plane per team** — Each team gets their own Kubernetes API server, controller manager, and etcd. Complete isolation but much higher cost and complexity. Used when teams need cluster-admin level access or have strict compliance requirements. Tools like vCluster implement this pattern. For most organisations, namespace-per-team provides the right balance of isolation and operational simplicity. ### Namespace-Per-Team — The Building Blocks A namespace is the fundamental unit of isolation in Kubernetes. Everything else — RBAC, ResourceQuotas, NetworkPolicies, LimitRanges — is scoped to a namespace. ```bash ## Create team namespaces with meaningful labels kubectl create namespace payments-production kubectl create namespace orders-production kubectl create namespace user-service-production ## Label namespaces for policy targeting kubectl label namespace payments-production \ team=payments \ environment=production \ tier=critical kubectl label namespace orders-production \ team=orders \ environment=production \ tier=standard ``` ### ResourceQuotas — Preventing Noisy Neighbours A noisy neighbour is a workload that consumes disproportionate cluster resources, degrading performance for everyone else. ResourceQuotas enforce hard limits per namespace. ```yaml ## payments-quota.yaml apiVersion: v1 kind: ResourceQuota metadata: name: payments-team-quota namespace: payments-production spec: hard: ## Compute — payments team gets guaranteed resources requests.cpu: "20" limits.cpu: "40" requests.memory: "40Gi" limits.memory: "80Gi" ## Storage requests.storage: "500Gi" persistentvolumeclaims: "20" ## Object counts — prevent runaway deployments pods: "100" services: "20" configmaps: "50" secrets: "50" ## Prevent creating LoadBalancer services without approval services.loadbalancers: "2" ``` ```bash kubectl apply -f payments-quota.yaml ## Check quota usage for a namespace kubectl describe resourcequota -n payments-production ## Output: ## Name: payments-team-quota ## Namespace: payments-production ## Resource Used Hard ## -------- ---- ---- ## limits.cpu 8 40 ## limits.memory 16Gi 80Gi ## pods 12 100 ## requests.cpu 4 20 ``` ### LimitRanges — Default Resource Requests ResourceQuotas enforce namespace-level limits. LimitRanges enforce container-level defaults. Without LimitRanges, developers who forget to set resource requests deploy pods with no resource requests — they consume unlimited resources and are invisible to the scheduler for capacity planning. ```yaml ## default-limitrange.yaml ## Apply to every team namespace apiVersion: v1 kind: LimitRange metadata: name: default-limits namespace: payments-production spec: limits: - type: Container ## Default values injected if developer does not specify default: cpu: "500m" memory: "512Mi" defaultRequest: cpu: "100m" memory: "128Mi" ## Absolute bounds — even explicit values cannot exceed these min: cpu: "50m" memory: "64Mi" max: cpu: "8" memory: "16Gi" ``` > 📌 **Remember:** LimitRanges only apply to new pods. Existing pods are not affected when you apply a LimitRange. To enforce limits on existing workloads, you need to trigger a rolling restart. ### Network Policies — Traffic Isolation By default, every pod in a Kubernetes cluster can reach every other pod. In a multi-tenant cluster this means a compromised pod in one team's namespace can make requests to any other team's database. Network Policies implement microsegmentation — explicit allow rules, with everything else denied. ```yaml ## default-deny-all.yaml ## Apply this to every team namespace first ## Then add explicit allow rules for what needs to communicate apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-all namespace: payments-production spec: podSelector: {} ## applies to all pods in this namespace policyTypes: - Ingress - Egress --- ## allow-dns.yaml ## DNS must be allowed for any pod to function apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-dns-egress namespace: payments-production spec: podSelector: {} policyTypes: - Egress egress: - ports: - port: 53 protocol: UDP - port: 53 protocol: TCP --- ## allow-within-namespace.yaml ## Pods in the same namespace can talk to each other apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-same-namespace namespace: payments-production spec: podSelector: {} policyTypes: - Ingress ingress: - from: - podSelector: {} ## any pod in the same namespace --- ## allow-from-orders.yaml ## Explicitly allow orders-service to call payments-service apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-from-orders namespace: payments-production spec: podSelector: matchLabels: app: payment-api policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: team: orders podSelector: matchLabels: app: order-service ports: - protocol: TCP port: 8080 ``` > ⚠️ **Security:** Network Policies require a CNI plugin that supports them — Calico, Cilium, or Weave Net. The default Kubernetes networking (Flannel) does not enforce Network Policies. If your CNI does not support them, NetworkPolicy resources are silently ignored. ---
Every time a resource is created or modified in Kubernetes, the request travels through admission controllers before being persisted. Admission controllers are the enforcement point for your security baseline. ``` kubectl apply -f deployment.yaml | v Kubernetes API Server | v Authentication (is this user who they say they are?) | v Authorization (does this user have permission?) | v Admission Controllers ← THIS IS WHERE POLICIES ARE ENFORCED - ValidatingAdmissionWebhook - MutatingAdmissionWebhook - ResourceQuota - LimitRanger - PodSecurity | v etcd (resource is persisted) | v Controllers reconcile actual state ``` ### Two Types of Webhooks **Validating admission webhooks** — read the request and decide to allow or deny it. They cannot modify the resource. If they return a denial, the request is rejected with an error message. **Mutating admission webhooks** — read the request and can modify it before it is persisted. Used to inject defaults, add labels, add sidecar containers. Mutating webhooks run before validating webhooks. ``` Request flow through webhooks: kubectl apply → MutatingWebhook (modify) → ValidatingWebhook (allow/deny) → etcd ``` ### Built-in Admission Controllers Worth Knowing **PodSecurity** — enforces Pod Security Standards. Three levels: Privileged (no restrictions), Baseline (prevents known privilege escalations), Restricted (strongest security). ```bash ## Enable Pod Security enforcement on a namespace kubectl label namespace payments-production \ pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/warn=restricted \ pod-security.kubernetes.io/audit=restricted ``` **ResourceQuota** — enforces namespace-level resource quotas (already covered above). **LimitRanger** — enforces LimitRange defaults (already covered above). ---
Kyverno is a Kubernetes-native policy engine. Policies are written in YAML — no new language to learn, no Rego, no Go. If you can write Kubernetes manifests, you can write Kyverno policies. Kyverno can: * **Validate** — reject resources that violate policy * **Mutate** — automatically fix or enhance resources as they are created * **Generate** — automatically create related resources (like NetworkPolicies for new namespaces) * **Cleanup** — automatically delete stale resources ### Why Kyverno Over OPA/Gatekeeper Both are excellent tools. The practical difference: | | Kyverno | OPA/Gatekeeper | |:---|:---|:---| | Policy language | YAML | Rego (custom language) | | Learning curve | Low — familiar K8s syntax | High — must learn Rego | | Mutation | First-class support | Limited, newer feature | | Generation | Built-in | Not supported | | K8s-specific | Designed for K8s only | General purpose | For platform teams whose focus is Kubernetes governance, Kyverno's simpler syntax and K8s-native design make it the practical choice. OPA/Gatekeeper is better when you need policy enforcement across multiple systems beyond Kubernetes. ### Installing Kyverno ```bash helm repo add kyverno https://kyverno.github.io/kyverno/ helm repo update helm install kyverno kyverno/kyverno \ --namespace kyverno \ --create-namespace \ --set replicaCount=3 ## 3 replicas for production HA kubectl get pods -n kyverno ## NAME READY STATUS ## kyverno-admission-controller-xxx 1/1 Running ## kyverno-background-controller-xxx 1/1 Running ## kyverno-cleanup-controller-xxx 1/1 Running ## kyverno-reports-controller-xxx 1/1 Running ``` ### Validation Policy — Enforce Security Standards ```yaml ## require-non-root.yaml ## Reject any pod that tries to run as root apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: require-non-root-user annotations: policies.kyverno.io/title: Require Non-Root User policies.kyverno.io/description: > Pods must not run as root. Set runAsNonRoot: true in the pod security context. spec: ## audit = report violations without blocking ## enforce = block violating resources validationFailureAction: Enforce background: true ## also scan existing resources rules: - name: check-runAsNonRoot match: any: - resources: kinds: - Pod validate: message: > Running as root is not allowed. Set spec.securityContext.runAsNonRoot: true pattern: spec: securityContext: runAsNonRoot: "true" ``` ```yaml ## require-resource-requests.yaml ## Every container must define resource requests apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: require-resource-requests spec: validationFailureAction: Enforce rules: - name: check-container-resources match: any: - resources: kinds: - Pod validate: message: > All containers must specify CPU and memory requests. Add resources.requests.cpu and resources.requests.memory. pattern: spec: containers: - resources: requests: cpu: "?*" memory: "?*" ``` ```yaml ## disallow-latest-tag.yaml ## Latest tag makes deployments non-reproducible and dangerous apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: disallow-latest-tag spec: validationFailureAction: Enforce rules: - name: require-image-tag match: any: - resources: kinds: - Pod validate: message: > Using 'latest' tag is not allowed in production. Specify a specific version tag like :v1.2.3 foreach: - list: "request.object.spec.containers" deny: conditions: any: - key: "{{element.image}}" operator: Equals value: "*:latest" - key: "{{element.image}}" operator: NotContains value: ":" ``` ### Mutation Policy — Automatically Fix Pods Mutation policies run before validation — they can inject correct values automatically so developers do not need to remember every security requirement. ```yaml ## add-default-security-context.yaml ## Automatically inject a security context if one is not specified apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: add-default-security-context spec: rules: - name: add-security-context match: any: - resources: kinds: - Pod mutate: patchStrategicMerge: spec: securityContext: ## Only set these if not already defined +(runAsNonRoot): true +(runAsUser): 1000 +(fsGroup): 2000 ``` ### Generation Policy — Auto-Create Resources for New Namespaces When a new team namespace is created, Kyverno can automatically create the NetworkPolicy, ResourceQuota, and LimitRange — eliminating manual setup and ensuring every namespace has the same security baseline. ```yaml ## generate-namespace-defaults.yaml apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: generate-namespace-defaults spec: rules: ## Auto-create default-deny NetworkPolicy - name: generate-network-policy match: any: - resources: kinds: - Namespace generate: apiVersion: networking.k8s.io/v1 kind: NetworkPolicy name: default-deny-all namespace: "{{request.object.metadata.name}}" synchronize: true ## keep in sync — if deleted, recreate data: spec: podSelector: {} policyTypes: - Ingress - Egress ## Auto-create LimitRange - name: generate-limit-range match: any: - resources: kinds: - Namespace generate: apiVersion: v1 kind: LimitRange name: default-limits namespace: "{{request.object.metadata.name}}" synchronize: true data: spec: limits: - type: Container default: cpu: "500m" memory: "512Mi" defaultRequest: cpu: "100m" memory: "128Mi" ``` ### Checking Policy Compliance ```bash ## Check what policies are installed and their status kubectl get clusterpolicies kubectl get clusterpolicies -o wide ## Check policy reports — which resources are passing/failing kubectl get policyreport --all-namespaces kubectl get clusterpolicyreport ## Detailed compliance report for a namespace kubectl describe policyreport -n payments-production ## Test a policy without applying it (dry run) kyverno apply require-non-root.yaml \ --resource deployment.yaml ## Output shows: ## PASS: 0 ## FAIL: 1 ← deployment violates the policy ## WARN: 0 ## ERROR: 0 ## SKIP: 0 ``` ---
Kubernetes ships with built-in resource types: Deployments, Services, ConfigMaps, Pods. Custom Resource Definitions (CRDs) let you add your own resource types to the Kubernetes API. When you install ArgoCD, it adds `Application` and `ApplicationSet` as new resource types. When you install Kyverno, it adds `ClusterPolicy`. When you install Cert-Manager, it adds `Certificate` and `Issuer`. All of these are CRDs. ### Why CRDs Matter for Platform Engineering CRDs let you model your platform concepts as Kubernetes objects. Instead of writing documentation saying "to create a new microservice, fill out this form and open a ticket", you create a `Microservice` CRD that developers can apply with kubectl. The operator watching that CRD provisions everything automatically. ```yaml ## microservice-crd.yaml ## Define a new resource type that developers can use apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: microservices.platform.company.io spec: group: platform.company.io versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object required: ["image", "team"] properties: image: type: string description: "Container image including tag" team: type: string description: "Owning team name" replicas: type: integer minimum: 1 maximum: 10 default: 2 port: type: integer default: 8080 resources: type: object properties: cpu: type: string default: "100m" memory: type: string default: "128Mi" status: type: object properties: ready: type: boolean message: type: string scope: Namespaced names: plural: microservices singular: microservice kind: Microservice shortNames: - ms ``` ```bash kubectl apply -f microservice-crd.yaml ## Now developers can create Microservice resources kubectl apply -f - <<EOF apiVersion: platform.company.io/v1 kind: Microservice metadata: name: payment-processor namespace: payments-production spec: image: razorpay/payment-processor:v2.4.1 team: payments replicas: 3 port: 8080 resources: cpu: "500m" memory: "512Mi" EOF ## Query like any K8s resource kubectl get microservices -n payments-production kubectl get ms -n payments-production ## short name works kubectl describe ms payment-processor -n payments-production ``` The CRD defines the schema. An operator (next section) watches for Microservice resources and creates the underlying Deployment, Service, HPA, and PodDisruptionBudget automatically. ---
An operator is a Kubernetes controller that watches custom resources and reconciles the cluster state to match what those resources describe. The reconciliation loop is the same pattern Kubernetes uses internally for built-in resources. The Deployment controller watches Deployment objects and ensures the right number of ReplicaSets and Pods exist. Your operator watches your custom resources and ensures the right infrastructure exists. ``` Operator reconciliation loop: Watch for Microservice resources | v Microservice created/updated/deleted? | YES | v Read the desired state from the Microservice spec | v Query current state from Kubernetes API | v Calculate diff (what needs to change?) | v Apply changes (create/update/delete Deployment, Service, etc.) | v Update Microservice status (ready: true/false) | v Wait for next change event → back to top ``` ### A Simple Operator with Kubebuilder Kubebuilder is the standard framework for building Kubernetes operators. It generates scaffolding and handles the controller runtime. ```bash ## Install Kubebuilder curl -L -o kubebuilder \ https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH) chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/ ## Create a new operator project mkdir microservice-operator && cd microservice-operator kubebuilder init --domain company.io --repo github.com/yourorg/microservice-operator ## Create the API and controller scaffolding kubebuilder create api \ --group platform \ --version v1 \ --kind Microservice ``` ```go // controllers/microservice_controller.go // The reconcile function is called every time a Microservice resource changes func (r *MicroserviceReconciler) Reconcile( ctx context.Context, req ctrl.Request, ) (ctrl.Result, error) { log := log.FromContext(ctx) // Step 1: Fetch the Microservice resource microservice := &platformv1.Microservice{} if err := r.Get(ctx, req.NamespacedName, microservice); err != nil { // Resource was deleted — nothing to do return ctrl.Result{}, client.IgnoreNotFound(err) } // Step 2: Define the desired Deployment deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: microservice.Name, Namespace: microservice.Namespace, }, Spec: appsv1.DeploymentSpec{ Replicas: µservice.Spec.Replicas, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": microservice.Name, "team": microservice.Spec.Team, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ "app": microservice.Name, "team": microservice.Spec.Team, }, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: microservice.Name, Image: microservice.Spec.Image, Ports: []corev1.ContainerPort{ {ContainerPort: int32(microservice.Spec.Port)}, }, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(microservice.Spec.Resources.CPU), corev1.ResourceMemory: resource.MustParse(microservice.Spec.Resources.Memory), }, }, }, }, }, }, }, } // Step 3: Set ownership so Deployment is deleted with Microservice ctrl.SetControllerReference(microservice, deployment, r.Scheme) // Step 4: Create or update the Deployment found := &appsv1.Deployment{} err := r.Get(ctx, types.NamespacedName{ Name: deployment.Name, Namespace: deployment.Namespace, }, found) if errors.IsNotFound(err) { log.Info("Creating Deployment", "name", deployment.Name) return ctrl.Result{}, r.Create(ctx, deployment) } // Step 5: Update status microservice.Status.Ready = true microservice.Status.Message = "Deployment running" r.Status().Update(ctx, microservice) return ctrl.Result{}, nil } ``` ### When to Write a Custom Operator vs Use Existing Ones Do not write a custom operator for every problem. The CNCF ecosystem has operators for most common platform needs: * **Databases** — CloudNativePG (PostgreSQL), MongoDB Community Operator, Redis Operator * **Certificate management** — cert-manager * **Secrets management** — External Secrets Operator * **Monitoring** — Prometheus Operator * **GitOps** — ArgoCD (itself uses the operator pattern) * **Kafka** — Strimzi Write a custom operator when you need to automate platform-specific workflows that no existing operator handles — like the Microservice abstraction above, or a custom self-service environment provisioner. ---
A DevOps Engineer knows how to deploy applications on Kubernetes. A Platform Engineer builds and operates the Kubernetes...
When 10 teams share one Kubernetes cluster, you need isolation. Without it: Team A's memory leak exhausts nodes, crashin...
Every time a resource is created or modified in Kubernetes, the request travels through admission controllers before bei...
Kyverno is a Kubernetes-native policy engine. Policies are written in YAML — no new language to learn, no Rego, no Go. I...
Kubernetes ships with built-in resource types: Deployments, Services, ConfigMaps, Pods. Custom Resource Definitions (CRD...
An operator is a Kubernetes controller that watches custom resources and reconciles the cluster state to match what thos...
Until now, everything in this module has been about operating Kubernetes. Cluster API (CAPI) is about operating the clus...
Platform Engineers control workload placement using taints, tolerations, and node affinity. This is how you ensure GPU w...
❌ No default-deny NetworkPolicy — assuming internal traffic is safe 💥 A developer accidentally misconfigures a service ...
When a pod is rejected by Kyverno: When a namespace is missing policies (NetworkPolicy, LimitRange): When a CAPI cluster...
This project builds a complete multi-tenant Kubernetes platform using everything from this module. By the end you will h...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.