Containers · Kubernetes · CI/CD · 60 Topics

EKS · ECR · CircleCI Pro Playbook

From docker build to a production EKS rolling deploy triggered by CircleCI — each topic with real commands, real output, a plain-English analogy, and the gotcha that pages you at 2am. Copy, run, adapt.

Part I — Containers & ECR

Docker Foundations

01Images & layers — what a container actually is

Scenario: before EKS, ECR, or any pipeline makes sense, one mental model must click: an image is a stack of read-only layers, and a container is that stack running with a thin writable film on top.

🧠
Analogy: an image is a stack of transparent sheets on an overhead projector — base OS on the bottom sheet, dependencies on the next, your app on top. Projecting the stack (running it) is a container; you scribble on a disposable film above the stack, never on the sheets. Ten projectors can share the same sheets — that's why containers start in milliseconds and images dedupe on disk.
See the layers yourself
$ docker pull ruby:3.3-slim
$ docker history ruby:3.3-slim --format "{{.CreatedBy}}\t{{.Size}}" | head -4
$ docker image ls ruby:3.3-slim

# run it — the writable film:
$ docker run -it ruby:3.3-slim bash
root@c1f2:/# echo "scribble" > /tmp/x     # exists only in THIS container
root@c1f2:/# exit
$ docker run ruby:3.3-slim cat /tmp/x     # fresh container, fresh film
Output
/bin/sh -c apt-get update && apt-get install...   28MB
/bin/sh -c #(nop) COPY ruby...                    45MB
/bin/sh -c #(nop) CMD ["irb"]                     0B
REPOSITORY   TAG        SIZE
ruby         3.3-slim   208MB

cat: /tmp/x: No such file or directory   ← containers are disposable!

✅ Do — good use cases

  • Treat containers as cattle: state lives in databases/S3/volumes, never inside the container
  • docker history when an image is mysteriously huge — the fat layer names itself
  • Share base layers deliberately: same base image across services = one download on every node

