A COMPLETE DESCENT100,000 FT β†’ 0 FT

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.

1B requests/daysub-millisecond hopsfull YAML manifestsDocker β†’ containerdmTLS & CAtelemetryanimated flows
ALTITUDE 01100,000 FT

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?

Analogy #1 β€” The restaurant

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.

Why? β€” The real pain before Kubernetes
  • 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

Key idea β€” memorize this

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.

DESIRED STATE "6 replicas of payment-api" (what you wrote in YAML) ACTUAL STATE "5 running, 1 node just died" (what exists right now) CONTROL LOOP observe β†’ diff β†’ act runs forever, every few seconds amber = "actual" flows back in; the loop never stops comparing
The reconciliation loop β€” everything in Kubernetes is a version of this picture

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.

Analogy #2 β€” The thermostat

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.

ALTITUDE 0280,000 FT

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.

Why care about the physical layer?

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 us-west (a metro area, e.g. Oregon) AVAILABILITY ZONE us-west-a AVAILABILITY ZONE us-west-b RACK 12 ToR SWITCH (top of rack) server: k8s node-1 node-2 ← your pod is here server: k8s node-3 …42 servers per rack RACK 13 ToR SWITCH node-4 node-5 RACK 40 ToR SWITCH node-9 ← replica pod RACK 41 ToR SWITCH
Two zones = two separate buildings (or halls) with independent power, cooling, and network. A zone can burn down; your app survives because replicas were spread.
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

Why spine-leaf and not a simple tree?

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.

SPINE 1 SPINE 2 SPINE 3 LEAF (ToR) A LEAF B LEAF C LEAF D any server β†’ any server = exactly 2 hops, ~50–200 Β΅s. Multiple equal paths = no single choke point. pod on node under Leaf A pod on node under Leaf D
Spine-leaf fabric. Watch the packets take different spines β€” that is ECMP load-spreading in action.
Key numbers to keep in your head
PathTypical latencyMeaning for you
Same node, podβ†’pod~0.02–0.05 msloopback-ish; sidecars live here
Cross-rack, same zone~0.05–0.2 msnormal microservice hop
Zone β†’ zone (same region)~0.5–2 msfine for HA, costly for chatty calls
Region β†’ region (coast to coast)~60–70 ms RTTnever in a sub-ms path; async replication only
User β†’ nearest edge (good CDN/anycast)~5–30 msthe 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.

ALTITUDE 0360,000 FT

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.

Analogy β€” The office building

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.

ONE SHARED LINUX KERNEL (the host) namespaces + cgroups + overlayfs are kernel features β€” Docker just asks nicely for them container: payment-api sees: PID 1 = java sees: own IP 10.244.1.7 capped: 2 CPU / 4Gi disk: image layers + scratch container: fraud-svc sees: PID 1 = python sees: own IP 10.244.1.9 capped: 1 CPU / 2Gi container: redis sees: PID 1 = redis-server sees: own IP 10.244.1.12
Three "machines" that are really three processes. Kill the kernel and all three die β€” that's the isolation trade-off vs VMs.

Images and layers β€” why builds are fast (or slow)

Why layers?

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.

L1: eclipse-temurin:21-jre base (read-only) L2: apk add curl (read-only) L3: COPY libs/ (dependencies, read-only) L4: COPY app.jar (changes every build) writable layer (this container only) shared with container B β€” not copied! shared shared shared writable layer (container B) two containers, one image: read-only layers stored once on disk, only the thin writable layers differ (copy-on-write)
OverlayFS: image = stacked read-only layers; each running container adds one scratch layer on top.

A production multi-stage Dockerfile, line by line

Why multi-stage?

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"]
Key Dockerfile rules (interview-grade)
  • Order layers by change frequency: base β†’ OS packages β†’ dependencies β†’ source code. Cache hit rate decides build speed.
  • Pin versions (maven:3.9-...-21, never latest) β€” 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

Why compose if we have Kubernetes?

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
CommandWhat 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.2Creates 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> shStarts 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.2Shows every layer with size β€” your #1 tool for hunting image bloat.
docker system prune -aDeletes 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.

ALTITUDE 0440,000 FT

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.

CONTROL PLANE (usually 3 nodes for HA β€” quorum!) kube-apiserver the ONLY front door etcd key-value truth store (Raft) kube-scheduler picks a node for each pod controller-manager Deployment/RS/Node loops WORKER NODE 1 kubelet node agent (a loop!) kube-proxy Service β†’ pod routing containerd runs containers (CRI) pods… payment-api-7d4f… WORKER NODE 2 … NODE 5,000 kubelet kube-proxy pods… (CNI gives each pod its own IP) kubelets WATCH the API server (pull, not push) β€” the API server never reaches into nodes to command them
The whole machine. Note the direction of the cyan packets: agents pull their orders. This is why a cluster survives a control-plane outage β€” pods keep running.

Each component, and the promise it guards

ComponentIts 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.

Key insight

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).

Why do rolling deploys drop requests if you skip this?

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:

t=0 delete preStop: sleep 8s (still serving!) endpoints removed β†’ LBs drain (1–3s) SIGTERM app finishes in-flight requests, closes pools SIGKILL (grace 30s up) terminationGracePeriodSeconds must be > preStop + longest request time, or SIGKILL cuts requests mid-flight
Graceful termination timeline. The preStop sleep is the cheapest zero-downtime trick in Kubernetes.
ALTITUDE 0530,000 FT

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

