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.
$ 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/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 historywhen 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
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.
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"]
=> 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 bareruby) USER app— a container escape from root is a node compromise
❌ Don't — anti-patterns
COPY . .beforebundle 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)
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.
COPY --from=build is the delivery van between them. Nobody ships the sawdust to the customer.# ---------- 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"]
$ 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=buildexactly what runtime needs — bundle dir + app, nothing else- Runtime libs only in the final stage (
libpq5, notlibpq-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
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.
.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..git .env* log/* tmp/* node_modules storage/* coverage *.md .dockerignore Dockerfile
$ 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
COPY . . layer (topic 2's "random" cache misses, solved).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.
: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.# 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}'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:latestin 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
git log old..new). Kubernetes also skips pulls it believes it has: unique tags make every deploy an unambiguous, definitely-pulled change (see gotcha).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.
get-login-password is the front desk swapping your badge for a 12-hour visitor pass that Docker (who only understands passes) can use.# 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 tableLogin 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+IMMUTABLEat creation time (topics 8, 9) — retrofitting is a ticket nobody files- EKS nodes pull with their node role automatically — zero
imagePullSecretsneeded (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
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.
{
"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" }
}
]
}$ 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)
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.
# 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; }{
"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-slimre-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
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.
# 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-slimtag 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.42silently mean something new (supply-chain 101) - Hardcoding
docker.iobases in Dockerfiles used by CI at scale — anonymous pull limits will find you at the worst moment
: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.
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.# 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=> 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 bashto 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
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."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.
$ 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.
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 applyto 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
apply 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.
localhost), checked in and out together, and the room itself is disposable — checkout means a NEW room later, never the same one repainted.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$ 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/yfluently — 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)
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.
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 }]$ 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 imageonly 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
replicas: or HPA — topic 22), and safe rollouts all hang off this one object.selector.matchLabels ↔ template.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.
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.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)$ 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 endpointsas 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: LoadBalancerper microservice — 12 services = 12 cloud load balancers billing hourly
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.
app=shop, tier=web and every rule for that combination applies to you instantly. No registration desk, no lists to update.# 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"
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
-lon 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
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).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.
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 }] }$ 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
envFromfor 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
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.
# 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.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 secretaway 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 describescreenshots in Slack — leak paths people forget
| 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.
$ 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$ 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
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).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.
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.$ 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
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.
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$ 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
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.
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$ 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-downtimekubectl rollout statusas 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: Recreatefor 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
rollout undo at leisure). Progressive delivery (canaries) is this same machinery with weights.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).
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$ 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 (omitreplicaswhen HPA owns it) - Autoscaling on memory for apps that never release it (Ruby/JVM) — memory only ratchets up; it scales out and never in
behavior block encodes the operational wisdom: scale up eagerly, scale down suspiciously.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.
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"] }] } } } }$ 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 waitbefore the rollout (topic 60's pattern) concurrencyPolicy: Forbidfor 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: Alwaysin a Job template — invalid; and OnFailure restarts in place (logs vanish) vs Never + backoff (new pod, old logs kept — usually better)
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.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.
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 } }$ 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
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.
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.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 } }$ 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
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.kafka-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.
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 }$ 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
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.
cluster-admin just because asking twice was annoying.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 }$ 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 --asin 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-adminfor 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
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.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.
# taint the special nodes (Karpenter/nodegroup does this via config): $ kubectl taint nodes ip-10-0-40-113 workload=gpu:NoSchedule
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 } }$ 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
requiredaffinity wherepreferredwould 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)
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).
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$ 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
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.
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.# 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!$ 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 --previousfor crash-looping pods — the current container hasn't failed yetport-forwardfor poking cluster services safely from your laptop — no Ingress, no NodePort
❌ Don't — anti-patterns
kubectl editon prod objects — un-reviewed, un-git-ed drift (topic 11); fine for experiments, banned as habitdelete podas a fix without reading why it was sick — the reconciler gives you a fresh one that's sick the same way
kubectl explain is the underrated one: complete, version-accurate schema docs for any field, offline, no browser tabs.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.
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$ 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
--previousfor crashes; exit codes decoded (topic 10's table transfers exactly)kubectl debugephemeral 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
describe, or get events --sort-by=.lastTimestamp) — Kubernetes usually states the diagnosis in English before you start guessing.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.
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$ 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
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.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.
$ 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}]}'{ "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
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.
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 }$ 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: truefrom 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
eksctl delete cluster actually deletes everything.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.
$ 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{ "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/kubensfor fast, visible switchingsts get-caller-identityFIRST 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-kubeconfigcommand instead - Long-lived static tokens for humans anywhere — the 15-minute exec flow exists so credentials age out by design
get-token. The same flow powers CI (topic 53): a pipeline that can assume a role can reach the cluster with zero stored secrets.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.
# 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{ "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)
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.
# 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$ 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
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.
# 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-s3spec:
serviceAccountName: shop # ← that's it. SDKs discover the rest.
containers:
- name: app
image: $ECR/shop:8f3ab12 # aws-sdk / aws cli inside: just works$ 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 —
shopreads its bucket, nots3:* - 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
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).
$ 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=truepods: 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 containerin events — that's subnet exhaustion announcing itself; it won't self-heal
pg_stat_activity. Your AWS networking knowledge applies to pods with zero adaptation.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.
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 } } }$ 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.nameannotation) — not an ALB per service (topic 14's cost warning) target-type: ipon 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: LoadBalancereverywhere — NLB-per-service sprawl with none of the routing
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.
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$ 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.cpuas 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)
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.
# 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$ 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+encryptedas 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
allowVolumeExpansion grows disks without pod surgery), snapshots via VolumeSnapshot API for backup pipelines. IRSA (topic 38) signs the driver's AWS calls, naturally.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.
$ 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{ "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
PRESERVEconflict 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)
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.
# 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[ { "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)
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.
# 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@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 logsas your production log strategy — it reads the node's ring buffer: gone on rotation, gone with the pod (topic 31's--previousis a courtesy, not an archive)
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.
# 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 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
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.
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 testWorkflow build #1442 ✓ 3m12s ├─ test ✓ 2m58s (ran in parallel) └─ lint ✓ 41s $ git push → triggers automatically. That's the whole contract.
✅ Do — good use cases
- Named
runsteps — "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
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).
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.largedocker 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_classper 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)
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."
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.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 leg0: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
requiresbetween 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
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.
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).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: . }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)
save_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.
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#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 historyshows all)
--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.
# 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$ 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
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.
# 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)- 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)$ 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
$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.
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 | lessOrb 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/rubocopneeds no dependency
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.
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!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
store_test_results pays twice: timing data for splitting AND the flaky-test detection UI, which names the tests silently taxing every developer.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.
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"]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
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.
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-suiteWorkflow 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
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.
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 >>$ 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
enumtypes over free strings —force_env: prodcan 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)
when: logic, one config file serves as several pipelines without copy-paste drift.<< 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.
# 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 UIcircleci@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_artifactsfor logs/screenshots on every test job — most failures then explain themselves without SSHcircleci config validatein 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)
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.
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✓ 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 applyof manifests that aren't in git / reviewed — the pipeline's authority comes from the repo's review process
Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.