Chapter 212-3 hours

Docker, K8s & CI/CD

A first-principles walkthrough of how backend code gets packaged and run in production, from the kernel features that make a container (namespaces and cgroups), through Docker images, layers and multi-stage builds, into Kubernetes orchestration (pods, deployments, services, probes, autoscaling), and out to CI/CD pipelines and zero-downtime rollouts. Written to explain not just what each piece does but why it exists and how it works underneath. Application examples in Go, Python, JavaScript, TypeScript and Java.

Part I / Why Containers Exist

The Problem Containers Solve

Every container concept later in this manual is an answer to one recurring pain: code that runs on one machine fails on another because the two machines aren’t identical. Your laptop has Python 3.11, the server has 3.9; your build assumes a system library that prod lacks; an environment variable is set here and unset there. The result is the oldest joke in software, “but it works on my machine”, and it is not a joke, it’s a class of outages.

The root cause is that a running program is never just its source code. It’s the code plus a whole invisible environment: the language runtime and its exact version, third-party libraries, system packages, configuration, file paths, and OS-level dependencies. Traditionally you reproduced that environment by hand on every machine, install steps, setup scripts, a wiki page nobody kept current, and any drift between environments became a bug that only appears in one place.

Before containers, deploying software was like mailing a recipe and hoping the recipient’s kitchen has the same oven, the same pans, and the same brand of flour. Containers mail the entire kitchen, oven, pans, ingredients, pre-measured, sealed in a box that runs the same way wherever you open it.

WITHOUT containers, same code, different worldsyour laptopapp + Python 3.11libs A, B (v2)✓ worksthe serverapp + Python 3.9libs A (v1), no B✗ breaks”works on my machine”WITH a container, the environment travels with the codecontainer imageapp + runtime + libs + configlaptop ✓staging ✓prod ✓
The deployment gap

Three problems, one solution

  • Dependency hell & drift: the exact runtime and libraries are baked in, so there’s no “install steps” to get wrong and no version skew between environments.
  • Isolation: two apps that need conflicting versions of the same library can run side by side on one host, each in its own sealed box, without interfering.
  • Density & portability: containers are light enough to pack many onto a single machine and to move freely between laptop, CI, and any cloud, the foundation that orchestration (Part IV) and modern CI/CD (Part V) build on.

The one idea

A container makes “the thing that runs in production” identical to “the thing you built and tested.” Everything else, images, Docker, Kubernetes, pipelines, is machinery for building, shipping, and running that identical artifact at scale.

Containers vs Virtual Machines

The instinctive question is “isn’t this just a virtual machine?” The answer reveals what containers actually are. A VM virtualizes hardware: a hypervisor emulates a whole machine, and each VM runs its own complete operating system, its own kernel, on top of which sit its libraries and your app. A container virtualizes the operating system: all containers on a host share the host’s single kernel, and isolation is enforced by kernel features rather than by emulating separate hardware.

Virtual MachinesContainersApp Abins / libsGuest OS + kernelApp Bbins / libsGuest OS + kernelHypervisorHost OS + kernelPhysical hardwareApp Abins/libsno kernelApp Bbins/libsno kernelApp Cbins/libsno kernelContainer runtime (e.g. containerd)ONE shared Host OS kernelPhysical hardwareNo per-app guest OS means containers start in milliseconds, weigh megabytes not gigabytes,and you can pack far more of them onto the same hardware.
The decisive difference
DimensionVirtual MachineContainer
Isolates byEmulating hardware (hypervisor)Kernel features (namespaces + cgroups)
OS per instanceFull guest OS + own kernelShares the host kernel; no guest OS
SizeGigabytesMegabytes
Start timeSeconds to minutes (boot an OS)Milliseconds (start a process)
Density per hostTensHundreds to thousands
Isolation strengthStronger (separate kernels)Weaker (shared kernel), a real tradeoff

The shared-kernel tradeoff

Sharing one kernel is what makes containers light and is their main security caveat: a kernel-level escape affects every container on the host, whereas VMs have a thicker boundary. This is why multi-tenant platforms often run containers inside VMs, and why container security (sec 17, and the security chapter) focuses on least privilege, non-root users, and dropping capabilities. Containers are isolation, not a hard security sandbox by default.

What a Container Actually Is

Demystified: a container is just a normal Linux process (or a few) that the kernel has been told to isolate. There is no “container” object in the kernel, the magic is three older Linux features combined so a process believes it has a machine to itself. Understanding these three removes all the mystery and explains every limit and behavior you’ll hit.

NamespacesWHAT IT CAN SEE/ pid, its own process tree/ net, its own interfaces/ mnt, its own mounts/ uts/ipc/user, hostname, …cgroupsWHAT IT CAN USE/ CPU shares / quota/ memory limit/ I/O bandwidth/ pids countUnion FSWHAT IT RUNS ON/ the image’s layers/ read-only base + libs/ thin writable top layer/ its private root ”/“Combine the three and an ordinary process is convinced it owns the machine, that illusion is a container.
The three ingredients

1. Namespaces: the illusion of being alone