❌ Don't — anti-patterns

  • docker exec-ing into prod containers to hotfix files — the next restart erases it (and the image lies about what's running)
  • Treating an image like a VM snapshot — no init system, no SSH daemon, one process per container
Pattern & benefit: layers are content-addressed and cached — rebuilds reuse unchanged layers, registries store each layer once, and nodes pulling your v2 image download only the layers that changed. Every optimization in topics 2–5 is really layer management.
Gotcha — why it goes wrong: deleting a file in a later layer doesn't shrink the image — the file still ships in the earlier layer, just hidden (RUN download-huge.zip then RUN rm huge.zip = full size, invisible file). Cleanup must happen in the same RUN as the mess: RUN curl ... && ... && rm -rf /tmp/*.
02Dockerfile essentials — cache-friendly, small, safe★ used daily

Scenario: the Dockerfile every Rails/Node/Python service needs — ordered so rebuilds take seconds, sized so pulls are fast, and not running as root.

🧠
Analogy: Docker builds like a fussy chef with a photographic memory: he reuses yesterday's prep (cache) for every step — until the first step whose ingredients changed, after which he redoes EVERYTHING below. So put the stable prep (installing gems) above the volatile prep (copying your code, which changes every commit).
Dockerfile — the ordering IS the optimization
FROM ruby:3.3-slim

WORKDIR /app

# 1. dependency manifests FIRST — this layer rarely changes:
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4

# 2. app code LAST — changes every commit, busts only this layer:
COPY . .

# 3. never root:
RUN useradd -m app && chown -R app:app /app
USER app

EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0"]
Rebuild after a code change
=> CACHED [2/5] WORKDIR /app
=> CACHED [3/5] COPY Gemfile Gemfile.lock ./
=> CACHED [4/5] RUN bundle install          ← 3 minutes saved
=> [5/5] COPY . .                            ← only this re-runs
Building 2.1s — vs 3m40s with COPY . . first

✅ Do — good use cases

  • Order: base → system deps → dependency manifests → install → code → config
  • Slim/alpine bases; pin versions (ruby:3.3-slim, never bare ruby)
  • USER app — a container escape from root is a node compromise

❌ Don't — anti-patterns

  • COPY . . before bundle install — every commit reinstalls every gem (the #1 slow-CI cause)
  • Secrets in ENV/ARG/layers — they're readable by anyone with the image (docker history!)
  • apt-get without rm -rf /var/lib/apt/lists/* in the same RUN (topic 1's hidden-fat rule)
Pattern & benefit: a well-ordered Dockerfile turns CI image builds from minutes to seconds (topic 51 banks on this), and small images pull faster onto every EKS node — scale-ups (topic 41) are only as fast as your image pull. Cheap wins, compounding daily.
Gotcha — why it goes wrong: cache invalidation is by checksum, not filename — COPY . . includes file metadata churn (logs, .git, editor swap) that busts layers "randomly" until .dockerignore (topic 4) exists. And CMD vs ENTRYPOINT: CMD is the default args (overridable at run), ENTRYPOINT the fixed prefix — K8s command:/args: override ENTRYPOINT/CMD respectively, a mapping worth memorizing now (topic 12).
03Multi-stage builds — build heavy, ship light★ used daily

Scenario: compiling assets needs Node, native gems need build-essential — but production needs none of that. Multi-stage: build in a fat workshop, ship only the finished product.

🧠
Analogy: a furniture workshop and a showroom. The workshop (build stage) is full of saws, glue, and sawdust (compilers, dev headers, node_modules). The showroom (final stage) receives only the finished chairCOPY --from=build is the delivery van between them. Nobody ships the sawdust to the customer.
Dockerfile — two stages
# ---------- stage 1: the workshop ----------
FROM ruby:3.3 AS build
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev nodejs
COPY Gemfile Gemfile.lock ./
RUN bundle config set without "development test" && bundle install
COPY . .
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile

# ---------- stage 2: the showroom ----------
FROM ruby:3.3-slim
WORKDIR /app
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /app /app
USER 1000
CMD ["bin/rails", "server", "-b", "0.0.0.0"]
Output
$ docker image ls shop
REPOSITORY  TAG      SIZE
shop        1-stage  1.42GB     ← compilers, node, dev gems, apt cache
shop        2-stage  289MB      ← 5× smaller, 5× faster node pulls

✅ Do — good use cases

  • Any compiled/bundled app: Rails assets, Go/Rust binaries (final stage can be FROM scratch!), React builds served by nginx
  • COPY --from=build exactly what runtime needs — bundle dir + app, nothing else
  • Runtime libs only in the final stage (libpq5, not libpq-dev)

❌ Don't — anti-patterns

  • One-stage images with build-essential in production — bigger attack surface AND slower pulls
  • Copying the whole build stage (COPY --from=build / /) — you've reinvented the one-stage image with extra steps
Pattern & benefit: smaller images = faster CI pushes (topic 51), faster EKS scale-ups (every new node pulls your image — topic 41), smaller scan reports (topic 8), and fewer CVEs by construction: the compiler that isn't shipped can't be exploited. One Dockerfile, both worlds.
Gotcha — why it goes wrong: native gems compiled in stage 1 link against shared libraries that must ALSO exist in stage 2 — build against ruby:3.3, ship on alpine, and you get cannot load such file — libpq at 2am. Keep both stages on the same base family (debian↔debian), and install the runtime (-dev-less) variant of every lib in the final stage.
04Build context & .dockerignore — what the daemon sees

Scenario: docker build says "sending 890MB build context" for your 4MB app — it's shipping .git, log/, and node_modules to the daemon before building a thing.

🧠
Analogy: the build context is everything you carry to the tailor so he can make your shirt. Without a packing list (.dockerignore) you're hauling your entire wardrobe, your photo albums (.git), and yesterday's laundry (log/, tmp/) to every fitting — slow trips, and the tailor might accidentally sew a sock (a secret!) into the shirt.
.dockerignore — the packing list
.git
.env*
log/*
tmp/*
node_modules
storage/*
coverage
*.md
.dockerignore
Dockerfile
Output — before / after
$ docker build .
=> transferring context: 890.4MB   0.9s + upload    ← before
=> transferring context: 4.2MB     0.1s             ← after

# and the security angle:
$ docker build . && docker run shop cat .env
DATABASE_URL=postgres://prod:REAL_PASSWORD@...      ← COPY . . shipped it!

✅ Do — good use cases

  • .dockerignore in every repo, written the same day as the Dockerfile
  • Always exclude: .git, .env*, logs, tmp, test artifacts, local node_modules
  • Verify what's inside: docker run --rm img find /app -name ".env*"

❌ Don't — anti-patterns

  • COPY . . without .dockerignore — .env files and .git history baked into layers, pushed to ECR, pullable by anyone with read access
  • Ignoring Gemfile.lock-style manifests by accident with broad globs — builds go non-reproducible
Pattern & benefit: smaller context = faster local builds and dramatically faster CI (the context uploads on every build — topic 51), plus stable layer caching: files that never enter the context can never bust the COPY . . layer (topic 2's "random" cache misses, solved).
Gotcha — why it goes wrong: the syntax is subtly its own: log/* keeps the dir but drops contents, **/*.key for nested matches, and a trailing !keep-me.txt re-includes. Test with docker build --no-cache --progress=plain or list the context via a scratch COPY — a wrong glob silently ships gigabytes (or excludes your app; both happen).
05Tagging strategies — :latest is not a version★ used daily

Scenario: "what exactly is running in prod right now?" If the answer is :latest, the answer is "nobody knows." Tags are your deploy audit trail, rollback lever, and cache key.

🧠
Analogy: a tag is a sticky label on a jar — and :latest is a label that says "the newest jam, probably." Two shops (nodes) reading the same label at different times get different jam. A git-SHA tag is a batch number: exact, immutable, traceable to the recipe (commit) that produced it.
The scheme that works everywhere
# CI builds ONE image, labels it three ways (labels are free — same digest):
$ docker build -t shop:${CIRCLE_SHA1} .
$ docker tag shop:${CIRCLE_SHA1}  $ECR/shop:${CIRCLE_SHA1}      # forever tag
$ docker tag shop:${CIRCLE_SHA1}  $ECR/shop:main                # branch pointer
$ docker tag shop:${CIRCLE_SHA1}  $ECR/shop:v1.42.0             # release tag

# deploys reference the SHA — always:
$ kubectl set image deploy/shop app=$ECR/shop:8f3ab12
# rollback = pointing at the previous SHA. No rebuild.

# what's REALLY running (tags can lie, digests can't):
$ kubectl get pod shop-abc -o jsonpath='{.status.containerStatuses[0].imageID}'
Output
203856262582.dkr.ecr.ap-south-1.amazonaws.com/shop@sha256:71f3c2...
                                                    ↑ the digest — ground truth

✅ Do — good use cases

  • Deploy by git SHA (or SHA-stamped semver) — deploys become greppable history
  • Branch tags (:main, :staging) as moving pointers for humans/dev clusters only
  • Make prod tags immutable in ECR (topic 9) — retagging over a deployed tag becomes impossible

❌ Don't — anti-patterns

  • image: shop:latest in a Deployment — a re-push means two nodes can run different code under one name, and "rollback" has no target
  • Semver-only tags with manual bumping — the day someone forgets, two builds share a tag
Pattern & benefit: SHA tags make three hard things trivial: what's deployed? (read the tag) — roll it back (previous SHA, no rebuild) — what changed? (git log old..new). Kubernetes also skips pulls it believes it has: unique tags make every deploy an unambiguous, definitely-pulled change (see gotcha).
Gotcha — why it goes wrong: imagePullPolicy defaults to IfNotPresent for any tag except :latest — re-pushing a reused tag (:staging) then redeploying does nothing on nodes that cached the old bytes. The infamous "I deployed but nothing changed." Unique-per-build tags kill this class of bug dead; that's the real argument, beyond hygiene.

ECR — Your Registry

06ECR — repos, auth, push, pull★ used daily

Scenario: your image needs a home that EKS nodes and CI can both reach, with IAM instead of passwords. ECR is Docker Hub inside your AWS account — and the auth dance is the only tricky part.

🧠
Analogy: ECR is a bonded warehouse inside your AWS compound — goods (images) never leave the premises, and entry uses your company badge (IAM), not a separate warehouse password. get-login-password is the front desk swapping your badge for a 12-hour visitor pass that Docker (who only understands passes) can use.
The full loop
# one-time: create the repo
$ aws ecr create-repository --repository-name shop \
    --image-scanning-configuration scanOnPush=true \
    --image-tag-mutability IMMUTABLE

# the auth dance — badge → 12h docker credential:
$ aws ecr get-login-password --region ap-south-1 \
  | docker login --username AWS --password-stdin \
    203856262582.dkr.ecr.ap-south-1.amazonaws.com

# push — the registry hostname IS part of the tag:
$ docker tag shop:8f3ab12 203856262582.dkr.ecr.ap-south-1.amazonaws.com/shop:8f3ab12
$ docker push 203856262582.dkr.ecr.ap-south-1.amazonaws.com/shop:8f3ab12

$ aws ecr describe-images --repository-name shop \
    --query "imageDetails[].{tags:imageTags,size:imageSizeInBytes}" --output table
Output
Login Succeeded
The push refers to repository [2038...amazonaws.com/shop]
8f3ab12: digest: sha256:71f3c2... size: 289MB
-------------------------------------
|      tags        |     size       |
|  ["8f3ab12"]     |  289345021     |

✅ Do — good use cases

  • One repo per service (shop, shop-worker) — policies and lifecycle rules stay per-service
  • scanOnPush + IMMUTABLE at creation time (topics 8, 9) — retrofitting is a ticket nobody files
  • EKS nodes pull with their node role automatically — zero imagePullSecrets needed (the ECR+EKS superpower)

❌ Don't — anti-patterns

  • Saving the login password anywhere — it expires in 12h by design; re-run the dance (or use OIDC in CI — topic 53)
  • One mega-repo with tag prefixes (app-api-v1, app-worker-v2) — lifecycle policies can't tell them apart cleanly
Pattern & benefit: auth is pure IAM — the same policy language you use everywhere gates push (CI role) vs pull (node role), CloudTrail logs every access, and images replicate/scan/expire with AWS-native tooling. No Docker Hub rate limits taking prod down on a Friday.
Gotcha — why it goes wrong: the two classic errors decode as: no basic auth credentials = your 12h pass expired (re-login); repository ... not found = ECR does NOT auto-create repos on push — someone must create-repository first (script it in IaC). And the region is baked into the hostname: pushing to ap-south-1 while the cluster pulls from us-east-1 is a cross-region replication topic (9), not a typo to shrug at.
07Lifecycle policies — the registry that cleans itself

Scenario: CI pushes an image per commit — 40/day. A year later: 14,000 images, a real storage bill, and a repo where nobody can find anything. Lifecycle policies are the janitor.

🧠
Analogy: a fridge rule taped to the office fridge: "meal-prep boxes (release tags): keep the newest 30. Experiment leftovers (untagged): out after 14 days." The janitor enforces it nightly, nobody argues, and the fridge never becomes an archaeology site.
lifecycle-policy.json
{
  "rules": [
    {
      "rulePriority": 1,
      "description": "expire untagged images after 14 days",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days", "countNumber": 14
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "keep last 50 SHA builds",
      "selection": {
        "tagStatus": "tagged", "tagPrefixList": ["sha-"],
        "countType": "imageCountMoreThan", "countNumber": 50
      },
      "action": { "type": "expire" }
    }
  ]
}
Apply & dry-run
$ aws ecr put-lifecycle-policy --repository-name shop \
    --lifecycle-policy-text file://lifecycle-policy.json
$ aws ecr start-lifecycle-policy-preview --repository-name shop   # DRY RUN first!
→ preview: 13,204 images would expire, 96 kept

✅ Do — good use cases

  • Untagged-image expiry everywhere — retagging orphans old digests constantly (they're invisible fat)
  • Keep-last-N per tag prefix — deep enough to roll back through a bad week (50 SHAs ≈ weeks of history)
  • The preview API before applying — this tool DELETES images

❌ Don't — anti-patterns

  • Expiring by age alone on low-traffic repos — a service that ships quarterly loses ALL its images in a 90-day rule
  • Forgetting release tags in the keep rules — the rollback target that got janitored is a bad day (immutability won't save you from expiry)
Pattern & benefit: set once per repo (in Terraform/CloudFormation, so new repos inherit it), and storage cost + repo noise stay flat forever. Rules evaluate priority-ascending and an image matches at most one — the mental model is a firewall rule list for jam jars.
Gotcha — why it goes wrong: the counter-intuitive bit — a currently running pod does not protect its image: expire the digest a node later needs (scale-up, node replacement) and pods land in ImagePullBackOff for an image that no longer exists anywhere. Keep-last-N must comfortably cover everything still deployed anywhere (prod, staging, that one canary namespace everyone forgot).
08Image scanning — CVEs before they ship

Scenario: your base image ships OpenSSL; OpenSSL ships a critical CVE on Tuesday. Scanning tells you which of your 30 images are affected — before a pentester (or worse) does.

🧠
Analogy: scanning is the airport X-ray for your shipping containers: every push rolls through the machine (scan-on-push), and the screen lists what's inside by threat level. Enhanced scanning is the upgrade where the X-ray also re-checks containers already in the warehouse whenever a new threat is discovered — because a jar that was safe Monday can be a known hazard Wednesday.
Code
# basic: scan on push (set at repo creation — topic 6)
# enhanced (Amazon Inspector): continuous rescanning as new CVEs publish
$ aws ecr put-registry-scanning-configuration \
    --scan-type ENHANCED \
    --rules '[{"scanFrequency":"CONTINUOUS_SCAN","repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]'

$ aws ecr describe-image-scan-findings \
    --repository-name shop --image-id imageTag=8f3ab12 \
    --query "imageScanFindings.findingSeverityCounts"

# gate the pipeline on it (topic 60):
$ SEV=$(aws ecr describe-image-scan-findings ... \
        --query "imageScanFindings.findingSeverityCounts.CRITICAL" --output text)
$ [ "$SEV" = "None" ] || { echo "CRITICAL CVEs — blocking deploy"; exit 1; }
Output
{
    "CRITICAL": 1,
    "HIGH": 4,
    "MEDIUM": 11
}
CRITICAL: CVE-2026-31337  openssl 3.0.11-1  → fixed in 3.0.13
CRITICAL CVEs — blocking deploy

✅ Do — good use cases

  • Scan-on-push everywhere; enhanced/continuous for anything long-lived in prod
  • Fix by rebuilding on a newer base — most CVEs live in the base image; FROM ruby:3.3-slim re-pulled monthly clears swathes of them
  • Gate CI on CRITICAL (block) — report HIGH (ticket), ignore noise below

❌ Don't — anti-patterns

  • Blocking deploys on every MEDIUM — teams route around a gate that cries wolf, and then the CRITICAL sails through too
  • Scanning but never rebuilding — a 9-month-old image is a CVE museum regardless of what the dashboard says
Pattern & benefit: the slim images from topic 3 pay off again — fewer packages, fewer findings, real signal. Continuous scanning inverts the workflow from "audit quarterly" to "AWS emails you when Tuesday's CVE affects Wednesday's prod," which is the only version that scales.
Gotcha — why it goes wrong: a green scan ≠ secure app — scanners read OS package databases and known ecosystems; your vendored code, misconfigurations, and app-level holes (SQLi!) are invisible to them. And distroless/scratch images can scan "clean" partly because there's no package DB to read — pair registry scanning with dependency scanning in CI (bundler-audit, npm audit) for the app layer.
09Immutable tags, replication & pull-through cache

Scenario: three registry-level switches that prevent whole bug classes: tags that can't be overwritten, images that follow you to other regions, and Docker Hub dependencies you stop being hostage to.

🧠
Analogy: immutability turns tags into engraved serial numbers instead of sticky labels — nobody can peel "v1.42" off one jar and slap it on different jam (accidentally or maliciously). Replication is automatic branch warehouses: stock pushed to Mumbai appears in Singapore overnight. Pull-through cache is the local importer for foreign goods — first order fetches from Docker Hub, every later order ships from your own shelf.
Code
# 1. immutable tags — reject overwrites:
$ aws ecr put-image-tag-mutability --repository-name shop \
    --image-tag-mutability IMMUTABLE
$ docker push $ECR/shop:v1.42.0     # second push of same tag:
# → tag invalid: The image tag 'v1.42.0' already exists ✋

# 2. cross-region replication (registry-level, one-time):
$ aws ecr put-replication-configuration --replication-configuration \
  '{"rules":[{"destinations":[{"region":"ap-southeast-1","registryId":"203856262582"}]}]}'

# 3. pull-through cache — stop depending on Docker Hub availability:
$ aws ecr create-pull-through-cache-rule \
    --ecr-repository-prefix dockerhub \
    --upstream-registry-url registry-1.docker.io
# then: FROM 2038...amazonaws.com/dockerhub/library/ruby:3.3-slim
Output
tag invalid: The image tag 'v1.42.0' already exists in the 'shop' repository
replication: shop:8f3ab12 → ap-southeast-1 ✓ (lag: seconds–minutes)
pull-through: first pull 28s (upstream) → cached pulls 3s, rate-limit-proof

✅ Do — good use cases

  • IMMUTABLE on every prod repo — with SHA tagging (topic 5) you never legitimately retag anyway
  • Replication for DR/multi-region clusters — nodes pull from their own region: faster + no cross-region transfer fees
  • Pull-through cache for all public base images — Docker Hub rate limits have broken more prod deploys than most outages

❌ Don't — anti-patterns

  • Mutable prod repos "for flexibility" — the flexibility is precisely the ability to make v1.42 silently mean something new (supply-chain 101)
  • Hardcoding docker.io bases in Dockerfiles used by CI at scale — anonymous pull limits will find you at the worst moment
Pattern & benefit: three switches, each closing a failure class: immutability kills tag-swap surprises and hardens supply chain; replication makes region failover an image-availability non-event; pull-through removes a third-party SPOF from every build and node pull. All three are set-and-forget.
Gotcha — why it goes wrong: immutability breaks moving-pointer workflows (:staging re-pushed per build) — those tags simply stop working; that's the design, so split: mutable dev repo, immutable prod repo (or drop pointer tags for SHA-only). And replication copies new pushes, not history — pre-existing images need a one-time manual copy before you point a new region's cluster at them.
10docker run — the local debugging loop★ used daily

Scenario: "works in the image, fails in the cluster" — or vice versa. Before touching Kubernetes, reproduce locally: the exact image, ports, env vars, and a shell inside it.

🧠
Analogy: debugging in EKS first is like diagnosing an engine noise only while driving on the highway. docker run is the garage: same engine (exact image digest), controlled conditions, you can pop the hood mid-run (exec), rev components individually (override the command), and nobody honks.
The daily commands
# run the EXACT prod image, mapping ports and env:
$ docker run --rm -p 3000:3000 \
    -e DATABASE_URL=postgres://localhost:5432/dev \
    -e RAILS_ENV=production \
    $ECR/shop:8f3ab12

# poke inside a RUNNING container:
$ docker exec -it shop-local bash
root@a1:/app# env | grep RAILS

# bypass the entrypoint entirely — "just give me a shell in the image":
$ docker run --rm -it --entrypoint bash $ECR/shop:8f3ab12

# the forensic trio:
$ docker logs -f shop-local
$ docker inspect shop-local --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
$ docker stats --no-stream
Output
=> Booting Puma on 0.0.0.0:3000
137 true          ← exit 137 + OOMKilled: memory, not your code!
CONTAINER    CPU %   MEM USAGE / LIMIT
shop-local   4.1%    412MiB / 512MiB

✅ Do — good use cases

  • Reproduce cluster issues with the same digest and same env — "the same image" by tag can lie (topic 5)
  • --entrypoint bash to explore an image that crashes at boot
  • Learn exit codes now: 137 = OOM/SIGKILL, 143 = SIGTERM, 126/127 = bad command — K8s (topic 31) speaks the same codes

❌ Don't — anti-patterns

  • Debugging with a locally BUILT image while prod runs a CI-built one — different base pull dates, different bytes; pull the ECR image
  • --privileged / root shells as a habit — you're testing a different security reality than the cluster's
Pattern & benefit: the container contract — image + env + ports + command — is identical locally and in EKS, so every finding transfers. The garage loop is seconds; the cluster loop is minutes: debug locally, verify in the cluster. Half of "Kubernetes issues" die here as plain image issues.
Gotcha — why it goes wrong: localhost isn't a cluster: DNS names like postgres.default.svc don't resolve (use host DBs or docker networks), IRSA credentials (topic 38) don't exist (export AWS keys locally or better, use profiles), and your laptop has no memory limits — add --memory 512m to feel prod's constraints, or the OOM (exit 137) will only ever happen "mysteriously, in the cluster."
Part II — Kubernetes · The Engine

Core Objects

11The mental model — desired state & reconciliation

Scenario: the one idea that makes ALL of Kubernetes click: you never tell it what to do — you declare what should exist, and controllers work forever to make reality match.

🧠
Analogy: a thermostat, not a fireplace. A fireplace is imperative: light it, feed it, it dies when you stop. A thermostat is declarative: "21°C" — and it heats, cools, and corrects forever, including after a window opens (a node dies). Every K8s controller is a thermostat for one kind of thing; your YAML is just the temperature dial.
Declare 3 replicas — then sabotage one
$ kubectl apply -f deploy.yaml        # "there shall be 3"
$ kubectl get pods
shop-7f9b-2xkcd  Running
shop-7f9b-8mzpq  Running
shop-7f9b-p4wt7  Running

$ kubectl delete pod shop-7f9b-2xkcd  # sabotage!
$ kubectl get pods                    # seconds later:
shop-7f9b-8mzpq  Running
shop-7f9b-p4wt7  Running
shop-7f9b-vv9r4  Running   ← 12s old. The thermostat noticed.
The loop, in words
while true:
  observed = what actually exists      (via API server)
  desired  = what the YAML declares    (in etcd)
  if observed != desired: act to converge
# every controller — Deployment, HPA, node lifecycle — is this loop.

✅ Do — good use cases

  • YAML in git, kubectl apply to reconcile — the file IS the truth (GitOps is this idea industrialized)
  • Let controllers fix things — deleting a broken pod is safe because it comes back clean
  • Read every K8s feature as "which controller watches which field"

❌ Don't — anti-patterns

  • Imperative edits (kubectl edit, scale, set image) as your normal workflow — reality drifts from git until the next apply "mysteriously reverts" someone's hotfix
  • Fighting the reconciler — manually deleting pods to "scale down" a Deployment is a whack-a-mole you lose
Pattern & benefit: self-healing falls out for free — node dies, pods reschedule; container crashes, it restarts — because convergence never stops. And operations become diffs: change YAML, apply, watch the system walk from old state to new (topic 21 is exactly this walk, choreographed).
Gotcha — why it goes wrong: declarative means eventuallyapply returning success means "desire recorded," not "done." The pod may still be Pending (no node fits — topic 19), pulling an image, or crash-looping. The skill is watching convergence: kubectl rollout status, get events, describe (topic 31) — not assuming the apply did the thing.
12Pods — the atom of Kubernetes

Scenario: the smallest deployable unit isn't a container — it's a Pod: one or more containers sharing a network identity and lifecycle. Almost everything else in K8s exists to manage these.

🧠
Analogy: a Pod is a shared hotel room, containers are its occupants: one room number and phone line for all (one IP — they reach each other on localhost), checked in and out together, and the room itself is disposable — checkout means a NEW room later, never the same one repainted.
pod.yaml — you'll rarely write one bare, but must read them fluently
apiVersion: v1
kind: Pod
metadata:
  name: shop
  labels:
    app: shop
spec:
  containers:
    - name: app
      image: 203856262582.dkr.ecr.ap-south-1.amazonaws.com/shop:8f3ab12
      command: ["bin/rails"]          # overrides image ENTRYPOINT
      args: ["server", "-b", "0.0.0.0"]   # overrides image CMD
      ports:
        - containerPort: 3000
      env:
        - name: RAILS_ENV
          value: production
Output
$ kubectl apply -f pod.yaml && kubectl get pod shop -o wide
NAME   READY   STATUS    RESTARTS   IP            NODE
shop   1/1     Running   0          10.0.42.17    ip-10-0-40-113...
             ↑ 1 of 1 containers    ↑ the pod's own IP (topic 39)

✅ Do — good use cases

  • One main process per container, one main container per pod (+ helpers — topic 26)
  • Read READY x/y fluently — 0/1 Running means "up but failing its readiness probe" (topic 20)
  • Remember command/args ↔ ENTRYPOINT/CMD mapping (topic 2) when a container "ignores" your command

❌ Don't — anti-patterns

  • Bare pods for services — no controller means no restart-elsewhere when the node dies; that's Deployments (topic 13)
  • Two apps stuffed in one pod "to talk on localhost" — they now scale, deploy, and die together; separate pods + a Service (topic 14)
Pattern & benefit: the pod boundary is what makes sidecars work (shared localhost + volumes — topic 26) while keeping the unit disposable. Internalize "pods are ephemeral; names and IPs never persist" and Services, ConfigMaps, and probes all suddenly have an obvious reason to exist.
Gotcha — why it goes wrong: Running is a weaker claim than people assume — it means "containers started," not "app works" (0/1 READY tells the truth). And a pod stuck Pending isn't broken code — it's the scheduler failing to find a node that fits (resources — topic 19, taints — topic 28); kubectl describe pod's Events section names the reason in plain English (topic 31).
13Deployments — self-healing, versioned replicas★ used daily

Scenario: the object you'll write most: "run 3 copies of this image, keep them alive, and roll new versions out safely." Deployments manage ReplicaSets manage Pods.

🧠
Analogy: a franchise head office: you set the standard ("3 outlets, this exact menu/livery — the pod template") and the office keeps 3 outlets running — one burns down (node failure), a replacement opens. New menu? It opens refreshed outlets one by one while old ones serve on (rolling update), and keeps the old blueprints on file for instant rollback.
deploy.yaml — the canonical object
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop
spec:
  replicas: 3
  selector:
    matchLabels:
      app: shop            # MUST match template labels (the contract)
  template:                # ← everything below is the pod blueprint
    metadata:
      labels:
        app: shop
    spec:
      containers:
        - name: app
          image: $ECR/shop:8f3ab12
          ports: [{ containerPort: 3000 }]
Output
$ kubectl apply -f deploy.yaml
$ kubectl get deploy,rs,pods -l app=shop
deployment.apps/shop   3/3   3   3
replicaset.apps/shop-7f9b4c6d   3   3   3     ← one RS per template version
pod/shop-7f9b4c6d-2xkcd   1/1   Running
pod/shop-7f9b4c6d-8mzpq   1/1   Running
pod/shop-7f9b4c6d-p4wt7   1/1   Running

✅ Do — good use cases

  • Deployments for every stateless service — web apps, APIs, workers
  • ≥2 replicas for anything that matters — one replica means every deploy/node event is an outage window
  • Change the image by editing YAML + apply (topic 11) — CI does kubectl set image only as a scripted, git-reflected step (topic 60)

❌ Don't — anti-patterns

  • Editing pods a Deployment owns — the reconciler reverts you within seconds (as designed)
  • Deployments for databases — pods are interchangeable cattle here; stateful things want StatefulSets (topic 25) or better, RDS
Pattern & benefit: the three-layer split is the feature: Deployment holds the history (each template version = a ReplicaSet, kept for rollback — topic 21), ReplicaSet holds the count, pods do the work. Self-healing, scaling (replicas: or HPA — topic 22), and safe rollouts all hang off this one object.
Gotcha — why it goes wrong: the selector.matchLabelstemplate.labels contract bites twice: mismatch = instant error, but a selector that ALSO matches pods from another Deployment = two head offices fighting over the same outlets (pods thrash). Keep selectors unique per app (app: shop), and never change a selector on a live Deployment — it's immutable for good reason.
14Services — a stable address for moving pods★ used daily

Scenario: pods die and respawn with new IPs constantly (topic 12) — so how does anything call them? A Service is the permanent phone number in front of an ever-changing staff.

🧠
Analogy: a company switchboard: customers dial one number forever (shop.default.svc / the ClusterIP); the switchboard connects each call to whoever's currently at a desk (Ready pods matching the selector — topic 15). Staff quit and join hourly; the number outlives them all. Load balancing is the operator rotating through desks.
service.yaml + the three types
apiVersion: v1
kind: Service
metadata:
  name: shop
spec:
  type: ClusterIP          # internal-only — the default and the workhorse
  selector:
    app: shop              # "route to Ready pods with this label"
  ports:
    - port: 80             # the Service's port
      targetPort: 3000     # the container's port

# type: NodePort      → opens a high port on EVERY node (debug/legacy)
# type: LoadBalancer  → provisions a cloud NLB per service (£! — see topic 40)
Output — DNS + endpoints are the whole story
$ kubectl get svc shop
NAME   TYPE        CLUSTER-IP     PORT(S)
shop   ClusterIP   172.20.14.90   80/TCP

$ kubectl get endpoints shop
NAME   ENDPOINTS
shop   10.0.42.17:3000,10.0.51.3:3000,10.0.63.8:3000   ← the Ready pods

# from any pod in the cluster:
$ curl http://shop            # same namespace
$ curl http://shop.default.svc.cluster.local   # fully qualified

✅ Do — good use cases

  • ClusterIP + DNS names for ALL service-to-service traffic — never pod IPs
  • kubectl get endpoints as your first debugging move for "connection refused" — empty endpoints = selector mismatch or nothing Ready
  • One Ingress/ALB for HTTP instead of N LoadBalancer services (topic 40)

❌ Don't — anti-patterns

  • Hardcoding pod IPs or node IPs anywhere — they're weather, not architecture
  • type: LoadBalancer per microservice — 12 services = 12 cloud load balancers billing hourly
Pattern & benefit: Service + DNS is what makes pods disposable safely — consumers bind to a name; readiness probes (topic 20) decide who receives traffic; deploys swap the staff behind the number with zero caller awareness (topic 21's whole trick). It's the seam that decouples everything from everything.
Gotcha — why it goes wrong: the classic triple-check when a Service "doesn't work": (1) selector typo — matches zero pods, empty endpoints, connection refused; (2) targetPort ≠ the port the app actually listens on; (3) pods Running but not Ready (probe failing — topic 20) — endpoints empty on purpose. All three present identically from the caller's side; get endpoints distinguishes them in one command.
15Labels & selectors — the glue of everything

Scenario: nothing in Kubernetes holds a list of pods. Deployments, Services, NetworkPolicies, PDBs — they all say "whatever matches these labels." Labels are the join keys of the cluster.

🧠
Analogy: colored wristbands at a festival: security (Services), the caterer (PDBs), and the medics (NetworkPolicies) don't keep guest lists — they act on whoever wears the matching band. Walk in wearing app=shop, tier=web and every rule for that combination applies to you instantly. No registration desk, no lists to update.
Code
# the standard label set — adopt it early:
# app.kubernetes.io/name: shop
# app.kubernetes.io/instance: shop-prod
# app.kubernetes.io/version: "8f3ab12"

$ kubectl get pods -l app=shop                    # equality
$ kubectl get pods -l 'app in (shop,worker),env!=dev'   # set expressions
$ kubectl get pods -l app=shop --show-labels

$ kubectl label pod shop-7f9b-2xkcd debug=true    # tag live objects
$ kubectl get pods -l debug=true

# annotations = labels' sibling for NON-selecting metadata:
# kubectl annotate deploy shop deployed-by="circleci build 1442"
Output
NAME                  READY  STATUS   LABELS
shop-7f9b4c6d-2xkcd   1/1    Running  app=shop,pod-template-hash=7f9b4c6d,debug=true
shop-7f9b4c6d-8mzpq   1/1    Running  app=shop,pod-template-hash=7f9b4c6d

✅ Do — good use cases

  • A consistent label scheme from day one — every future selector (Service, PDB, netpol, monitoring) rides on it
  • -l on every kubectl command — labels are how you address groups (delete all of a canary, get logs across replicas)
  • Labels for selecting; annotations for metadata (build ids, ownership, tool config)

❌ Don't — anti-patterns

  • Renaming labels casually on live workloads — every selector pointing at the old name silently detaches (Service loses its pods!)
  • Encoding data into label values (config=this-that-other) — labels are keys for matching, not a database
Pattern & benefit: loose coupling at cluster scale — a Service doesn't know pods exist; it knows a label query. That's why you can label a hand-made debug pod app=shop and receive live traffic (scary, useful), or remove a label from a sick pod to quarantine it out of the Service while keeping it alive for autopsy — the single best labels trick in production debugging (topic 31).
Gotcha — why it goes wrong: because selectors are silent joins, typos fail without errors: app: shoop in a Service selector = empty endpoints and no complaint from anyone (topic 14's #1 cause). And that quarantine trick cuts both ways — the Deployment's ReplicaSet counts by the same labels, so the de-labeled pod is no longer "one of the 3": a fresh replacement spawns immediately. Know that's coming.
16ConfigMaps — config out of the image★ used daily

Scenario: same image in staging and prod, different settings (topic 5's "one artifact" promise) — the non-secret config lives in ConfigMaps, injected as env vars or files.

🧠
Analogy: the image is a sealed appliance; a ConfigMap is the settings card you slide into its slot at install time. Same microwave in every kitchen — the card sets clock, power, language per kitchen. Swapping cards doesn't rebuild microwaves; but note: most appliances only read the card at power-on (env vars don't hot-reload).
configmap.yaml + consumption
apiVersion: v1
kind: ConfigMap
metadata:
  name: shop-config
data:
  RAILS_LOG_LEVEL: "info"
  FEATURE_NEW_CHECKOUT: "true"
  nginx.conf: |                # whole files work too
    keepalive_timeout 65;
---
# in the Deployment's pod template:
spec:
  containers:
    - name: app
      envFrom:
        - configMapRef: { name: shop-config }   # every key → env var
      volumeMounts:
        - { name: nginx, mountPath: /etc/nginx/conf.d }
  volumes:
    - name: nginx
      configMap: { name: shop-config, items: [{ key: nginx.conf, path: default.conf }] }
Output
$ kubectl exec deploy/shop -- env | grep -E "RAILS_LOG|FEATURE"
RAILS_LOG_LEVEL=info
FEATURE_NEW_CHECKOUT=true
$ kubectl exec deploy/shop -- cat /etc/nginx/conf.d/default.conf
keepalive_timeout 65;

✅ Do — good use cases

  • All non-secret env config: log levels, feature flags, hostnames, tuning knobs
  • envFrom for 12-factor apps — the whole map becomes ENV in one line
  • Per-environment ConfigMaps with identical keys — same image, different card (Kustomize overlays shine here)

❌ Don't — anti-patterns

  • Secrets in ConfigMaps "temporarily" — they're plaintext to anyone with read access; that's topic 17's job
  • Baking env-specific config into images — you're back to one-image-per-environment, and topic 5's audit trail dies
Pattern & benefit: config becomes a first-class, versionable object — reviewed in git, applied like everything else (topic 11), swappable without touching images. The env-var path keeps apps 12-factor-plain; the volume path handles tools that insist on config files. One mechanism, both shapes.
Gotcha — why it goes wrong: the update trap — editing a ConfigMap does NOT restart consumers: env vars are read at process start (pods keep old values until recreated; kubectl rollout restart deploy/shop), and volume-mounted files DO update in place (~a minute) but only help apps that re-read files. Teams "update config" and stare at unchanged behavior constantly. Pro pattern: hash the ConfigMap into a pod-template annotation so config changes roll pods automatically.
17Secrets — credentials without committing them

Scenario: DATABASE_URL, API keys, TLS certs — injected like ConfigMaps but access-controlled, and (on EKS) ideally sourced from AWS Secrets Manager rather than hand-created.

🧠
Analogy: a ConfigMap is the settings card taped to the fridge; a Secret is the key locker in the manager's office — same slide-into-the-slot mechanics, but the room is badge-controlled (RBAC), entries are logged, and the smart shops don't even keep keys on site: a courier fetches them from the bank vault (Secrets Manager) at opening time.
Code
# create (imperative — keeps the value out of git):
$ kubectl create secret generic shop-secrets \
    --from-literal=DATABASE_URL='postgres://shop:S3cr3t@db.prod:5432/shop'

# consume — identical shape to ConfigMaps:
#   envFrom: [{ secretRef: { name: shop-secrets } }]

$ kubectl get secret shop-secrets -o jsonpath='{.data.DATABASE_URL}' | base64 -d
# ↑ note: base64 is ENCODING, not encryption!

# the EKS-grade approach — External Secrets Operator syncs from AWS:
# ExternalSecret → reads secretsmanager:prod/shop/db → creates/refreshes
# the k8s Secret. Rotation in Secrets Manager flows to the cluster.
# Pod access to AWS uses IRSA (topic 38) — no AWS keys in the cluster at all.
Output
secret/shop-secrets created
postgres://shop:S3cr3t@db.prod:5432/shop     ← one command, decoded.
                                                base64 protects from shoulder-surfing, nothing else

✅ Do — good use cases

  • Source of truth in AWS Secrets Manager/SSM + External Secrets Operator — rotation, audit, one place
  • RBAC that keeps get secret away from humans who don't need it (topic 27)
  • Enable EKS envelope encryption (KMS) so secrets are encrypted inside etcd too

❌ Don't — anti-patterns

  • Secret YAML with base64 values committed to git — that's plaintext with sunglasses on; git history is forever
  • Secrets in ConfigMaps, pod env dumps in logs, or kubectl describe screenshots in Slack — leak paths people forget
Pattern & benefit: the Secret object gives you the injection mechanics + RBAC boundary; Secrets Manager gives lifecycle (rotation, versioning, cross-account audit). Wiring them via ESO means an RDS password rotation propagates to pods with zero deploys — the difference between "we should rotate" and rotating.
Gotcha — why it goes wrong: same restart trap as ConfigMaps (env vars read at boot — rotate the secret, pods keep the old password until rolled), plus the false-comfort trap: base64 in YAML looks encrypted to newcomers and gets committed. It is | base64 -d away from plaintext. Treat any secret that ever touched git as burned — rotate it.
18Namespaces — apartments in the cluster

Scenario: staging and prod on one cluster, three teams sharing it — namespaces carve the cluster into scoped, quota-limited, access-controlled compartments.

🧠
Analogy: apartments in one building: each flat (namespace) has its own front-door key (RBAC), its own electricity budget (ResourceQuota), and its own room names — flat 3's "kitchen" doesn't clash with flat 5's. But the building shares plumbing and walls: nodes, and the network — by default any flat can knock on any other flat's door (topic 32 adds the locks).
Code
$ kubectl create namespace staging
$ kubectl get pods -n staging          # -n on everything, or:
$ kubectl config set-context --current --namespace=staging   # switch default

# DNS across namespaces — same service name, no clash:
#   http://shop            → shop in YOUR namespace
#   http://shop.staging.svc → staging's shop, explicitly

# guardrails per namespace:
$ kubectl apply -n staging -f - <<EOF
apiVersion: v1
kind: ResourceQuota
metadata: { name: staging-quota }
spec:
  hard: { requests.cpu: "8", requests.memory: 16Gi, pods: "40" }
EOF
Output
$ kubectl get ns
NAME              STATUS   AGE
default           Active   240d
kube-system       Active   240d    ← the cluster's own machinery — look, don't touch
staging           Active   1m
prod              Active   239d
$ kubectl describe quota -n staging
requests.cpu:    6250m / 8       ← quota being consumed

✅ Do — good use cases

  • Namespace per environment or per team — with RBAC and quotas attached from day one
  • kubens/context defaults so you stop typing -n (and stop forgetting it — see gotcha)
  • Prod in its own namespace minimum; separate cluster when blast-radius money is on the table

❌ Don't — anti-patterns

  • Everything in default — no quotas, no access boundaries, and one team's runaway CronJob eats everyone's nodes
  • Treating namespaces as security isolation — they're organizational; network (topic 32) and node isolation are separate work
Pattern & benefit: most K8s objects are namespace-scoped, so RBAC ("this team, this namespace"), quotas, and cleanup (delete namespace staging-pr-42 — everything inside vanishes) all inherit the boundary. Ephemeral per-PR namespaces are the cleanest preview-environment trick in the book (topic 56 can drive them).
Gotcha — why it goes wrong: the wrong-namespace facepalm is a rite of passage: "my pods are GONE" = you're looking in default while they run in staging (kubectl get pods -A is the panic button). The dangerous version runs the other direction — applying prod YAML while your context points at staging works fine, silently. Put the namespace in your shell prompt; it pays for itself the first week.

Keeping Apps Alive & Scaled

19Requests & limits — the resource contract★ used daily

Scenario: the two numbers that decide where pods schedule, who gets throttled, and who gets killed at 2am. Get these wrong and the symptoms look like anything but what they are.

🧠
Analogy: booking a co-working desk: the request is your reservation — "I need one desk and two monitors" — the floor manager (scheduler) only seats you where that's free. The limit is the house rule — spread onto three desks (memory over limit) and security escorts you OUT (OOMKilled); hog the shared printer (CPU over limit) and you just wait in line (throttled). Eviction jargon: reserve nothing and sit anywhere free (BestEffort) — first out when the floor fills.
Code
resources:
  requests:            # what the SCHEDULER reserves — sizing truth
    cpu: 250m          # 0.25 cores
    memory: 512Mi
  limits:              # the enforcement line
    memory: 512Mi      # over this → OOMKilled (exit 137!)
    # cpu limit omitted on purpose — throttling hurts latency;
    # many prod shops set memory limit = request, no CPU limit.
The two failure signatures — memorize them
$ kubectl get pod shop-x
shop-x   0/1   Pending      ← requests don't FIT any node
  Events: 0/5 nodes available: insufficient memory.

$ kubectl describe pod shop-y | grep -A2 "Last State"
Last State:  Terminated
  Reason:    OOMKilled     ← memory limit hit. Exit code 137 (topic 10!)
$ kubectl top pods         # actual usage vs your numbers
shop-z   412m   1490Mi     ← request says 512Mi... that's the problem

✅ Do — good use cases

  • Set requests on EVERYTHING — they're the scheduler's only truth and Karpenter's sizing input (topic 41)
  • Memory limit = memory request (predictable kills beat noisy neighbors); think hard before CPU limits
  • kubectl top / metrics history to size from reality, not vibes

❌ Don't — anti-patterns

  • No requests (BestEffort) in prod — scheduled anywhere, evicted first, invisible to autoscalers
  • Copy-pasted 100m/128Mi on a JVM/Rails app — a boot-time OOM loop that looks like a code bug
Pattern & benefit: honest requests make everything downstream true: bin-packing density, HPA math (topic 22 scales on percentage of request), Karpenter's node choices, and cost attribution (topic 46). This is the highest-leverage YAML stanza in the whole cluster.
Gotcha — why it goes wrong: the two signatures point opposite directions and get confused constantly: Pending + "insufficient" = requests too big (or cluster full — topic 41); OOMKilled/137 restarts = limit too small (or a real leak). And CPU throttling is the stealth one — no kill, no event, just mysterious p99 latency; check throttling metrics before blaming the app (topic 45).
20Probes — liveness, readiness, startup★ used daily

Scenario: three health checks with three different consequences: restart me (liveness), don't send me traffic yet (readiness), give me time to boot (startup). Confusing them causes self-inflicted outages.

🧠
Analogy: a restaurant kitchen. Readiness is the "OPEN" sign — flipped off during rush-cleanup, customers route to other branches, nobody gets fired. Liveness is the fire alarm — trips only when the kitchen is truly unrecoverable, and the response is drastic: gut and rebuild (restart). Startup is the "opening soon" paper on a new branch — checks pause until first opening, so nobody calls the fire brigade on a kitchen still installing ovens.
Code
containers:
  - name: app
    readinessProbe:              # gate TRAFFIC (Service endpoints — topic 14)
      httpGet: { path: /up, port: 3000 }
      periodSeconds: 5
      failureThreshold: 3        # 15s of failures → out of rotation
    livenessProbe:               # gate EXISTENCE — restart on failure
      httpGet: { path: /up, port: 3000 }
      periodSeconds: 10
      failureThreshold: 6        # a full minute before the nuclear option
    startupProbe:                # boot grace — others wait for this
      httpGet: { path: /up, port: 3000 }
      failureThreshold: 30       # 30 × 5s = up to 150s to boot
      periodSeconds: 5
Output — readiness in action during a bad deploy
$ kubectl get pods -w
shop-new-1   0/1   Running   0    ← up but NOT ready: no traffic, deploy holds
$ kubectl get endpoints shop
shop   10.0.42.17:3000,10.0.51.3:3000    ← only OLD ready pods serving
$ kubectl describe pod shop-new-1 | tail -2
Warning  Unhealthy  Readiness probe failed: HTTP 500   ← the reason, in Events

✅ Do — good use cases

  • Readiness on every service pod — it's what makes rolling deploys (topic 21) actually zero-downtime
  • Liveness checks that test ONLY "is this process wedged" — no dependencies in the check
  • Startup probes for slow booters (Rails + big gem loads, JVMs) instead of inflated liveness delays

❌ Don't — anti-patterns

  • Liveness probe that pings the database — DB blips, ALL pods fail liveness, K8s restarts the entire fleet mid-incident: you've automated making outages worse
  • One identical endpoint doing all three jobs without thinking through the consequences each implies
Pattern & benefit: probes are how the platform learns your app's truth: readiness feeds Service endpoints and rollout gating; liveness recovers wedged processes at 3am unattended; startup absorbs boot variance. Deep principle: readiness failures are normal operations (deploys, warmups); liveness failures should be rare emergencies — tune thresholds to match.
Gotcha — why it goes wrong: aggressive liveness is the classic self-DDoS: pod under load responds slowly → probe times out → restart → cold cache makes the others slower → cascade of restarts. If RESTARTS climbs during traffic spikes, suspect the probe before the app. Readiness checking dependencies is arguable-but-dangerous the same way: DB blip = zero endpoints = total outage from a partial one.
21Rolling updates & rollback — deploys without downtime★ used daily

Scenario: ship v2 while v1 serves traffic, gated by readiness, reversible in one command — this is the choreography your CircleCI pipeline (topic 60) will trigger.

🧠
Analogy: replacing a bridge plank by plank while traffic flows: lay a new plank (surge pod), let inspectors certify it (readiness probe), only then remove an old plank (maxUnavailable). A plank fails inspection? Work halts — the bridge stays drivable on old planks. Rollback = the old blueprints are still in the site office (previous ReplicaSet, kept by revisionHistoryLimit).
Code
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1            # 1 extra pod may exist during the roll
      maxUnavailable: 0      # NEVER dip below 4 ready — true zero-downtime
  minReadySeconds: 10        # "ready" must hold 10s before counting
The choreography + the levers
$ kubectl set image deploy/shop app=$ECR/shop:9c4de01   # (CI does this)
$ kubectl rollout status deploy/shop
Waiting for rollout: 1 out of 4 new replicas updated...   ← plank by plank
deployment "shop" successfully rolled out

$ kubectl rollout history deploy/shop
REVISION  CHANGE-CAUSE
41        shop:8f3ab12
42        shop:9c4de01
$ kubectl rollout undo deploy/shop            # ← THE command, 5 seconds
$ kubectl rollout undo deploy/shop --to-revision=41

✅ Do — good use cases

  • maxUnavailable: 0 + real readiness probes for user-facing services — the pair IS zero-downtime
  • kubectl rollout status as your CI gate (topic 60) — pipeline fails if the roll fails
  • Keep app versions N/N-1 compatible (DB schema especially — your Rails migration discipline applies verbatim)

❌ Don't — anti-patterns

  • strategy: Recreate for services (kill all, then start new) — that's scheduled downtime wearing a strategy name
  • Rolling deploys without readiness probes — K8s counts "started" as "ready" and happily swaps live planks for uninspected ones
Pattern & benefit: the rollout is the reconciler (topic 11) walking between two ReplicaSets under safety constraints — which is why a bad image strands itself: new pods never go ready, old pods never terminate, users never notice (rollout undo at leisure). Progressive delivery (canaries) is this same machinery with weights.
Gotcha — why it goes wrong: two rollout stalls look identical (1 updated, 3 available... forever): a failing readiness probe on the new version — describe the new pod, read Events (topic 31) — or no room for the surge pod (topic 19's Pending). Also rollout undo reverts the pod template ONLY — config, secrets, and DB migrations don't ride along; your deploy process must keep those compatible (again: N/N-1).
22HPA — autoscaling pods on demand

Scenario: traffic triples at 8pm and halves at midnight. The HorizontalPodAutoscaler adds and removes replicas to hold a utilization target — the pod half of elasticity (nodes are topic 41).

🧠
Analogy: a supermarket manager watching average queue length at the tills: target is "70% busy" — queues build, open more tills (pods); tills idle, close some — but never below the two always-open (minReplicas), never more than exist in the building (maxReplicas). Note what he watches: percentage of each till's rated capacity — your requests (topic 19) define that rating.
Code
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: shop }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: shop }
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }   # % of REQUESTS
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300     # scale down slowly, up fast
Output
$ kubectl get hpa shop --watch
NAME  REFERENCE        TARGETS        MINPODS  MAXPODS  REPLICAS
shop  Deployment/shop  cpu: 64%/70%   3        30       6
shop  Deployment/shop  cpu: 91%/70%   3        30       9      ← 8pm rush
shop  Deployment/shop  cpu: 38%/70%   3        30       9      ← holding (stabilization)
shop  Deployment/shop  cpu: 41%/70%   3        30       5      ← eased down

✅ Do — good use cases

  • CPU-target HPA for request-serving fleets — 60-75% targets leave headroom for spikes
  • Scale queue workers on queue depth (KEDA/external metrics) — CPU is the wrong signal for consumers
  • minReplicas ≥ your zero-downtime floor; pair with a PDB (topic 29)

❌ Don't — anti-patterns

  • HPA + a hardcoded replicas: in the same YAML you keep re-applying — apply resets to 4, HPA scales back, forever fighting (omit replicas when HPA owns it)
  • Autoscaling on memory for apps that never release it (Ruby/JVM) — memory only ratchets up; it scales out and never in
Pattern & benefit: capacity follows load instead of following fear — the 3am fleet stops being sized for the 8pm rush, which with Karpenter consolidating the freed nodes (topics 41, 46) is where the real money appears. The behavior block encodes the operational wisdom: scale up eagerly, scale down suspiciously.
Gotcha — why it goes wrong: TARGETS: <unknown>/70% means the metrics-server add-on is missing or — the subtle one — pods have no CPU requests: utilization is "% of request," and % of nothing is unknowable (topic 19 again). HPA silently does nothing. Also mind the interaction: HPA wants more pods, cluster has no room — pods go Pending until node autoscaling (topic 41) reacts; elasticity needs BOTH layers awake.
23Jobs & CronJobs — run-to-completion work

Scenario: DB migrations, nightly reports, one-off backfills — work that should run, finish, and be verifiable. Deployments restart forever; Jobs run to completion.

🧠
Analogy: a Deployment hires permanent staff (always on shift, replaced when they quit); a Job hires a contractor for one task — done means paid and gone, failed means try another contractor (backoffLimit times), and the paperwork (finished pod + logs) stays on file. A CronJob is the standing weekly appointment that spawns a fresh contractor each time.
Code
apiVersion: batch/v1
kind: Job
metadata: { name: shop-migrate-8f3ab12 }   # unique name per run!
spec:
  backoffLimit: 2                # retries before giving up
  activeDeadlineSeconds: 600     # hard timeout
  ttlSecondsAfterFinished: 3600  # auto-cleanup the finished pod
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: $ECR/shop:8f3ab12
          command: ["bin/rails", "db:migrate"]
---
apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-report }
spec:
  schedule: "30 2 * * *"                 # 02:30 — in the CONTROLLER's TZ (UTC)
  concurrencyPolicy: Forbid              # never overlap runs
  jobTemplate: { spec: { template: { spec: {
    restartPolicy: Never,
    containers: [{ name: report, image: $ECR/shop:8f3ab12,
                   command: ["bin/rake", "reports:nightly"] }] } } } }
Output
$ kubectl apply -f migrate-job.yaml && kubectl wait --for=condition=complete \
    job/shop-migrate-8f3ab12 --timeout=600s
job.batch/shop-migrate-8f3ab12 condition met        ← CI gates on this (topic 60)
$ kubectl logs job/shop-migrate-8f3ab12 | tail -1
== 20260711 AddStatusToOrders: migrated (0.0213s) ==

✅ Do — good use cases

  • Migrations as Jobs gated by kubectl wait before the rollout (topic 60's pattern)
  • concurrencyPolicy: Forbid for anything non-reentrant; idempotent payloads regardless (retries!)
  • ttlSecondsAfterFinished — or finished pods pile up for archaeology nobody does

❌ Don't — anti-patterns

  • Migrations in container entrypoints ("migrate then serve") — 4 replicas race the migration on every deploy; one Job, then the rollout
  • restartPolicy: Always in a Job template — invalid; and OnFailure restarts in place (logs vanish) vs Never + backoff (new pod, old logs kept — usually better)
Pattern & benefit: completion is a first-class, machine-checkable state — pipelines wait on it, failed is visible, retries are declarative, and CronJobs replace the pet server whose crontab nobody dares touch. Unique job names per run (SHA-suffixed) make history greppable: every migration that ever ran, as objects.
Gotcha — why it goes wrong: CronJob schedules run in the controller's timezone — UTC on EKS — so 30 2 * * * is 08:00 IST; "the report runs at the wrong time" is this, every time (set timeZone: "Asia/Kolkata", supported in modern K8s). And a CronJob whose previous run hangs + Forbid silently skips forever — activeDeadlineSeconds is the seatbelt.
24DaemonSets — one pod per node

Scenario: log shippers, node monitoring agents, the CNI itself — some things must run on every node, including nodes that don't exist yet.

🧠
Analogy: a smoke detector building code: not "install 12 detectors" (a count — that's a Deployment) but "every room has one, including rooms built next year." Add a floor (node joins via Karpenter — topic 41) and detectors appear in each new room automatically; demolish the floor and its detectors go with it.
Code — a log shipper on every node
apiVersion: apps/v1
kind: DaemonSet
metadata: { name: fluent-bit, namespace: logging }
spec:
  selector: { matchLabels: { app: fluent-bit } }
  template:
    metadata: { labels: { app: fluent-bit } }
    spec:
      tolerations:                       # run even on tainted nodes (topic 28)
        - operator: Exists
      containers:
        - name: fluent-bit
          image: $ECR/fluent-bit:3.1
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits:   { memory: 128Mi }
          volumeMounts:
            - { name: varlog, mountPath: /var/log, readOnly: true }
      volumes:
        - { name: varlog, hostPath: { path: /var/log } }
Output
$ kubectl get ds -n logging
NAME        DESIRED  CURRENT  READY  NODE SELECTOR
fluent-bit  5        5        5      <none>
$ kubectl get pods -n logging -o wide | head -3
fluent-bit-2xk   Running   ip-10-0-40-113...    ← one per node,
fluent-bit-8mz   Running   ip-10-0-41-207...       names match nodes

✅ Do — good use cases

  • Node-level infrastructure: log collection (topic 45), metrics agents, security scanners
  • Tight requests/limits — this cost multiplies by node count forever (topic 46)
  • Broad tolerations — infra should run on the tainted nodes too

❌ Don't — anti-patterns

  • DaemonSets for apps — "one per node" is an infrastructure shape, not a scaling strategy (that's HPA)
  • hostPath mounts beyond read-only system paths — you're drilling holes in the container boundary
Pattern & benefit: the per-node contract is enforced by the reconciler (topic 11), so elasticity stays safe: Karpenter can add 20 nodes at 8pm and every one comes up shipping logs and metrics from second one — no "we forgot to install the agent on the new nodes" class of incident, ever.
Gotcha — why it goes wrong: you'll meet DaemonSets before you write one — aws-node (the VPC CNI, topic 39) and kube-proxy live in kube-system as DaemonSets, and a sick CNI pod on one node makes that node's pods fail weirdly while the rest of the cluster hums. When one node's workloads all misbehave: kubectl get pods -n kube-system -o wide | grep <node> before blaming your app.
25StatefulSets & PVCs — when pods need identity

Scenario: Kafka, Elasticsearch, Redis — things where "which replica am I?" and "my disk follows me" matter. StatefulSets give pods stable names, per-pod volumes, and ordered rollout.

🧠
Analogy: Deployment pods are hotel guests — any room, new room every visit, luggage-free. StatefulSet pods are tenants with name-plated flats: kafka-0 always returns to flat 0, whose storage unit in the basement (its PVC → an EBS volume) holds its stuff across every checkout. Renovations proceed flat-by-flat in order, never all at once.
Code — the parts that differ from a Deployment
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: kafka }
spec:
  serviceName: kafka-headless      # gives per-pod DNS
  replicas: 3
  selector: { matchLabels: { app: kafka } }
  template:
    metadata: { labels: { app: kafka } }
    spec:
      containers:
        - name: kafka
          image: $ECR/kafka:3.7
          volumeMounts: [{ name: data, mountPath: /var/lib/kafka }]
  volumeClaimTemplates:            # ← a PVC PER POD, born and bound with it
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp3      # EBS CSI (topic 42)
        resources: { requests: { storage: 100Gi } }
Output — identity everywhere
$ kubectl get pods -l app=kafka
kafka-0   1/1   Running     ← stable names, ordered 0→1→2
kafka-1   1/1   Running
kafka-2   1/1   Running
$ kubectl get pvc
data-kafka-0   Bound   pvc-9f2...   100Gi   gp3    ← per-pod disks
data-kafka-1   Bound   pvc-c81...   100Gi   gp3
# per-pod DNS: kafka-0.kafka-headless.default.svc  ← brokers address each other

✅ Do — good use cases

  • Clustered stateful systems where members have roles/identity: Kafka, ES, etcd-likes
  • gp3 StorageClass with volumeBindingMode: WaitForFirstConsumer — the volume lands in the pod's AZ (topic 42)
  • Serious question first: does this belong in RDS/MSK/ElastiCache instead? Usually yes (topic 46)

❌ Don't — anti-patterns

  • StatefulSets because "we have a volume" — one shared read-only config disk isn't identity; state ≠ StatefulSet by default
  • Running your primary Postgres on self-managed StatefulSets while a managed RDS exists — you know exactly how much care Postgres deserves; that's the argument for RDS
Pattern & benefit: the trio — stable name, stable per-pod DNS, stable per-pod storage — is exactly what quorum systems need to survive pod churn: kafka-1 that dies comes back as kafka-1, reattached to kafka-1's disk, findable at kafka-1's address. Ordered, one-at-a-time updates respect quorum during upgrades.
Gotcha — why it goes wrong: EBS volumes are AZ-lockedkafka-0's disk lives in ap-south-1a forever, so if only 1b has capacity, kafka-0 sits Pending with "volume node affinity conflict" (topic 42). And deleting the StatefulSet keeps the PVCs (data safety by default) — returning replicas re-adopt old disks, which is either the feature you wanted or a very confusing "where is this old data from" moment. Deliberate cleanup: delete PVCs explicitly.

Beyond the Basics

26Init containers & sidecars — helpers in the pod

Scenario: "wait for the DB before booting", "ship this pod's logs", "terminate TLS next to the app" — jobs that belong beside your container, not inside your app code.

🧠
Analogy: a rock concert. Init containers are the road crew — rigging and soundchecks run in order, to completion, and the band doesn't walk out until the crew walks off. Sidecars are the sound engineer at the desk during the show — a separate person working the whole gig alongside the band, sharing the same stage (network + volumes).
Code
spec:
  initContainers:
    - name: wait-for-db                  # runs FIRST, must exit 0
      image: postgres:16-alpine
      command: ["sh", "-c",
        "until pg_isready -h $DB_HOST; do echo waiting; sleep 2; done"]
    - name: run-once-setup               # then this one
      image: $ECR/shop:8f3ab12
      command: ["bin/rails", "db:prepare"]   # (dev clusters only — see topic 23!)

  containers:
    - name: app
      image: $ECR/shop:8f3ab12
    - name: log-shipper                  # sidecar — lives with the app
      image: $ECR/vector:0.39
      restartPolicy: Always              # "native sidecar": starts before app,
      volumeMounts:                      #  stops after it (K8s 1.29+)
        - { name: logs, mountPath: /var/log/app }
Output
$ kubectl get pod shop-x
shop-x   0/2   Init:0/2    ← road crew stage 1 (readable in STATUS!)
shop-x   0/2   Init:1/2
shop-x   0/2   PodInitializing
shop-x   2/2   Running     ← band + engineer both up
$ kubectl logs shop-x -c wait-for-db    # -c picks the container

✅ Do — good use cases

  • Init: dependency waits, fetching config/assets, permissions fix-ups — sequential, run-once
  • Sidecars: log/metric shippers, proxies (service mesh), auth helpers — concurrent, whole-lifetime
  • Native sidecars (initContainer + restartPolicy: Always) so shippers outlive the app's shutdown

❌ Don't — anti-patterns

  • Heavy work in init on every pod start — 5 minutes of init × every scale-up event = your HPA (topic 22) reacting in geological time
  • Sidecar-per-concern sprawl — each adds requests/limits (topic 19) multiplied by every replica; DaemonSets (topic 24) are the cheaper shape for node-wide agents
Pattern & benefit: separation with shared fate — the app container stays a pure app (no retry-wrapper shell scripts baked into images), while helpers version and fail independently but share localhost and volumes. The pod abstraction (topic 12) exists substantially to make this composition possible.
Gotcha — why it goes wrong: a looping init container shows as Init:CrashLoopBackOff and people debug the app that never even started — read the STATUS column and logs -c <init-name> first. Pre-native-sidecar clusters have the shutdown race too: log shipper dies before the app's last logs flush — that final-crash evidence you needed most, gone (the native sidecar ordering fixes exactly this).
27RBAC & ServiceAccounts — who may do what

Scenario: the CI deployer should update Deployments in prod — and nothing else. The metrics pod should read pods — and nothing else. RBAC is the permission grammar; ServiceAccounts are identities for non-humans.

🧠
Analogy: office keycards. A Role is a keycard template ("opens meeting rooms on floor 3"); a RoleBinding prints that card for a person or a robot vacuum (ServiceAccount — robots get badges too). ClusterRole templates open doors on every floor. Nobody gets the master key labeled cluster-admin just because asking twice was annoying.
Code — least-privilege deployer
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: deployer, namespace: prod }
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "patch", "update"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]           # enough to watch a rollout
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: ci-deployer, namespace: prod }
subjects:
  - kind: User
    name: circleci-deployer          # ← mapped from IAM (topic 36!)
roleRef: { kind: Role, name: deployer, apiGroup: rbac.authorization.k8s.io }
Verify — before CI finds out for you
$ kubectl auth can-i patch deployments -n prod --as=circleci-deployer
yes
$ kubectl auth can-i delete secrets -n prod --as=circleci-deployer
no      ← exactly as designed
$ kubectl auth can-i --list -n prod --as=circleci-deployer   # full card summary

✅ Do — good use cases

  • Role per function (deployer, reader, oncall-debugger), bound narrowly per namespace
  • kubectl auth can-i --as in CI setup — test the card before trusting the pipeline to it
  • Dedicated ServiceAccount per workload that talks to the K8s API (default SA should stay powerless)

❌ Don't — anti-patterns

  • cluster-admin for CI "to unblock the deploy" — your pipeline is now a cluster-wide remote-execution service one leaked token away
  • Wildcard verbs/resources (verbs: ["*"]) — the whole point was the enumeration
Pattern & benefit: RBAC is additive-only (no "deny" to reason about) — read any subject's power as the union of its bindings, verifiable in one can-i --list. On EKS this snaps onto IAM (topic 36): AWS identities map into these Users/Groups, so your existing IAM hygiene extends into the cluster instead of competing with it. ServiceAccounts complete the story for pods — and IRSA (topic 38) rides on them for the AWS side.
Gotcha — why it goes wrong: RBAC gates the K8s API, not your app's endpoints — a Service (topic 14) is reachable per network rules (topic 32) regardless of anyone's keycards; teams conflate these constantly. And the sneaky escalation: the right to create pods in a namespace ≈ the right to mount any ServiceAccount in it — pod-creation rights are broader than they look; treat them as such.
28Scheduling — taints, tolerations & affinity

Scenario: GPU nodes only for ML pods, spot nodes only for retry-safe workers, replicas spread across AZs — steering which pods land where.

🧠
Analogy: seating a wedding. A taint is a "RESERVED — family only" sign on a table: it repels everyone without the matching invitation note (a toleration). Affinity is guest preference: "seat me near the stage" (node affinity) — and topology spread is the planner's rule "don't put all the groom's friends on one table" (replicas across AZs), or one tipped table takes out the whole friend group.
Code
# taint the special nodes (Karpenter/nodegroup does this via config):
$ kubectl taint nodes ip-10-0-40-113 workload=gpu:NoSchedule
Pod side — toleration + affinity + spread
spec:
  tolerations:                       # "I'm allowed at the reserved table"
    - { key: workload, operator: Equal, value: gpu, effect: NoSchedule }
  affinity:
    nodeAffinity:                    # "and I WANT that table"
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - { key: node.kubernetes.io/instance-type, operator: In,
                  values: [g5.xlarge, g5.2xlarge] }
  topologySpreadConstraints:         # replicas across AZs
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway
      labelSelector: { matchLabels: { app: shop } }
Output
$ kubectl get pods -l app=shop -o wide
shop-...-2xk   Running   ip-10-0-40-113   ap-south-1a
shop-...-8mz   Running   ip-10-0-51-207   ap-south-1b   ← spread held
shop-...-p4w   Running   ip-10-0-63-88    ap-south-1c

✅ Do — good use cases

  • Taint expensive/special nodes (GPU, spot, high-mem) — only tolerating pods land there
  • Topology spread across zones for every serious Deployment — one AZ event shouldn't be an outage
  • Remember topic 24: DaemonSets need broad tolerations to cover tainted nodes

❌ Don't — anti-patterns

  • Hard required affinity where preferred would do — over-constrained pods go Pending in every capacity crunch
  • Pinning to specific node names/IPs — nodes are cattle (Karpenter recycles them constantly — topic 41)
Pattern & benefit: remember the direction and everything follows: taints repel (node pushes pods away), affinity attracts (pod pulls toward nodes), tolerations merely permit. The taint+toleration+affinity trio is how one cluster safely mixes on-demand web, spot workers, and GPU batch — the topic 46 cost story depends on it.
Gotcha — why it goes wrong: a toleration does NOT schedule the pod onto the tainted node — it only allows it; without matching affinity, your GPU pod may happily land on a cheap CPU node and fail at runtime. Pair them. And NoSchedule taints added later don't evict existing pods (that's NoExecute) — the mixed state after re-tainting confuses everyone's mental model of "but the taint is there!"
29PDBs & graceful shutdown — surviving disruption

Scenario: node upgrades, Karpenter consolidation, spot reclaims — pods get evicted routinely. Two safety nets decide whether users notice: PodDisruptionBudgets (how many at once) and graceful shutdown (how each one exits).

🧠
Analogy: renovating a staffed restaurant. The PDB is the manager's rule: "never pull more than one waiter off the floor at a time" — renovators (node drains) must wait between pulls. Graceful shutdown is each waiter's exit routine: stop taking new tables (fail readiness), finish serving current ones (drain in-flight requests), then leave. SIGKILL is the trapdoor under a waiter mid-order.
Code — both nets
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: shop }
spec:
  minAvailable: 2                     # (or maxUnavailable: 1)
  selector: { matchLabels: { app: shop } }
