1. Pods, Deployments & Services
A Pod is the smallest deployable unit in Kubernetes — not a
container. A Pod wraps one or more containers that share a network namespace (one
IP address, one localhost) and can share storage volumes. In practice
you'll run one container per Pod almost always, with a second "sidecar" container
only for things tightly coupled to the main one, like a log shipper.
apiVersion: v1
kind: Pod
metadata:
name: web-single
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/acme/web-app:1.4.0
ports:
- containerPort: 8080
You'll almost never create a bare Pod, though — Pods are ephemeral and Kubernetes won't reschedule one that dies on its own. A Deployment wraps Pods in a desired-state description: how many replicas, which container image, how to roll out updates. Under the hood a Deployment manages a ReplicaSet, which manages the Pods themselves — you'll interact with the Deployment and rarely touch the ReplicaSet directly.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/acme/web-app:1.4.0
ports:
- containerPort: 8080
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
Pods get new IP addresses every time they're rescheduled, so nothing should ever talk to a Pod IP directly. A Service gives a stable virtual IP and DNS name that load-balances across every Pod matching its label selector — Pods come and go, the Service address doesn't move.
apiVersion: v1
kind: Service
metadata:
name: web-app
spec:
type: ClusterIP
selector:
app: web
ports:
- port: 80
targetPort: 8080
The selector: app: web here is the entire mechanism — the Service
doesn't know about the Deployment at all, it just watches for any Pod whose labels
match and routes traffic to it. This is how you'll swap a Deployment's Pods out from
under a Service with zero downtime during a rolling update in Week 12.
Kubernetes has almost no concept of "this Deployment owns that Service." Everything is wired together loosely through label selectors. When something isn't receiving traffic, the first thing to check is always whether the Service's selector actually matches the Pod's labels — a typo here is one of the most common real-world Kubernetes outages.
2. Control Plane vs. Worker Nodes
A Kubernetes cluster is a set of machines split into two roles. The
control plane makes decisions; worker nodes run
your containers. In a managed service like EKS or GKE you never see the control
plane machines directly, but understanding what runs there explains almost every
piece of kubectl behavior.
kube-apiserver # the front door -- every kubectl command talks to this over HTTPS
etcd # distributed key-value store holding all cluster state
kube-scheduler # decides which worker node a new Pod should run on
kube-controller-manager # runs the reconciliation loops (Deployment, ReplicaSet, ...)
kubelet # agent that talks to the API server and runs Pods on this node
kube-proxy # programs network rules so Services route traffic correctly
container runtime # containerd (or CRI-O) -- actually pulls images and runs containers
The reconciliation model is the same idea as Terraform's plan/
apply loop, just running continuously instead of on demand. You declare
"3 replicas of web-app" in a Deployment; the controller manager
compares that desired state against etcd's record of actual state every few
seconds, and if a Pod has died it creates a replacement without you doing anything.
That's the mechanism behind Kubernetes "self-healing" — there's no magic, just a
loop that never stops running.
You never tell Kubernetes "start a Pod" the way you'd tell Docker "run a container." You tell it what the end state should look like, and every controller's entire job is closing the gap between that and reality. This mental model is why kubectl delete pod on a Pod owned by a Deployment doesn't actually remove it for long — a new one appears within seconds, because the desired replica count hasn't changed.
3. kubectl & Declarative YAML
kubectl is your one interface to the API server, and it reads its
connection details — cluster address, credentials, default namespace — from a
kubeconfig file, usually ~/.kube/config.
You'll switch between clusters (a local kind cluster, a staging EKS
cluster) by switching contexts.
kubectl config get-contexts # list every cluster/user combo kubectl knows about
kubectl config use-context kind-devops # switch the active context
kubectl config set-context --current --namespace=web-app # default namespace for this context
Almost everything you'll do falls into one of two styles: imperative
commands that mutate the cluster directly, and declarative
manifests applied with kubectl apply. This course uses declarative YAML
almost exclusively, for the same reason it uses Terraform instead of clicking
through the AWS console: the file is the source of truth, it's reviewable in a pull
request, and it's the exact same mechanism CI will use to deploy in later weeks.
kubectl apply -f deployment.yaml -f service.yaml
kubectl get deployments
kubectl get pods -o wide
kubectl describe pod web-app-7d9f6c5b8-x2k4p # events, restarts, resource usage
kubectl logs web-app-7d9f6c5b8-x2k4p # container stdout/stderr
kubectl logs -f web-app-7d9f6c5b8-x2k4p # follow, like tail -f
kubectl exec -it web-app-7d9f6c5b8-x2k4p -- sh # shell into a running container
kubectl delete -f deployment.yaml -f service.yaml
Pod names get a random suffix appended by the ReplicaSet, so you'll rarely type them
from memory — copy them from kubectl get pods, or use
kubectl logs deployment/web-app to target the Deployment directly and
let Kubernetes pick a Pod for you.
kubectl create deployment web-app --image=ghcr.io/acme/web-app:1.4.0 --dry-run=client -o yaml prints a valid Deployment manifest without touching the cluster. Real Kubernetes engineers scaffold manifests this way constantly and edit the result — nobody writes every field from memory.
4. ConfigMaps & Secrets
Baking configuration into a container image means rebuilding the image every time a
setting changes — the exact problem environment variables solved for
docker run back in Week 3. Kubernetes has two objects for this:
ConfigMaps for ordinary configuration, and Secrets
for anything sensitive.
apiVersion: v1
kind: ConfigMap
metadata:
name: web-app-config
data:
LOG_LEVEL: "info"
FEATURE_NEW_CHECKOUT: "true"
API_TIMEOUT_MS: "3000"
kubectl create secret generic web-app-db \
--from-literal=DB_USER=app_svc \
--from-literal=DB_PASSWORD='S7!kq2vLpR' \
--dry-run=client -o yaml > db-secret.yaml
Both get consumed the same way — as environment variables or as mounted files — which is why the split exists at all: it's purely about who's allowed to read the value and how it's stored, not how the app accesses it.
spec:
containers:
- name: web
image: ghcr.io/acme/web-app:1.4.0
envFrom:
- configMapRef:
name: web-app-config
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: web-app-db
key: DB_PASSWORD
Critically: a Kubernetes Secret is base64-encoded, not encrypted, by
default. kubectl get secret web-app-db -o jsonpath='{.data.DB_PASSWORD}' | base64
-d reveals the plaintext to anyone with read access to that namespace. That's
enough to keep credentials out of your YAML in Git, but it is not a substitute for a
real secrets manager — Week 14 covers encrypting Secrets at rest and integrating an
external vault.
Treat a Secret YAML file exactly like a plaintext password file, because that's effectively what it is. Generate it with kubectl create secret ... --dry-run=client -o yaml into a file that's in .gitignore, or better, use a tool like Sealed Secrets or an external secrets operator so only an encrypted version ever reaches your repository.
5. Running a Local Cluster with kind or minikube
You don't need a cloud account to learn Kubernetes. kind ("Kubernetes in Docker") runs an entire cluster as Docker containers on your laptop, and is fast enough to spin up and tear down as part of a CI job — which is exactly how you'll use it starting in Week 12.
# macOS
brew install kind kubectl
# Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
kind create cluster --name devops
kubectl cluster-info --context kind-devops
For a multi-node cluster that more closely mirrors production, give kind
a config file instead of relying on the single-node default:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
kind create cluster --name devops --config kind-config.yaml
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# devops-control-plane Ready control-plane 45s v1.30.0
# devops-worker Ready <none> 30s v1.30.0
# devops-worker2 Ready <none> 30s v1.30.0
minikube is the older, single-node-by-default alternative and is
still common in tutorials — minikube start gets you a similar result,
with a built-in dashboard (minikube dashboard) that's handy while
you're still building a mental model of the objects involved. Either tool is fine
for this course; examples assume kind because it starts faster and
matches what you'll use in a GitHub Actions job later.
A kind cluster can't see images sitting in your local Docker daemon by default — docker build -t web-app:dev . followed by kind load docker-image web-app:dev --name devops copies it into the cluster's nodes. Forgetting this step is the single most common reason a fresh kind deployment sits in ErrImageNeverPull.
6. Hands-on Exercise
Deploy a configurable app to a local kind cluster
Put a Deployment, Service, and ConfigMap together into a real running app you can scale and inspect.
Requirements:
- Install
kindandkubectl, then create a two-worker cluster nameddevopsusing akind-config.yamllike the one above. - Write a
ConfigMapnamedweb-app-configwith at least two keys, and aDeploymentfor a small container image (e.g.nginxdemos/hello) that consumes it viaenvFrom. - Write a
ClusterIPService in front of the Deployment, matching its Pod labels with a selector. - Apply all three manifests with a single
kubectl apply -fcommand, then confirm three Pods areRunningwithkubectl get pods -o wide. - Scale the Deployment to 5 replicas with
kubectl scale deployment web-app --replicas=5and confirm the Service still balances across all of them usingkubectl port-forward svc/web-app 8080:80plus a fewcurl localhost:8080calls. - Delete one Pod by name with
kubectl delete pod <name>and confirm a replacement appears within seconds — capture the before/after output ofkubectl get pods.
If a Pod stays in Pending, run kubectl describe pod <name> and read the Events section at the bottom first — it's almost always a scheduling reason (insufficient resources, an image pull failure) spelled out in plain English, and it's the fastest debugging tool you have in Kubernetes.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does a Service sit in front of Pods instead of clients addressing Pod IPs directly?
Why does a Service sit in front of Pods instead of clients addressing Pod IPs directly?
Pod IPs are not stable — a Pod that crashes or is rescheduled gets a brand-new IP address, and a Deployment can add or remove Pods at any time during scaling or a rollout. A Service provides one fixed virtual IP and DNS name, and load-balances across whichever Pods currently match its label selector, so clients never need to track individual Pod addresses.
Q2
You run kubectl delete pod on a Pod owned by a Deployment with replicas: 3. What happens, and why?
You run kubectl delete pod on a Pod owned by a Deployment with replicas: 3. What happens, and why?
A new Pod appears within seconds. The Deployment's desired state still says 3 replicas; deleting a Pod only changes actual state, and the controller manager's reconciliation loop notices the mismatch and asks the scheduler to place a replacement. To actually reduce the count you have to change the desired state itself, e.g. kubectl scale --replicas=2 or editing the manifest.
Q3
Why is a Kubernetes Secret not sufficient on its own to keep credentials secure?
Why is a Kubernetes Secret not sufficient on its own to keep credentials secure?
By default a Secret's values are only base64-encoded, which is trivially reversible and not encryption — anyone with API read access to that namespace, or access to an unencrypted etcd backup, can recover the plaintext. It keeps secrets out of plain YAML committed to Git, but production clusters need encryption at rest for etcd and, usually, an external secrets manager as the actual source of truth.
Q4
You deploy an image to a fresh kind cluster and every Pod sits in ErrImageNeverPull. What's the most likely cause?
You deploy an image to a fresh kind cluster and every Pod sits in ErrImageNeverPull. What's the most likely cause?
The image was built locally with docker build but never loaded into the kind cluster's nodes, and the manifest's image pull policy prevents it from trying to fetch the tag from a remote registry. Running kind load docker-image <tag> --name <cluster> copies the image into every node so the kubelet can find it without a registry pull.