A namespace partitions a global kernel resource so the process only sees its own slice. The PID namespace makes the container’s main process believe it’s PID 1 with no siblings; the network namespace gives it its own interfaces and ports; the mount namespace gives it its own view of the filesystem. From inside, it looks like a private machine. From the host, it’s just processes with extra labels, ps on the host sees them all.

2. cgroups: enforcing limits

Namespaces hide the rest of the system; control groups (cgroups) cap how much the process may consume, CPU, memory, I/O, process count. This is what stops one container from starving its neighbors, and it’s exactly the mechanism Kubernetes drives when you set resource requests and limits (sec 17). Exceed a memory limit and the kernel’s OOM killer terminates the container, the famous OOMKilled status.

3. A filesystem image: its own root

Finally the process needs files: its own / with a runtime, libraries, and your binary. That comes from the image, layered onto the mount namespace as the container’s root filesystem (sec 04). The process can’t see the host’s files, only the image’s, plus whatever you explicitly mount in (sec 09).

Part II / Docker / Images & Containers

Images, Layers & the Union Filesystem

Two words get confused constantly, so nail them first. An image is the immutable, on-disk template, a packaged filesystem plus metadata (what to run, which ports, env). A container is a running instance of an image, the image brought to life as an isolated process (sec 03). The relationship is exactly class-to-object: one image, many containers.

An image is to a container what a class is to an object, or what a baked, frozen meal is to the same meal reheated on your plate. You build the image once; you run many identical containers from it.

Images are stacks of read-only layers

The clever part is how an image stores its filesystem: as a stack of layers, each one a set of file changes, stacked by a union filesystem that merges them into a single coherent /. Each instruction in your build adds one layer (sec 05). Layers are immutable and content-addressed, so identical layers are stored once and shared across images, if ten images use the same base, that base lives on disk and travels over the network only once.

IMAGE = read-only layers (shared)L4 COPY app binary +2 MBL3 pip/go install deps +40 MBL2 apt-get runtime libs +25 MBL1 base image (e.g. debian) 50 MBthese four layers are stored once & sharedCONTAINERS add a writable topwritable (C1)writable (C2)both share the sameread-only layers belowcopy-on-writeA container reads straight from the shared layers. The instant it modifies a file,that file is copied up into its own writable layer, the image underneath is never touched.That writable layer is ephemeral: delete the container and those changes vanish (sec 09 for persistence).
Union filesystem

The writable layer is not storage

Anything a container writes to its own filesystem dies with the container. Logs, uploads, database files written “inside” are gone on restart. Persistent data must live in a volume (sec 09) or an external service. This ephemerality is a feature, it’s what makes containers disposable and replaceable, but it’s the most common beginner trap.

The Dockerfile

A Dockerfile is the recipe that builds an image: a sequence of instructions, executed top to bottom, each producing one layer (sec 04). docker build reads it, runs each step, and caches the result. Because the file is plain text in your repo, your build is reproducible and reviewable, the environment becomes code. Here is the same web service containerized five times over. Read one tab and then another: the instructions are identical in order and purpose, and only the toolchain changes.


# FROM picks the base image, the first (bottom) layer. Pin a version, never :latest
FROM golang:1.22

# WORKDIR sets the working dir for the instructions that follow (and at runtime)
WORKDIR /app

# Copy dependency manifests FIRST and download, this layer caches across code edits (sec 7)
COPY go.mod go.sum ./
RUN go mod download

# Now copy the source and build the binary
COPY . .
RUN go build -o /server ./cmd/server

# Document the port the app listens on (metadata; publishing happens at run time sec 8)
EXPOSE 8080

# CMD is the default command run when a container starts from this image
CMD ["/server"]
InstructionWhat it does
FROMThe base image, the starting layer. Everything builds on it.
WORKDIRSets (and creates) the current directory for later steps and at runtime.
COPY / ADDCopy files from your build context into the image. Prefer COPY; ADD has surprising URL/tar behavior.
RUNExecute a command at build time (install deps, compile), bakes the result into a layer.
ENVSet environment variables baked into the image (config defaults, sec 16).
EXPOSEDocuments the listening port. Informational; doesn’t publish it (sec 08).
ENTRYPOINT / CMDWhat runs when the container starts. ENTRYPOINT = the executable, CMD = default args.

Multi-Stage Builds

The single most impactful technique for production images. The problem: building needs heavy tools (compilers, the full SDK, build-time dev dependencies) that the running app doesn’t. Ship them and your image is huge and full of attack surface. A multi-stage build uses one stage to build and a second, clean stage that copies only the finished artifact, the toolchain is discarded.

STAGE 1 / builder (discarded)golang:1.22 / python full+ compiler / full SDK+ build dependencies+ your source tree+ intermediate objects/server (the only keeper)~900 MB, thrown away after buildCOPY —fromSTAGE 2 / final (shipped)distroless / alpine/server~15 MB, binary + minimal runtime only
Multi-stage

# ---- Stage 1: build with the full Go toolchain ----
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# static binary, no libc dependency, perfect for a scratch/distroless final image
RUN CGO_ENABLED=0 go build -o /server ./cmd/server