---
# in the pod template — the exit routine:
spec:
  terminationGracePeriodSeconds: 45   # time between SIGTERM and SIGKILL
  containers:
    - name: app
      lifecycle:
        preStop:
          exec: { command: ["sh", "-c", "sleep 8"] }
          # ← absorbs the endpoint-removal race: keep serving briefly
          #   while Services stop routing to us
Output — a drain respecting the PDB
$ kubectl drain ip-10-0-40-113 --ignore-daemonsets
evicting pod default/shop-7f9b-2xkcd
error when evicting pod "shop-7f9b-8mzpq": Cannot evict pod as it would
violate the pod's disruption budget.    ← waits, retries — THE NET WORKING
pod/shop-7f9b-2xkcd evicted
$ # sequence per pod: preStop hook → SIGTERM → (app drains) → gone
$ # only if still alive at 45s: SIGKILL (exit 137)

✅ Do — good use cases

  • PDB for every multi-replica service — it's what makes node ops (topics 41, 44) boring
  • Handle SIGTERM in the app (Puma/Sidekiq do natively) — finish in-flight, close connections, exit 0
  • preStop sleep of 5-10s — covers the seconds where you're terminating but still in Service endpoints

❌ Don't — anti-patterns

  • minAvailable = replica count — a PDB that permits zero disruptions blocks node upgrades forever (EKS will eventually force it — topic 44)
  • PDBs on single-replica Deployments — same deadlock, smaller cast; run 2+ replicas or accept the blip