Why namespaces?

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

Why not bake config into the image?

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

Honest truth about Secrets

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)

Why does a pod need an identity?

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: {}
Three fields people get wrong in interviews
  • 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

Why a Service at all?

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 typeWhat it doesWhen
ClusterIPVirtual IP inside the cluster onlyDefault. 95% of services.
NodePortOpens the same high port (30000–32767) on EVERY nodeBuilding block under LoadBalancer; rarely used alone.
LoadBalancerAsks the cloud for a real external L4 LB β†’ NodePort β†’ podsExposing an ingress gateway to the internet.
Headless (clusterIP: None)DNS returns pod IPs directly, no VIPStatefulSets: databases, Kafka β€” clients that must address individual members.
ExternalNameDNS CNAME to an outside nameAliasing 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

Why?

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

Why? Isn't the cluster private?

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"]
ALTITUDE 0620,000 FT

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:

What "sub-millisecond" really means

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)

L4 LB 20Β΅s
Envoy gateway 60Β΅s
sidecar 40Β΅s
app logic 350Β΅s
Redis GET 200Β΅s
sidecar 40Β΅s
serialize+out 90Β΅s
network/LBgatewaymesh sidecarsyour codecacheegress

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)

πŸ“± USER pay.tmo.example DNS / GSLB returns anycast VIP ANYCAST + BGP same IP, many cities EDGE POP TLS ends here; CDN cache NEAREST REGION β€” everything below stays inside the building L4 NLB Service type LoadBalancer ISTIO INGRESS GATEWAY Envoy: route, authn JWT, mTLS out payment-api pod sidecar Envoy β†’ app :8080 Redis (in-zone) hot data, ~200Β΅s GET fraud-svc pod called over mesh mTLS Postgres (async) write-behind, off hot path TELEMETRY PLANE (passive) every hop above emits metrics + traces + logs β€” Prometheus / OTel / Grafana (next altitude)
One request, end to end. Cyan = request path Β· amber = cache lookup Β· violet = mesh call to another service Β· dashed = async write, off the hot path.

Why anycast? (the trick that shrinks the internet)

Why does the same IP exist in 50 cities?

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

LayerWhoQuestion it answersSees
GlobalGSLB / anycastWhich region?BGP topology, region health
L4Cloud NLB / IPVS / MaglevWhich node? (flow-hash 5-tuple β†’ consistent backend)IPs + ports only β€” that's why it's ~20 Β΅s
L7 edgeEnvoy / Nginx ingressWhich service & version? (host, path, headers)Full HTTP after TLS termination
L7 meshEnvoy sidecarsWhich pod? (least-request, zone-local, outlier ejection)HTTP + mTLS identity per hop
The three silent latency killers at this scale
  • 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.
Analogy β€” The airport

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.

ALTITUDE 0710,000 FT

Istio, Envoy, Nginx β€” the traffic layer, demystified

Why a service mesh at all?

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.

istiod (control plane) watches K8s + your YAML β†’ compiles xDS β†’ CA POD payment-api envoy sidecar iptables grabs all traffic app :8080 plain HTTP inside POD fraud-svc envoy sidecar app :9000 mTLS tunnel between sidecars β€” apps never know xDS config + certificates pushed live
Sidecar pattern: iptables (set up by istio-init) redirects all pod traffic through Envoy. Green = encrypted service-to-service; violet = control plane pushing config/certs.

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"]
The mental model that untangles Istio's YAML zoo

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).

ALTITUDE 085,000 FT

Certificates & CAs: how trust actually works

Why do we need a CA? Why not just encrypt?

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)

payment-api envoy fraud-svc envoy 1 Β· ClientHello (TLS 1.3: key share included β€” saves a round trip) 2 Β· ServerHello + server cert (SPIFFE ID) + CertificateRequest 3 Β· client cert (its SPIFFE ID) + Finished β€” BOTH sides now proven 4 Β· application data, encrypted β€” resumed conns skip 1–3 entirely "mutual" = the client shows a certificate too. Both sides verify against the same mesh root CA.
TLS 1.3 mutual handshake: 1 round trip to establish, ~0 for resumption. This is why steady-state mTLS costs microseconds, not milliseconds.

cert-manager β€” certificates as Kubernetes objects

Why cert-manager?

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
Where else certificates hide in Kubernetes

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.

ALTITUDE 092,000 FT

Telemetry: seeing 35,000 req/s without drowning

Why three different signals?

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.

pods + sidecars /metrics Β· spans Β· stdout Prometheus PULLS /metrics every 15s OTel Collector receives spans, samples, routes Loki / ELK log aggregation (DaemonSet ships) Grafana one pane: M + T + L linked Alertmanager dedupe β†’ group β†’ page note the direction on Prometheus: it PULLS (scrapes). Pull = the monitoring system notices when a target vanishes.
The observability plane. A silent bonus of Istio: every Envoy already emits uniform metrics and trace spans for every service β€” L7 observability with zero app changes.

The four golden signals (Google SRE) β€” your dashboard skeleton

SignalQuestionPromQL you'd actually run
TrafficHow much demand?sum(rate(http_server_requests_seconds_count[1m]))
ErrorsWhat fraction fails?sum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m]))
Latencyp99 experience? (never averages!)histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m])))
SaturationHow 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] }
Tracing in one picture (words edition)

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.

ALTITUDE 10GROUND LEVEL Β· 0 FT

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 β€”