# ---- Stage 2: tiny runtime, no compiler, non-root ----
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /server /server   # copy ONLY the artifact across stages
EXPOSE 8080
USER nonroot:nonroot                  # never run as root (sec 17, security)
ENTRYPOINT ["/server"]
# Result: a ~10-15 MB image with no shell, no package manager, minimal CVEs

The size you can reach depends on what the language needs at run time. Go compiles to a single static binary, so its final stage can be near-empty (distroless/scratch, ~15 MB). Python, Node and Java all need a runtime, so the win there is discarding the build toolchain: a pre-built virtualenv into a -slim base, production-only node_modules, or a JRE instead of the JDK. In every case the rule is identical, copy the finished artifact into a clean stage and leave the compiler behind.

Why this matters in production

Smaller images pull faster (quicker autoscaling and rollouts, sec 18), cost less to store and transfer, and shrink the attack surface dramatically, a distroless image has no shell for an attacker to use. Multi-stage builds are the default for any serious service image.

Image Optimization & Layer Caching

Two levers make builds fast and images lean, and both follow directly from the layer model (sec 04). The first is cache ordering; the second is base-image choice plus pruning what you copy in.

Order instructions from least- to most-frequently-changed

Docker caches each layer and reuses it on the next build as long as that instruction and everything before it are unchanged. The moment one layer’s inputs change, that layer and every layer after it are rebuilt. So the golden rule is: put the things that rarely change (installing dependencies) before the things that change every commit (copying your source). That’s why every Dockerfile above copies the dependency manifest and installs before copying the full source, you edit code constantly but dependencies rarely, so the expensive install layer stays cached.

deps BEFORE code, goodCOPY . . + build ↻ rebuiltRUN install deps ✓ CACHEDCOPY manifest ✓ CACHEDFROM base ✓ CACHEDedit code -> only the top layer rebuilds (seconds)code BEFORE deps, badRUN install deps ↻ rebuiltCOPY . . ↻ busts cacheFROM base ✓ CACHEDedit code -> deps reinstall every time (minutes)
Cache invalidation

Pick a small base & copy less

  • Base image: -slim variants drop hundreds of MB; alpine is tiny (musl libc, watch for compatibility); distroless ships only your app and its runtime with no shell or package manager (most secure). Choose the smallest that runs your app.
  • .dockerignore: keeps junk out of the build context and the image (faster builds, smaller images, no secrets leaking in).
  • Combine related RUNs & clean up in the same layer: deleting a cache in a later layer doesn’t shrink the image (the bytes still live in the earlier layer); clean within the same RUN.

what counts as junk depends on the toolchain

.git
*.md
.env            # never bake secrets into an image (sec 16)
*.log
.vscode/

# Go-specific: the build happens INSIDE the image, so a binary compiled
# on your laptop must not be copied in. It is built for the wrong OS
# half the time, and it silently shadows the one the Dockerfile builds.
/server
*.test
vendor/         # only if you are NOT using vendored deps

Cleanup must be in the same layer