Pattern & benefit: these two turn "the platform constantly kills pods" from terrifying to routine: PDBs pace voluntary disruptions cluster-wide (drains, consolidation), graceful shutdown makes each individual death invisible to users. Together with rolling updates (topic 21) they're the complete zero-downtime story — deploys, scale-downs, and upgrades all ride the same rails.
Gotcha — why it goes wrong: the shutdown race nobody expects: SIGTERM arrives before all kube-proxies/ALBs stop routing to the pod — an app that exits instantly on SIGTERM serves connection-refused for a few seconds on every single deploy, showing up as a mysterious low-grade 502 rate. The preStop sleep exists precisely for this. And PDBs don't cover involuntary disruption (node crash, OOM) — they pace the polite killers only.
30kubectl mastery — the 20 commands that matter★ used daily

Scenario: kubectl is your irb/psql for the cluster. Fluency here is the visible difference between reading about Kubernetes and operating it.

🧠
Analogy: kubectl verbs map to a hospital ward round: get is the glance at the ward board, describe is reading one patient's full chart including nurse notes (Events!), logs is what the patient says, exec is examining them directly, apply is updating the treatment plan, rollout undo is reverting to yesterday's meds.
The daily deck
# orient
$ kubectl get pods -o wide                  # -A for all namespaces
$ kubectl get deploy,svc,ingress -l app=shop
$ kubectl get events --sort-by=.lastTimestamp | tail -15   # cluster gossip

# inspect
$ kubectl describe pod shop-7f9b-2xkcd     # ALWAYS read Events at the bottom
$ kubectl logs deploy/shop --tail=100 -f   # aggregated, follow
$ kubectl logs shop-7f9b-2xkcd --previous  # the CRASHED container's logs!

# interact
$ kubectl exec -it deploy/shop -- bin/rails console      # yes, really
$ kubectl port-forward svc/shop 3000:80    # cluster service → localhost
$ kubectl cp shop-7f9b-2xkcd:/tmp/dump.rdb ./dump.rdb

# change (CI's verbs — humans prefer apply)
$ kubectl apply -f k8s/                    # directory at a time
$ kubectl rollout restart deploy/shop      # bounce pods, same spec
$ kubectl scale deploy/shop --replicas=6

# power tools
$ kubectl get pod shop-x -o yaml           # the FULL truth of any object
$ kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.podIP}{"\n"}{end}'
$ kubectl explain deployment.spec.strategy --recursive   # offline docs!
Output — the two aliases that save hours
$ alias k=kubectl
$ source <(kubectl completion bash)    # tab-complete pod names — non-negotiable
$ k get po                              # short names: po svc deploy rs ns cm ds

✅ Do — good use cases

  • describe + Events as your reflex for anything unhealthy (topic 31 systematizes this)
  • logs --previous for crash-looping pods — the current container hasn't failed yet
  • port-forward for poking cluster services safely from your laptop — no Ingress, no NodePort

❌ Don't — anti-patterns

  • kubectl edit on prod objects — un-reviewed, un-git-ed drift (topic 11); fine for experiments, banned as habit
  • delete pod as a fix without reading why it was sick — the reconciler gives you a fresh one that's sick the same way
Pattern & benefit: every kubectl command is an API call you could script — the same verbs power your CI (topic 60), your dashboards, and your 2am hands. kubectl explain is the underrated one: complete, version-accurate schema docs for any field, offline, no browser tabs.
Gotcha — why it goes wrong: context — every kubectl mistake story begins "I thought I was on staging." Your kubeconfig holds many clusters (topic 35); the prompt must show cluster + namespace (kube-ps1 or manual). Second habit-gotcha: logs deploy/shop picks ONE pod behind the deploy — for the full picture across replicas use -l app=shop --prefix, or you'll chase a bug that only exists on the replica you didn't read.
31Debugging workloads — a decision tree★ used daily

Scenario: pod's broken, users waiting. Instead of random kubectl-ing, walk the same tree every time: STATUS → Events → logs → exec. Each state has a short suspect list.

🧠
Analogy: ER triage, not house-MD guessing: the STATUS column is the triage tag that routes you — Pending goes to "can't be seated" (scheduling), ImagePullBackOff to "supply problem", CrashLoopBackOff to "patient keeps collapsing". Events are the paramedic's notes — what happened before arrival. Only then interview the patient (logs) and examine (exec).
The tree
Pending              → describe: Events say why —
                       "insufficient cpu/memory"     → topic 19 / 41
                       "didn't tolerate taint"       → topic 28
                       "volume node affinity"        → topic 42
ImagePullBackOff     → typo'd tag? missing repo (topic 6)? expired by
                       lifecycle policy (topic 7)? ECR perms on node role?
CrashLoopBackOff     → kubectl logs POD --previous   ← the crash's own words
                       exit 137 = OOM (topic 19) | 143 = SIGTERM | 1 = app error
Running, 0/1 READY   → readiness probe failing (topic 20): describe → Unhealthy events
Running, but weird   → kubectl exec -it POD -- sh    # env? DNS? disk full?
Service unreachable  → kubectl get endpoints SVC     # empty? topic 14's triple-check

# when there's no shell in the image (distroless):
$ kubectl debug -it shop-x --image=busybox --target=app   # ephemeral container
# node-level suspicion (all pods on ONE node sick):
$ kubectl get pods -A -o wide --field-selector spec.nodeName=ip-10-0-40-113
Output — a real 5-minute diagnosis
$ kubectl get pods → shop-x CrashLoopBackOff
$ kubectl logs shop-x --previous | tail -3
/usr/local/bundle/gems/pg-1.5.6/lib/pg.rb:63: could not connect to server
    (db.internal:5432)
$ kubectl exec -it shop-y -- nc -zv db.internal 5432   # from a HEALTHY pod
db.internal (10.0.8.44:5432) open   ← DB fine → check shop-x's env:
$ kubectl exec shop-x -- env | grep DB_HOST → DB_HOST=db.intrenal  ← typo. Done.

✅ Do — good use cases

  • Same tree every time — boring process beats brilliant guessing at 2am
  • --previous for crashes; exit codes decoded (topic 10's table transfers exactly)
  • kubectl debug ephemeral containers for slim images; the quarantine-by-label trick (topic 15) for live autopsies

❌ Don't — anti-patterns

  • Deleting the pod first — evidence gone, and the reconciler grows an identical sick one (topic 30)
  • Assuming the app is the patient — nodes, CNI pods (topic 24), DNS, and pulled-out-from-under volumes get sick too
Pattern & benefit: ~90% of workload incidents resolve inside this tree without leaving kubectl — and each branch terminates in a topic you now have. The habit that compounds most: Events first (describe, or get events --sort-by=.lastTimestamp) — Kubernetes usually states the diagnosis in English before you start guessing.
Gotcha — why it goes wrong: CrashLoopBackOff's BackOff is exponential — up to 5 minutes between attempts — so "it's not even trying" is usually "it's waiting to retry" (watch RESTARTS and the backoff timer in describe). And the cruelest evidence-loss: logs on a restarted container shows the new, healthy boot — without --previous you're reading the wrong incident entirely.
32NetworkPolicies — firewalls between pods

Scenario: by default every pod can reach every pod — the payments service is one SSRF away from anything in the cluster. NetworkPolicies are pod-level firewall rules, label-addressed.

🧠
Analogy: the default cluster is an open-plan office — anyone can walk to anyone's desk. NetworkPolicies install badge-readers on doors, with a crucial convention: the moment a desk gets ANY badge-reader rule, its door defaults to locked except for what the rules allow. Guards check wristbands (labels — topic 15), not names.
Code — lock down the DB tier
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: db-ingress, namespace: prod }
spec:
  podSelector:                       # WHO this shields
    matchLabels: { tier: db }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:               # only the app tier...
            matchLabels: { tier: app }
        - namespaceSelector:         # ...or anything in monitoring
            matchLabels: { name: monitoring }
      ports:
        - { protocol: TCP, port: 5432 }
---
# the classic baseline — default-deny, then allow explicitly:
# podSelector: {}  +  policyTypes: [Ingress]  and no ingress rules
#   = every pod in the namespace: locked by default
Output — prove it
$ kubectl exec -it app-pod -- nc -zv db.prod.svc 5432
db.prod.svc (172.20.8.44:5432) open              ← app tier: allowed
$ kubectl exec -it random-pod -- nc -zv db.prod.svc 5432
nc: connect ... Operation timed out               ← everyone else: dropped
                 ↑ note: DROPPED (timeout), not refused — the netpol signature

