Blog › ICP guides
Kubernetes engineer on retainer: Pod scheduling, workload controllers, networking, RBAC security, and Helm chart governance on monthly retainer
August 12, 2026 · ~20 min read
A growth-stage SaaS company had run three production incidents in two months from the same Kubernetes cluster. The first: during a traffic spike, a memory-constrained node evicted pods mid-request. The root cause was straightforward after the fact — no PodDisruptionBudget was configured on the affected Deployment, and no ResourceQuota was applied to the namespace, so the scheduler had no reliable guidance on which pods were safe to evict and which were serving live traffic. The second: a DaemonSet log collector consumed 2 CPU cores unexpectedly during a log burst from a high-volume service, throttling application pods on the same node to the point of timeout cascades. The root cause was equally straightforward: the DaemonSet had resources.requests.cpu: "0" and no limits section at all, so the CPU scheduler gave it uncapped access during bursts with no cost accounting. The third: a junior engineer pushed a networking change that opened traffic between the payments namespace and the analytics namespace, which should have been isolated by policy. The root cause: no NetworkPolicy existed in either namespace, so the cluster’s default allow-all posture permitted the traffic without any warning.
A fractional Kubernetes platform engineer on monthly retainer designed the remediation across three sessions. For the eviction problem: PodDisruptionBudget with minAvailable: 2 on each production Deployment, plus a ResourceQuota per namespace enforcing requests.cpu, limits.memory, and count/pods ceilings. For the DaemonSet overconsumption: a LimitRange in every namespace setting default CPU requests and limits, making it structurally impossible to schedule a container with zero CPU request. For the namespace isolation failure: ingress and egress NetworkPolicy rules establishing a default-deny posture in every namespace, followed by explicit allow rules for the services that required cross-namespace communication.
None of the remediation produced a visible feature. The PodDisruptionBudget, ResourceQuota, LimitRange, and NetworkPolicy YAML files were each under 30 lines. The platform engineer’s work was in the hours of cluster audit, namespace budget design, eviction behavior testing, and NetworkPolicy rule verification that preceded those 30-line files — invisible in the YAML, visible only in the absence of the three incident classes that had caused the previous two months of on-call escalations.
Kubernetes engineers, platform engineers, and Kubernetes architects on monthly retainer — fractional Kubernetes platform engineers, Kubernetes consultant retainers, and Kubernetes advisory engagements — do their highest-value work in the Pod scheduling design, ResourceQuota and LimitRange governance, NetworkPolicy isolation, HPA autoscaling configuration, and Helm chart architecture that produces the stable, secure, and well-governed cluster the engineering director reports on to the CTO. This guide covers the Kubernetes platform layer in depth: scheduling, workload controllers, networking, security, observability, and Helm — and how to structure a Kubernetes engineer retainer that makes the hours behind each platform function visible.
Pod scheduling and resource management
Pod scheduling is the process by which the Kubernetes scheduler assigns a Pod to a node. The scheduler filters nodes by hard constraints (the Pod cannot run here) and then scores remaining nodes by soft preferences (the Pod prefers to run here). A platform engineer on retainer spends meaningful hours designing the constraint and preference rules that determine where workloads land — work that is entirely invisible in the absence of the scheduling failures those rules prevent.
NodeSelector and NodeAffinity
nodeSelector is the simplest scheduling constraint: a map of label key-value pairs that the target node must match. It is a hard requirement — no matching node means the Pod remains Pending. NodeAffinity provides the same capability with richer expression and the ability to specify soft preferences in addition to hard requirements:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
# Simple hard requirement: node must have this label
# nodeSelector:
# topology.kubernetes.io/zone: us-east-1a
affinity:
nodeAffinity:
# Hard requirement: Pod will NOT schedule if no node matches
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64
- key: node-role.kubernetes.io/spot
operator: DoesNotExist # Avoid spot instances for API servers
# Soft preference: prefer nodes in us-east-1a, fall back to us-east-1b
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- us-east-1a
- weight: 20
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- us-east-1b
The operator field in matchExpressions supports In (label value is in the list), NotIn (label value is not in the list), Exists (label key exists, any value), and DoesNotExist (label key is absent). The Gt and Lt operators work with numeric string values. The IgnoredDuringExecution suffix on both affinity types means that if a running pod’s node later loses the matching label, the pod continues running — eviction does not occur. A hypothetical RequiredDuringExecution form would evict pods when nodes lose labels, but this is not yet implemented in Kubernetes as of 2026.
PodAffinity and PodAntiAffinity
While NodeAffinity constrains scheduling based on node labels, PodAffinity and PodAntiAffinity constrain scheduling based on the labels of other pods already running in the cluster. This enables co-location (place this pod near pods with label app: cache) and spreading (do not place two pods with label app: api on the same node):
affinity:
podAntiAffinity:
# Hard requirement: no two api-server pods on the same node
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: api-server
topologyKey: kubernetes.io/hostname # "same node" = same hostname
# Soft preference: spread api-server pods across availability zones
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: api-server
topologyKey: topology.kubernetes.io/zone # "same zone" = same AZ label
podAffinity:
# Soft preference: co-locate api-server near its cache sidecar service
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector:
matchLabels:
app: redis-cache
topologyKey: kubernetes.io/hostname
The topologyKey field defines what “co-located” or “spread apart” means. With topologyKey: kubernetes.io/hostname, two pods are considered co-located if they are on the same node. With topologyKey: topology.kubernetes.io/zone, two pods are co-located if they are in the same availability zone. The anti-affinity rule with topologyKey: kubernetes.io/hostname and a hard requirement is the standard pattern for ensuring that a Deployment’s replicas land on distinct nodes, preventing a single node failure from taking down all replicas simultaneously.
Taints and tolerations
Taints allow a node to repel pods; tolerations allow a pod to be scheduled on tainted nodes. The combination enables dedicated node pools for specific workloads (GPU nodes for ML workloads, high-memory nodes for analytics, spot nodes for batch jobs) without requiring every pod to affirmatively request those nodes:
# Taint a node so only GPU workloads are scheduled on it:
kubectl taint nodes gpu-node-1 nvidia.com/gpu=present:NoSchedule
# Pods without a matching toleration will not be scheduled on gpu-node-1.
# Pods with this toleration CAN be scheduled there (but still need NodeAffinity
# to be REQUIRED to land there — toleration is a gate, not a magnet):
spec:
tolerations:
# Exact match: key, operator Equal, value, effect must all match
- key: "nvidia.com/gpu"
operator: Equal
value: "present"
effect: NoSchedule
# Wildcard: tolerate ALL taints with this effect
- operator: Exists
effect: NoSchedule
# NoExecute with tolerationSeconds: pod is evicted after 300s if taint is applied
- key: "node.kubernetes.io/not-ready"
operator: Exists
effect: NoExecute
tolerationSeconds: 300 # Wait 300s before evicting — useful for DaemonSets
- key: "node.kubernetes.io/unreachable"
operator: Exists
effect: NoExecute
tolerationSeconds: 300
The three taint effects differ in behavior: NoSchedule prevents new pods from scheduling (existing pods are unaffected); PreferNoSchedule is a soft version that discourages but does not prevent scheduling; NoExecute is the strongest — it both prevents new scheduling and evicts existing pods that do not tolerate the taint (with tolerationSeconds providing a grace period before eviction). DaemonSets for logging and monitoring routinely tolerate node.kubernetes.io/not-ready:NoExecute and node.kubernetes.io/unreachable:NoExecute with generous tolerationSeconds to ensure they remain scheduled on nodes during transient network issues or brief health-check failures.
Resource requests, limits, and LimitRange
Resource requests and limits are the most operationally consequential fields in a Pod spec. The platform engineer’s recurring retainer work is auditing clusters where application engineers have set these incorrectly — or not at all:
resources:
requests:
cpu: "250m" # Scheduler guarantee: this pod needs 0.25 CPU cores
memory: "256Mi" # Scheduler guarantee: this pod needs 256 MiB memory
limits:
cpu: "1000m" # CFS bandwidth quota: max 1 CPU core (throttled, not killed)
memory: "512Mi" # OOMKill threshold: container killed immediately if exceeded
---
# LimitRange: namespace-level enforcement for all Pods and Containers
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: production
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "512Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
min:
cpu: "50m"
memory: "64Mi"
max:
cpu: "4"
memory: "8Gi"
- type: Pod
max:
cpu: "8"
memory: "16Gi"
CPU and memory behave fundamentally differently when a limit is exceeded. CPU throttling: when a container exceeds its CPU limit, the Linux kernel’s CFS bandwidth quota mechanism throttles it — it does not receive CPU cycles for the remainder of the quota period (typically 100ms). The container continues running, but slower. This is why CPU-heavy containers with low limits exhibit high request latency: they are running but waiting for quota replenishment. Memory eviction: when a container exceeds its memory limit, the kernel sends SIGKILL immediately — there is no grace period, no SIGTERM, no opportunity for graceful shutdown. The pod restarts with an OOMKilled status. The LimitRange object prevents the zero-request DaemonSet scenario described in the opening: any container submitted to the namespace without explicit requests receives the defaultRequest values, and any container submitted with requests below the min values is rejected at admission.
ResourceQuota and PriorityClass
apiVersion: v1
kind: ResourceQuota
metadata:
name: production-quota
namespace: production
spec:
hard:
requests.cpu: "20"
requests.memory: "40Gi"
limits.cpu: "60"
limits.memory: "120Gi"
count/pods: "150"
count/services: "30"
count/services.loadbalancers: "5"
count/persistentvolumeclaims: "20"
---
# PriorityClass for guaranteed scheduling of critical services
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: production-critical
value: 1000000
preemptionPolicy: PreemptLowerPriority
globalDefault: false
description: "Production-critical services that preempt lower-priority workloads during resource contention"
---
# Built-in system priority classes (do not modify these values):
# system-cluster-critical: 2000000000 (kube-dns, metrics-server)
# system-node-critical: 2000001000 (kubelet, node-local-dns)
ResourceQuota is enforced at the time a resource is created or updated — not at scheduling time. A pod that exceeds the namespace quota is rejected at admission with a 403 Forbidden; it never enters the Pending state. This makes ResourceQuota the correct mechanism for preventing a runaway deployment from consuming an entire cluster’s resources. PriorityClass with PreemptLowerPriority enables the scheduler to evict lower-priority pods to make room for higher-priority pods when the cluster is under resource pressure. The value field is an integer; higher values are higher priority. Pods without a priorityClassName receive priority 0.
Workload controllers
The workload controllers — Deployment, StatefulSet, DaemonSet, CronJob, Job, and the autoscalers — are the layer above raw Pod scheduling where application teams interact with Kubernetes most directly. The platform engineer’s retainer work in this layer is configuring the controllers for the operational properties that application teams cannot or do not configure themselves: zero-downtime rolling updates, ordered stateful pod initialization, DaemonSet resource governance, and autoscaler behavior tuning.
Deployment: rolling updates and PodDisruptionBudget
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
namespace: production
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2 # Spin up 2 extra pods before terminating old ones
maxUnavailable: 0 # Never reduce below 6 available pods during rollout
minReadySeconds: 30 # Pod must be ready for 30s before counted as Available
progressDeadlineSeconds: 600 # Rollout must complete within 10 minutes
selector:
matchLabels:
app: api-server
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: api-server
image: registry.example.com/api-server:v2.1.0
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
---
# PodDisruptionBudget: guarantees minimum availability during voluntary disruptions
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-server-pdb
namespace: production
spec:
minAvailable: 4 # At least 4 pods must be available during node drains
selector:
matchLabels:
app: api-server
The maxSurge: 2, maxUnavailable: 0 combination is the zero-downtime rolling update pattern: the deployment controller creates 2 new pods (surge) before terminating any old ones, ensuring the total available pod count never drops below the desired replica count during the rollout. minReadySeconds: 30 prevents a pod that passes its readiness probe once but then fails immediately afterward from being counted as successfully updated — it must remain ready for 30 continuous seconds before the rollout controller considers it healthy and proceeds to update the next batch. The PodDisruptionBudget with minAvailable: 4 constrains voluntary disruptions (node drains for maintenance, cluster upgrades): kubectl drain will not proceed if draining a node would leave fewer than 4 api-server pods available.
StatefulSet: ordered initialization and stable storage
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: database
spec:
serviceName: postgres-headless # Required: links pods to a headless Service
replicas: 3
podManagementPolicy: OrderedReady # Default: pod-N waits for pod-(N-1) to be Running+Ready
# podManagementPolicy: Parallel # Use when pods are independent (e.g., worker replicas)
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0 # Set partition: 2 to update only pods with ordinal >= 2 (canary rollout)
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: gp3-encrypted
resources:
requests:
storage: 100Gi
---
# Headless Service: enables stable DNS for StatefulSet pods
apiVersion: v1
kind: Service
metadata:
name: postgres-headless
namespace: database
spec:
clusterIP: None # Headless: no VIP, DNS returns pod IPs directly
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
StatefulSet pods have stable, predictable identities: the pod named postgres-0 is always the first replica, postgres-1 the second, and postgres-2 the third. With a headless service, each pod gets a stable DNS name: postgres-0.postgres-headless.database.svc.cluster.local, postgres-1.postgres-headless.database.svc.cluster.local, and so on. Clients (such as a Patroni-managed PostgreSQL cluster primary election agent) can address individual pods by name, which is not possible with a standard Deployment where pods have random names and share a ClusterIP service VIP. The volumeClaimTemplates creates a separate PVC per replica; these PVCs are NOT deleted when the StatefulSet is scaled down, preserving data for recovery when the pod is rescheduled.
DaemonSet: node coverage and resource governance
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: logging
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # Update one node at a time
selector:
matchLabels:
app: fluent-bit
template:
spec:
tolerations:
# Tolerate not-ready and unreachable so logs are collected even on degraded nodes
- key: "node.kubernetes.io/not-ready"
operator: Exists
effect: NoExecute
tolerationSeconds: 300
- key: "node.kubernetes.io/unreachable"
operator: Exists
effect: NoExecute
tolerationSeconds: 300
# Also tolerate control-plane nodes if logging there is required
- key: "node-role.kubernetes.io/control-plane"
operator: Exists
effect: NoSchedule
containers:
- name: fluent-bit
image: fluent/fluent-bit:3.1
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m" # Capped: prevents log burst from consuming 2+ CPU cores
memory: "256Mi"
volumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true
volumes:
- name: varlog
hostPath:
path: /var/log
The DaemonSet resource governance incident in the opening (2 CPU cores consumed during a log burst) is prevented by the explicit limits.cpu: "500m": even during a log burst that would otherwise demand 2 full cores, the CFS bandwidth quota limits the fluent-bit container to 0.5 cores per 100ms quota period. The application pods on the same node retain their CPU access. The tolerationSeconds: 300 on not-ready and unreachable allows the log collector to stay running for 5 minutes before eviction when a node enters a degraded state, preventing log gaps during transient network partitions or health-check failures.
CronJob and Job completion patterns
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
namespace: production
spec:
schedule: "0 2 * * *" # 02:00 UTC daily
concurrencyPolicy: Forbid # Skip if previous run is still running
startingDeadlineSeconds: 3600 # Do not start if more than 1 hour overdue
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
suspend: false # Set true to pause without deleting
jobTemplate:
spec:
backoffLimit: 2 # Retry failed pods up to 2 times
activeDeadlineSeconds: 7200 # Kill job if it runs longer than 2 hours
template:
spec:
restartPolicy: OnFailure
containers:
- name: reporter
image: registry.example.com/reporter:v1.4
---
# Indexed Job: work-queue pattern where each pod processes a specific shard
apiVersion: batch/v1
kind: Job
metadata:
name: data-migration
spec:
completions: 10 # 10 total completions needed
parallelism: 3 # Run at most 3 pods simultaneously
completionMode: Indexed # Each pod gets a unique index (JOB_COMPLETION_INDEX env var)
backoffLimit: 4
template:
spec:
restartPolicy: Never
containers:
- name: migrator
image: registry.example.com/migrator:v2
env:
- name: JOB_COMPLETION_INDEX
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
concurrencyPolicy: Forbid is the safe default for CronJobs that perform database operations or generate reports: if the previous run is still executing when the next scheduled trigger fires, the new run is skipped entirely rather than running concurrently and producing duplicate output or database contention. concurrencyPolicy: Replace terminates the previous run and starts a new one — useful for jobs where freshness matters more than completion of the previous run. startingDeadlineSeconds: 3600 prevents a large backlog from forming if the CronJob controller misses multiple scheduled triggers (for example, during a control plane outage): the job is only started if the scheduled time was less than one hour ago.
HorizontalPodAutoscaler v2 and VerticalPodAutoscaler
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 3
maxReplicas: 20
metrics:
# Scale on CPU utilization relative to requests
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # Scale out when average CPU > 70% of request
# Scale on memory utilization
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
# Scale on custom Prometheus metric via prometheus-adapter
- type: External
external:
metric:
name: http_requests_per_second
selector:
matchLabels:
service: api-server
target:
type: AverageValue
averageValue: "500" # Scale out when >500 req/s per pod
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # Wait 60s before scaling up again
policies:
- type: Pods
value: 4
periodSeconds: 60 # Add at most 4 pods per 60 seconds
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 minutes before scaling down
policies:
- type: Percent
value: 10
periodSeconds: 60 # Remove at most 10% of pods per 60 seconds
---
# VerticalPodAutoscaler: recommendation-only mode (does not restart pods)
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-server-vpa
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
updatePolicy:
updateMode: "Off" # "Off" = recommendations only, no automatic restarts
# "Auto" = automatically adjusts requests and restarts pods
resourcePolicy:
containerPolicies:
- containerName: api-server
minAllowed:
cpu: 100m
memory: 128Mi
maxAllowed:
cpu: 4
memory: 4Gi
The behavior.scaleDown.stabilizationWindowSeconds: 300 is one of the most important HPA tuning parameters. Without it, the HPA evaluates the current metric every 15 seconds and can scale down aggressively after a traffic spike, removing pods just before a second spike arrives and causing a scale-thrash cycle. The 5-minute stabilization window requires that the metric remain below the scale-down threshold for 5 consecutive minutes before pods are removed, absorbing the tail of a traffic spike. The VPA in updateMode: "Off" is the platform engineer’s observability tool during the initial retainer engagement: it watches historical resource usage and generates recommendations visible in kubectl describe vpa api-server-vpa without actually restarting pods. After reviewing the recommendations against observed behavior, the platform engineer updates the Deployment’s requests to align with actual usage.
Kubernetes networking
Kubernetes networking is the layer where the most subtle production failures and the most consequential security failures occur. The platform engineer on retainer designs the Service types, Ingress configuration, and NetworkPolicy rules that determine how traffic flows into, within, and out of the cluster — and which traffic is prohibited.
Service types
# ClusterIP: stable VIP, kube-proxy iptables/IPVS rules route to pod IPs
apiVersion: v1
kind: Service
metadata:
name: api-server
namespace: production
spec:
type: ClusterIP
selector:
app: api-server
ports:
- port: 80
targetPort: 8080
protocol: TCP
---
# LoadBalancer: provisions a cloud load balancer (NLB/ALB via AWS LBC, GCP LB, etc.)
apiVersion: v1
kind: Service
metadata:
name: api-server-lb
namespace: production
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-internal: "true"
spec:
type: LoadBalancer
# spec.loadBalancerIP: "10.0.0.100" # Request specific IP (cloud-provider-dependent)
externalTrafficPolicy: Local # Preserve client source IP; only route to local pods
selector:
app: api-server
ports:
- port: 443
targetPort: 8443
---
# Headless Service: clusterIP: None, DNS returns individual pod IPs (StatefulSet use)
apiVersion: v1
kind: Service
metadata:
name: postgres-headless
namespace: database
spec:
clusterIP: None
selector:
app: postgres
ports:
- port: 5432
---
# ExternalName: CNAME alias to external service (no selector, no endpoint tracking)
apiVersion: v1
kind: Service
metadata:
name: legacy-api
namespace: production
spec:
type: ExternalName
externalName: legacy.internal.example.com
externalTrafficPolicy: Local on a LoadBalancer or NodePort service instructs kube-proxy to only forward traffic to pods on the local node rather than load-balancing across all cluster nodes. This preserves the original client IP in the X-Forwarded-For header and eliminates the extra network hop for cross-node forwarding — but it means nodes with no local pods will receive traffic that is then dropped, making load distribution across pods uneven unless an external load balancer is configured to perform health checks against the NodePort and route only to nodes with healthy local pods.
Ingress with nginx and cert-manager TLS
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.example.com
secretName: api-example-com-tls # cert-manager populates this Secret
rules:
- host: api.example.com
http:
paths:
- path: /v2(/|$)(.*)
pathType: Prefix
backend:
service:
name: api-server-v2
port:
number: 80
- path: /v1(/|$)(.*)
pathType: Prefix
backend:
service:
name: api-server-v1
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: api-server-v2
port:
number: 80
---
# cert-manager Certificate resource (created automatically by the Ingress annotation,
# or explicitly for more control):
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-example-com
namespace: production
spec:
secretName: api-example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- api.example.com
- www.api.example.com
duration: 2160h # 90 days
renewBefore: 360h # Renew 15 days before expiry
NetworkPolicy: namespace isolation and default-deny
NetworkPolicy is the Kubernetes mechanism for enforcing network segmentation at the pod level. It is the platform layer that would have prevented the namespace isolation failure in the opening scenario. NetworkPolicy is only enforced if the cluster’s CNI plugin supports it (Calico, Cilium, Weave, and Amazon VPC CNI with Network Policy add-on all support it; Flannel without a network policy plugin does not):
# Pattern 1: Default deny-all (apply first in every namespace)
# Empty podSelector matches ALL pods in namespace
# Empty policyTypes with no ingress/egress rules = deny all traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {} # Applies to ALL pods in namespace
policyTypes:
- Ingress
- Egress
---
# Pattern 2: Allow traffic within the same namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {} # Any pod in this namespace
egress:
- to:
- podSelector: {} # Any pod in this namespace
---
# Pattern 3: Allow ingress from the nginx ingress controller namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress-controller
namespace: payments
spec:
podSelector:
matchLabels:
app: payments-api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
ports:
- protocol: TCP
port: 8080
---
# Pattern 4: Allow specific cross-namespace traffic (payments -> external DNS only)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: payments
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
The default-deny-all pattern applied first to the payments namespace means that the junior engineer’s networking change in the opening scenario — deploying a service that accepted connections from the analytics namespace — would have been silently blocked by the existing NetworkPolicy before any traffic could flow. The namespaceSelector and podSelector used together in the same from entry (Pattern 3) create an AND condition: the source pod must be in the matching namespace AND have the matching pod labels. Placed in separate array elements under from, they would create an OR condition: the source can be any pod in the matching namespace OR any pod with the matching labels in any namespace — a common and consequential distinction that the platform engineer validates during NetworkPolicy authoring.
CoreDNS and service discovery
# Service DNS: <service>.<namespace>.svc.cluster.local
# Pod DNS: <pod-ip-dashes>.<namespace>.pod.cluster.local
# ndots:5 means any name with fewer than 5 dots is tried as relative first,
# so "api-server" resolves as:
# api-server.production.svc.cluster.local (found — returns ClusterIP)
# while "api.example.com" (2 dots) is tried as:
# api.example.com.production.svc.cluster.local (fails)
# api.example.com.svc.cluster.local (fails)
# api.example.com.cluster.local (fails)
# api.example.com. (succeeds — external DNS)
# This causes up to 3 wasted DNS queries per external hostname lookup.
# Fix: use FQDNs with trailing dots in configs, or set ndots:2 for workloads
# that don't need short-name in-cluster resolution.
---
# CoreDNS ConfigMap: add a stub zone for an on-premises service
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
# Stub zone: forward internal.example.com queries to on-prem DNS
internal.example.com:53 {
forward . 10.0.0.2 10.0.0.3
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
Kubernetes security
Kubernetes security at the platform layer is principally about RBAC design, service account token governance, and Pod security admission controls that constrain what workloads can do at the kernel and network level. The platform engineer designs these controls so that a compromised application container cannot escalate privilege, read secrets from other namespaces, or access the Kubernetes API with more permissions than it needs.
RBAC: Roles, ClusterRoles, and least-privilege design
# Role: namespace-scoped. Only grants permissions within the payments namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: payments-app-reader
namespace: payments
rules:
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch"]
---
# RoleBinding: grants the Role to a ServiceAccount in the same namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: payments-app-reader-binding
namespace: payments
subjects:
- kind: ServiceAccount
name: payments-app
namespace: payments
roleRef:
kind: Role
name: payments-app-reader
apiGroup: rbac.authorization.k8s.io
---
# ClusterRole: cluster-scoped permissions (or granted namespace-scoped via RoleBinding)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: node-reader
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
---
# ClusterRoleBinding: grants a ClusterRole cluster-wide
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: node-reader-binding
subjects:
- kind: Group
name: platform-engineers
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: node-reader
apiGroup: rbac.authorization.k8s.io
The platform engineer’s RBAC design work follows three principles. First, use Role and RoleBinding in preference to ClusterRole and ClusterRoleBinding whenever the permission only needs to apply within a specific namespace — a ClusterRole granted to an application service account gives that account permission to read secrets across every namespace in the cluster. Second, design service account roles by enumerating only the resources and verbs the application actually needs (an application that reads ConfigMaps and Secrets should not have update or create permissions). Third, audit with kubectl auth can-i --list --as=system:serviceaccount:payments:payments-app to verify what permissions each service account actually holds after configuration.
ServiceAccount token projection and automount controls
# ServiceAccount: disable auto-mounting for accounts used by web-facing pods
apiVersion: v1
kind: ServiceAccount
metadata:
name: api-server
namespace: production
automountServiceAccountToken: false # Default mount is disabled at SA level
---
# Pod spec: explicitly mount a projected token only for pods that need API access
spec:
serviceAccountName: api-server
automountServiceAccountToken: false # Pod-level override (takes precedence over SA)
volumes:
- name: kube-api-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # Short-lived token; automatically rotated
audience: "https://kubernetes.default.svc"
containers:
- name: api-server
volumeMounts:
- name: kube-api-token
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
readOnly: true
PodSecurityAdmission and securityContext
# Apply PodSecurityAdmission labels to a namespace:
# "enforce" rejects pods that violate the policy
# "warn" allows pods but emits a warning
# "audit" allows pods but logs a violation
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
---
# Pod spec that satisfies the "restricted" PodSecurity profile:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault # Required by restricted profile
containers:
- name: api-server
image: registry.example.com/api-server:v2.1.0
securityContext:
allowPrivilegeEscalation: false # Required by restricted profile
readOnlyRootFilesystem: true # Required by restricted profile
runAsNonRoot: true
capabilities:
drop:
- "ALL" # Drop all Linux capabilities
add:
- "NET_BIND_SERVICE" # Re-add only if port < 1024 binding needed
volumeMounts:
- name: tmp
mountPath: /tmp # Writable tmp volume for readOnlyRootFilesystem
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
The restricted PodSecurityAdmission profile (replacing the deprecated PodSecurityPolicy) enforces the security baseline that prevents the most common container privilege escalation paths: runAsNonRoot: true prevents containers from running as root even if the image entrypoint is root by default; readOnlyRootFilesystem: true prevents the container from writing to its own filesystem (attackers cannot drop persistent tooling); allowPrivilegeEscalation: false prevents setuid binaries from escalating to root; capabilities.drop: ["ALL"] removes all Linux capabilities from the container (including NET_ADMIN, SYS_PTRACE, and SYS_MODULE that would allow network configuration, process inspection, and kernel module loading). The platform engineer validates that each workload can run under the restricted profile and provides emptyDir volumes for the paths that the application legitimately needs to write to (temporary files, cache directories) when readOnlyRootFilesystem is enabled.
Service mesh mTLS with Istio
# PeerAuthentication: require mTLS for all pods in the namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT # All pod-to-pod traffic must use mTLS; plaintext rejected
---
# AuthorizationPolicy: restrict which services can call the payments API
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: payments-api-authz
namespace: payments
spec:
selector:
matchLabels:
app: payments-api
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/production/sa/order-service"
- "cluster.local/ns/production/sa/checkout-service"
to:
- operation:
methods: ["POST"]
paths: ["/v1/charge", "/v1/refund"]
Kubernetes observability
Kubernetes observability at the platform layer means configuring the components that make cluster resource usage, workload health, and scheduling behavior visible: Prometheus scraping via ServiceMonitors, metrics-server for HPA and kubectl top, and structured pod probes that distinguish slow-starting containers from unhealthy ones.
Prometheus Operator ServiceMonitor and PrometheusRule
# ServiceMonitor: tells Prometheus Operator which services to scrape
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: api-server-monitor
namespace: production
labels:
release: prometheus # Must match Prometheus CR's serviceMonitorSelector
spec:
selector:
matchLabels:
app: api-server
monitoring: enabled
endpoints:
- port: metrics # Named port on the Service
path: /metrics
interval: 30s
scrapeTimeout: 10s
scheme: http
---
# PrometheusRule: alerting rules for the api-server workload
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: api-server-alerts
namespace: production
labels:
release: prometheus
spec:
groups:
- name: api-server.rules
interval: 30s
rules:
- alert: ApiServerHighErrorRate
expr: |
rate(http_requests_total{job="api-server", status=~"5.."}[5m])
/ rate(http_requests_total{job="api-server"}[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "API server error rate above 5% for 2 minutes"
description: "Error rate is {{ $value | humanizePercentage }}"
- alert: ApiServerHPANearMaxReplicas
expr: |
kube_horizontalpodautoscaler_status_current_replicas{
horizontalpodautoscaler="api-server-hpa"
}
/
kube_horizontalpodautoscaler_spec_max_replicas{
horizontalpodautoscaler="api-server-hpa"
} > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "HPA is at >90% of max replicas — scale ceiling approaching"
Pod probes: readiness, liveness, and startup
containers:
- name: api-server
image: registry.example.com/api-server:v2.1.0
ports:
- containerPort: 8080
name: http
# startupProbe: allows slow-starting containers time to initialize
# Before startupProbe succeeds, liveness and readiness probes are suspended
startupProbe:
httpGet:
path: /healthz/startup
port: 8080
failureThreshold: 30 # 30 failures × 10s period = 5 minutes max startup time
periodSeconds: 10
successThreshold: 1
# readinessProbe: controls whether pod receives traffic from Services
# Failing readiness removes pod from Service endpoints (no traffic)
# Passing readiness adds pod back to Service endpoints
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3 # Remove from rotation after 3 failures (15s)
successThreshold: 1 # Restore to rotation after 1 success
# livenessProbe: controls whether pod is restarted
# Failing liveness causes kubelet to kill and restart the container
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 5 # Restart after 5 consecutive failures (50s)
successThreshold: 1
# exec probe for a gRPC service without an HTTP health endpoint:
# livenessProbe:
# exec:
# command: ["/bin/grpc_health_probe", "-addr=:9090"]
# initialDelaySeconds: 10
# periodSeconds: 10
The three probe types serve distinct functions and are commonly misconfigured by application teams. startupProbe is the platform engineer’s solution to a recurring problem: the failureThreshold on livenessProbe is set conservatively (5 failures × 10 seconds = 50 seconds before restart) to avoid restarting healthy pods under load, but a slow-starting container (a JVM warming up, a Python application initializing a large model) takes 3 minutes to become ready. Without startupProbe, the liveness probe fires before the application is ready and restarts it repeatedly, preventing it from ever completing initialization. With startupProbe configured with a high failureThreshold, the liveness and readiness probes are suspended until startup succeeds, then take over normal health checking. The readinessProbe and livenessProbe must target different health check semantics: readiness reflects whether the pod is ready to serve traffic (database connection established, warmup complete, feature flags loaded); liveness reflects whether the pod process is alive and not deadlocked (if liveness fails, a restart is the correct remediation).
Kubernetes Events and resource metrics
# Inspect recent cluster events sorted by timestamp:
kubectl get events -n production --sort-by=lastTimestamp
# Common event reasons to monitor:
# BackOff — container is crash-looping; check logs
# OOMKilled — container exceeded memory limit; increase limit or fix leak
# Evicted — pod evicted by kubelet; check node memory pressure
# FailedScheduling — no node satisfies constraints; check affinity, taints, quota
# Pulling — image pull in progress (long pulls indicate registry issues)
# Failed — image pull failed; check image name and pull secret
# Resource usage from metrics-server:
kubectl top pods -n production --containers
kubectl top nodes
# metrics-server resource requirements (small clusters):
# resources:
# requests:
# cpu: 100m
# memory: 200Mi
# limits:
# cpu: 500m
# memory: 500Mi
Helm chart authoring
Helm chart authoring is the platform layer that governs how applications are packaged, configured, and deployed across environments. The platform engineer on retainer spends meaningful hours designing chart structures that make per-environment parameterization explicit, mandatory fields validated at render time, and multi-environment deployments repeatable.
values.yaml: required fields, defaults, and toYaml
# values.yaml: default values with documentation
replicaCount: 3
image:
repository: registry.example.com/api-server
tag: "" # Must be provided at deploy time; empty default forces explicit set
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
nodeSelector: {}
tolerations: []
affinity: {}
podDisruptionBudget:
enabled: true
minAvailable: 2
hpa:
enabled: false
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 70
---
# templates/deployment.yaml: use required and default functions
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "api-server.fullname" . }}
labels:
{{- include "api-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ required "image.tag is required" .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
_helpers.tpl: consistent naming across all resources
{{/*
templates/_helpers.tpl
*/}}
{{/* Expand the name of the chart */}}
{{- define "api-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* Create a default fully qualified app name, capped at 63 characters */}}
{{- define "api-server.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/* Common labels applied to all resources */}}
{{- define "api-server.labels" -}}
helm.sh/chart: {{ include "api-server.chart" . }}
{{ include "api-server.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Selector labels (must be stable across upgrades) */}}
{{- define "api-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "api-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/* Create chart name and version label */}}
{{- define "api-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* ServiceAccount name */}}
{{- define "api-server.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "api-server.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
Chart dependencies, conditional resources, and chart testing
# Chart.yaml: declare chart dependencies
apiVersion: v2
name: api-server
description: API server with optional Redis cache and PostgreSQL
type: application
version: 1.4.0
appVersion: "2.1.0"
dependencies:
- name: redis
version: "19.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled # Only installed if redis.enabled: true in values
- name: postgresql
version: "14.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
---
# Update and vendor chart dependencies:
# helm dependency update ./api-server
# Creates charts/ directory with .tgz archives of dependencies
---
# templates/hpa.yaml: conditional resource (only rendered if hpa.enabled)
{{- if .Values.hpa.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "api-server.fullname" . }}
labels:
{{- include "api-server.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "api-server.fullname" . }}
minReplicas: {{ .Values.hpa.minReplicas }}
maxReplicas: {{ .Values.hpa.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.hpa.targetCPUUtilizationPercentage }}
{{- end }}
---
# Chart testing with chart-testing (ct):
# ct lint --charts charts/api-server # Validates Chart.yaml, values, and rendered templates
# ct install --charts charts/api-server # Installs into temp namespace, runs tests, then deletes
---
# Deploy with atomic rollback on failure:
helm upgrade --install api-server ./api-server \
--namespace production \
--create-namespace \
--values ./values/production.yaml \
--set image.tag=${CI_COMMIT_SHA} \
--atomic \
--timeout 5m \
--history-max 10
# Rollback to previous release:
helm rollback api-server 0 --namespace production # 0 = previous release
# Inspect release history:
helm history api-server --namespace production
The --atomic flag on helm upgrade --install is the platform engineer’s deployment safety net: if any resource in the new release fails to reach a ready state within the timeout window (5 minutes in this example), Helm automatically rolls back to the previous release and exits non-zero. This prevents a partial upgrade from leaving the cluster in an inconsistent state where some pods are running the new version and others the old. The --history-max 10 flag limits the number of release history entries Kubernetes stores as Secrets, preventing unbounded Secret accumulation in clusters with frequent deployments.
HourTab for Kubernetes platform engineer retainers
Kubernetes platform engineer retainer work produces visible infrastructure outcomes: zero pod evictions during traffic spikes after PodDisruptionBudget and ResourceQuota governance is in place; namespace isolation enforced after NetworkPolicy default-deny posture is established; HPA scaling that stabilizes correctly after scaleDown.stabilizationWindowSeconds tuning prevents oscillation; and Helm chart deployments that roll back atomically when a new version fails readiness checks. Each outcome is visible. The hours that produced it are not.
The ResourceQuota and LimitRange design that preceded the eviction prevention: auditing every Deployment and DaemonSet in the namespace to identify zero-request containers and unset limits, calculating namespace budget ceilings that allow peak-traffic burst without starving adjacent workloads, writing and testing the quota and limit range configuration, and verifying enforcement behavior by submitting quota-exceeding test pods at the admission webhook. This work produced a 30-line ResourceQuota YAML and a 25-line LimitRange YAML. The hours were 10 to 16, depending on namespace complexity and the number of workloads requiring remediation.
The NetworkPolicy audit that preceded the namespace isolation enforcement: inventorying every existing inter-namespace traffic flow that was legitimate and needed an explicit allow rule, writing the default-deny-all policy, writing the allow-same-namespace and allow-from-ingress-controller policies, testing each allow rule with ephemeral curl debug pods to verify traffic flows as intended, and verifying that previously unauthorized traffic paths (like analytics to payments) are now silently dropped. This produced four to eight NetworkPolicy manifests. The hours were 12 to 20, depending on the number of legitimate cross-namespace traffic flows and the complexity of the ingress controller configuration.
The HPA behavior tuning that preceded the stable scale-down: monitoring the HPA’s scaling events over several traffic cycles to observe oscillation patterns, calibrating stabilizationWindowSeconds and scaleDown.policies to match the traffic pattern’s post-spike tail, configuring external Prometheus metrics via prometheus-adapter for queue-depth-driven autoscaling, and load-testing the tuned configuration under synthetic traffic to verify stable behavior. This produced changes to an existing HPA manifest. The hours were 6 to 14, depending on the number of metrics and the stability of the traffic pattern.
The Helm chart refactoring that preceded the multi-environment deployment standardization: converting per-environment ad hoc kubectl apply YAML directories into a parameterized Helm chart with values schema, _helpers.tpl template libraries for consistent naming, conditional resources controlled by values flags, dependency declaration for Redis and PostgreSQL subcharts, and ct lint and ct install chart testing in CI. This produced the chart directory. The hours were 12 to 25, depending on the number of resources being parameterized and the number of environments requiring distinct values files.
Monthly retainer amounts for Kubernetes platform engineer advisory and architecture consulting typically range from $6,000 to $12,000 per month for platform advisory and architecture review, increasing to $13,000 to $30,000 per month for full-time fractional platform engineering covering hands-on cluster configuration, workload migration, and multi-cluster governance.
HourTab gives Kubernetes platform engineers and Kubernetes architects a retainer dashboard their engineering directors can bookmark without creating an account: the month’s committed hours, the hours consumed, and the work log entries that connect each block to the specific platform function performed. When the VP Engineering can see that 14 of the month’s 40 retainer hours went to NetworkPolicy authoring and testing across three namespaces and 10 went to HPA behavior tuning and load testing for the two highest-traffic Deployments, the retainer renewal conversation is grounded in the actual distribution of Kubernetes platform advisory work rather than an abstract sense of whether the cluster stability investment produced value.
The retainer model fits Kubernetes platform engineering because Kubernetes clusters are living systems: every new workload deployed by an application team may arrive without resource requests or limits and require LimitRange remediation; every new namespace requires a NetworkPolicy default-deny posture and a ResourceQuota budget; every Kubernetes version upgrade introduces deprecated API versions that Helm charts must be updated to use; and every new cloud provider feature (NLB annotations, storage class configurations, node pool taint propagation) requires platform-layer evaluation before application teams adopt it. A monthly hour commitment provides the Kubernetes platform engineer’s sustained availability across the full cluster maintenance and evolution calendar.
For Kubernetes consultants documenting retainer work, sharing a live hours dashboard replaces the weekly status email: the client sees the current month’s hour consumption and the work log entries that narrate what each block of Kubernetes platform advisory hours accomplished.
Frequently asked questions
What does a Kubernetes engineer on retainer typically do?
A Kubernetes engineer or platform engineer on monthly retainer provides ongoing Kubernetes platform advisory and hands-on configuration across five principal service areas. Pod scheduling and resource governance: designing NodeAffinity and PodAntiAffinity rules that spread workloads across failure domains, configuring LimitRange and ResourceQuota per namespace to enforce resource boundaries and prevent CPU starvation and memory OOMKills, setting PriorityClass for critical workloads, and implementing PodDisruptionBudgets that guarantee minimum availability during voluntary disruptions. Workload controller design: configuring Deployment rolling update strategies for zero-downtime deploys, designing StatefulSet pod management for ordered initialization of clustered databases, tuning HorizontalPodAutoscaler behavior including scaleDown stabilization windows and custom Prometheus metrics, and auditing DaemonSet resource limits to prevent log collectors from consuming unbounded CPU during log bursts. Networking and NetworkPolicy: writing ingress and egress NetworkPolicy rules that enforce namespace isolation, configuring Ingress controllers with TLS termination via cert-manager, and tuning CoreDNS for custom stub zones. RBAC and security: designing Role and ClusterRole hierarchies on the principle of least privilege, disabling automountServiceAccountToken on Pods that do not require API server access, configuring PodSecurityAdmission namespace labels for the restricted profile, and auditing securityContext settings for runAsNonRoot, readOnlyRootFilesystem, and capability dropping. Helm chart authoring and governance: designing chart values schemas with required field validation, maintaining _helpers.tpl template libraries, configuring chart dependencies and condition flags, and establishing helm upgrade --atomic --timeout deployment governance across environments.
What Kubernetes work is most commonly underlogged in a retainer?
The most systematically underlogged categories are: ResourceQuota and LimitRange design (defining per-namespace CPU and memory budgets, enforcing minimum request values to prevent containers with zero requests from monopolizing node resources, calibrating quota ceilings that allow legitimate burst — typically 8 to 16 hours per cluster namespace audit invisible in the YAML files); NetworkPolicy authoring (designing ingress and egress rules for namespace isolation, writing the default-deny-all and allow-same-namespace policies, testing traffic flow with ephemeral debug pods — typically 10 to 20 hours per cluster security posture review invisible in the absence of unauthorized cross-namespace traffic); HPA behavior tuning (calibrating scaleDown.stabilizationWindowSeconds to prevent oscillation, configuring external Prometheus metrics for queue-depth-driven autoscaling, testing scale behavior under synthetic load — typically 6 to 14 hours per workload invisible in the stable scaling curve); and Helm chart refactoring (converting per-environment ad hoc YAML overlays into a parameterized chart with values schema validation, _helpers.tpl template libraries, and dependency condition flags — typically 12 to 25 hours per chart invisible in the values.yaml diff). Detailed work log entries that capture the specific cluster configurations applied and the incidents they prevent connect the invisible Kubernetes platform investment to its concrete outcomes.
What should a Kubernetes engineer retainer agreement include?
Kubernetes engineer retainer agreements should specify: scope boundary between cluster platform advisory, security audit, workload optimization, and feature-specific engineering (RBAC design and NetworkPolicy authoring produce no deployable application artifact — define these explicitly as in-scope platform functions with their own hour allocation); cluster access level required (read-only kubeconfig for audit and review; write access to specific namespaces for configuration changes; cluster-admin for LimitRange, ResourceQuota, and PodSecurityAdmission configuration); namespace governance scope (which namespaces the platform engineer owns the ResourceQuota and LimitRange configuration for, versus namespaces owned by application teams); Helm chart ownership (whether the retainer covers chart authoring, chart review only, or values governance for application teams authoring their own charts); security posture scope (whether PodSecurityAdmission enforcement level changes, NetworkPolicy authoring, and RBAC audit are included, and whether Istio mTLS or service mesh configuration is in scope); and a shared work log documenting each scheduling configuration session, ResourceQuota and LimitRange design sprint, NetworkPolicy authoring engagement, HPA tuning session, and Helm chart refactoring engagement. Monthly retainer amounts for Kubernetes platform engineer advisory typically range from $6,000 to $12,000 per month for platform advisory and architecture review, increasing to $13,000 to $30,000 per month for full-time fractional platform engineering.
What are typical retainer rates for Kubernetes engineers and platform engineers?
Kubernetes engineers command rates that reflect the platform’s operational depth, the scheduling and networking expertise required for production-grade cluster configuration, and the RBAC and security design skill that high-value retainer work demands. Entry-level Kubernetes engineers with 1 to 3 years of experience, CKA certification, and basic cluster operations skill typically bill $85 to $145 per hour, with monthly retainers running 10 to 20 hours for cluster health review and advisory work. Mid-level Kubernetes platform engineers with 3 to 8 years of experience, expertise in RBAC design, HPA configuration, Helm chart authoring, and NetworkPolicy design, typically bill $140 to $250 per hour, with monthly retainers running 15 to 35 hours. Senior Kubernetes architects and platform engineers with 8 to 14 years of experience, expertise in multi-cluster federation, service mesh (Istio), advanced scheduling, and enterprise security posture, typically bill $200 to $390 per hour, with monthly retainers running 20 to 50 hours. Kubernetes consulting firms and platform engineering consultancies typically bill $170 to $310 per hour. Monthly retainer amounts range from $6,000 to $12,000 per month for platform advisory and architecture review retainers, increasing to $13,000 to $30,000 per month for full-time fractional platform engineering engagements.
How should Kubernetes engineer retainer hours be logged?
Kubernetes retainer work log entries should capture the platform advisory category (Pod scheduling design, ResourceQuota and LimitRange governance, NetworkPolicy authoring, HPA tuning, Helm chart refactoring, RBAC audit, observability configuration), the specific namespace or workload, the task performed, and the finding or deliverable. Example: “ResourceQuota and LimitRange Governance — production namespace. Task: design and apply namespace-level resource governance to prevent memory OOMKill and CPU starvation. Work: audited all Deployments and DaemonSets in production namespace — found 6 containers with cpu requests: 0, 3 containers with no memory limits, and 2 DaemonSets with no resource section — 2 hours; designed LimitRange with default CPU request 100m, default CPU limit 500m, default memory request 128Mi, default memory limit 512Mi, min CPU 50m, min memory 64Mi — applied and verified with kubectl describe limitrange — 3 hours; designed ResourceQuota with requests.cpu: 8, limits.cpu: 20, requests.memory: 16Gi, limits.memory: 48Gi, count/pods: 100 — verified enforcement by submitting a quota-exceeding pod at admission webhook — 2 hours; applied PodDisruptionBudget minAvailable: 2 to each stateful Deployment; verified behavior during kubectl drain simulation — 3 hours. Total: 10 hours. Production incidents prevented at platform layer: CPU starvation from zero-request DaemonSet, memory OOMKill from unlimited container, unprotected voluntary disruption during node maintenance. Application code changes: zero.” Entries that document the specific cluster configurations applied and the incidents they prevent connect the 10 hours of platform governance work to the production stability it produced, making the Kubernetes platform retainer investment legible to the engineering director reviewing the work log.