RUN apt-get install ... on one line and RUN rm -rf /var/lib/apt/lists/* on the next does not reduce size, the files still exist in the install layer. Chain them: RUN apt-get update && apt-get install -y X && rm -rf /var/lib/apt/lists/* so the cleanup happens before the layer is sealed.

Runtime, Registries & Tags

You’ve built an image, now run it, share it, and version it. Three commands cover 90% of daily use, and three concepts (registry, tag, runtime) frame the lifecycle.

# BUILD an image from the Dockerfile in . and name:tag it
docker build -t myapp:1.4.0 .

# RUN a container from it:
#   -d detached  -p host:container publishes a port  -e sets an env var (sec 16)
docker run -d -p 8080:8080 -e DATABASE_URL=$DB myapp:1.4.0

# Inspect & debug
docker ps                      # running containers
docker logs -f <id>            # stream stdout/stderr (your app should log there sec logging)
docker exec -it <id> sh        # shell INTO a running container to poke around
docker stop <id>               # sends SIGTERM, then SIGKILL after a grace period (sec graceful shutdown)

# SHARE via a registry
docker tag  myapp:1.4.0 registry.example.com/team/myapp:1.4.0
docker push registry.example.com/team/myapp:1.4.0     # upload
docker pull registry.example.com/team/myapp:1.4.0     # download (what prod/K8s does)

Registry, repository, tag

A registry is the server that stores images (Docker Hub, GitHub Container Registry, AWS ECR, Google Artifact Registry). A repository is a named image within it; a tag is a label for a specific version (myapp:1.4.0). Pushing uploads your layers; pulling downloads them, and thanks to content-addressed layers (sec 04), only layers the registry doesn’t already have move over the wire. This push/pull cycle is the hand-off between your build (sec 19) and where the image runs (Part IV).

docker buildDockerfilepushRegistrystores tagged layerspullCI runs testsstagingproduction (K8s)
The image lifecycle

Never deploy :latest

latest is just a tag that moves; it doesn’t mean “newest” and gives you no idea what is running. Two machines pulling :latest a day apart can get different images, the exact non-reproducibility containers exist to kill. Tag with an immutable version or the git commit SHA (myapp:1.4.0, myapp:git-9f2c1a) so a running container maps to exact source.

Part III / Multi-Container & Local Dev

Networking & Volumes

Two needs appear the moment you run more than a toy: containers must talk to each other, and some data must outlive the container. Docker answers these with networks and volumes, and both directly address gaps left by the isolation model (sec 03) and the ephemeral writable layer (sec 04).

Networking: from isolated to connected

By default each container has its own network namespace, so it can’t see others. Put containers on the same user-defined bridge network and Docker gives them a private virtual LAN plus built-in DNS: a container reaches another simply by its name (http://db:5432). This is the foundation of service discovery, and it’s why Compose (sec 10) and Kubernetes (sec 15) let services address each other by name instead of brittle IPs. Separately, port publishing (-p 8080:8080) pokes a hole from the host into a container so the outside world can reach it.

bridge network: appnetapi:8080db:5432connect to “db”host :8080 -> api :8080 (published)db is NOT published, private to the network
Container networking

Volumes: data that survives

Because a container’s writable layer is destroyed with the container (sec 04), anything that must persist, a database’s files, user uploads, lives in a volume: storage managed by Docker (or the host) and mounted into the container, decoupled from its lifecycle. Delete and recreate the container; the volume and its data remain. There are two flavors: named volumes (Docker-managed, the default for real data) and bind mounts (a host directory mapped in, handy in development to live-edit code without rebuilding).

MechanismUse it for
Named volume -v dbdata:/var/lib/postgresql/dataPersistent app data managed by Docker, databases, caches you want to keep.
Bind mount -v ./src:/app/srcDev-time live reload, edit on the host, see changes in the container instantly.
tmpfsSensitive scratch data kept in RAM only, never written to disk.

Stateless containers, stateful volumes

Treat containers as disposable and keep all durable state in volumes or external managed services. In Kubernetes especially, the prevailing pattern is stateless app containers (trivially scaled and replaced, sec 18) with state pushed to managed databases or persistent volumes. If killing a container loses data, the design has leaked state into the wrong place.

Docker Compose

Running a real app means several containers, API, database, cache, maybe a worker (the task-queue chapter’s setup), with the right networks, volumes, env, and start order. Wiring that by hand with many docker run commands is tedious and error-prone. Docker Compose declares the whole local stack in one YAML file and brings it up with a single command. It’s the standard for local development and simple single-host deployments.

services:
  api:
    build: .                      # build the image from the local Dockerfile (sec 5/6)
    ports:
      - "8080:8080"               # publish to the host (sec 9)
    environment:                  # config via env, not baked in (sec 16)
      DATABASE_URL: postgres://app:secret@db:5432/app   # reach "db" by name (sec 9 DNS)
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy   # wait until the DB passes its healthcheck
      cache:
        condition: service_started

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - dbdata:/var/lib/postgresql/data   # named volume = data survives restarts (sec 9)
    healthcheck:                          # so depends_on can wait for readiness
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5

  cache:
    image: redis:7

volumes:
  dbdata:                          # declared once, referenced above
docker compose up -d        # build (if needed) + start everything in the background
docker compose ps           # status of all services
docker compose logs -f api  # tail one service's logs
docker compose down         # stop & remove containers + network (volumes kept)
docker compose down -v      # ...and delete the volumes too (fresh DB)
1 containerdocker runmany, one hostdocker composefleet, many nodesKubernetes -> Part IV
Where we are

Part IV / Kubernetes / Orchestration

Why Orchestration

Docker gives you containers; it does not, by itself, run them reliably across many machines. The moment you have dozens of containers on a fleet of servers, a new set of problems appears that no docker run can solve, and orchestration is the layer that solves them. Kubernetes (often “K8s”) is the de-facto standard.

The problem at fleet scaleWhat an orchestrator does
Which machine should each container run on?Scheduling: places containers on nodes by available CPU/memory.
A container (or a whole node) dies at 3am.Self-healing: restarts crashed containers, reschedules off dead nodes, automatically.
Traffic spikes; you need more copies, then fewer.Scaling: runs N replicas and adds/removes them on demand (sec 18).
Containers come and go; their IPs change.Service discovery + load balancing: a stable address that fans out to healthy replicas (sec 15).
Ship a new version without downtime.Rolling updates & rollback: replace replicas gradually, revert on failure (sec 18).
Config & secrets differ per environment.Config management: inject settings/secrets without rebuilding the image (sec 16).

The core idea: declarative, desired state

The mindset shift that makes Kubernetes click: you don’t issue imperative commands (“start a container here”). You declare the desired state (“I want 5 replicas of this image, reachable on this address”) and Kubernetes continuously works to make reality match. If a container dies, actual drops to 4, and a control loop notices the gap and starts another, without you doing anything. You describe the what; Kubernetes handles the how and keeps it true.

Imperative is telling a driver every turn. Declarative is giving a destination to a self-correcting autopilot: drift off course, a crash, a dead node, and it steers back on its own, continuously, without new instructions.

Reconciliation in one line

Everything in Kubernetes is a control loop comparing desired state (what you declared) to actual state (what’s running) and taking action to close the gap. Internalize that loop and the entire system, Deployments, scaling, healing, rollouts, becomes one idea applied over and over.

Kubernetes Architecture

A Kubernetes cluster is two kinds of machines: a control plane (the brain that decides what should run) and a set of worker nodes (the muscle where containers actually run). You talk only to the control plane, you submit desired state, and the cluster makes it happen.

CONTROL PLANE, the brainapi-serverthe one front doorschedulerpicks a nodecontroller-mgrruns the loopsetcdthe source of truthall state lives in etcd; everything talks via the api-serverWORKER NODE 1, the musclekubeletkube-proxy (networking)podpodpodWORKER NODE 2podpodpodyou: kubectl applydesired state (YAML)api-server writes your desired state to etcd; controllers + scheduler reconcile it onto nodes; kubelets run the pods.kubelets continuously report actual state back, closing the loop.
Cluster anatomy
ComponentRole
api-serverThe single entry point. Every read/write of cluster state goes through it (your kubectl, every component).
etcdDistributed key-value store holding the entire cluster state, the source of truth.
schedulerDecides which node a new pod runs on, based on resource requests and constraints.
controller-managerRuns the reconciliation loops (the Deployment controller, etc.) that drive actual toward desired.
kubeletThe agent on each node; starts/stops containers and reports their health up to the api-server.
kube-proxyPrograms node networking so Service virtual IPs route to the right pods (sec 15).

Pods

The pod is Kubernetes’ atomic unit, the smallest thing it schedules. Crucially, you do not deploy containers directly; you deploy pods, and a pod wraps one or more containers that are always co-located on the same node and share a network namespace and storage. Containers in a pod reach each other on localhost and can share mounted volumes, they’re a single cooperating unit.

Usually a pod holds exactly one container (your app). The multi-container case is the sidecar pattern: a helper alongside the main app, a log shipper, a metrics exporter, or a service-mesh proxy (the Envoy from the gRPC chapter’s sec 19). The sidecar shares the pod’s network so it can observe or proxy the app’s traffic transparently.

POD, shares one IP & lifecycleapp container:8080sidecarlog shipper / proxylocalhostone pod IP / same node / live and die together
Pod internals

Pods are mortal and disposable

A pod is ephemeral: it can be killed and replaced at any time (node failure, scaling down, a rollout), and the replacement gets a new IP. So you never hard-code a pod’s IP and never rely on a specific pod surviving. This is exactly why Services exist (sec 15), to provide a stable address in front of a constantly-churning set of pods, and why you let a Deployment manage pods rather than creating them directly (sec 14).

Deployments & ReplicaSets

You almost never create pods by hand, a bare pod isn’t replaced if it dies. Instead you declare a Deployment: “keep N replicas of this pod template running, always.” The Deployment manages a ReplicaSet, whose one job is to maintain the replica count, and the ReplicaSet manages the pods. This is the reconciliation loop (sec 11) made concrete: declare 5, a pod dies, actual becomes 4, the controller starts a new one, self-healing, for free.

Deploymentreplicas: 3managesReplicaSetkeeps count = 3pod ✓ runningpod ✓ runningpod ✗ died ->controller recreates itActual fell to 2, desired is 3 -> the gap is detected and a fresh pod is started, automatically.
The ownership chain
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3                 # desired state: always keep 3 pods
  selector:
    matchLabels: { app: api } # which pods this Deployment owns
  template:                   # the POD TEMPLATE stamped out for each replica
    metadata:
      labels: { app: api }
    spec:
      containers:
        - name: api
          image: registry.example.com/team/myapp:1.4.0   # pinned tag, never :latest (sec 8)
          ports:
            - containerPort: 8080
          # resources, env, and probes go here too, see sec 16 and sec 17
kubectl apply -f deployment.yaml     # submit desired state (declarative)
kubectl get pods -l app=api          # see the 3 pods it created
kubectl scale deployment/api --replicas=10   # change desired count -> controller adds 7
kubectl rollout status deployment/api        # watch a new version roll out (sec 18)
kubectl rollout undo  deployment/api          # roll back to the previous version (sec 18)

Services, Gateway API & Ingress

Pods are mortal and their IPs churn (sec 13), so you can’t point traffic at a pod. A Service is the fix: a stable virtual IP and DNS name in front of a dynamic set of pods, with built-in load balancing. It uses a label selector to track “all pods with app: api”, as pods come and go, the Service’s set of healthy endpoints updates automatically, and callers never notice. This is Kubernetes’ service discovery, and it’s why one service addresses another by name (http://api), exactly as Compose did on one host (sec 9).

callerhttp://apiService: apistable IP + DNS / selector app=apipod (app=api) ✓pod (app=api) ✓pod removed, droppedOnly Ready pods (per readiness probe sec 17) receive traffic; the rest are pulled from rotation.
Service abstraction

Service types & Ingress

A ClusterIP Service (the default) is reachable only inside the cluster, perfect for service-to-service calls. To accept traffic from outside, you either use a LoadBalancer Service (the cloud provisions an external load balancer, one per service, expensive at scale) or, far more commonly for HTTP, an Ingress: a single L7 entry point that routes by host and path to many backing Services. Ingress is where TLS termination and host/path routing live, the cluster’s public front door, and the natural home for the REST edge described in the gRPC chapter (sec 18-19 there).

ExposureReachable fromUse for
ClusterIPInside the cluster onlyService-to-service (most internal services)
NodePortA port on every nodeBasic/dev external access
LoadBalancerThe internet (cloud LB)One TCP/UDP service exposed directly
IngressThe internet, via L7 rulesHTTP host/path routing + TLS for many services through one entry point
Gateway APIThe internet, via listeners and routesRole-oriented HTTP/TCP routing with a reusable Gateway and separate route resources
apiVersion: v1
kind: Service
metadata:
  name: api               # other pods reach this as http://api (cluster DNS)
spec:
  selector: { app: api }  # front the pods labelled app=api (from the Deployment sec 14)
  ports:
    - port: 80            # the Service port callers use
      targetPort: 8080    # the containerPort it forwards to
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: edge
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api          # route external HTTP to the api Service
                port: { number: 80 }

Gateway API: GatewayClass, Gateway & HTTPRoute

Gateway API is the newer alternative to Ingress. You will still encounter Ingress in production systems: its API is stable, but Kubernetes has frozen it and recommends Gateway instead for new designs.

The Ingress above puts the hostname, path, and destination Service in one resource. Gateway API separates the entry point from those routing rules, so several applications can share a Gateway while defining their own routes.

See the Gateway API documentation and the Ingress documentation.

The three resources divide that work:

  • GatewayClass is a cluster-scoped blueprint shared by many Gateways. Its controllerName selects the implementation, such as Envoy Gateway, that will set up and manage their proxies or load balancers.
  • Gateway is an individual entry point created using that class. It references the blueprint through gatewayClassName: envoy and defines listeners: the protocols and ports that accept traffic, such as HTTP on port 80.
  • HTTPRoute contains the familiar Ingress rules: match a hostname and path, then send the request to a Service. parentRefs attaches it to a Gateway; backendRefs identifies the destination Service and port.

Think of the image-to-container relationship from earlier: one reusable definition, many instances. One GatewayClass can serve many Gateways, and each Gateway can have several HTTPRoutes attached to it.

GatewayClassgatewayClassName: envoyclientGatewayone of many :80HTTPRoutehost + path rulesServicestable endpointPodPodOne GatewayClass can be referenced by many Gateways; each Gateway can route traffic to Services.
Gateway API request flow

Like Ingress, Gateway API needs a controller to turn configuration into working networking. The controller named by controllerName must be installed and running; writing its name does not install it.

This YAML uses Envoy Gateway as one example implementation. Other Gateway API implementations can be used with their own documented controller name.

The controller reads the listeners and routes and configures the proxies that handle requests. The diagram shows how the configuration fits together; Gateway and HTTPRoute are API objects, not separate servers.

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: api-gateway
spec:
  gatewayClassName: envoy
  listeners:
    - name: http
      protocol: HTTP
      port: 80
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-httproute
spec:
  parentRefs:
    - name: api-gateway
  hostnames:
    - "api.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: api
          port: 80

Config, Secrets & Environment

An image must be environment-agnostic: the same tested image runs in dev, staging, and prod, only the configuration differs (database URLs, feature flags, credentials). So configuration is injected at run time, never baked into the image. This is the containerized form of the config-management chapter’s core rule, and the classic expression of twelve-factor “config in the environment.” Kubernetes provides two objects: ConfigMaps for non-secret settings and Secrets for sensitive values.

one tested imagemyapp:1.4.0dev+ ConfigMap-dev + Secret-devstaging+ ConfigMap-stg + Secret-stgproduction+ ConfigMap-prod + Secret-prod
Config injection
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data:
  LOG_LEVEL: "info"
  FEATURE_NEW_CHECKOUT: "true"
---
apiVersion: v1
kind: Secret
metadata: { name: api-secrets }
type: Opaque
stringData:                       # plaintext here; stored base64-encoded in etcd
  DATABASE_URL: "postgres://app:secret@db:5432/app"
---
# ...inside the Deployment's container spec (sec 14):
#   envFrom:
#     - configMapRef: { name: api-config }    # all keys -> env vars
#     - secretRef:    { name: api-secrets }   # secret keys -> env vars
# Your app then reads os.Getenv("LOG_LEVEL") / os.environ["DATABASE_URL"] (sec config chapter)

Secrets are not encrypted by default

A Kubernetes Secret is only base64-encoded in etcd, not encrypted, base64 is encoding, not protection. For real safety enable encryption at rest for etcd, lock down RBAC so few identities can read Secrets, and for sensitive systems use an external manager (Vault, cloud secret stores, or the Secrets Store CSI driver). And never commit real secret values to Git, the same never-leak-credentials discipline as the security and auth chapters.

Health Probes & Resource Management

For self-healing and zero-downtime to work, Kubernetes needs to know two things about every pod: is it alive? and is it ready for traffic? It can’t guess, your app must tell it, via probes. And to schedule and isolate fairly, it needs to know how much CPU and memory each container wants, via requests and limits (the cgroups of sec 03, surfaced as config).

Three probes, three questions

  • Liveness: “is the process healthy, or wedged?” If it fails, Kubernetes restarts the container. Catches deadlocks and hung states a crash wouldn’t.
  • Readiness: “can it serve requests right now?” If it fails, the pod is pulled from its Service’s rotation (sec 15) but not restarted. This is what enables zero-downtime rollouts and backpressure: a pod warming up or briefly overloaded stops receiving traffic until it recovers.
  • Startup: “has a slow-starting app finished booting?” Guards the other probes so a long cold start isn’t mistaken for failure.
kubelet probes the podGET /healthz / /readyliveness fails-> RESTART the containerreadiness fails-> remove from Service rotation
Liveness vs readiness

Requests & limits

A request is the amount the scheduler reserves for a container (and uses to pick a node); a limit is the hard ceiling the runtime enforces via cgroups. Exceed a CPU limit and the container is throttled; exceed a memory limit and it’s killed (OOMKilled). Setting these well is the difference between a stable cluster and noisy-neighbor chaos, and they feed autoscaling (sec 18).

the probe paths and the memory numbers both depend on the runtime

# ...inside the Deployment's containers: entry (sec 14)
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }   # wedged? -> restart
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /ready, port: 8080 }      # ready for traffic? -> in/out of rotation
            periodSeconds: 5
          resources:
            requests: { cpu: "100m", memory: "128Mi" } # scheduler reserves this
            limits:   { cpu: "500m", memory: "256Mi" } # cgroup ceiling; over memory -> OOMKilled
          # Go starts in milliseconds, so a startupProbe is rarely needed
          # and initialDelaySeconds can stay small.

Scaling & Rollouts

Two everyday operations close out orchestration: handling more load (scaling) and shipping new versions without downtime (rollouts). Both lean on everything built so far, replicas, Services, readiness probes, and both are the Kubernetes expression of topics from the scaling chapter.

Horizontal scaling & autoscaling

Horizontal scaling means running more pod replicas (preferred over making one pod bigger, the horizontal-vs-vertical theme from the scaling chapter). You can set the count manually, or hand it to the Horizontal Pod Autoscaler (HPA), which watches a metric (typically CPU) and adjusts replicas to hold a target, scaling out under load and back in when it subsides. Because app pods are stateless (sec 9), any replica can serve any request, so adding pods just works.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }  # keep avg CPU ~70%
# Requires resource requests (sec 17), utilization is measured against the request.

Rolling updates & rollback

When you change the image tag and re-apply, a Deployment performs a rolling update by default: it brings up new-version pods a few at a time, waits for each to pass its readiness probe (sec 17) before sending it traffic, and only then retires old pods. At every moment enough healthy pods are serving, so users see no downtime. If new pods fail readiness, the rollout stalls instead of taking the app down, and one command rolls back to the last good version.

startv1 v1 v1mid-rolloutv2 v1 v1 (+v2 warming)donev2 v2 v2A new pod only takes traffic after passing readiness; old pods drain gracefully (sec 17).If v2 never becomes Ready, the rollout halts, capacity is never sacrificed.
Rolling update

It all comes together here

Zero-downtime deploys aren’t one feature, they’re the composition of replicas (sec 14), readiness probes (sec 17), graceful shutdown, and the Service abstraction (sec 15) keeping a stable address over a shifting pod set. Each piece earned its place; the rollout is where they pay off.

Part V / CI/CD & Production

CI/CD Pipelines

Containers make the artifact reproducible; CI/CD makes the path from a code commit to that artifact running in production automatic. CI (Continuous Integration) is the build-and-verify half, on every push, build the image, run tests, scan it. CD (Continuous Delivery/Deployment) is the ship half, push the image to a registry and roll it out. The whole point is to remove manual, error-prone steps so deploys are frequent, small, and boring.

commitgit pushbuildimage sec 6testunit/integscanCVEspushregistry sec 8deployrollout sec 18Each stage is a gate: a failed test or a critical CVE stops the pipeline before anything reaches production.
The pipeline

the same six gates; only the test and build steps change

name: ci-cd
on:
  push:
    branches: [main]

jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with: { go-version: "1.22", cache: true }

      - name: Run tests              # CI gate, must pass before we ship (sec testing chapter)
        run: go test -race ./...     # -race in CI: it costs minutes and
                                     # catches the bugs of chapter 20

      - name: Build image
        run: docker build -t $REGISTRY/myapp:${{ github.sha }} .   # tag = commit SHA (sec 8)

      - name: Scan image for vulnerabilities
        run: trivy image --severity HIGH,CRITICAL --exit-code 1 $REGISTRY/myapp:${{ github.sha }}

      - name: Push to registry
        run: |
          echo "$REGISTRY_TOKEN" | docker login $REGISTRY -u ci --password-stdin
          docker push $REGISTRY/myapp:${{ github.sha }}

      - name: Deploy to Kubernetes   # update the Deployment's image -> triggers a rolling update (sec 18)
        run: kubectl set image deployment/api api=$REGISTRY/myapp:${{ github.sha }}

Deployment Strategies & GitOps

The rolling update (sec 18) is the default, but riskier releases call for strategies that limit blast radius, and mature teams change how deploys are triggered altogether with GitOps.

StrategyHow it worksTrade-off
RollingReplace pods gradually (the default)Simple, zero-downtime; old + new run briefly together
Blue-greenStand up the full new version (green) beside old (blue), then flip all traffic at onceInstant switch & instant rollback; needs double the capacity during the cutover
CanarySend a small % of traffic to the new version, watch metrics, then ramp upSafest for risky changes; more orchestration/observability needed
blue-green, flip 100%blue v1green v2switch router blue->green instantlyproblem? flip back to blue at oncecanary, trickle then rampv1-95%v2-5%watch v2’s error rate & latencyhealthy -> 5% -> 25% -> 100%bad -> route 0% back, blast radius tiny
Blue-green vs canary

GitOps: Git as the source of truth

The pipeline in sec 19 pushes changes to the cluster. GitOps inverts that: the desired state of the cluster lives entirely in a Git repository (all those YAML manifests), and an in-cluster agent (Argo CD, Flux) continuously pulls from Git and reconciles the cluster to match. This is the reconciliation loop of sec 11 extended to deployments themselves: Git is desired state, the cluster is actual state, the agent closes the gap. You deploy by merging a pull request; you roll back with git revert; and the repo is a complete, audited history of every change to production.

The throughline

Notice the same idea at three scales: a container keeps the artifact identical, a Deployment keeps the running pods matching a spec, and GitOps keeps the whole cluster matching Git. Declare desired state, let a loop reconcile reality, it’s containers and Kubernetes all the way down.

Production Cheat-Sheet

The whole manual compressed to what you reach for under pressure.

ConceptOne-liner
ContainerAn isolated process, namespaces (what it sees) + cgroups (what it uses) + an image filesystem.
vs VMContainers share one host kernel; VMs each ship a full OS. Lighter, faster, weaker isolation.
Image vs containerImage = immutable template (class); container = running instance (object).
LayersImages are stacked read-only layers, shared & cached; the container’s writable top is ephemeral.
DockerfileRecipe; each instruction = a layer. RUN at build time, CMD at run time.
Multi-stageBuild in a heavy stage, copy only the artifact into a tiny final image.
Cache orderDeps before code, a changed layer rebuilds everything below it.
Registry & tagsPush/pull images by name:tag; pin a version or SHA, never :latest.
VolumesData that must survive lives in a volume, not the container’s writable layer.
ComposeDeclarative multi-container stack on one host, local dev.
OrchestrationScheduling, self-healing, scaling, discovery, rollouts across many nodes.
DeclarativeDeclare desired state; a control loop reconciles actual toward it. The whole model.
PodSmallest unit; one+ containers sharing network & storage. Mortal, gets a new IP when replaced.
DeploymentKeeps N replicas of a pod running; self-heals; does rolling updates.
ServiceStable IP/DNS load-balancing across matching pods, service discovery.
IngressOne L7 entry point: host/path routing + TLS to many Services.
Gateway APINewer, role-oriented networking API: GatewayClass selects a controller; Gateway listens; HTTPRoute routes to Services.
Config/SecretsInject at run time (ConfigMap/Secret); same image everywhere. Secrets aren’t encrypted by default.
ProbesLiveness -> restart; readiness -> in/out of rotation; startup -> guard slow boots.
Requests/limitsRequest = reserved (scheduling); limit = cgroup ceiling. Over memory -> OOMKilled.
ScalingRun more replicas; HPA autoscales on a metric (needs requests set).
Rollout/rollbackGradual replace gated by readiness; kubectl rollout undo reverts.
CI/CDCommit -> build -> test -> scan -> push -> deploy, automatically; tag by SHA.
StrategiesRolling (default), blue-green (instant flip), canary (trickle & watch).
GitOpsGit is desired state; an agent pulls & reconciles. Deploy by PR, roll back by revert.

The whole topic in one breath: a container packages your app with its entire environment so “what runs in prod” equals “what you tested” (sec 1), achieved with Linux namespaces and cgroups, not a heavy VM (sec 2-3). You build an image as cached, shareable layers (sec 4) from a Dockerfile (sec 5), shrink and harden it with multi-stage builds and good cache ordering (sec 6-7), then push a SHA-tagged image to a registry (sec 8). Locally you wire several containers with networks, volumes, and Compose (sec 9-10). At fleet scale, Kubernetes reconciles declared desired state onto nodes (sec 11-12): Deployments keep replicas of pods alive and self-healing (sec 13-14), Services and Gateway API or Ingress give stable addressing and routing (sec 15), ConfigMaps/Secrets inject per-environment config (sec 16), probes and resource requests/limits drive health and scheduling (sec 17), and HPA plus rolling updates deliver scaling and zero-downtime releases (sec 18). CI/CD automates commit->build->test-> scan->push->deploy (sec 19), and blue-green/canary/GitOps make releases safe and auditable (sec 20). One idea recurs at every layer: declare the desired state and let a loop keep reality matching it.

Grounded in the Docker & Kubernetes docs / OCI image-spec / the Twelve-Factor App / Go 1.22+ / Python 3.11+ application examples.

Backend from First Principles / Chapter 21 / Containers. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 21+.