✅ Do — good use cases

  • Default-deny per namespace + explicit allows — the only version that stays honest over time
  • Shield the crown jewels first: DB tier, payment services, internal admin
  • Label-addressed rules (topic 15's wristbands) so policies survive pod churn

❌ Don't — anti-patterns

  • Forgetting DNS in egress policies — a default-deny-egress that blocks port 53 to kube-dns breaks everything, mysteriously
  • Assuming namespaces isolate (topic 18) — without netpols they're labels on an open floor
Pattern & benefit: lateral movement — the thing that turns one compromised pod into a cluster-wide incident — gets cut to the declared paths. On EKS the VPC CNI enforces these natively (or Cilium if installed), and because rules are label-based, they're YAML in git like everything else: reviewable security (topic 11's philosophy applied to the network).
Gotcha — why it goes wrong: two silent traps: (1) on some CNI setups policies aren't enforced until the feature/agent is enabled — your rules apply cleanly and do nothing; always test with nc like above. (2) The additive-lock semantics surprise: adding a narrowly-scoped ingress rule to a previously rule-less pod flips it from "allow all" to "deny all except this" — the unrelated traffic that broke was collateral of your first policy. Timeouts (not refusals) are your tell.
Part III — EKS · Kubernetes on AWS

Cluster Foundations

33EKS anatomy — what AWS runs vs what you run

Scenario: "managed Kubernetes" — but managed what, exactly? Knowing the control-plane/data-plane split tells you what can break on whose side, and what you're actually paying for.

🧠
Analogy: EKS is air traffic control as a service: AWS staffs the tower 24/7 (API server, etcd, scheduler — multi-AZ, patched, backed up) and you own the airplanes and runways (nodes, pods, networking add-ons). Tower fees are flat (~$0.10/hr); the planes bill like the EC2 they are. When something falls out of the sky, first question: tower problem or plane problem?
See the split
$ aws eks describe-cluster --name prod --query \
    "cluster.{ver:version,endpoint:endpoint,status:status}"
$ kubectl get nodes -o wide          # YOUR side: EC2 instances (or Fargate)
$ kubectl get pods -n kube-system    # the shared frontier:
NAME                        # aws-node (CNI), coredns, kube-proxy, csi drivers
                            # — run on YOUR nodes, shipped as EKS add-ons (topic 43)

# control plane logs land in CloudWatch when enabled (topic 45):
$ aws eks update-cluster-config --name prod \
    --logging '{"clusterLogging":[{"types":["api","audit","authenticator"],"enabled":true}]}'
Output
{ "ver": "1.32", "endpoint": "https://B2E7...gr7.ap-south-1.eks.amazonaws.com",
  "status": "ACTIVE" }
NAME               STATUS   VERSION   INSTANCE-TYPE
ip-10-0-40-113…    Ready    v1.32.x   m6i.large      ← your planes
ip-10-0-51-207…    Ready    v1.32.x   m6i.large

✅ Do — good use cases

  • Let AWS own the control plane worry (etcd backups, API HA) — that's the product
  • Enable control-plane logging (api/audit/authenticator) on day one — you'll want it the day you need it
  • Learn the kube-system roster — those pods (topic 24!) are where "AWS side" meets "your side"

❌ Don't — anti-patterns

  • Treating EKS as fully hands-off — nodes, add-on versions, upgrades (topic 44) and everything in Part II remain your job
  • One giant shared cluster for wildly different trust levels without the isolation work (topics 18, 27, 32) — the cluster is the blast radius
Pattern & benefit: the split gives you upstream-conformant Kubernetes minus the scariest ops (etcd!), with deep AWS integration at every seam — IAM for auth (topic 36), VPC for networking (topic 39), ALBs for ingress (topic 40), CloudWatch for logs (topic 45). Everything in Part II applies unmodified; Part III is learning the seams.
Gotcha — why it goes wrong: the cluster endpoint is public by default — fine to start (auth is still IAM + TLS), but most orgs want it private or CIDR-restricted, and flipping endpoint access on a live cluster is a change you plan, not improvise. Decide at creation. And note add-ons like CoreDNS run on YOUR nodes: "DNS is down" is usually a you-side capacity/eviction issue, not an AWS outage.
34Creating clusters — eksctl (and why IaC wins)

Scenario: from zero to a working cluster. eksctl turns ~40 manual steps (VPC, IAM roles, control plane, nodes, add-ons) into one declarative YAML — the fastest sane path to a real cluster.

🧠
Analogy: building the airport from topic 33: doing it in the console is hand-drawing blueprints during construction. eksctl is the prefab kit with a manifest — one document declares runways (VPC/subnets), tower hookup (IAM), and hangars (node groups), and the kit assembles it in the right order. Keep the manifest; it IS the airport's documentation.
cluster.yaml — declarative, in git
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: prod
  region: ap-south-1
  version: "1.32"
iam:
  withOIDC: true                  # enables IRSA (topic 38) — always yes
managedNodeGroups:
  - name: general
    instanceTypes: [m6i.large, m6a.large]
    minSize: 2
    maxSize: 10
    desiredCapacity: 3
    privateNetworking: true       # nodes in private subnets
addons:
  - { name: vpc-cni }
  - { name: coredns }
  - { name: kube-proxy }
  - { name: aws-ebs-csi-driver }
Output
$ eksctl create cluster -f cluster.yaml
2026-07-11 …  building cluster stack "eksctl-prod-cluster"     ← CloudFormation
2026-07-11 …  deploying stack (control plane ~10 min)
2026-07-11 …  building managed nodegroup "general"
2026-07-11 …  EKS cluster "prod" in "ap-south-1" is ready ✓   (~15-20 min total)
$ kubectl get nodes    # kubeconfig updated automatically

✅ Do — good use cases

  • Config-file mode always (never a 12-flag one-liner) — the YAML in git is your cluster's source of truth
  • withOIDC: true from day one — retrofitting IRSA later is annoying ceremony
  • Graduating to Terraform/CDK when the org does — same declarative idea, broader estate

❌ Don't — anti-patterns

  • Console-clicking a production cluster — unreproducible, undiffable, and six months later nobody knows why subnet-b exists
  • One cluster config mutated by hand afterward — eksctl owns its CloudFormation stacks; out-of-band edits make drift it can't reconcile
Pattern & benefit: the config file captures every decision that's painful to change later (VPC layout, OIDC, private networking) in reviewable form — a second cluster (staging, DR, another region) is a copy with three lines changed. Under the hood it's CloudFormation, so teardown is equally clean: eksctl delete cluster actually deletes everything.
Gotcha — why it goes wrong: plan the VPC before the first apply — pod networking (topic 39) drinks IPs by the hundreds, and undersized subnets (/24s, say) strangle a growing cluster with "failed to assign an IP address" months later, when re-IPing is a migration. Also: whoever creates the cluster becomes its first admin (topic 36) — create it as a role your team can assume, not as one person's laptop identity.
35Connecting — kubeconfig, contexts & the auth flow★ used daily

Scenario: how does kubectl on your laptop authenticate to a cluster in Mumbai? One command writes the kubeconfig, and understanding the flow makes every access error decodable.

🧠
Analogy: your kubeconfig is a keyring of hotel keycards, one per cluster; the current context is whichever card is in your hand — half of all ops accidents are swiping the wrong one. EKS cards are special: they don't store a key at all, but a note saying "ask AWS for a fresh 15-minute key" — kubectl runs the AWS CLI behind your back on every call.
Code
$ aws eks update-kubeconfig --name prod --region ap-south-1
Updated context arn:aws:eks:ap-south-1:2038…:cluster/prod in ~/.kube/config

# the keyring:
$ kubectl config get-contexts
CURRENT   NAME                          CLUSTER
*         …cluster/prod                 prod
          …cluster/staging              staging
$ kubectl config use-context …cluster/staging     # swap cards (or: kubectx)

# what the kubeconfig actually does per request:
#   exec: aws eks get-token --cluster-name prod
#   → short-lived token, signed by YOUR IAM identity
$ aws sts get-caller-identity      # ← "who am I" — the first auth-debug command
Output
{ "Arn": "arn:aws:sts::2038…:assumed-role/platform-admin/balu" }
$ kubectl get nodes
NAME              STATUS   AGE     ← IAM identity → (topic 36) → RBAC → allowed

✅ Do — good use cases

  • Cluster + namespace in your shell prompt (kube-ps1) — the cheapest outage insurance there is (topic 30)
  • kubectx/kubens for fast, visible switching
  • sts get-caller-identity FIRST on any access error — most are wrong-profile, not wrong-RBAC

❌ Don't — anti-patterns

  • Sharing kubeconfig files around the team — each person's file just wraps their own IAM identity; share the update-kubeconfig command instead
  • Long-lived static tokens for humans anywhere — the 15-minute exec flow exists so credentials age out by design
Pattern & benefit: no cluster passwords exist anywhere — access rides your existing IAM (SSO, MFA, assumed roles all just work), tokens expire in minutes, and CloudTrail sees every get-token. The same flow powers CI (topic 53): a pipeline that can assume a role can reach the cluster with zero stored secrets.
Gotcha — why it goes wrong: the error taxonomy — memorize it: error: You must be logged in (Unauthorized) = AWS side: expired SSO session, wrong AWS_PROFILE (get-caller-identity reveals it). Forbidden: user cannot list pods = you're IN, but RBAC says no — that's topic 36/27 territory. And i/o timeout on the endpoint = network, not auth (private endpoint + you're off-VPN). Three different doors, three different keys.
36Access entries — mapping IAM into the cluster

Scenario: your teammate's IAM role can sign in (topic 35) but "cannot list pods" — because authentication (AWS knows who you are) and authorization (the cluster knows what you may do) are wired separately. Access entries are the wire.

🧠
Analogy: the national passport (IAM) proves who you are, but the building's tenant register (access entries) decides whether that passport holder gets a keycard, and which keycard template (topic 27's RBAC roles, or an AWS-managed policy like "AmazonEKSAdminPolicy"). New hire with a valid passport but no register entry: stopped in the lobby with "Unauthorized."
Code — the modern way (access entries API)
# grant a role cluster-admin (the break-glass/platform team entry):
$ aws eks create-access-entry --cluster-name prod \
    --principal-arn arn:aws:iam::2038…:role/platform-admin
$ aws eks associate-access-policy --cluster-name prod \
    --principal-arn arn:aws:iam::2038…:role/platform-admin \
    --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \
    --access-scope type=cluster

# grant CI a NARROW entry — mapped to k8s RBAC (topic 27's deployer Role):
$ aws eks create-access-entry --cluster-name prod \
    --principal-arn arn:aws:iam::2038…:role/circleci-deployer \
    --kubernetes-groups deployers        # ← RoleBindings bind this group

$ aws eks list-access-entries --cluster-name prod
Output
{ "accessEntries": [
    "arn:…:role/platform-admin",
    "arn:…:role/circleci-deployer",     ← group: deployers → Role: deployer (topic 27)
    "arn:…:role/eks-node-role"          ← nodes get one automatically
]}

✅ Do — good use cases

  • Access entries (the API) over the legacy aws-auth ConfigMap — auditable, Terraform-able, no risky ConfigMap surgery
  • Map roles, not individual users — people join teams; teams assume roles
  • AWS-managed policies for broad tiers (admin/view); kubernetes-groups + your own RBAC for precise ones (CI!)

❌ Don't — anti-patterns

  • Everyone through one shared admin entry — CloudTrail then says "platform-admin did it" about five different humans
  • Hand-editing the aws-auth ConfigMap on older clusters without ceremony — one bad indent locked whole teams out of clusters (ask around)
Pattern & benefit: the full chain is now legible: IAM identity → access entry → (managed policy OR kubernetes-group) → RBAC → verb on resource. Every hop is declarative and auditable, onboarding a team is one entry + one RoleBinding, and the old bootstrap horror ("only the cluster creator can get in") dissolved into a normal API you can Terraform.
Gotcha — why it goes wrong: the two errors from topic 35 route here: Unauthorized = no access entry for your principal (check get-caller-identity's ARN against list-access-entries — note it must be the role ARN, not the assumed-role session ARN). Forbidden = entry exists, but its group has no RoleBinding covering that verb (kubectl auth can-i — topic 27). Auth vs authz: knowing which failed is 90% of the fix.
37Managed node groups vs Fargate — the data plane menu

Scenario: your pods need machines. EKS gives three flavors: managed node groups (EC2 you see), Fargate (VMs you don't), and Karpenter-provisioned nodes (topic 41). Choosing wrong costs money or flexibility.

🧠
Analogy: staffing a kitchen: managed node groups are salaried chefs — you pick how many and their skill (instance type); idle hours still bill, but they're always warm. Fargate is a caterer per dish — each pod gets its own right-sized kitchen that exists only while cooking: zero idle, but pricier per plate and no shared pantry (no DaemonSets — topic 24!). Karpenter (topic 41) is the gig-economy staffing agency: chefs summoned to fit tonight's exact orders.
Code
# managed node group (in cluster.yaml — topic 34):
managedNodeGroups:
  - name: workers-spot
    instanceTypes: [m6i.large, m6a.large, m5.large]   # diversity = spot safety
    spot: true                                        # ~70% off (topic 46)
    labels: { workload: batch }
    taints: [{ key: spot, value: "true", effect: NoSchedule }]   # topic 28!

# Fargate profile — pods matching land on Fargate automatically:
fargateProfiles:
  - name: cron-stuff
    selectors:
      - namespace: cron          # every pod in ns "cron" → its own micro-VM
Output
$ kubectl get nodes -L eks.amazonaws.com/capacityType
NAME                    STATUS   CAPACITYTYPE
ip-10-0-40-113…         Ready    ON_DEMAND
ip-10-0-51-207…         Ready    SPOT
fargate-ip-10-0-77-9…   Ready    <none>     ← one "node" per Fargate pod

✅ Do — good use cases

  • Managed node groups (or Karpenter) as the default — full K8s feature set, best economics at steady load
  • Spot node groups/pools for interruption-tolerant work (workers, CI, batch) — tainted so only tolerating pods land (topic 28)
  • Fargate for spiky, isolated, low-ops workloads: cron jobs, one-off tasks, tiny internal tools

❌ Don't — anti-patterns

  • Whole production on Fargate for "no ops" — no DaemonSets (your log/metric agents — topic 24), slower starts, per-pod pricing that beats EC2 only when idle-heavy
  • Self-managed (unmanaged) node groups without a specific reason — you inherit AMI patching and drain choreography that managed groups do for you
Pattern & benefit: managed groups automate the tedious node lifecycle — AMI updates arrive as orderly rolling replacements that respect your PDBs (topic 29). The real design skill is mixing: on-demand for the web tier, spot for queues, Fargate for the weird cron nobody wants to size — one cluster, three economics (topic 46 does the math).
Gotcha — why it goes wrong: Fargate's fine print bites late: no DaemonSets means your logging strategy changes (sidecars or Fluent Bit built into the Fargate platform — topic 45), no EBS volumes (topic 42), and every pod boots a fresh micro-VM (~30-60s — your HPA response time just grew). Spot's fine print is the 2-minute interruption notice: graceful shutdown (topic 29) isn't optional there, it's the whole deal.
38IRSA & Pod Identity — pods calling AWS, keylessly★ used daily

Scenario: your app pod needs S3; the external-secrets pod needs Secrets Manager (topic 17). The wrong answer is AWS keys in a Secret. The right answer: bind an IAM role to a ServiceAccount.

🧠
Analogy: instead of hiding a master key under every doormat (static AWS keys in env vars), the building has a concierge with a guest list: a pod walks up wearing its ServiceAccount name-badge (topic 27), the concierge checks the list (IAM trust policy) and hands over a 15-minute visitor pass (STS credentials) for exactly the floors listed (the role's policy). Lost badge? Passes expire before it matters.
Code — IRSA via eksctl (Pod Identity is the newer sibling)
# create role + trust + ServiceAccount annotation in one shot:
$ eksctl create iamserviceaccount --cluster prod \
    --namespace default --name shop \
    --attach-policy-arn arn:aws:iam::2038…:policy/shop-s3-readwrite \
    --approve

# newer alternative — EKS Pod Identity (no OIDC ceremony):
$ aws eks create-pod-identity-association --cluster-name prod \
    --namespace default --service-account shop \
    --role-arn arn:aws:iam::2038…:role/shop-s3
Pod side — just use the ServiceAccount
spec:
  serviceAccountName: shop      # ← that's it. SDKs discover the rest.
  containers:
    - name: app
      image: $ECR/shop:8f3ab12  # aws-sdk / aws cli inside: just works
Output — prove it from inside the pod
$ kubectl exec -it deploy/shop -- aws sts get-caller-identity
{ "Arn": "arn:aws:sts::2038…:assumed-role/shop-s3/botocore-session-…" }
                                  ↑ the POD's own identity — no keys anywhere
$ kubectl exec -it deploy/shop -- env | grep AWS_
AWS_ROLE_ARN=arn:aws:iam::2038…:role/shop-s3
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/…/token

✅ Do — good use cases

  • One role per workload, least-privilege policy — shop reads its bucket, not s3:*
  • Every AWS-touching pod: app buckets, external-secrets (topic 17), ALB controller (topic 40), EBS CSI (topic 42) — all run on this
  • Prefer Pod Identity on new clusters (simpler); IRSA remains fine and ubiquitous

❌ Don't — anti-patterns

  • AWS access keys in Secrets/env — long-lived, leakable, unrotated; the exact thing this kills
  • One fat "cluster-aws-role" shared by all workloads — every pod can touch everything; blast radius maximized
Pattern & benefit: zero stored credentials, auto-rotating STS tokens, per-workload least privilege, and CloudTrail attribution per role — "which pod deleted that object" becomes answerable. It's the cluster-side twin of CI's OIDC (topic 53): the same "prove identity, receive short-lived credentials" move, everywhere.
Gotcha — why it goes wrong: the debugging flowchart for AccessDenied from a pod: (1) is serviceAccountName actually set (the default SA has nothing)? (2) does sts get-caller-identity in the pod show the expected role — if it shows the node's role, the association/annotation is broken; (3) only then look at the policy JSON. For IRSA specifically: the trust policy binds namespace+SA name — renaming either silently breaks the handshake.

Networking, Scaling & Ops

39VPC CNI — pods are real VPC citizens

Scenario: on EKS, every pod gets a real VPC IP — not an overlay address. That's why RDS security groups can allow pods directly, and why undersized subnets strangle clusters (topic 34's warning, explained).

🧠
Analogy: most Kubernetes networks give pods internal extension numbers behind a switchboard (overlay). The VPC CNI gives every pod a real phone number from the city's pool (subnet CIDR): anyone in the city (VPC) can dial a pod directly, city services (security groups, RDS, flow logs) treat it as a first-class line — and the city can run out of numbers.
See it
$ kubectl get pods -o wide | head -3
shop-7f9b-2xkcd   Running   10.0.42.17    ip-10-0-40-113…
shop-7f9b-8mzpq   Running   10.0.42.31    ip-10-0-40-113…
                            ↑ real subnet IPs, same range as the node

# the ENI math — why nodes have pod limits:
# m6i.large: 3 ENIs × 10 IPs ≈ 29 pods max, regardless of CPU/mem!
$ kubectl describe node ip-10-0-40-113… | grep -A1 "Allocatable" | grep pods

# because pods are VPC citizens, this works:
#   RDS security group: allow 5432 FROM sg-cluster-nodes
# and prefix delegation raises the density ceiling:
$ kubectl set env ds/aws-node -n kube-system ENABLE_PREFIX_DELEGATION=true
Output
pods: 29                        ← the ENI ceiling on m6i.large
# with prefix delegation:
pods: 110                       ← same instance, /28 prefixes per ENI slot

✅ Do — good use cases

  • Size pod subnets generously at VPC design time (topic 34) — /19-ish per AZ for real clusters; IPs are the resource that runs out silently
  • Prefix delegation on modern clusters — density stops being ENI-bound
  • Use VPC-native reachability: security groups to RDS/ElastiCache, flow logs for pod traffic forensics

❌ Don't — anti-patterns

  • Packing tiny pods onto big nodes without checking the pod-per-node ceiling — CPU idle, node "full" anyway
  • Ignoring failed to assign an IP address to container in events — that's subnet exhaustion announcing itself; it won't self-heal
Pattern & benefit: no overlay means no encapsulation tax and no translation layer when debugging — a pod IP in your Rails logs is the same IP in VPC flow logs, security groups, and RDS pg_stat_activity. Your AWS networking knowledge applies to pods with zero adaptation.
Gotcha — why it goes wrong: the two capacity ceilings are DIFFERENT: nodes fill by requests (topic 19) or by IP/ENI limit — whichever first. A cluster can go Pending-storms with acres of free CPU because subnets ran dry or per-node pod limits hit. describe node shows both ledgers; check the pods line, not just cpu/memory. (And remember topic 24: the CNI itself is the aws-node DaemonSet — a sick one takes its whole node's networking with it.)
40Ingress → ALB — the AWS Load Balancer Controller★ used daily

Scenario: HTTPS from the internet to your pods: one ALB, host/path routing, ACM certificates — declared as a Kubernetes Ingress and materialized by the controller.

🧠
Analogy: the Ingress is the reception desk blueprint ("shop.example.com → shop team, /api → API team"); the AWS Load Balancer Controller is the contractor who reads blueprints and builds the actual lobby (a real ALB, listeners, target groups). Edit the blueprint in git, the contractor renovates the lobby to match — you never lay bricks (click console) yourself.
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip          # ALB → pod IPs directly (topic 39!)
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443},{"HTTP":80}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:…:certificate/9a1…
    alb.ingress.kubernetes.io/healthcheck-path: /up
spec:
  ingressClassName: alb
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: shop, port: { number: 80 } } }
Output
$ kubectl get ingress shop
NAME   CLASS   HOSTS              ADDRESS
shop   alb     shop.example.com   k8s-default-shop-8f3ab12c-142…elb.amazonaws.com
                                   ↑ a real ALB appeared (~2 min); point DNS here
