Kubernetes,
from the satellite view
to the wire.
One page. Every layer. We start where a request is born β a phone in someone's hand β and we descend through DNS, anycast, load balancers, Istio, Envoy, Nginx, the Kubernetes control plane, Docker, Linux namespaces, and finally the physical rack in a data center. At every altitude we ask the same question: why does this piece exist? Full YAML for everything. Nothing assumed.
What is Kubernetes, and why does anyone need it?
Before any YAML, one honest question: what problem was so painful that Google, and then the whole industry, built this thing?
Imagine you run one small restaurant. You (the owner) cook, serve, clean, and take payments. That is one app on one server. Now imagine you suddenly have 500 restaurants across the country. You cannot personally run each one. You need: a head office that decides where new restaurants open, managers who make sure each kitchen is staffed, a system that replaces a sick chef instantly, and a phone line so customers always reach the nearest open restaurant.
Kubernetes is that head office. Your app containers are the chefs. Servers are the buildings. Kubernetes constantly checks: is every kitchen staffed the way the plan says? If not, it fixes it β without waking you up.
- Servers die. At the scale of thousands of machines, hardware fails every single day. Somebody (or something) must notice and move your app elsewhere.
- Traffic is spiky. A payments app at 9 AM Monday needs 10Γ the machines it needs at 3 AM. Humans cannot resize fleets by hand fast enough.
- Deployments broke things. "It worked on my machine" β different servers had different library versions. Docker fixed packaging; Kubernetes fixed running the packages at scale.
- Bin-packing money. If your servers sit 15% utilized, you are burning cash. A scheduler that packs workloads tightly saves millions.
The one sentence that defines Kubernetes
Kubernetes is a reconciliation machine: you declare a desired state ("run 6 copies of my payment API, each with 2 CPU"), it observes the actual state of the world, and it works forever to make actual = desired.
Hold onto that picture. A Deployment is a reconciliation loop. The scheduler is a reconciliation loop. The kubelet is a reconciliation loop. Istio's control plane is a reconciliation loop. Once you see the loop, Kubernetes stops being 40 confusing words and becomes one repeated idea.
A thermostat doesn't "turn on the heater when you press a button." You declare a desired temperature (22Β°C). It measures actual temperature (18Β°C), sees the difference, and acts (heat on) until desired = actual. Kubernetes is a building full of thermostats β one per resource type β each guarding a different promise.
The data center: where your pod physically lives
People learn Kubernetes as if it floats in the cloud. It doesn't. Every pod is a Linux process on a real machine, in a real rack, drawing real power, connected by real cables. Understanding the physical layout explains why zones, topology spread, and network latency exist.
Because latency is physics. Light in fiber travels ~200,000 km/s. A request from Seattle to a Virginia data center pays ~60β70 ms round-trip before any code runs. Inside one data center, rack-to-rack is ~50β200 microseconds. If you must answer in sub-millisecond time, every hop has to stay inside the building β that single fact drives anycast, regional clusters, and cache placement, all of which we'll meet below.
The hierarchy: region β zone β data center β rack β server
Region
A metro area containing multiple zones, ~1β2 ms between zones. You pick regions to be near users (physics again).
Availability Zone
One or more buildings with independent power feeds, generators, cooling, and network uplinks. Failure domain #1.
Rack
A metal cabinet holding ~40 servers plus a Top-of-Rack (ToR) switch. Shares one power strip β so a rack is failure domain #2.
The network inside: spine-leaf (Clos) fabric
Old data centers used a tree: servers β access switch β aggregation β core. Problem: two servers under different branches had to travel up to the core and back down β the core became a choke point, and east-west traffic (server-to-server, which is 70%+ of modern traffic thanks to microservices) crushed it. Spine-leaf gives every leaf a direct link to every spine, so any server reaches any other server in exactly 2 hops (leaf β spine β leaf), with many equal-cost paths (ECMP) to spread load.
| Path | Typical latency | Meaning for you |
|---|---|---|
| Same node, podβpod | ~0.02β0.05 ms | loopback-ish; sidecars live here |
| Cross-rack, same zone | ~0.05β0.2 ms | normal microservice hop |
| Zone β zone (same region) | ~0.5β2 ms | fine for HA, costly for chatty calls |
| Region β region (coast to coast) | ~60β70 ms RTT | never in a sub-ms path; async replication only |
| User β nearest edge (good CDN/anycast) | ~5β30 ms | the part you can't remove, only shrink |
This table is the entire reason multi-region architecture exists. "Sub-millisecond response" is only possible for the part of the journey you control β inside the data center. So the game is: get the user's packet to the nearest region fast (anycast), then never leave the building.
Docker: what a container really is
Kubernetes runs containers, so we must be brutally clear on this: a container is not a small virtual machine. It is a normal Linux process wearing three disguises.
A virtual machine is building a whole new house for each tenant: own foundation, plumbing, electricity (own kernel, own OS). Heavy, slow to build, very isolated.
A container is giving each tenant a locked office inside one shared building: same foundation and plumbing (the host's Linux kernel), but their own door, own furniture, own name plate. They believe they own the building. Cheap, starts in milliseconds.
The three disguises (this is the whole trick)
1 Β· Namespaces = "what can I see?"
The kernel lies to the process. PID namespace: "you are process 1, there are no others." Network namespace: "here is your own eth0, your own IP." Mount namespace: "this folder is your entire disk." Also UTS (hostname), IPC, user namespaces.
2 Β· cgroups = "what can I use?"
Control groups cap resources: "this process tree may use at most 2 CPUs and 4 GiB RAM." Exceed memory β the kernel OOM-kills you. This is exactly what K8s resources.limits becomes.
3 Β· Union filesystem = "what disk do I have?"
The image is a stack of read-only layers; the container adds one thin writable layer on top (OverlayFS). Ten containers from one image share the read-only layers β that's why containers are tiny on disk.
Images and layers β why builds are fast (or slow)
Every instruction in a Dockerfile creates one immutable layer, identified by a content hash. Layers are cached and shared. If you change only your source code, Docker rebuilds only the layers after the COPY of your code β everything above (OS, JDK, dependencies) is reused from cache. Order your Dockerfile from least-changing to most-changing, and builds drop from minutes to seconds.
A production multi-stage Dockerfile, line by line
Building a Java app needs Maven, a JDK, source code β ~800 MB of tools. Running it needs only a JRE and one jar β ~200 MB. Shipping build tools to production is wasted bandwidth, slower pod startup, and a bigger attack surface (more binaries an attacker can abuse). Multi-stage builds use one stage to compile and a second, tiny stage to run, copying across only the artifact.
Dockerfile# ---------- STAGE 1: build (has JDK + Maven; thrown away) ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Copy ONLY the dependency manifest first. If pom.xml is unchanged,
# this layer (and the slow dependency download) comes from cache.
COPY pom.xml .
RUN mvn -q dependency:go-offline
# Now copy source β the only part that changes on every commit.
COPY src ./src
RUN mvn -q package -DskipTests
# ---------- STAGE 2: runtime (tiny, what actually ships) ----------
FROM eclipse-temurin:21-jre-alpine AS runtime
# Never run as root. Create an unprivileged user.
RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /app
# Pull ONLY the jar from stage 1. Maven, JDK, source: gone.
COPY --from=build --chown=app:app /src/target/payment-api.jar app.jar
# Document the port (metadata only β does not open anything).
EXPOSE 8080
# Container-level health check (K8s uses its own probes instead,
# but this helps plain-Docker and docker compose setups).
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1
# exec form (JSON array) = java becomes PID 1 and receives SIGTERM
# directly. Shell form ("java -jar ...") would wrap it in /bin/sh,
# which swallows signals β broken graceful shutdown.
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
- Order layers by change frequency: base β OS packages β dependencies β source code. Cache hit rate decides build speed.
- Pin versions (
maven:3.9-...-21, neverlatest) β builds must be reproducible. - USER app, never root. Combine with K8s
runAsNonRoot: true. - ENTRYPOINT in exec form so PID 1 gets SIGTERM β graceful shutdown works.
- .dockerignore (
.git,target/,node_modules) β keeps the build context small and secrets out of images. - One process per container. Need a helper? That's a second container in the same pod (sidecar), not a second process.
docker compose β the whole dev environment in one file
Compose is for your laptop: one command (docker compose up) brings up app + database + cache with networking and volumes wired. It is a local rehearsal of what K8s does in production β same images, simpler orchestration, zero cluster needed.
docker-compose.ymlservices:
payment-api:
build:
context: .
dockerfile: Dockerfile
target: runtime # build only up to the runtime stage
ports:
- "8080:8080" # hostPort:containerPort
environment:
SPRING_PROFILES_ACTIVE: local
DB_HOST: postgres # service name IS the DNS name
REDIS_HOST: redis
depends_on:
postgres:
condition: service_healthy # wait until pg healthcheck passes
redis:
condition: service_started
deploy:
resources:
limits: { cpus: "2", memory: 1g } # cgroups, same idea as K8s limits
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_PASSWORD: localdev # local only β never in prod files
volumes:
- pgdata:/var/lib/postgresql/data # named volume: data survives restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
pgdata:
Docker commands you actually use, with what each really does
| Command | What happens underneath |
|---|---|
docker build -t payment-api:1.4.2 . | Sends the build context to the daemon, executes Dockerfile top-down, one cached layer per step, tags the final layer stack. |
docker run -d -p 8080:8080 payment-api:1.4.2 | Creates namespaces + cgroups, mounts the layer stack via overlayfs, adds a writable layer, starts ENTRYPOINT as PID 1, sets an iptables DNAT rule host:8080 β container:8080. |
docker exec -it <id> sh | Starts a new process inside the container's existing namespaces β that's why you "enter" it. |
docker logs -f <id> | Tails the JSON file where the daemon captured the process's stdout/stderr. (Rule: containers log to stdout, never to files.) |
docker image history payment-api:1.4.2 | Shows every layer with size β your #1 tool for hunting image bloat. |
docker system prune -a | Deletes stopped containers, unused networks, dangling and unused images. Reclaims the disk your laptop is crying about. |
Important for K8s: Kubernetes no longer uses Docker Engine on its nodes. It talks to containerd (or CRI-O) through the CRI interface. Your Docker-built images still work everywhere because they follow the OCI image format β Docker is the build tool; containerd is the runtime on the node.
Kubernetes architecture: the machine that runs the loop
Now open the hood. A cluster is just two kinds of machines: a few control plane nodes (the brain) and many worker nodes (the muscle). Every component below is one of those thermostats from Altitude 01 β each guards a different promise.
Each component, and the promise it guards
| Component | Its promise ("desired state" it enforces) | Why it exists |
|---|---|---|
| kube-apiserver | "All reads/writes of cluster state go through me, authenticated, authorized, validated." | One front door = one place for authn (who are you?), authz/RBAC (may you?), admission control (is this object sane?), and audit logs. Nothing talks to etcd directly except the API server. |
| etcd | "I am the single source of truth, consistent across replicas." | Uses the Raft consensus algorithm; with 3 members it tolerates 1 failure, with 5 it tolerates 2. Lose etcd quorum β cluster becomes read-only brain-dead (existing pods still run!). |
| kube-scheduler | "Every pod with no node gets the best node." | Two phases: filter (which nodes CAN run it: enough CPU? right zone? tolerates taints?) then score (which node SHOULD: spread, bin-packing, affinity). Writes only one field: pod.spec.nodeName. |
| controller-manager | "Every controller loop runs: DeploymentβReplicaSetβPods, Node health, Endpoints, Jobsβ¦" | ~30 thermostats in one binary. The Deployment controller is what performs rolling updates. |
| kubelet | "Every pod assigned to MY node is actually running and healthy." | Watches the API server for pods with its nodeName, tells containerd to pull images and start containers, runs liveness/readiness probes, reports status back. |
| kube-proxy | "Traffic to any Service IP reaches a healthy backend pod." | Programs iptables/IPVS rules on every node so the virtual Service IP (which no machine owns!) gets DNAT'ed to a real pod IP. At very large scale, eBPF (Cilium) replaces it. |
| CNI plugin | "Every pod gets a routable IP; any pod can reach any pod." | Calico/Cilium/AWS-VPC-CNI wire the pod network. Pod-to-pod with no NAT is a core K8s guarantee. |
| CoreDNS | "Names like payment-api.payments.svc.cluster.local resolve to Service IPs." | Service discovery. Your code calls a stable name, never a pod IP (pods are cattle; their IPs churn constantly). |
Life of a pod: from kubectl apply to running process
This sequence is the most asked "explain Kubernetes" interview question. Walk it slowly β every arrow is a separate actor doing its own small job:
You apply a Deployment
kubectl apply -f deploy.yaml β HTTPS POST to the API server. It authenticates you (client cert/OIDC token), checks RBAC ("may this user create Deployments in namespace payments?"), runs admission webhooks (e.g. "inject the Istio sidecar", "reject containers running as root"), then persists the object to etcd. Nothing is running yet. Only a record exists.
Deployment controller wakes up
It was WATCHing for Deployments. New desired state: 6 replicas. Actual: 0. It creates a ReplicaSet object (the versioned "cookie cutter" for pods).
ReplicaSet controller stamps out Pods
Desired 6, actual 0 β it creates 6 Pod objects in etcd. Each pod has no node assigned β status: Pending.
Scheduler assigns nodes
Watching for unscheduled pods, it filters nodes (enough free CPU/mem? matching nodeSelector? tolerations vs taints? topology spread satisfied?) and scores the survivors. It binds each pod: sets spec.nodeName: node-2. Still nothing running β this was just another etcd write.
kubelet on node-2 sees its new pod
Asks containerd via CRI: pull registry.tmo.example/payment-api:1.4.2 (layers cached? skip), create the network namespace, call the CNI plugin β pod gets IP 10.244.1.7, start init containers, then app containers with the cgroup limits from the YAML.
Probes gate the traffic
kubelet runs the startup probe (is the JVM even up yet?), then readiness (may I send traffic?) and liveness (should I restart you?). Only when readiness passes does the pod's IP get added to the Service's EndpointSlice.
kube-proxy + DNS make it reachable
kube-proxy on every node updates iptables/IPVS: Service IP 10.96.14.3:80 β {10.244.1.7:8080, β¦}. CoreDNS already resolves payment-api.payments to 10.96.14.3. The pod is now serving.
Notice what did NOT happen: no component sent a command to another component. Each one only wrote to the API server and watched the API server. This "choreography via shared state" is why Kubernetes scales and why components can crash and recover independently β the state is the message bus.
Pod lifecycle & the termination dance (where outages hide)
Pod phases
Pending β scheduled but containers not all started (image pulling, no room, or unschedulable).
Running β bound to a node, at least one container running.
Succeeded / Failed β all containers exited (0 / non-zero). Jobs live here.
Unknown β node stopped reporting.
Container states
Waiting (ImagePullBackOff, CrashLoopBackOff live here β read the reason!),
Running,
Terminated (exit code + reason, e.g. OOMKilled=137).
When a pod is deleted, two things happen in parallel: (a) kubelet sends SIGTERM to your process, and (b) the endpoint controller removes the pod from EndpointSlices, and every kube-proxy / Envoy must then update its routing. (b) takes 1β3 seconds to propagate. If your app dies instantly on SIGTERM, load balancers keep sending it traffic for those seconds β 502s during every deploy. The fix is a preStop sleep β keep serving while the network forgets you:
Every core object, full YAML, every field explained
This is the working library. One realistic app β payment-api in namespace payments β expressed as complete, production-shaped manifests. Read the comments; they carry the "why" for each field.
1 Β· Namespace + ResourceQuota β the apartment walls
One cluster, many teams. Namespaces give each team a scoped world: their own names, their own RBAC, their own quotas. Without quotas, one team's runaway job can starve the whole cluster β quotas make the blast radius a wall, not a suggestion.
00-namespace.yamlapiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
team: payments-platform
istio-injection: enabled # Istio auto-injects Envoy sidecars here
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: payments-quota
namespace: payments
spec:
hard:
requests.cpu: "200" # team may request at most 200 cores total
requests.memory: 400Gi
limits.cpu: "400"
limits.memory: 800Gi
pods: "500"
2 Β· ConfigMap β configuration that is NOT secret
The same image must run in dev, staging, and prod. Config differs per environment; the image must not. ConfigMaps decouple them: build once, configure everywhere. (Twelve-factor app, factor III.)
10-configmap.yamlapiVersion: v1
kind: ConfigMap
metadata:
name: payment-api-config
namespace: payments
data:
# Simple keys β injected as env vars below
DB_HOST: "payments-db.payments.svc.cluster.local"
DB_POOL_SIZE: "40"
CACHE_TTL_SECONDS: "300"
FEATURE_NEW_LEDGER: "false"
# Whole file β mounted as a volume below
application.yaml: |
server:
shutdown: graceful
management.endpoints.web.exposure.include: health,prometheus
3 Β· Secret β the same idea, but access-controlled
A Secret's values are only base64-encoded, not encrypted β base64 is a format, not a lock. What makes Secrets safer than ConfigMaps: RBAC can grant config read without secret read, they can be encrypted at rest in etcd (EncryptionConfiguration with a KMS), kubelet keeps them in tmpfs (RAM) not disk, and they are not shown in kubectl describe pod. In serious setups, the values live in Vault / AWS Secrets Manager / Azure Key Vault and are synced in by External Secrets Operator β Git never sees them.
11-secret.yamlapiVersion: v1
kind: Secret
metadata:
name: payment-api-secrets
namespace: payments
type: Opaque
stringData: # stringData = write plain, stored base64
DB_PASSWORD: "REPLACED-BY-EXTERNAL-SECRETS"
STRIPE_API_KEY: "REPLACED-BY-EXTERNAL-SECRETS"
---
# Registry credential so kubelet can pull private images
apiVersion: v1
kind: Secret
metadata:
name: regcred
namespace: payments
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: eyJhdXRocyI6ey4uLn19 # base64 of docker config
4 Β· ServiceAccount + RBAC β identity and permissions (auth inside the cluster)
Two reasons. (1) If the pod calls the Kubernetes API (many apps and all operators do), RBAC must know who is asking. (2) Cloud IAM federation: the ServiceAccount token can be exchanged for AWS/Azure/GCP credentials (IRSA / Workload Identity) β no long-lived cloud keys in your cluster. Principle of least privilege: this app can read its own ConfigMaps/Secrets and nothing else.
20-rbac.yamlapiVersion: v1
kind: ServiceAccount
metadata:
name: payment-api-sa
namespace: payments
automountServiceAccountToken: false # don't mount API creds unless needed
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role # Role = namespaced; ClusterRole = cluster-wide
metadata:
name: payment-api-role
namespace: payments
rules:
- apiGroups: [""] # "" = core API group
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["payment-api-secrets"] # ONLY this one secret
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding # glue: who β gets β which role
metadata:
name: payment-api-binding
namespace: payments
subjects:
- kind: ServiceAccount
name: payment-api-sa
namespace: payments
roleRef:
kind: Role
name: payment-api-role
apiGroup: rbac.authorization.k8s.io
5 Β· Deployment β the centerpiece (read every comment)
30-deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
namespace: payments
labels: { app: payment-api, version: v1 }
spec:
replicas: 6 # HPA will own this number later
revisionHistoryLimit: 5 # keep 5 old ReplicaSets for rollback
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # may run 25% extra pods during rollout
maxUnavailable: 0 # NEVER dip below desired capacity
selector:
matchLabels: { app: payment-api } # must match template labels; immutable
template: # β the Pod recipe stamped per replica
metadata:
labels: { app: payment-api, version: v1 }
annotations:
prometheus.io/scrape: "true" # telemetry: scrape me
prometheus.io/port: "8080"
prometheus.io/path: "/actuator/prometheus"
spec:
serviceAccountName: payment-api-sa
imagePullSecrets:
- name: regcred
terminationGracePeriodSeconds: 45 # > preStop(8) + slowest request
# ---- spread replicas so one failure domain can't take us out ----
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone # spread across zones
whenUnsatisfiable: DoNotSchedule
labelSelector: { matchLabels: { app: payment-api } }
affinity:
podAntiAffinity: # avoid same node
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector: { matchLabels: { app: payment-api } }
topologyKey: kubernetes.io/hostname
# ---- pod-level security: applies to all containers ----
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile: { type: RuntimeDefault }
# ---- init container: runs to completion BEFORE the app ----
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ["sh","-c","until nc -z $DB_HOST 5432; do echo waiting; sleep 2; done"]
env:
- name: DB_HOST
valueFrom: { configMapKeyRef: { name: payment-api-config, key: DB_HOST } }
containers:
- name: payment-api
image: registry.tmo.example/payments/payment-api:1.4.2 # pinned, never :latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
# ---- config in: env from ConfigMap + Secret ----
envFrom:
- configMapRef: { name: payment-api-config }
- secretRef: { name: payment-api-secrets }
env:
- name: POD_NAME # downward API: pod tells app who it is
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: NODE_NAME
valueFrom: { fieldRef: { fieldPath: spec.nodeName } }
# ---- resources: THE most important 6 lines for stability ----
resources:
requests: # scheduler reserves this much (bin packing)
cpu: "1"
memory: 1Gi
limits: # cgroup hard caps
memory: 1Gi # limit == request β predictable, no OOM surprises
# NOTE: no cpu limit β avoids CFS throttling latency;
# many latency-sensitive teams do exactly this
# ---- the three probes ----
startupProbe: # protects slow JVM boot from liveness kills
httpGet: { path: /actuator/health/liveness, port: http }
failureThreshold: 30
periodSeconds: 2 # up to 60s allowed to start
readinessProbe: # gate for traffic; fail = removed from LB, NOT restarted
httpGet: { path: /actuator/health/readiness, port: http }
periodSeconds: 5
failureThreshold: 2
livenessProbe: # deadlock detector; fail = container restarted
httpGet: { path: /actuator/health/liveness, port: http }
periodSeconds: 10
failureThreshold: 3 # be forgiving; liveness restarts cause cascades
# ---- graceful shutdown (see timeline above) ----
lifecycle:
preStop:
exec: { command: ["sh","-c","sleep 8"] }
# ---- container-level hardening ----
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: app-config
mountPath: /app/config
readOnly: true
- name: tmp # app needs SOME writable dir with RO rootfs
mountPath: /tmp
volumes:
- name: app-config
configMap: { name: payment-api-config, items: [{ key: application.yaml, path: application.yaml }] }
- name: tmp
emptyDir: {}
- Readiness vs liveness: readiness failure = "stop sending me traffic" (pod stays alive β perfect for "my DB is briefly down"). Liveness failure = "restart me" (only for truly stuck processes). Wiring DB health into liveness turns a DB blip into a restart storm.
- Requests vs limits: requests are for the scheduler (reservation and bin-packing); limits are for the kernel (cgroup caps). Memory over limit = OOMKill (exit 137). CPU over limit = throttled, which shows up as mysterious p99 latency.
- maxUnavailable: 0 + PDB below = capacity never dips during deploys or node drains.
6 Β· Service β a stable virtual IP over churning pods
Pods die and are reborn with new IPs constantly. Callers need one stable address. A Service is a virtual IP that no machine owns β kube-proxy programs every node's kernel so packets to that IP get rewritten (DNAT) to a live pod picked from the EndpointSlice. It's routing-by-configuration, not a physical box.
40-service.yamlapiVersion: v1
kind: Service
metadata:
name: payment-api
namespace: payments
labels: { app: payment-api }
spec:
type: ClusterIP # internal-only; Ingress/mesh will expose it
selector: { app: payment-api } # pods with this label = backends
ports:
- name: http # NAMED port: Istio uses names for protocol detection
port: 80 # what callers dial
targetPort: http # the container port name
protocol: TCP
| Service type | What it does | When |
|---|---|---|
| ClusterIP | Virtual IP inside the cluster only | Default. 95% of services. |
| NodePort | Opens the same high port (30000β32767) on EVERY node | Building block under LoadBalancer; rarely used alone. |
| LoadBalancer | Asks the cloud for a real external L4 LB β NodePort β pods | Exposing an ingress gateway to the internet. |
| Headless (clusterIP: None) | DNS returns pod IPs directly, no VIP | StatefulSets: databases, Kafka β clients that must address individual members. |
| ExternalName | DNS CNAME to an outside name | Aliasing external services. |
7 Β· HorizontalPodAutoscaler β the elastic waistband
50-hpa.yamlapiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payment-api
namespace: payments
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-api
minReplicas: 6
maxReplicas: 120
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 60 } # % of REQUESTS
- type: Pods # custom metric via Prometheus Adapter:
pods: # scale on real load, not just CPU
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: "800" }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react instantly to spikes
policies: [{ type: Percent, value: 100, periodSeconds: 30 }] # may double / 30s
scaleDown:
stabilizationWindowSeconds: 300 # cool down slowly β avoid flapping
8 Β· PodDisruptionBudget β armor against "helpful" maintenance
Node upgrades drain nodes: pods get evicted. Without a PDB, an upgrade wave could evict 5 of your 6 replicas at the same moment. A PDB tells the eviction API: "never take me below N healthy" β drains then proceed one pod at a time.
51-pdb.yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payment-api-pdb
namespace: payments
spec:
minAvailable: 80%
selector: { matchLabels: { app: payment-api } }
9 Β· NetworkPolicy β firewalls between pods
By default every pod can talk to every pod. If an attacker pops one pod (say, a vulnerable image in a forgotten namespace), they can reach your database. NetworkPolicies flip the model to default-deny: only declared paths exist. This is L3/L4 zero-trust; Istio mTLS (later) adds identity at L7.
60-networkpolicy.yaml# 1) default deny everything in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: payments }
spec:
podSelector: {} # all pods here
policyTypes: [Ingress, Egress]
---
# 2) allow only: gateway β payment-api, payment-api β db + dns
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: payment-api-allow, namespace: payments }
spec:
podSelector: { matchLabels: { app: payment-api } }
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: istio-ingress } }
ports: [{ port: 8080, protocol: TCP }]
egress:
- to: [{ podSelector: { matchLabels: { app: payments-db } } }]
ports: [{ port: 5432, protocol: TCP }]
- to: [{ namespaceSelector: {}, podSelector: { matchLabels: { k8s-app: kube-dns } } }]
ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }] # never forget DNS!
The other workload kinds: StatefulSet, DaemonSet, Job, CronJob (full YAML)
StatefulSet β for things with a name and a disk
Deployments treat pods as identical cattle. Databases need stable identity: payments-db-0 is always the leader, keeps its own disk across restarts, and members start in order. That's a StatefulSet + headless Service + volumeClaimTemplates.
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: payments-db, namespace: payments }
spec:
serviceName: payments-db-headless # gives payments-db-0.payments-db-headless DNS
replicas: 3
selector: { matchLabels: { app: payments-db } }
template:
metadata: { labels: { app: payments-db } }
spec:
containers:
- name: postgres
image: postgres:16
ports: [{ containerPort: 5432, name: pg }]
volumeMounts: [{ name: data, mountPath: /var/lib/postgresql/data }]
volumeClaimTemplates: # EACH replica gets ITS OWN PersistentVolume
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-ssd
resources: { requests: { storage: 200Gi } }
---
apiVersion: v1
kind: Service
metadata: { name: payments-db-headless, namespace: payments }
spec:
clusterIP: None # headless: DNS β pod IPs directly
selector: { app: payments-db }
ports: [{ port: 5432, name: pg }]
DaemonSet β exactly one per node
For node-scoped agents: log shippers, node exporters, CNI. Add a node β the pod appears automatically.
apiVersion: apps/v1
kind: DaemonSet
metadata: { name: node-exporter, namespace: monitoring }
spec:
selector: { matchLabels: { app: node-exporter } }
template:
metadata: { labels: { app: node-exporter } }
spec:
tolerations: [{ operator: Exists }] # run even on tainted nodes
hostNetwork: true # see the real node network
containers:
- name: node-exporter
image: prom/node-exporter:v1.8.1
ports: [{ containerPort: 9100 }]
Job & CronJob β run to completion
apiVersion: batch/v1
kind: CronJob
metadata: { name: settlement-report, namespace: payments }
spec:
schedule: "0 2 * * *" # 2 AM daily
concurrencyPolicy: Forbid # never overlap runs
jobTemplate:
spec:
backoffLimit: 3 # retry failed pods 3Γ
activeDeadlineSeconds: 3600 # kill if running > 1h
template:
spec:
restartPolicy: Never
containers:
- name: report
image: registry.tmo.example/payments/settlement:2.1.0
args: ["--date=yesterday"]
1,000,000,000 requests β and answering in sub-millisecond time
First, kill the fear with arithmetic. A billion requests per day is:
Average
~11,600 req/s
1e9 Γ· 86,400 s
Peak (β3Γ average)
~35,000 req/s
design target
Pods needed
~90 pods
at 500 req/s per pod, +20% headroom, spread over 3 regions
Ninety pods is a Tuesday for Kubernetes. The hard part is never the count β it's the latency budget and the failure math. Now the honest physics:
No response crosses the internet in under 1 ms β userβedge alone is 5β30 ms of pure light-speed cost. When engineers say "we respond in sub-ms," they mean one of two truthful things:
(1) Server-side budget: time from the request entering our building to the response leaving it is <1 ms β every internal hop measured in microseconds.
(2) Cache-hit at the edge: the answer was precomputed and sits in memory at a location near the user, so our added latency β 0.
Both require the same discipline: few hops, no disk, no cross-zone chatter, everything hot in RAM.
The server-side microsecond budget (where 1 ms actually goes)
Total β 800 Β΅s. Notice what is absent: no relational DB on the hot path (a Postgres query is 1β10 ms by itself β it alone blows the budget), no cross-zone hop (+1β2 ms), no TLS full handshake (session resumption/TLS 1.3 0-RTT keep it off the steady state), no cold JIT. The budget is a list of things you're not allowed to do.
The full journey of one request (follow the packet)
Why anycast? (the trick that shrinks the internet)
With anycast, we announce the identical IP prefix via BGP from every edge location. Internet routers naturally forward each user's packets to the topologically nearest announcement β Seattle users land in Seattle, Mumbai users in Mumbai β with zero logic on our side and instant failover: if the Seattle POP withdraws its BGP route (or dies), routers converge to the next-closest within seconds. This is how DNS root servers and every serious CDN work.
Layered load balancing β each layer answers a different question
| Layer | Who | Question it answers | Sees |
|---|---|---|---|
| Global | GSLB / anycast | Which region? | BGP topology, region health |
| L4 | Cloud NLB / IPVS / Maglev | Which node? (flow-hash 5-tuple β consistent backend) | IPs + ports only β that's why it's ~20 Β΅s |
| L7 edge | Envoy / Nginx ingress | Which service & version? (host, path, headers) | Full HTTP after TLS termination |
| L7 mesh | Envoy sidecars | Which pod? (least-request, zone-local, outlier ejection) | HTTP + mTLS identity per hop |
- Conntrack/SNAT exhaustion: at 35k req/s, connection tracking tables and ephemeral ports run out. Fixes: HTTP/2 multiplexing (one TCP conn, many streams β Envoy does this pod-to-pod automatically), keep-alives everywhere, and eBPF dataplane (Cilium) to bypass iptables' O(n) rule chains.
- Cross-zone hops: mesh locality-aware routing keeps calls in-zone (0.1 ms) instead of cross-zone (1β2 ms). One setting, 10Γ hop latency difference.
- CPU throttling: CFS-quota throttling from CPU limits shows up as p99 spikes with low average CPU. Either drop CPU limits or use static CPU manager policy for pinned cores.
GSLB/anycast = "fly to the nearest airport." L4 NLB = the terminal signs sending you to a security lane (fast, doesn't read your ticket details). L7 gateway = the gate agent who reads your ticket (host/path) and points you to the exact gate. Sidecars = the jet bridge that connects to precisely one plane (pod) and checks crew ID (mTLS) both ways.
Istio, Envoy, Nginx β the traffic layer, demystified
Every service needs the same non-business features: retries, timeouts, mTLS, metrics, circuit breaking, canary routing. Before meshes, each team re-implemented these in their own language libraries β inconsistently, and every fix meant redeploying every app. A mesh moves all of it into a proxy that sits next to each app (the sidecar). Now the platform team upgrades traffic policy for 500 services by changing configuration, not code.
Envoy
The proxy (C++, built at Lyft). Does the actual data-plane work: routing, LB, TLS, retries, telemetry. Configured live over the xDS API β no restarts.
Istio
The control plane (istiod). Reads your YAML (VirtualService, etc.), watches K8s services/endpoints, compiles everything to Envoy xDS config, and runs the CA that issues workload certificates.
Nginx
A classic web server/reverse proxy, also usable as a K8s Ingress controller. Rock-solid, config-file driven; less dynamic than Envoy (traditionally reloads config rather than streaming updates). Common as a simpler edge when you don't need a mesh.
Istio YAML: gateway, routing, resilience β complete set
70-istio-gateway.yaml# Gateway: which ports/hosts the ingress Envoy listens on (L4βL6)
apiVersion: networking.istio.io/v1
kind: Gateway
metadata: { name: payments-gw, namespace: payments }
spec:
selector: { istio: ingressgateway } # which gateway pods implement this
servers:
- port: { number: 443, name: https, protocol: HTTPS }
hosts: ["pay.tmo.example"]
tls:
mode: SIMPLE
credentialName: pay-tmo-example-tls # Secret from cert-manager (next altitude)
- port: { number: 80, name: http, protocol: HTTP }
hosts: ["pay.tmo.example"]
tls: { httpsRedirect: true } # force HTTPS
71-virtualservice.yaml# VirtualService: L7 routing rules β the "if this request, then where"
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: payment-api-vs, namespace: payments }
spec:
hosts: ["pay.tmo.example"]
gateways: [payments-gw]
http:
- name: canary-internal-testers # header-based canary first
match:
- headers: { x-canary: { exact: "true" } }
route:
- destination: { host: payment-api.payments.svc.cluster.local, subset: v2 }
- name: weighted-rollout # then 95/5 traffic split
route:
- destination: { host: payment-api.payments.svc.cluster.local, subset: v1 }
weight: 95
- destination: { host: payment-api.payments.svc.cluster.local, subset: v2 }
weight: 5
timeout: 2s # hard deadline for the whole call
retries:
attempts: 2
perTryTimeout: 800ms
retryOn: connect-failure,refused-stream,503 # retry ONLY safe failures
72-destinationrule.yaml# DestinationRule: what happens AFTER routing β subsets, LB, circuit breaking
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: payment-api-dr, namespace: payments }
spec:
host: payment-api.payments.svc.cluster.local
subsets: # subsets = label-selected pod groups
- { name: v1, labels: { version: v1 } }
- { name: v2, labels: { version: v2 } }
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST # better tail latency than round robin
localityLbSetting: { enabled: true } # prefer same-zone pods (the 10Γ trick)
connectionPool:
http:
http2MaxRequests: 1000
maxRequestsPerConnection: 0 # 0 = unlimited; keep HTTP/2 conns hot
tcp: { maxConnections: 200 }
outlierDetection: # circuit breaker: eject sick pods
consecutive5xxErrors: 5
interval: 10s
baseEjectionTime: 30s
maxEjectionPercent: 50 # never eject > half the fleet
73-security.yaml# Enforce mTLS for every workload in the namespace
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: { name: strict-mtls, namespace: payments }
spec:
mtls: { mode: STRICT } # plaintext connections rejected
---
# L7 authorization: WHO may call payment-api, and how
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: { name: payment-api-authz, namespace: payments }
spec:
selector: { matchLabels: { app: payment-api } }
action: ALLOW
rules:
- from:
- source:
principals: # SPIFFE identity from the mTLS cert!
- cluster.local/ns/istio-ingress/sa/ingress-gw-sa
to:
- operation: { methods: [GET, POST], paths: ["/api/*"] }
- from:
- source: { principals: [cluster.local/ns/payments/sa/fraud-svc-sa] }
to:
- operation: { methods: [GET], paths: ["/internal/risk/*"] }
---
# End-user auth at the edge: validate JWTs (e.g. from Entra ID / OIDC)
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata: { name: jwt-auth, namespace: istio-ingress }
spec:
selector: { matchLabels: { istio: ingressgateway } }
jwtRules:
- issuer: "https://login.microsoftonline.com/TENANT/v2.0"
jwksUri: "https://login.microsoftonline.com/TENANT/discovery/v2.0/keys"
audiences: ["api://payment-api"]
Gateway = "open this door." VirtualService = "read the request, decide the destination." DestinationRule = "having decided, use these manners (LB, pools, circuit breaking, subsets)." PeerAuthentication = "services must show certificates to each other." AuthorizationPolicy = "even with a certificate, here is who may do what." RequestAuthentication = "humans must show a valid JWT."
The Nginx alternative β simpler edge, no mesh
75-nginx-ingress.yamlapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: payment-api-ingress
namespace: payments
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "5"
nginx.ingress.kubernetes.io/limit-rps: "100" # per-client rate limit
nginx.ingress.kubernetes.io/enable-cors: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod # auto-TLS (next altitude)
spec:
ingressClassName: nginx
tls:
- hosts: [pay.tmo.example]
secretName: pay-tmo-example-tls
rules:
- host: pay.tmo.example
http:
paths:
- path: /api
pathType: Prefix
backend:
service: { name: payment-api, port: { name: http } }
How it works underneath: the ingress-nginx controller pod watches Ingress objects and endpoints, generates an nginx.conf with an upstream block containing the pod IPs, and hot-applies it (backend changes via a Lua layer without full reloads). Choose Nginx ingress when you need a solid HTTP edge; choose Istio when you also need mesh features (mTLS everywhere, per-service traffic policy, canaries between internal services).
Certificates & CAs: how trust actually works
Encryption without identity is worthless: you could be having a perfectly encrypted conversation with the attacker. TLS solves two problems at once: (1) who am I talking to? β proven by a certificate, which is a public key signed by someone you already trust (the CA); (2) can anyone read this? β no, keys are agreed via ephemeral Diffie-Hellman. The CA is simply the shared root of trust: "I don't know you, but someone I trust vouched for you." Same logic as passports: you trust the issuing government, not the traveler.
Public chain (users β your edge)
Browser trusts root CAs baked into the OS. Root signs an intermediate; intermediate signs your leaf cert for pay.tmo.example. Browser verifies the signature chain up to a root it knows. Automated by ACME/Let's Encrypt or your enterprise PKI.
Private mesh CA (pod β pod)
istiod runs a private CA. Each Envoy sidecar generates a key, sends a CSR with its ServiceAccount token; istiod verifies with the K8s API and signs a short-lived (24h) cert whose SAN is a SPIFFE ID: spiffe://cluster.local/ns/payments/sa/payment-api-sa. Identity = cryptographic, rotated daily, tied to the ServiceAccount β not to an IP.
The mTLS handshake between two sidecars (watch it happen)
cert-manager β certificates as Kubernetes objects
Certificates expire. Humans forget. Half the famous outages of the last decade include the sentence "the certificate expired." cert-manager makes certs declarative: you state "this Secret must always hold a valid cert for pay.tmo.example," and a controller (another thermostat!) proves domain ownership via ACME, obtains the cert, and renews it ~30 days before expiry. Forever.
80-cert-manager.yaml# The issuer: HOW to get certificates (Let's Encrypt via ACME here;
# swap for Vault/Venafi/private PKI in enterprises)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-prod }
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform-team@tmo.example
privateKeySecretRef: { name: le-account-key }
solvers:
- dns01: # DNS-01: prove ownership by writing a TXT
route53: { region: us-west-2 } # record; works for wildcards too
---
# The certificate: WHAT you want; controller keeps the Secret fresh
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: pay-tmo-example, namespace: istio-ingress }
spec:
secretName: pay-tmo-example-tls # referenced by the Istio Gateway earlier
issuerRef: { name: letsencrypt-prod, kind: ClusterIssuer }
dnsNames: [pay.tmo.example]
duration: 2160h # 90 days
renewBefore: 720h # renew with 30 days to spare
privateKey: { algorithm: ECDSA, size: 256 } # smaller, faster handshakes
The cluster itself runs on TLS everywhere: kubectlβAPI server (your client cert or OIDC token), API serverβetcd, API serverβkubelets, kubelet server certs β all issued by the cluster CA created at install (kubeadm) or managed for you (EKS/AKS/GKE). Admission webhooks need certs. So a real cluster has at least three trust domains: public CA (users), cluster CA (K8s internals), mesh CA (workload mTLS). Keep them mentally separate and TLS questions become easy.
Telemetry: seeing 35,000 req/s without drowning
Because each answers a different question at a different cost.
Metrics (cheap, aggregated numbers): "IS something wrong?" β p99 latency just doubled.
Traces (sampled, per-request): "WHERE is it wrong?" β the 40 ms hides in fraud-svc's Redis call.
Logs (expensive, detailed): "WHY is it wrong?" β the exact error, stack trace, and request ID.
At 35k req/s you cannot log everything verbosely or trace every request β metrics are your always-on radar; traces are sampled (e.g. 1%, plus 100% of errors); logs are structured JSON, searched only after the first two point the finger.
The four golden signals (Google SRE) β your dashboard skeleton
| Signal | Question | PromQL you'd actually run |
|---|---|---|
| Traffic | How much demand? | sum(rate(http_server_requests_seconds_count[1m])) |
| Errors | What fraction fails? | sum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m])) |
| Latency | p99 experience? (never averages!) | histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m]))) |
| Saturation | How close to a wall? | container_memory_working_set_bytes / on(...) kube_pod_container_resource_limits{resource="memory"} Β· plus CPU throttling: rate(container_cpu_cfs_throttled_periods_total[5m]) |
Wiring it: ServiceMonitor, alert rule, OTel collector
90-telemetry.yaml# Prometheus Operator: "scrape every service labeled app=payment-api"
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata: { name: payment-api, namespace: payments }
spec:
selector: { matchLabels: { app: payment-api } }
endpoints:
- port: http
path: /actuator/prometheus
interval: 15s
---
# Alert on the symptom users feel β not on CPU
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata: { name: payment-api-alerts, namespace: payments }
spec:
groups:
- name: payment-api.slo
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_server_requests_seconds_count{app="payment-api",status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count{app="payment-api"}[5m])) > 0.01
for: 2m # must persist 2 min β kills flappy pages
labels: { severity: page }
annotations:
summary: "payment-api 5xx > 1% for 2m"
- alert: P99LatencyBudgetBurn
expr: |
histogram_quantile(0.99, sum by (le)
(rate(http_server_requests_seconds_bucket{app="payment-api"}[5m]))) > 0.25
for: 5m
labels: { severity: page }
---
# OpenTelemetry Collector: one pipeline for traces (as a ConfigMap excerpt)
apiVersion: v1
kind: ConfigMap
metadata: { name: otel-collector-conf, namespace: monitoring }
data:
config.yaml: |
receivers:
otlp: { protocols: { grpc: { endpoint: 0.0.0.0:4317 } } }
processors:
batch: {}
tail_sampling: # the smart part: decide AFTER seeing the trace
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 250 } }
- { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 1 } }
exporters:
otlp/tempo: { endpoint: tempo.monitoring:4317, tls: { insecure: true } }
service:
pipelines:
traces: { receivers: [otlp], processors: [tail_sampling, batch], exporters: [otlp/tempo] }
The edge gateway stamps each request with a trace ID (W3C traceparent header). Every hop creates a span (its own timed segment, pointing at its parent) and forwards the header. The collector reassembles spans into a waterfall. Reading a trace: the widest bar that isn't waiting on a child is your bottleneck. Tail sampling keeps 100% of errors and slow traces but only 1% of boring ones β full insight at 1% of the cost.
Touchdown: the complete deployment, start to finish
Everything above, assembled. Here is the repo layout, the exact order to apply it, and how to verify each layer. This is the runbook you'd actually follow.
repo layoutpayment-api/
βββ Dockerfile # multi-stage (Altitude 03)
βββ .dockerignore
βββ docker-compose.yml # local dev
βββ src/ Β· pom.xml
βββ k8s/
βββ 00-namespace.yaml # Namespace + ResourceQuota
βββ 10-configmap.yaml
βββ 11-secret.yaml # (in reality: ExternalSecret manifest)
βββ 20-rbac.yaml # SA + Role + RoleBinding
βββ 30-deployment.yaml
βββ 40-service.yaml
βββ 50-hpa.yaml
βββ 51-pdb.yaml
βββ 60-networkpolicy.yaml
βββ 70-istio-gateway.yaml
βββ 71-virtualservice.yaml
βββ 72-destinationrule.yaml
βββ 73-security.yaml # PeerAuthentication + AuthorizationPolicy + JWT
βββ 80-cert-manager.yaml
βββ 90-telemetry.yaml # ServiceMonitor + PrometheusRule + OTel
Build, ship, deploy β the exact commands
# 1 Β· build & push the image (CI does this on every merge)
docker build -t registry.tmo.example/payments/payment-api:1.4.2 .
docker push registry.tmo.example/payments/payment-api:1.4.2
# 2 Β· foundation: namespace, quota, identity, config
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/20-rbac.yaml
kubectl apply -f k8s/10-configmap.yaml -f k8s/11-secret.yaml
# 3 Β· workload + stable address
kubectl apply -f k8s/30-deployment.yaml -f k8s/40-service.yaml
kubectl -n payments rollout status deploy/payment-api # waits for readiness
# 4 Β· resilience & scaling
kubectl apply -f k8s/50-hpa.yaml -f k8s/51-pdb.yaml -f k8s/60-networkpolicy.yaml
# 5 Β· edge & mesh
kubectl apply -f k8s/80-cert-manager.yaml
kubectl apply -f k8s/70-istio-gateway.yaml -f k8s/71-virtualservice.yaml \
-f k8s/72-destinationrule.yaml -f k8s/73-security.yaml
# 6 Β· observability
kubectl apply -f k8s/90-telemetry.yaml
Verify each layer (the debugging ladder)
# Is the pod healthy? (events section = 90% of answers)
kubectl -n payments get pods -o wide
kubectl -n payments describe pod payment-api-xxxxx
kubectl -n payments logs deploy/payment-api --previous # logs of the CRASHED container
# Is the Service actually selecting pods? (empty ENDPOINTS = label mismatch or readiness failing)
kubectl -n payments get endpointslices -l kubernetes.io/service-name=payment-api
# Can I reach it from inside the cluster? (tests Service + DNS + NetworkPolicy)
kubectl -n payments run tmp --rm -it --image=curlimages/curl -- \
curl -s http://payment-api.payments.svc.cluster.local/actuator/health
# Is mTLS on? Is routing right? (istioctl reads the live Envoy config)
istioctl x describe pod payment-api-xxxxx -n payments
istioctl proxy-config routes deploy/istio-ingressgateway -n istio-ingress
# Is the cert real & fresh?
kubectl -n istio-ingress get certificate pay-tmo-example -o wide
# Is scaling behaving?
kubectl -n payments get hpa payment-api --watch
# Emergency exits
kubectl -n payments rollout undo deploy/payment-api # instant rollback
kubectl -n payments rollout history deploy/payment-api
Best-practices checklist (print this)
Images & Docker
β multi-stage builds Β· β pinned tags + digests Β· β non-root USER Β· β .dockerignore Β· β exec-form ENTRYPOINT Β· β scan images (Trivy) in CI Β· β one process per container Β· β logs to stdout
Workloads
β requests set on everything Β· β memory limit = request Β· β think hard before CPU limits Β· β all three probes, correctly separated Β· β preStop sleep + grace period Β· β topology spread across zones Β· β PDBs on every service
Traffic
β maxUnavailable: 0 rollouts Β· β canary via mesh weights Β· β timeouts & bounded retries (retry budgets!) Β· β outlier detection Β· β locality-aware LB Β· β HTTP/2 keep-alive between services
Security
β default-deny NetworkPolicies Β· β STRICT mTLS Β· β least-privilege RBAC per SA Β· β no secrets in Git (External Secrets) Β· β readOnlyRootFilesystem, drop ALL caps Β· β admission policies (no root, no :latest)
Telemetry
β golden-signal dashboard per service Β· β alert on symptoms with "for:" windows Β· β p99, never averages Β· β tail-sampled traces Β· β structured JSON logs with trace IDs Β· β watch CPU throttling explicitly
Scale & cost
β HPA on real load metric, slow scale-down Β· β Cluster Autoscaler / Karpenter for nodes Β· β quotas per namespace Β· β cache before DB on hot paths Β· β load-test to find the real per-pod ceiling
Say it back three ways (the forgetting-proof recap)
If you can produce these three explanations from memory, the whole page is yours:
To a child
"Kubernetes is a robot manager for computer programs. You write a wish list β 'always keep six copies running' β and the robot checks the list forever, fixing anything that breaks, even at 3 AM."
To an engineer
"A declarative reconciliation system: desired state in etcd behind one API server; independent controllers watch, diff, and act. Networking gives every pod an IP, Services a stable VIP, and L4/L7 layers (kube-proxy, Envoy, Istio) route, secure, and observe traffic."
To an interviewer, under fire
"At 35k req/s peak, the design is: anycast to the nearest region, terminate TLS at Envoy, stay in-zone via locality LB, serve from RAM (Redis), write async, cap every hop with timeouts + outlier ejection, spread across zones with PDBs, and alert on p99 and error-rate SLOs β because the budget is microseconds and the enemy is any hop you didn't need."
β END OF DESCENT Β· re-read one altitude per day; explain each diagram out loud before moving on β