$ curl -I https://shop.example.com/up
HTTP/2 200        ← internet → ALB → pod IPs, TLS at the edge (ACM)

✅ Do — good use cases

  • One ALB fanning to many services via host/path rules (group.name annotation) — not an ALB per service (topic 14's cost warning)
  • target-type: ip on VPC CNI — pods as direct targets, no NodePort hop
  • ACM certs + ssl-redirect in annotations — TLS handled at the edge, renewed automatically

❌ Don't — anti-patterns

  • Hand-editing the ALB in the console — the controller reconciles it back (topic 11, but it's AWS resources now)
  • Skipping the controller install and using type: LoadBalancer everywhere — NLB-per-service sprawl with none of the routing
Pattern & benefit: the load balancer becomes YAML in git — reviewable, repeatable, deleted with the namespace. Deep synergy with everything prior: readiness (topic 20) gates target health, pod IPs (topic 39) are targets so deregistration is precise, and graceful shutdown (topic 29's preStop) covers the ALB drain window. The controller itself authenticates via IRSA (topic 38) — eat your own dogfood, fully.
Gotcha — why it goes wrong: Ingress created but no ADDRESS appears = the controller couldn't build — kubectl logs -n kube-system deploy/aws-load-balancer-controller names the reason (missing IAM permission via IRSA, or subnets lacking the kubernetes.io/role/elb tags — the two classics). And 502s after deploys are almost always the shutdown race from topic 29 (pod died faster than ALB deregistered) — preStop sleep, not app rewrites.
41Karpenter — nodes that fit the work

Scenario: HPA (topic 22) wants 12 more pods; the cluster has no room; pods sit Pending. Karpenter watches for exactly that and provisions right-sized EC2 in ~60 seconds — then consolidates it away when the rush ends.

🧠
Analogy: node groups are pre-booked banquet halls — fixed sizes, booked ahead, often half-empty. Karpenter is a venue broker watching the waitlist (Pending pods): "party of 12 with two vegetarians (GPU taints)? I'll book precisely that room, spot-priced" — and when guests leave, it merges the stragglers into one room and returns the rest (consolidation). The waitlist is its only boss.
NodePool — constraints, not instance lists
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: general }
spec:
  template:
    spec:
      requirements:
        - { key: karpenter.sh/capacity-type, operator: In, values: [spot, on-demand] }
        - { key: kubernetes.io/arch, operator: In, values: [amd64, arm64] }
        - { key: karpenter.k8s.aws/instance-category, operator: In, values: [m, c, r] }
      nodeClassRef: { name: default }       # AMI/subnets/SGs live here
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 60s
  limits: { cpu: "500" }                    # cluster-wide brake
Output — a scale-up event
$ kubectl get pods | grep Pending | wc -l
12                                          ← HPA scaled, no room
$ kubectl get nodeclaims -w
general-x7k2   ...   c6a.2xlarge   spot     Ready   68s   ← sized for the 12
$ kubectl get pods | grep Pending | wc -l
0
# later, 3am — consolidation notes in events:
Normal  DisruptionBlocked/Consolidated: replaced 3 nodes with 1 (saving $0.31/hr)

✅ Do — good use cases

  • Wide requirements (categories, both architectures) — flexibility is literally spot savings + availability
  • PDBs (topic 29) + honest requests (topic 19) FIRST — consolidation moves pods; these make it safe and smart
  • limits.cpu as the runaway brake — a bad HPA can't buy infinite EC2

❌ Don't — anti-patterns

  • Pinning one instance type "for consistency" — you've rebuilt a node group with extra steps and worse spot odds
  • Running Karpenter's own controller on nodes it manages — it must live on a small static group/Fargate, or it can evict itself (yes, really)
Pattern & benefit: the elasticity loop completes: HPA sizes the pod fleet to traffic (topic 22), Karpenter sizes the node fleet to the pod fleet, consolidation trims the fat continuously — capacity tracks load in both directions with bin-packing better than any human spreadsheet. This plus spot is where the topic 46 bill actually drops.
Gotcha — why it goes wrong: consolidation means routine pod eviction — workloads without PDBs and graceful shutdown (topic 29) experience it as "random 3am blips" and blame Karpenter (it was the messenger). And remember topic 39's ceilings: Karpenter computes pods-per-node from ENI limits too — weird "small" node choices are usually IP math, not a bug. Set consolidateAfter gentler for churn-sensitive fleets.
42Storage — EBS & EFS CSI drivers

Scenario: a PVC (topic 25) needs real disks behind it. On EKS that's the EBS CSI driver (fast, single-writer, AZ-locked) or EFS (shared, multi-writer, pricier) — choosing by access pattern.

🧠
Analogy: EBS is a personal storage unit welded to one neighborhood (AZ) — fast, private, one tenant at a time, and if you move across town the unit does NOT follow. EFS is a shared office filing room — everyone can open drawers simultaneously from any floor in any building (AZ), you pay per page stored, and it's slower per page than your private unit.
Code
# StorageClass — the gp3 default you actually want:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: gp3 }
provisioner: ebs.csi.aws.com
parameters: { type: gp3, encrypted: "true" }
volumeBindingMode: WaitForFirstConsumer   # ← bind AFTER scheduling: right AZ!
allowVolumeExpansion: true
---
# PVC — a Deployment-attached disk (single replica!) or via
# volumeClaimTemplates in a StatefulSet (topic 25):
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: reports-cache }
spec:
  accessModes: [ReadWriteOnce]            # EBS = one node at a time
  storageClassName: gp3
  resources: { requests: { storage: 50Gi } }
# EFS: accessModes: [ReadWriteMany], provisioner: efs.csi.aws.com
Output
$ kubectl get pvc reports-cache
NAME            STATUS   VOLUME      CAPACITY   STORAGECLASS
reports-cache   Bound    pvc-9f2…    50Gi       gp3
$ aws ec2 describe-volumes --filters Name=tag:kubernetes.io/created-for/pvc/name,Values=reports-cache \
    --query "Volumes[].{az:AvailabilityZone,type:VolumeType}"
[ { "az": "ap-south-1a", "type": "gp3" } ]     ← a real EBS volume, welded to 1a

✅ Do — good use cases

  • gp3 + WaitForFirstConsumer + encrypted as the default class — the three settings that prevent three incidents
  • EFS only when many pods genuinely write the same files (shared uploads, legacy apps)
  • First question, always: does this state belong in S3/RDS/ElastiCache instead? (topic 25's discipline)

❌ Don't — anti-patterns

  • RWO EBS volumes behind multi-replica Deployments — replica 2 deadlocks attaching a disk replica 1 holds
  • Immediate binding mode — volume born in 1a, pod schedulable only in 1b: the "affinity conflict" Pending from topic 25
Pattern & benefit: the CSI drivers make disks reconciled objects like everything else — PVC in git, volume provisioned/encrypted/expanded on demand (allowVolumeExpansion grows disks without pod surgery), snapshots via VolumeSnapshot API for backup pipelines. IRSA (topic 38) signs the driver's AWS calls, naturally.
Gotcha — why it goes wrong: AZ gravity is the recurring villain: EBS never crosses AZs, so a rescheduled pod can only follow its disk (Karpenter and the scheduler respect this — if binding waited). Multi-AZ + persistent local state = either EFS, per-AZ StatefulSets, or — usually — the honest answer that this state wanted RDS/S3 all along. Your Postgres instincts already know which.
43EKS add-ons — the cluster's system software

Scenario: VPC CNI, CoreDNS, kube-proxy, EBS CSI — the pods your cluster can't live without. EKS add-ons make them versioned, upgradeable objects instead of hand-applied YAML nobody owns.

🧠
Analogy: the difference between apps from the app store (managed add-ons — versioned, compatibility-checked, one-tap updates) and an APK someone sideloaded in 2024 (hand-applied manifests — works until it doesn't, and nobody remembers where it came from). Same software; wildly different upgrade day.
Code
$ aws eks list-addons --cluster-name prod
{ "addons": ["vpc-cni", "coredns", "kube-proxy", "aws-ebs-csi-driver"] }

# what versions exist / what's compatible with my k8s version?
$ aws eks describe-addon-versions --addon-name coredns \
    --kubernetes-version 1.32 \
    --query "addons[0].addonVersions[0].addonVersion"

# upgrade one — with explicit conflict strategy:
$ aws eks update-addon --cluster-name prod --addon-name coredns \
    --addon-version v1.11.3-eksbuild.2 \
    --resolve-conflicts PRESERVE          # keep my custom replica count etc.

$ kubectl get pods -n kube-system         # watch the rolling replacement
Output
{ "update": { "status": "InProgress", "type": "AddonUpdate" } }
coredns-7c9d…-x2k   1/1   Running   0   14s    ← rolled, respecting PDBs
coredns-7c9d…-9mw   1/1   Running   0   9s

✅ Do — good use cases

  • Managed add-ons for the core four + CSI drivers, Pod Identity agent, observability agents
  • PRESERVE conflict resolution when you've customized (CoreDNS replicas, CNI env like prefix delegation — topic 39)
  • Pin add-on versions in IaC (topic 34's cluster.yaml) — upgrades become diffs

❌ Don't — anti-patterns

  • Hand-editing add-on Deployments without PRESERVE awareness — the next add-on update steamrolls your changes (or conflicts and refuses; both surprise)
  • Ignoring add-on health — a Degraded vpc-cni is a cluster-wide incident incubating (topics 24, 39)
Pattern & benefit: the system layer becomes inventory you can audit: exact versions, compatibility validated against your K8s version, IRSA-wired permissions (the CSI/CNI need AWS access — topic 38), and upgrades that roll politely instead of "kubectl apply from some GitHub URL and pray." Upgrade day (topic 44) is mostly THIS list.
Gotcha — why it goes wrong: add-on compatibility is per-Kubernetes-minor — a cluster upgrade with stale add-ons (especially vpc-cni and kube-proxy) yields the weirdest post-upgrade bugs: DNS flaps, new nodes not joining, pods without IPs. The compatibility check (describe-addon-versions) is one command; skipping it is how upgrade war stories start. Order matters too: control plane → add-ons → nodes (topic 44).
44Cluster upgrades — the version treadmill, tamed

Scenario: Kubernetes releases ~3 minors/year; EKS supports each ~14 months (then extended support starts billing you extra ~6x for the control plane). Upgrades aren't optional — so make them boring.

🧠
Analogy: an escalator you must ride: stand still and the floor eventually moves under you anyway (forced upgrades / paid extended support). Riding safely is a fixed drill — check your bags (deprecated APIs), step on (control plane), bring your trolley (add-ons), then your luggage follows (node groups) one suitcase at a time (rolling, PDB-paced — topic 29). One floor per ride; no skipping.
The drill
# 0. preflight — what breaks? EKS runs upgrade insights for you:
$ aws eks list-insights --cluster-name prod \
    --filter kubernetesVersions=1.33 --query "insights[].{n:name,s:insightStatus.status}"
# (kubent/pluto scan manifests for deprecated APIs too)

# 1. control plane (one minor at a time — 1.32 → 1.33):
$ aws eks update-cluster-version --name prod --kubernetes-version 1.33

# 2. add-ons to versions compatible with 1.33 (topic 43)

# 3. nodes — managed groups roll respecting PDBs:
$ eksctl upgrade nodegroup --cluster prod --name general --kubernetes-version 1.33
#    (Karpenter nodes: drift replacement handles it — topic 41)

# 4. verify:
$ kubectl get nodes    # all v1.33.x
$ kubectl get pods -A | grep -v Running | grep -v Completed
Output
[ { "n": "Deprecated APIs removed in 1.33", "s": "PASSING" } ]
{ "update": { "type": "VersionUpdate", "status": "InProgress" } }   (~10 min)
NAME              STATUS   VERSION
ip-10-0-40-…      Ready    v1.33.1     ← rolled node by node, zero drama

✅ Do — good use cases

  • Quarterly cadence, staging first, same runbook every time — boring is the goal
  • Upgrade insights + deprecation scans BEFORE touching anything — API removals are the real breakage
  • PDBs everywhere (topic 29) — they're what makes step 3 a non-event

❌ Don't — anti-patterns

  • Camping on an old version "for stability" — you're trading small regular upgrades for one giant terrifying one, plus extended-support fees
  • Upgrading nodes before the control plane — kubelet must not be newer than the API server (the one ordering rule)
Pattern & benefit: the managed pieces carry the heavy lifting — control plane upgrades in place (~10 min, API keeps serving), managed node groups surge-and-drain politely, Karpenter replaces drifted nodes automatically. With the drill in a runbook (or CI!), an upgrade is an afternoon, not a project — which is what makes the quarterly cadence sustainable.
Gotcha — why it goes wrong: control planes upgrade one minor at a time and never downgrade — there is no undo button; your rollback plan is "workloads are fine because we tested in staging," not "revert the cluster." And the silent-until-fatal one: manifests using APIs removed in the target version (old Ingress/PSP-era groups) apply fine today and fail after — that's exactly what the preflight scans exist to catch.
45Observability — logs & metrics that answer questions

Scenario: a pod OOMed at 3:14am and its node is already consolidated away (topic 41). If logs and metrics didn't leave the cluster before the incident, they don't exist. Wire the pipeline on day one.

🧠
Analogy: pods are paper notebooks in a building that gets demolished nightly — anything worth keeping gets photocopied to an offsite archive (CloudWatch/Prometheus) continuously, by clerks stationed on every floor (a DaemonSet — topic 24). Metrics are the building's gauges photographed every minute; the archive answers "what did the pressure look like before the pipe burst."
The standard EKS wiring
# logs + metrics in one add-on (Container Insights):
$ aws eks create-addon --cluster-name prod \
    --addon-name amazon-cloudwatch-observability
# → Fluent Bit DaemonSet (stdout/stderr → CloudWatch Logs)
# → CloudWatch agent (node/pod metrics → Container Insights)

# the app side of the contract: log to STDOUT as JSON, one event per line
# (Rails: config.logger = stdout, lograge/json — no log FILES in containers)

# then questions become queries (Logs Insights):
fields @timestamp, kubernetes.pod_name, log
| filter kubernetes.labels.app = "shop" and log like /ERROR/
| sort @timestamp desc | limit 50

# the metrics that page you (alarms on):
# pod restarts spiking · deployment replicas < desired · node NotReady
# CPU throttling (topic 19!) · 5xx on the ALB (topic 40) · PVC near-full
Output — the 3:14am question, answered at 9am
@timestamp        pod                 log
03:14:02  shop-7f9b-2xkcd  Killed (OOM) — RSS 512Mi at limit      ← topic 19
03:13:58  shop-7f9b-2xkcd  ERROR ImportJob: loading 4.2M rows into memory
                            ↑ the cause, preserved from a pod that no longer exists

✅ Do — good use cases

  • stdout-only JSON logs from every app — the collector owns shipping; apps own content (12-factor)
  • Alert on symptoms users feel (5xx, latency, restarts) — not on every twitching gauge
  • Prometheus/Grafana (or AMP/AMG) when you outgrow Container Insights — same principles, richer queries

❌ Don't — anti-patterns

  • Logging to files inside containers — died with the pod, invisible to the DaemonSet, filled the node disk on the way out
  • kubectl logs as your production log strategy — it reads the node's ring buffer: gone on rotation, gone with the pod (topic 31's --previous is a courtesy, not an archive)
Pattern & benefit: the pipeline inverts debugging: instead of racing pod churn with kubectl, you interrogate history calmly — every ERROR across 40 replicas in one query, correlated with the metrics timeline (memory climbing for 20 minutes before the OOM). Karpenter's aggressive node recycling (topic 41) is only safe to run because evidence outlives nodes.
Gotcha — why it goes wrong: log volume is the bill nobody watched — debug-level logging × 40 replicas × 90-day retention makes CloudWatch a top-3 line item (topic 46); set log-group retention explicitly (7-30d hot, archive to S3) and keep prod at info. And multi-line stack traces need parser config or they arrive as 40 disjointed events — configure Fluent Bit's multiline once, thank yourself forever.
46Cost optimization — where the EKS money hides

Scenario: the bill doubled and "Kubernetes" is the line item. The money hides in four places — oversized requests, idle nodes, on-demand-everything, and data/log leakage — each with a specific fix you now know.

🧠
Analogy: an office lease audit: you're paying for desks nobody sits at (requests ≫ usage — topic 19), floors kept lit for two people (idle nodes Karpenter would consolidate — topic 41), premium leases where month-to-month would do (on-demand where spot fits — topic 37), and a storage room of unread paper (log retention — topic 45). The rent didn't rise; the usage rotted.
The audit, in commands
# 1. requests vs reality — the #1 leak:
$ kubectl top pods -A --sort-by=memory | head          # actual
$ kubectl get pods -A -o custom-columns=\
NAME:.metadata.name,REQ_MEM:.spec.containers[0].resources.requests.memory | head
# requested 2Gi, using 300Mi × 40 replicas = ~70Gi of phantom desks

# 2. node utilization — are floors lit for nobody?
$ kubectl top nodes
# consistently <40%? Karpenter consolidation (topic 41) or smaller instances

# 3. capacity mix — what % rides spot?
$ kubectl get nodes -L karpenter.sh/capacity-type --no-headers | \
    awk '{print $NF}' | sort | uniq -c

# 4. visibility — per-namespace/label cost attribution:
#    Kubecost / CloudWatch Container Insights / split-cost-allocation in CUR
Output — a typical first audit
   34 on-demand        ← workers & batch could be spot: −70% on that slice
    6 spot
nodes avg util: 31%    ← consolidation off / requests inflated
shop-worker  REQ 2Gi / actual 310Mi   ← ×40 replicas — the phantom floor
CloudWatch Logs: ₹48k/mo, retention: Never expire   ← the paper room

✅ Do — good use cases

  • Fix in leverage order: right-size requests (free!) → enable consolidation → spot for tolerant tiers → log retention
  • Attribution first (Kubecost/labels) — teams fix what they can see billed
  • Graviton (arm64) where images support it — same work, ~20% cheaper; Karpenter mixes it in automatically (topic 41's arch requirement)

❌ Don't — anti-patterns

  • Slashing requests blind to hit a number — undersized requests just move the cost to OOMs and Pending storms (topic 19's two signatures)
  • Spot for the stateful/singleton tier — the 2-minute reclaim (topic 37) is not a database's friend
Pattern & benefit: every fix is a topic you already hold: honest requests (19) shrink the fleet Karpenter (41) must provision, spot+taints (37, 28) reprice the tolerant tiers, PDBs (29) make all that churn invisible, retention (45) caps the archive. Cost work on EKS isn't a new skill — it's the same skills, pointed at the bill. 30-50% reductions on a first pass are normal, not heroic.
Gotcha — why it goes wrong: the invisible line items surprise most: NAT gateway data processing (pods in private subnets pulling images/APIs through NAT — ECR/S3 VPC endpoints fix it for pennies), cross-AZ data transfer between chatty services (topology-aware routing helps), and CloudWatch ingestion (topic 45). The EC2 line you stare at is often not where the growth is — read the CUR before optimizing the wrong thing (your pg_stat_statements instinct, applied to billing).
Part IV — CircleCI · Ship It Automatically

Pipeline Foundations

47.circleci/config.yml — jobs, steps, and the flow★ used daily

Scenario: one YAML file in your repo defines everything CircleCI does on every push. Learn its three nouns — steps in jobs in workflows — and every config on Earth becomes readable.

🧠
Analogy: a restaurant kitchen ticket system: a step is one instruction ("dice onions"), a job is one station's full ticket (prep station: dice, marinate, plate — run by one cook in one clean kitchen), and the workflow is the expeditor sequencing stations: "grill starts when prep finishes; dessert runs in parallel." Every push rings a new order in.
.circleci/config.yml — a real minimal pipeline
version: 2.1

jobs:
  test:
    docker:
      - image: cimg/ruby:3.3            # the job's clean kitchen (topic 48)
      - image: cimg/postgres:16.4       # sidecar service container!
        environment: { POSTGRES_PASSWORD: pw }
    steps:
      - checkout                        # git clone, wired for you
      - run: bundle install
      - run:
          name: Run tests
          command: bin/rails test
          environment: { DATABASE_URL: "postgres://postgres:pw@localhost:5432" }

  lint:
    docker: [{ image: cimg/ruby:3.3 }]
    steps: [checkout, { run: bin/rubocop }]

workflows:
  build:
    jobs:
      - test
      - lint          # no dependency → runs IN PARALLEL with test
Output — the UI reads exactly like the YAML
Workflow build  #1442  ✓ 3m12s
 ├─ test  ✓ 2m58s     (ran in parallel)
 └─ lint  ✓ 41s
$ git push → triggers automatically. That's the whole contract.

✅ Do — good use cases

  • Named run steps — "Run tests" in the UI beats a wall of raw commands
  • Service containers (postgres/redis) beside the job image — a throwaway DB per test run
  • Validate before pushing: circleci config validate (the local CLI — topic 59)

❌ Don't — anti-patterns

  • One mega-job doing lint+test+build+deploy in 40 steps — no parallelism, no partial reruns, one flaky step reruns everything
  • Copy-pasting steps across jobs — that's what commands/anchors and orbs (topic 54) exist for
Pattern & benefit: each job runs in a fresh, declared environment — "works in CI" therefore means "works reproducibly," the exact promise as containers themselves (topic 1). And because the config is code in the repo, pipeline changes ride the same PR review as the app changes they support.
Gotcha — why it goes wrong: every job starts from nothing — files created in test do NOT exist in lint: no shared disk, ever. Moving data between jobs is explicit: workspaces for build artifacts flowing down the pipeline, caches for dependencies across pipelines (topic 50) — confusing these two is the #1 beginner wall. Also: each run is its own shell — cd and exports don't survive between steps (use $BASH_ENV).
48Executors — docker vs machine (and resource_class)

Scenario: WHERE does a job run? The docker executor is fast and cheap; the machine executor is a full VM. Choosing wrong either slows every build or breaks Docker-in-Docker workflows (topic 51's whole drama).

🧠
Analogy: the docker executor is a fully-equipped rented kitchen — walk in, ovens hot, cook immediately; but it's inside someone else's building: you can't renovate (no privileged ops, no running your own containers natively). The machine executor is an empty plot with utilities — boots slower, costs more, but it's YOURS: build docker images, run compose stacks, mount anything. resource_class is simply the kitchen's size: small/medium/large burners (CPU/RAM).
Code
jobs:
  unit-tests:                      # 95% of jobs: docker executor
    docker:
      - image: cimg/ruby:3.3       # cimg/* = CircleCI's tuned convenience images
    resource_class: large          # 4 vCPU / 8GB — right-size per job!
    steps: [checkout, ...]

  build-image:                     # docker build needs a real VM (topic 51)
    machine:
      image: ubuntu-2404:current
    resource_class: large
    steps:
      - checkout
      - run: docker build -t shop:$CIRCLE_SHA1 .   # just works — it's your VM

  arm-build:                       # arm64 builds for Graviton nodes (topic 46!)
    machine: { image: ubuntu-2404:current }
    resource_class: arm.large
Output — the trade in numbers
docker executor  spin-up: ~2-5s    ← the default for a reason
machine executor spin-up: ~30-60s  ← pay it only when you need the VM
resource_class large vs medium: tests 11m → 6m (parallel-friendly suites)

✅ Do — good use cases

  • docker executor + cimg/* images for tests/lint/deploy steps — fastest spin-up
  • machine executor for: building images, docker compose integration tests, anything privileged
  • Right-size resource_class per job — a lint job on xlarge is money on fire; a test suite on small is time on fire

❌ Don't — anti-patterns

  • machine everywhere "so docker always works" — you've added ~a minute of boot to every job in the org
  • Custom job images rebuilt ad hoc when a cimg + 2 installs would do — now you maintain a base image too (unless you deliberately own one — then version it like any artifact, topic 5)
Pattern & benefit: executor choice per job means each job pays only for what it needs — snappy containers for the 20 test jobs, one real VM for the image build. And arm resource classes let CI build natively for the Graviton nodes Karpenter provisions (topic 41) — same architecture from build to runtime, no emulation weirdness.
Gotcha — why it goes wrong: running docker build in a docker-executor job fails with "Cannot connect to the Docker daemon" — there isn't one; the historical workaround setup_remote_docker gives you a remote daemon with its own quirks (files must be COPY-able from checkout, volumes don't mount your workspace). The clean answer for image work is the machine executor — decide by job type up front and this whole confusion never visits (topic 51 applies it).
49Workflows — fan-out, fan-in, and gates★ used daily

Scenario: lint, unit tests, and security scan in parallel; build only if all pass; deploy only from main. Workflows are the DAG that encodes your team's definition of "safe to ship."

🧠
Analogy: a relay race designed by an engineer: three runners start together on separate lanes (fan-out), the baton passes to the builder only when all three finish (fan-in via requires), and the anchor leg (deploy) only exists on the main track (branch filters). A dropped baton anywhere stops everything downstream — that's the point.
Code
workflows:
  build-test-deploy:
    jobs:
      - lint                          # ┐
      - test                          # ├─ fan-out: all start immediately
      - scan                          # ┘
      - build-image:
          requires: [lint, test, scan]     # fan-in: gate on ALL
          context: [aws-ecr]               # secrets scope (topic 52)
      - deploy-staging:
          requires: [build-image]
      - deploy-prod:
          requires: [deploy-staging]
          filters:
            branches: { only: main }       # the only track with an anchor leg
Output — the DAG, visualized by time
0:00  lint ▶            test ▶              scan ▶
0:41  lint ✓
2:58                    test ✓
3:10                                        scan ✓
3:10  build-image ▶                                ← started at MAX(deps), not SUM
5:02  build-image ✓ → deploy-staging ▶ ✓ → deploy-prod ▶ ✓
Total: 7m — the same jobs serially: 14m+

✅ Do — good use cases

  • Fan-out everything independent — wall-clock time is MAX of parallel branches, not the sum
  • Gates encode policy: nothing builds unless ALL checks pass; nothing hits prod except via staging
  • Failed at deploy? Rerun from failed — the DAG resumes; green jobs don't repeat

❌ Don't — anti-patterns

  • Chains of requires between independent jobs "to keep the graph tidy" — you serialized your parallelism
  • Deploy jobs without branch/tag filters — a feature-branch push deploying prod is a story you only tell once
Pattern & benefit: the workflow is your release policy as executable code — reviewable in the same PR, enforced identically at 3pm and 3am. Add a job to the fan-out and every future release inherits the check; nobody has to remember anything. The capstone (topic 60) is exactly this DAG pointed at ECR and EKS.
Gotcha — why it goes wrong: requires references job names as they appear in the workflow — rename a job (or alias it with name:) and forget one requires, and the config either errors or (worse) silently drops the dependency, letting deploy race ahead of tests. Diff workflow edits carefully — the graph IS the safety.
50Caching — stop reinstalling the world★ used daily

Scenario: bundle install takes 3 minutes on every single build — for the same Gemfile.lock as yesterday. Caches carry dependencies across pipelines; workspaces carry artifacts within one.

🧠
Analogy: a hotel kitchen that reorders every ingredient from scratch each morning vs one with a labeled pantry: the label is the checksum of the shopping list (Gemfile.lock) — list unchanged, cook from the pantry; list changed, shop once, relabel. The workspace is a different thing: the serving trolley wheeling today's prepped dishes from prep station to grill station (job → job, this pipeline only).
Code — both mechanisms
steps:
  - checkout
  - restore_cache:
      keys:
        - gems-v2-{{ checksum "Gemfile.lock" }}   # exact hit
        - gems-v2-                                 # prefix fallback: partial pantry
  - run: bundle install --path vendor/bundle       # fast if restored
  - save_cache:
      key: gems-v2-{{ checksum "Gemfile.lock" }}
      paths: [vendor/bundle]

  # workspace: pass the BUILT app down the DAG (topic 49):
  - persist_to_workspace: { root: ., paths: [public/assets, tmp/build] }
# and in a later job:
#   - attach_workspace: { at: . }
Output
restore_cache: found gems-v2-9f8c31…             (12s download)
bundle install: Using 214 gems from vendor/bundle (4s)      ← was 3m10s
save_cache: skipped — key exists                  (free)
Pipeline time: 9m → 4m. Multiply by every push, every day.

✅ Do — good use cases

  • Checksum-of-lockfile keys — cache exactness tied to the dependency truth (topic 22-of-Rails-playbook energy: the lockfile IS the receipt)
  • Fallback prefix keys — a partial pantry still beats empty shelves
  • Version prefix (gems-v2-) you can bump to nuke a poisoned cache instantly

❌ Don't — anti-patterns

  • Caching by branch name or a static key — stale forever, or a cache that never hits; checksums or bust
  • Workspaces for dependencies / caches for build outputs — right ideas, crossed wires: deps recur across pipelines (cache), artifacts flow within one (workspace)
Pattern & benefit: caching converts CI time from O(dependencies) to O(what-changed) — the same trick as Docker layer ordering (topic 2), applied one level up. Fast pipelines aren't vanity: they're deploy frequency, PR feedback while context is warm, and cheaper credits — all from four YAML lines.
Gotcha — why it goes wrong: caches are immutablesave_cache to an existing key silently no-ops; teams "update" a static-key cache for months while it serves January's gems (the fallback-key + re-save pattern handles this properly). And a corrupted cache produces impossible errors that survive reruns — that's what the v2 bump is for: change the prefix, force a clean world, move on with your day.
51Building images in CI — the ECR handshake★ used daily

Scenario: the pipeline's centerpiece: turn the commit into an image, tag it with the SHA (topic 5), push to ECR (topic 6) — fast, with layer caching that survives between builds.

🧠
Analogy: a bakery fulfilling the day's order: the machine executor is the bakery floor (real ovens — topic 48), the Dockerfile is the recipe whose prep-steps were smartly ordered (topic 2), DLC (Docker Layer Cache) keeps yesterday's par-baked bases on the rack so only today's topping changes get baked, and the ECR push is the delivery van with the batch number (SHA) stenciled on every crate.
Code — the build-push job
jobs:
  build-and-push:
    machine:
      image: ubuntu-2404:current
      docker_layer_caching: true        # DLC: layer cache across builds
    resource_class: large
    steps:
      - checkout
      - run:
          name: Auth to ECR (OIDC role — topic 53)
          command: |
            aws ecr get-login-password --region $AWS_REGION \
              | docker login --username AWS --password-stdin $ECR_HOST
      - run:
          name: Build and push
          command: |
            docker build -t $ECR_HOST/shop:$CIRCLE_SHA1 \
              --build-arg GIT_SHA=$CIRCLE_SHA1 .
            docker push $ECR_HOST/shop:$CIRCLE_SHA1
            # optional branch pointer for humans (mutable repo only — topic 9):
            # docker tag ... :main && docker push ... :main
Output — DLC earning its keep
#5 [2/6] COPY Gemfile Gemfile.lock ./         CACHED   ← topic 2's ordering
#6 [3/6] RUN bundle install                   CACHED   ← DLC across builds!
#7 [4/6] COPY . .                             0.4s
#9 exporting layers → pushing                 38s (only 2 layers changed)
Build+push: 1m41s (cold: 7m12s)

✅ Do — good use cases

  • machine executor + DLC for image jobs — the pairing that makes builds minute-scale
  • Tag with $CIRCLE_SHA1 — the pipeline variable IS your topic-5 strategy
  • Gate the push behind tests via requires (topic 49) — broken code never becomes an image

❌ Don't — anti-patterns

  • Building the image before/parallel-to tests then deploying it unconditionally — the gate exists to be stood behind
  • Secrets via --build-arg — build args bake into image history (topic 2's warning; docker history shows all)
Pattern & benefit: everything from Parts I converges here: layer ordering (2) + .dockerignore (4) decide cold-build speed, DLC amortizes it, SHA tags (5) make the artifact traceable, ECR policies (7-9) manage its lifetime. One job, ~20 lines, and every commit becomes a deployable, auditable artifact automatically.
Gotcha — why it goes wrong: DLC is per-volume and best-effort — parallel builds on different volumes miss the cache (looks random, isn't); high-volume teams add registry-based caching (--cache-from/buildx) for determinism. And multi-arch: building amd64-only while Karpenter provisions arm64 nodes (topics 41, 46) yields exec format error pods — build both (docker buildx --platform linux/amd64,linux/arm64) or constrain node arch. Decide deliberately, not at 2am.
52Env vars & contexts — secrets with a scope

Scenario: the deploy job needs AWS config; the notify step needs a Slack token — and none of it may live in the YAML. Project env vars for project things, contexts for shared+gated things.

🧠
Analogy: a film studio's prop lockers: each production (project) has its own locker, and the studio keeps shared lockers ("AWS gear", "Slack gear") that only certain productions may open (context restrictions) — and only for scenes on the approved list (workflow filters). Actors (jobs) never carry keys in their scripts (YAML); the locker hands props out at scene time.
Code
# set via UI/CLI, never YAML:
#   Project Settings → Environment Variables   (this repo only)
#   Organization Settings → Contexts           (shared, gated)

workflows:
  build-test-deploy:
    jobs:
      - deploy-prod:
          context: [aws-prod, slack-notifications]   # opt-in per job
          requires: [deploy-staging]
          filters: { branches: { only: main } }

# in the job — they're just env vars:
#   $AWS_REGION, $ECR_HOST, $SLACK_TOKEN ...
# built-ins you'll use constantly:
#   $CIRCLE_SHA1  $CIRCLE_BRANCH  $CIRCLE_BUILD_NUM  $CIRCLE_PULL_REQUEST
Output — masking in action
$ echo "deploying with $ECR_HOST"
deploying with **********                    ← values masked in logs
Job in workflow without context → $AWS_REGION: unset   ← scope enforced
Context "aws-prod" restricted to: main branch, platform-team group

✅ Do — good use cases

  • Contexts per secret domain (aws-staging, aws-prod, slack) — jobs request exactly what they need
  • Restrict prod contexts to protected branches/groups — a PR from a fork must never see prod credentials
  • Prefer OIDC (topic 53) over stored cloud keys at all — contexts then hold only the role ARN

❌ Don't — anti-patterns

  • Secrets in YAML, echoed in scripts, or in build args (topic 51) — the repo and logs are both leak surfaces
  • One "everything" context on every job — you've rebuilt the shared master key with extra YAML
Pattern & benefit: scoping is the actual security model: a compromised PR build in an unprivileged job simply has nothing to steal — prod credentials only materialize in the gated job, on the protected branch, for the approved group. Rotation is one UI edit, zero YAML churn, all pipelines pick it up next run.
Gotcha — why it goes wrong: masking is cosmetic, not containment — env | base64, a debug artifact, or a malicious dependency's postinstall script exfiltrates fine. Treat "code that runs in a privileged job" as the trust boundary: pin dependencies, gate contexts hard, and remember SSH reruns (topic 59) hand a human shell with the job's env — restrict who can trigger them on privileged jobs.

Pro Pipelines

53OIDC to AWS — deploys with zero stored keys★ used daily

Scenario: the old way: an IAM user's access keys pasted into CircleCI, unrotated since creation, powerful enough to deploy prod. The modern way: no stored keys at all — each job proves its identity and borrows a role for minutes.

🧠
Analogy: stored AWS keys are a master key mailed to a contractor, hoping they guard it forever. OIDC is photo-ID at the gate: the job arrives with a tamper-proof ID card issued by CircleCI (the OIDC token, stating org/project/branch), the AWS gatekeeper checks it against the guest list (trust policy) and issues a one-hour visitor badge (STS credentials). Nothing to steal at rest — the same move as IRSA (topic 38), one layer up.
AWS side — trust CircleCI's ID cards (once)
# IAM role "circleci-deployer" trust policy:
{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::2038…:oidc-provider/oidc.circleci.com/org/YOUR_ORG_ID" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": { "oidc.circleci.com/org/YOUR_ORG_ID:aud": "YOUR_ORG_ID" }
    # tighten further: sub claim can pin project and context
  }
}
# permissions: ECR push + EKS describe — least privilege (topics 27, 36)
Pipeline side
- run:
    name: Assume role via OIDC
    command: |
      CREDS=$(aws sts assume-role-with-web-identity \
        --role-arn arn:aws:iam::2038…:role/circleci-deployer \
        --role-session-name "circleci-${CIRCLE_BUILD_NUM}" \
        --web-identity-token "$CIRCLE_OIDC_TOKEN_V2" \
        --duration-seconds 3600 --output json)
      # export AWS_ACCESS_KEY_ID / SECRET / SESSION_TOKEN from $CREDS
      # (the aws-cli orb — topic 54 — wraps this whole dance in one line)
Output
$ aws sts get-caller-identity
{ "Arn": "arn:aws:sts::2038…:assumed-role/circleci-deployer/circleci-1442" }
                                             ↑ per-build session — CloudTrail gold
Stored AWS keys in CircleCI: 0

✅ Do — good use cases

  • OIDC for every AWS touch (ECR push, EKS deploy) — role ARN in a context (topic 52), zero secrets
  • Separate roles per environment: circleci-staging, circleci-prod — with the prod role assumable only by the gated context
  • Session names carrying the build number — CloudTrail answers "which build did that"

❌ Don't — anti-patterns

  • IAM-user keys in CI in 2026 — unrotated, org-wide, and the first thing every attacker greps for
  • One god-role for all pipelines — the fork-PR job and the prod deploy do not deserve the same badge
Pattern & benefit: the credential simply doesn't exist until the job runs, and expires before lunch — leak surface collapses to "compromise the running job itself" (which topic 52's scoping already gates). Notice the symmetry you now own end-to-end: pods→AWS via IRSA (38), kubectl→cluster via IAM (35/36), CI→AWS via OIDC — nobody, anywhere, stores a long-lived key.
Gotcha — why it goes wrong: $CIRCLE_OIDC_TOKEN_V2 only exists in jobs that use a context — no context, no token, and the assume-role fails with a misleading "invalid token" (attach any context, even an empty one). And scope the trust policy's sub condition: a trust policy checking only the org lets ANY project in your org assume the deployer role — pin project (and context) claims for prod roles.
54Orbs — packaged pipeline lego

Scenario: the ECR login dance, kubectl setup, Slack notifications — every team rewrites the same 30 lines. Orbs are versioned, published packages of jobs/commands/executors: bundle install for your pipeline.

🧠
Analogy: writing every pipeline step by hand is machining your own screws. Orbs are the standard parts catalog — certified fasteners (aws-ecr, kubernetes, slack) with version numbers and datasheets. You still design the machine (workflow — topic 49); you just stop lathing screws. And like any vendored part: pin the version, read the datasheet, know what's inside.
Code — the capstone's shopping list
version: 2.1

orbs:
  aws-cli: circleci/aws-cli@5.1      # OIDC setup in one command (topic 53!)
  aws-ecr: circleci/aws-ecr@9.3      # build+push, DLC-aware
  kubernetes: circleci/kubernetes@1.3
  slack: circleci/slack@5.1

jobs:
  build-push:
    machine: { image: ubuntu-2404:current, docker_layer_caching: true }
    steps:
      - checkout
      - aws-cli/setup:
          role_arn: $DEPLOY_ROLE_ARN          # the whole OIDC dance
      - aws-ecr/build_and_push_image:
          repo: shop
          tag: $CIRCLE_SHA1
      - slack/notify: { event: fail, template: basic_fail_1 }

# what's inside a command? read the source:
#   circleci orb source circleci/aws-ecr@9.3 | less
Output
Orb circleci/aws-ecr@9.3: command build_and_push_image
  → login, build (cache-aware), tag, push — 6 steps you didn't write
✓ pushed 2038…amazonaws.com/shop:8f3ab12
slack: (on fail only) posted to #deploys

✅ Do — good use cases

  • Certified/partner orbs for vendor glue: aws-cli, aws-ecr, kubernetes, slack, browser-tools
  • Pin versions (@5.1, not @volatile) — orbs are dependencies; treat them like the Gemfile treats gems
  • Private org orbs for YOUR repeated patterns — the deploy steps every service copies belong in one place

❌ Don't — anti-patterns

  • Random third-party orbs in privileged jobs without reading the source — you're piping secrets through someone's unaudited YAML (topic 52's trust boundary)
  • Orb-wrapping trivial one-liners — run: bin/rubocop needs no dependency
Pattern & benefit: orbs compress the undifferentiated 80% of pipeline code into audited, versioned imports — new service pipelines go from 300 lines to 60, and upgrades (new ECR auth flow, say) arrive as a version bump instead of a 12-repo find-and-replace. Private orbs do the same for your org's own conventions.
Gotcha — why it goes wrong: an orb is executable YAML from the internet running with your job's credentials — the supply-chain math is identical to gems/npm (pin, review, prefer certified). And debugging through an orb's abstraction can obscure which actual command failed: circleci orb source + the step's expanded output in the UI shows the real commands — read them before filing bugs against the wrong layer.
55Parallelism & test splitting — 20 minutes → 3

Scenario: the suite takes 20 minutes and every PR waits on it. Run it on 8 identical containers at once, each taking the slice the timing data says it should — CI's single biggest speed lever.

🧠
Analogy: 8 checkout lanes with a smart greeter: naive splitting sends equal numbers of customers per lane — one lane gets all the full trolleys (slow integration specs) and everyone waits on it anyway. Timing-based splitting is the greeter who knows each customer's usual checkout time and balances lanes by minutes, not headcount. The slowest lane defines the pipeline; the greeter's whole job is flattening it.
Code
jobs:
  test:
    docker: [{ image: cimg/ruby:3.3 }, { image: cimg/postgres:16.4, … }]
    parallelism: 8                    # 8 identical containers, indexed 0-7
    steps:
      - checkout
      - restore_cache: { keys: ["gems-v2-{{ checksum \"Gemfile.lock\" }}"] }
      - run: bundle install --path vendor/bundle
      - run:
          name: Run my slice
          command: |
            TESTS=$(circleci tests glob "test/**/*_test.rb" \
              | circleci tests split --split-by=timings)     # ← the greeter
            bin/rails test $TESTS
      - store_test_results: { path: test/reports }   # feeds timing data back!
Output
parallelism: 8 → containers 0-7
container 3: running 214 tests (est 2m41s)      ← balanced by timings
container 6: running 38 tests  (est 2m39s)      ← few tests, but the slow ones
Suite wall-clock: 21m04s → 2m58s
store_test_results: uploaded — next run splits even better + flaky tests flagged

✅ Do — good use cases

  • --split-by=timings + store_test_results — the feedback loop that keeps lanes level
  • Parallelize the suite before buying bigger resource_classes — 8 mediums usually beat 1 xlarge
  • Watch the UI's per-container timing bars — unbalanced bars = splitting is misconfigured

❌ Don't — anti-patterns

  • Tests that depend on each other or shared fixtures-in-order — parallel slices expose the coupling as "random" failures (fix the tests; they were lying anyway)
  • parallelism: 30 on a 4-minute suite — container spin-up + bundle install per container has a floor; there's a sweet spot, find it empirically
Pattern & benefit: test time stops scaling with suite size — it scales with (suite ÷ lanes), so the suite can keep growing while PR feedback stays at 3 minutes. store_test_results pays twice: timing data for splitting AND the flaky-test detection UI, which names the tests silently taxing every developer.
Gotcha — why it goes wrong: every container runs ALL steps — including DB setup against its own service containers (topic 47); tests hardcoding ports/shared external state collide across containers. And without stored timing data the first timed split falls back to filename splitting — the "it didn't get faster" first run is expected; the loop needs one green run to calibrate.
56Filters & conditionals — right pipeline, right trigger

Scenario: PRs get test+lint; main additionally deploys staging; version tags cut releases; docs-only changes skip the heavy jobs. Filters decide what runs when — precision here is speed AND safety.

🧠
Analogy: a train station's departure rules: commuter services run from every platform (tests on all branches), the express only departs platform 1 (deploys from main), the special holiday service runs only on flagged dates (tag releases — and note: someone must flag the date, tags don't trigger by default), and the museum shuttle doesn't run at all when the museum didn't change (path filtering).
Code
workflows:
  main-flow:
    jobs:
      - test        # no filters → every branch, every push
      - deploy-staging:
          requires: [test]
          filters: { branches: { only: main } }

  release:          # tag-driven — the explicit opt-in rule!
    jobs:
      - build-release:
          filters:
            tags:     { only: /^v\d+\.\d+\.\d+$/ }
            branches: { ignore: /.*/ }        # tags-only: MUST ignore branches

# path-based skipping (dynamic config / conditional steps):
- when:
    condition:
      matches: { pattern: "^docs/.*", value: << pipeline.parameters.changed-dir >> }
    steps: [run: echo "docs only — skipping build"]
Output
push feature/checkout   → workflow: main-flow (test only)
push main               → main-flow (test → deploy-staging)
push tag v1.42.0        → workflow: release (build-release)
push tag experiment     → nothing (regex didn't match) ✓

✅ Do — good use cases

  • Anchored regexes for release tags (/^v\d+\.\d+\.\d+$/) — sloppy patterns ship sloppy releases
  • Branch protection + filters together: the filter says "main deploys", protection says "main means reviewed"
  • Monorepos: path filtering / dynamic config so the mobile app's push doesn't rebuild the backend

❌ Don't — anti-patterns

  • Deploy jobs with no filters — topic 49's warning; the branch you forgot deploys prod
  • Complex bash-if trees inside jobs to fake filtering — the workflow graph should tell the truth about what runs
Pattern & benefit: filters make the pipeline cheap by default and dangerous only on purpose: feature branches burn minimal credits, deploys have exactly two doors (main, or a well-formed tag), and the release process becomes greppable YAML instead of tribal knowledge. Tag-based releases pair perfectly with SHA-tagged images (topics 5, 51): the tag names the release; the SHA names the bytes.
Gotcha — why it goes wrong: THE classic — jobs do not run for tags unless a tags filter exists on that job AND every job it requires: teams tag v1.0.0, nothing happens, confusion reigns. Add tag filters down the whole chain. Second: only: main with a typo'd branch name fails silently forever (nothing matches) — filters have no "unknown branch" warning; test them by pushing.
57Approval gates & scheduled pipelines

Scenario: prod deploys wait for a human thumbs-up; the nightly full-regression suite runs at 2am without anyone pushing. Two features, one theme: pipelines on your terms, not just git's.

🧠
Analogy: the approval gate is the two-man rule on the vault door — the machinery is armed (staging verified, image ready) but the final lever waits for an authorized hand, with the logbook recording whose. Scheduled pipelines are the night-shift janitor's clock: same building, same checklist, punched at 02:00 whether or not anyone worked that day.
Code
workflows:
  build-test-deploy:
    jobs:
      - test
      - deploy-staging: { requires: [test] }
      - hold-for-prod:                 # ← the vault lever
          type: approval               # a job that is ONLY a button
          requires: [deploy-staging]
          filters: { branches: { only: main } }
      - deploy-prod:
          requires: [hold-for-prod]
          context: [aws-prod]

  nightly-regression:
    triggers:
      - schedule:
          cron: "30 20 * * 1-5"        # 02:00 IST = 20:30 UTC — UTC again! (topic 23)
          filters: { branches: { only: main } }
    jobs:
      - full-e2e-suite
Output
Workflow build-test-deploy   #1447
 ✓ test → ✓ deploy-staging → ⏸ hold-for-prod  ON HOLD
   [Approve] clicked by balu@ at 14:32          ← audit trail
 ▶ deploy-prod ✓
Workflow nightly-regression  triggered by schedule @ 20:30 UTC ✓

✅ Do — good use cases

  • Approval before prod while the team builds deploy confidence — training wheels you remove deliberately later, not never
  • Restrict who can approve (contexts + groups — topic 52) — a gate anyone can click is theater
  • Schedules for nightly e2e, dependency audits, cache warming — work that shouldn't wait for a push

❌ Don't — anti-patterns

  • Approval gates on staging — gates belong where blast radius lives; friction everywhere = gates ignored everywhere
  • Schedules as cron-for-production-jobs (report generation, data syncs) — that's the cluster's CronJob (topic 23); CI schedules are for CI work
Pattern & benefit: the approval job needs zero code — it's pure workflow state with an audit log, and everything upstream (image, staging verification) is already done when the human decides: clicking approve is deciding, not waiting. On-hold workflows park indefinitely (they auto-cancel after ~90 days), so Friday's build can be Monday's approved deploy — the artifact didn't rot (topic 5's immutability, again).
Gotcha — why it goes wrong: approval jobs pass through requires chains people forget: any job requiring the approval job ALSO waits — accidentally gating your notification job behind the human. And schedule times are UTC (the third time this playbook has said this — it will still get you once). Note scheduled workflows here; CircleCI's newer "scheduled pipelines" feature is the same idea configured via API/UI — either way, the cron is UTC.
58Pipeline parameters — one config, many shapes

Scenario: "rebuild without cache", "deploy this branch to staging NOW", "run the expensive suite on demand" — parameters make the pipeline callable like a function instead of hackable via dummy commits.

🧠
Analogy: a config without parameters is a vending machine with one button — want the other snack, you shake the machine (push empty commits, comment out YAML). Parameters add the keypad: same machine, coded selections — B4 = deploy-only, C2 = no-cache rebuild — callable from the API by humans, bots, or other pipelines.
Code
version: 2.1

parameters:
  deploy_only:
    type: boolean
    default: false
  force_env:
    type: enum
    enum: ["", staging, prod]
    default: ""

workflows:
  build-test-deploy:
    when: { not: << pipeline.parameters.deploy_only >> }
    jobs: [test, lint, build-image, …]

  manual-deploy:
    when: << pipeline.parameters.deploy_only >>
    jobs:
      - deploy:
          env: << pipeline.parameters.force_env >>
Trigger via API — the keypad in action
$ curl -X POST https://circleci.com/api/v2/project/gh/balu/shop/pipeline \
    -H "Circle-Token: $TOKEN" -H "Content-Type: application/json" \
    -d '{"branch":"main","parameters":{"deploy_only":true,"force_env":"staging"}}'
{ "number": 1449, "state": "pending" }
→ pipeline 1449 runs ONLY manual-deploy → staging. No commit. No YAML edit.

✅ Do — good use cases

  • Operational buttons: deploy-only, skip-cache, run-expensive-suite — the things people fake with empty commits
  • enum types over free strings — force_env: prod can be validated; "prdo" cannot
  • Dynamic config (setup workflows) when parameters must be computed — e.g. from changed paths (topic 56's monorepo case)

❌ Don't — anti-patterns

  • Parameter sprawl — 15 booleans breeding 2^15 untested pipeline shapes; keep the keypad small
  • Parameters as a secrets channel — they're visible in the pipeline UI/API; secrets live in contexts (topic 52)
Pattern & benefit: the pipeline grows an API: ChatOps ("deploy staging" in Slack → curl), other pipelines triggering this one (the ops repo kicks the app repo's deploy), and reruns-with-intent replace the empty-commit ritual entirely. Combined with when: logic, one config file serves as several pipelines without copy-paste drift.
Gotcha — why it goes wrong: parameters resolve at config compile time<< pipeline.parameters.x >> is substituted before any job runs, so you can't set one from a step's output mid-pipeline (that's dynamic config's continuation trick, a different tool). And a workflow whose when: is false simply doesn't exist for that run — "where did my workflow go" is usually a parameter default you forgot you set.
59Debugging CI — SSH into the actual failure★ used daily

Scenario: green locally, red in CI, and the log line doesn't explain why. Stop the guess-push-wait loop: rerun the job with SSH and stand inside the failed environment.

🧠
Analogy: reading CI logs is studying photos of a crashed car; SSH rerun is walking into the garage where the wreck still sits — engine warm, skid marks fresh (same container, same env, same checkout, paused post-mortem). Ten minutes hands-on beats forty photo-guesses pushed one commit at a time.
The loop
# in the UI: Rerun → "Rerun job with SSH" → job runs, fails, then WAITS
# step "Enable SSH" prints:
$ ssh -p 64535 203.0.113.42            # your GitHub SSH key gets you in

circleci@container:~/project$ env | grep DATABASE_URL   # what does CI see?
circleci@container:~/project$ bin/rails test test/models/order_test.rb
circleci@container:~/project$ ls tmp/ log/              # leftover state?

# local sanity checks BEFORE pushing at all:
$ circleci config validate               # catch YAML errors in 1s
$ circleci local execute test            # run a job on your laptop's docker

# make failures explain themselves next time:
#   - store_artifacts: { path: log/test.log }
#   - store_artifacts: { path: tmp/screenshots }   # system test screenshots!
#   - store_test_results: { path: test/reports }   # failure summaries in UI
Output — a classic catch
circleci@container:~$ bin/rails test …
PG::ConnectionBad: could not connect: FATAL: role "shop" does not exist
circleci@container:~$ env | grep POSTGRES
POSTGRES_USER=postgres        ← CI's service container user ≠ local dev user.
                                 Fixture of the genre. 10 minutes, solved.

✅ Do — good use cases

  • SSH rerun as the SECOND move (after reading the full step output) — not the tenth
  • store_artifacts for logs/screenshots on every test job — most failures then explain themselves without SSH
  • circleci config validate in a pre-commit hook — YAML typos never reach the queue

❌ Don't — anti-patterns

  • The "fix? fix? fix?" commit spiral — twenty broken commits polluting history for what one SSH session answers
  • Leaving SSH reruns unrestricted on privileged jobs — a shell with the job's env is a shell with its secrets (topic 52's warning, live)
Pattern & benefit: the debugging decision tree from topic 31 gets a CI twin: read output → check artifacts → SSH in → interrogate env/state. The build container holds the truth ("works on my machine" is always an environment diff — env vars, service versions, parallel slices), and SSH puts your hands on it. The session lives ~10 min idle / 2h max, then self-cleans.
Gotcha — why it goes wrong: some failures vanish under SSH observation: flaky tests that only fail under full parallelism (topic 55's coupling), or timing-dependent races that a paused rerun doesn't reproduce. That's a finding, not a dead end — it tells you the bug lives in test interdependence or timing, which is exactly where to look. And remember the rerun uses the same commit: if the failure was infra-flake, plain "rerun from failed" (topic 49) is the cheaper first move.
60Capstone — the full pipeline: test → ECR → EKS★ the goal

Scenario: everything in one file: push to main → tests fan out → image builds and lands in ECR → migrations run as a Job → EKS rolls the Deployment → pipeline verifies the rollout. Sixty topics, one YAML.

🧠
Analogy: the full restaurant chain, end to end: recipe tested in the test kitchen (CI), batch-cooked and crated with a batch number (image → ECR), the recipe card updated at every branch (manifests), kitchens switched over one burner at a time while diners keep eating (rolling update, PDB-paced), and the area manager doesn't go home until every branch confirms the new dish serves (rollout status gate). One dropped pan anywhere stops the rollout chain-wide.
.circleci/config.yml — the whole thing, annotated by topic
version: 2.1
orbs:
  aws-cli: circleci/aws-cli@5.1                       # 54

jobs:
  test:
    docker: [{ image: cimg/ruby:3.3 }, { image: cimg/postgres:16.4,
               environment: { POSTGRES_PASSWORD: pw } }]
    parallelism: 4                                     # 55
    steps:
      - checkout
      - restore_cache: { keys: ["gems-v2-{{ checksum \"Gemfile.lock\" }}"] }  # 50
      - run: bundle install --path vendor/bundle
      - save_cache: { key: "gems-v2-{{ checksum \"Gemfile.lock\" }}",
                      paths: [vendor/bundle] }
      - run: |
          TESTS=$(circleci tests glob "test/**/*_test.rb" | circleci tests split --split-by=timings)
          bin/rails test $TESTS
      - store_test_results: { path: test/reports }

  build-push:
    machine: { image: ubuntu-2404:current, docker_layer_caching: true }  # 48, 51
    steps:
      - checkout
      - aws-cli/setup: { role_arn: $ECR_PUSH_ROLE }    # 53 — OIDC, no keys
      - run: |
          aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_HOST   # 6
          docker build -t $ECR_HOST/shop:$CIRCLE_SHA1 .    # 2, 3, 4
          docker push $ECR_HOST/shop:$CIRCLE_SHA1          # 5 — SHA tag

  deploy:
    docker: [{ image: cimg/deploy:2024.11 }]           # has kubectl + aws
    steps:
      - checkout
      - aws-cli/setup: { role_arn: $DEPLOY_ROLE }      # 53
      - run: aws eks update-kubeconfig --name prod     # 35 → 36 (access entry → RBAC 27)
      - run:                                           # migrations FIRST, as a Job — 23
          name: Run migrations
          command: |
            sed "s|IMAGE|$ECR_HOST/shop:$CIRCLE_SHA1|" k8s/migrate-job.yaml \
              | kubectl apply -f -
            kubectl wait --for=condition=complete job/migrate-$CIRCLE_SHA1 --timeout=600s
      - run:                                           # the rollout — 13, 21
          name: Deploy and VERIFY
          command: |
            kubectl set image deploy/shop app=$ECR_HOST/shop:$CIRCLE_SHA1
            kubectl rollout status deploy/shop --timeout=300s   # ← the gate
            # readiness probes (20) + maxUnavailable:0 (21) + PDB (29)
            # make this line mean "zero-downtime, verified"

workflows:
  ship:                                                # 49
    jobs:
      - test
      - build-push: { requires: [test], context: [aws-ecr] }        # 52
      - deploy:
          requires: [build-push]
          context: [aws-prod]
          filters: { branches: { only: main } }        # 56
Output — one push on main
✓ test        2m41s   (4 containers, timing-split)
✓ build-push  1m52s   (DLC warm, 2 layers pushed)
✓ deploy      3m18s
   job.batch/migrate-8f3ab12 condition met
   deployment "shop" successfully rolled out
git push → production, verified, in 7m51s. Rollback: previous SHA (topics 5, 21).

✅ Do — good use cases

  • Steal this skeleton verbatim — then evolve: approval gate before prod (57), staging hop (49), scan gate (8)
  • Keep the rollout-status gate — a deploy that doesn't verify is a hope, not a deploy
  • Graduate to GitOps (Argo CD) when the org scales — same artifacts (SHA images), the cluster pulls instead of CI pushing

❌ Don't — anti-patterns

  • Migrations inside container startup instead of the gated Job — topic 23's race, now in prod
  • kubectl apply of manifests that aren't in git / reviewed — the pipeline's authority comes from the repo's review process
Pattern & benefit: count the security posture: zero stored AWS keys (OIDC 53), zero cluster passwords (IAM 35/36), least-privilege at every hop (27, 52), immutable SHA-tagged artifacts (5, 9), migrations gated (23), rollout verified (21), all of it reviewed YAML in git (11). This pipeline is the sixty topics compiled — and it's boring, which was the goal all along.
Gotcha — why it goes wrong: the failure modes are now all ones you can name: rollout stuck = new pods not Ready (20) or no room (19/41); migrate Job hangs = lock contention (your Postgres playbook #33!); ImagePullBackOff = tag/repo/lifecycle (5/6/7); Unauthorized at deploy = OIDC claim or access entry (53/36). The pipeline doesn't remove failures — it makes every failure a topic number instead of a mystery. That's what "pro" means.
🎯 Quiz — Test Yourself

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total