Kubernetes all 4 written
The problem it solves that Compose does not, and real manifests: Pods, Services, ConfigMaps, Secrets, Ingress.
35 The problem Kubernetes solves that Compose does not
Docker Compose starts a defined set of containers and stops when you tell it to -- it has no ongoing opinion about whether they are still supposed to be running five minutes later. Kubernetes's entire job is having that opinion constantly: a Deployment's replica count is not a one-time instruction, it is a fact Kubernetes keeps making true, forever, until you change it.
A plain container that dies stays dead
This is not a flaw in Docker -- docker run was never asked to keep
watching. Killing a container is simply the end of its story, and nothing brings it back
unless something else is watching and reacts.
docker rm -f course-selfheal-demo > /dev/null 2>&1 || true
docker run -d --name course-selfheal-demo nginx:alpine > /dev/null
echo "started a plain container: course-selfheal-demo"
docker kill course-selfheal-demo > /dev/null
echo "killed it"
sleep 2
still_there=$(docker ps --filter name=course-selfheal-demo --format '{{.Names}}')
echo "docker ps for it now: '$still_there' (empty means gone, for good)"
docker rm -f course-selfheal-demo > /dev/null 2>&1 || true
check "a plain killed container does not come back on its own" "$still_there" ""
What it printed, when the build ran it
started a plain container: course-selfheal-demo killed it docker ps for it now: '' (empty means gone, for good) ok a plain killed container does not come back on its own
The same idea, as a real Kubernetes Deployment on a real cluster
This is not a simulation of Kubernetes -- kind runs an actual control
plane, an actual scheduler and an actual etcd, inside a container, sized for a laptop
instead of a data centre. The Deployment below is scheduled by a real API server, the
same mechanism a production cluster uses at any size.
cat > /tmp/cd-course-selfheal.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: selfheal-demo
spec:
replicas: 1
selector:
matchLabels:
app: selfheal-demo
template:
metadata:
labels:
app: selfheal-demo
spec:
containers:
- name: web
image: nginx:alpine
YAML
kubectl apply -f /tmp/cd-course-selfheal.yaml
kubectl wait --for=condition=Available deployment/selfheal-demo --timeout=30s
original_pod=$(kubectl get pods -l app=selfheal-demo -o jsonpath='{.items[0].metadata.name}')
echo "the pod actually running: $original_pod"
echo
echo "deleting that exact pod directly -- the same kind of thing that just killed the plain container:"
kubectl delete pod "$original_pod" > /dev/null
replacement=""
for i in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.5
candidate=$(kubectl get pods -l app=selfheal-demo -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
if [ -n "$candidate" ] && [ "$candidate" != "$original_pod" ]; then
replacement="$candidate"
break
fi
done
echo "what is running now, moments later, with nobody re-running anything: $replacement"
kubectl delete -f /tmp/cd-course-selfheal.yaml > /dev/null
rm -f /tmp/cd-course-selfheal.yaml
check "a replacement pod appeared with no command re-run by hand" "$([ -n "$replacement" ] && echo yes)" "yes"
check "the replacement is genuinely a NEW pod, not the same one restarted in place" \
"$([ "$replacement" != "$original_pod" ] && echo yes)" "yes"
What it printed, when the build ran it
deployment.apps/selfheal-demo created deployment.apps/selfheal-demo condition met the pod actually running: selfheal-demo-584b9cd55c-l5vk7 deleting that exact pod directly -- the same kind of thing that just killed the plain container: what is running now, moments later, with nobody re-running anything: selfheal-demo-584b9cd55c-5m8mm ok a replacement pod appeared with no command re-run by hand ok the replacement is genuinely a NEW pod, not the same one restarted in place
What actually did the watching. Nothing about the pod itself came back to life -- that exact process is gone. A separate, always-running controller noticed the Deployment's desired count (1) no longer matched reality (0), and asked the scheduler for a new pod to close the gap. This loop runs constantly, for every Deployment on the cluster, whether or not anyone is watching.
"Desired state" is not a one-time instruction -- changing it is the whole interface
Scaling a Deployment is not a separate command that does something special -- it is editing the one number Kubernetes is continuously trying to make true, the exact same mechanism that just brought the deleted pod back.
cat > /tmp/cd-course-scale.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: scale-demo
spec:
replicas: 2
selector:
matchLabels:
app: scale-demo
template:
metadata:
labels:
app: scale-demo
spec:
containers:
- name: web
image: nginx:alpine
YAML
kubectl apply -f /tmp/cd-course-scale.yaml
kubectl wait --for=condition=Available deployment/scale-demo --timeout=30s
before=$(kubectl get pods -l app=scale-demo --no-headers | wc -l | tr -d ' ')
echo "replicas: 2 in the file -- pods actually running: $before"
echo
echo "deleting BOTH pods at once, simulating the whole fleet crashing:"
kubectl delete pods -l app=scale-demo > /dev/null
recovered=0
for i in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.5
running=$(kubectl get pods -l app=scale-demo --no-headers 2>/dev/null | grep -c Running)
if [ "$running" = "2" ]; then recovered=2; break; fi
done
echo "running again after the crash, with nobody intervening: $recovered"
echo
echo "now editing the SAME file to replicas: 4, and re-applying it -- this is 'scaling':"
sed -i.bak 's/replicas: 2/replicas: 4/' /tmp/cd-course-scale.yaml
kubectl apply -f /tmp/cd-course-scale.yaml
kubectl wait --for=condition=Available deployment/scale-demo --timeout=30s
after=$(kubectl get pods -l app=scale-demo --no-headers | wc -l | tr -d ' ')
echo "pods running after re-applying with replicas: 4: $after"
kubectl delete -f /tmp/cd-course-scale.yaml > /dev/null
rm -f /tmp/cd-course-scale.yaml /tmp/cd-course-scale.yaml.bak
check "the fleet recovered to full strength after a total crash, unattended" "$recovered" "2"
check "scaling is the exact same file, one number changed, re-applied" "$after" "4"
What it printed, when the build ran it
deployment.apps/scale-demo created deployment.apps/scale-demo condition met replicas: 2 in the file -- pods actually running: 2 deleting BOTH pods at once, simulating the whole fleet crashing: running again after the crash, with nobody intervening: 2 now editing the SAME file to replicas: 4, and re-applying it -- this is 'scaling': deployment.apps/scale-demo configured deployment.apps/scale-demo condition met pods running after re-applying with replicas: 4: 4 ok the fleet recovered to full strength after a total crash, unattended ok scaling is the exact same file, one number changed, re-applied
What this lesson is NOT claiming. This single-node cluster proves the reconciliation loop -- desired state pursued continuously -- which is Kubernetes's actual defining feature. It does not demonstrate scheduling across multiple physical machines, since kind runs one node; that property (a pod landing on whichever machine in a fleet has room for it) is real and important, but requires more than a laptop to show honestly, so this course states it here rather than faking it with one node pretending to be many.
Why this is not "Compose, but fancier"
- Compose's
restart: alwaysrestarts a container that exits -- it does not notice a container that was forcibly killed and never exits cleanly in the way Kubernetes's controller loop, checked above, genuinely does. - Compose has no concept of "spread these five containers across whichever of my ten machines has room" -- it runs everything on the one machine it was invoked on.
- Kubernetes's API is the same reconciliation idea for every resource, not just replica count -- lesson 37 uses the identical pattern for configuration, and lesson 38 for how traffic reaches a pod at all.
36 Pods, Deployments and Services, as real manifests
A bare Pod, applied directly, is honest about what it is: one running instance, with no controller replacing it if it dies -- exactly like the plain Docker container in lesson 35. A Deployment exists specifically to put the reconciliation loop in front of Pods. A Service exists to solve a problem neither of those touches: Pods get new IP addresses every time they are replaced, and nothing should ever hardcode one.
A bare Pod: the smallest unit, and its real limitation
cat > /tmp/cd-course-bare-pod.yaml << 'YAML'
apiVersion: v1
kind: Pod
metadata:
name: bare-pod
labels:
app: bare-pod
spec:
containers:
- name: web
image: nginx:alpine
YAML
kubectl apply -f /tmp/cd-course-bare-pod.yaml
kubectl wait --for=condition=Ready pod/bare-pod --timeout=30s
echo "the bare pod is running, on its own, with no Deployment above it"
echo
echo "deleting it directly -- the same action lesson 35 showed a Deployment's pod surviving:"
kubectl delete pod bare-pod
still_gone=""
for i in 1 2 3 4 5 6; do
sleep 1
if ! kubectl get pod bare-pod > /dev/null 2>&1; then still_gone="yes"; fi
done
echo "still gone after waiting: $still_gone"
rm -f /tmp/cd-course-bare-pod.yaml
check "a bare pod, with nothing watching it, does not come back" "$still_gone" "yes"
What it printed, when the build ran it
pod/bare-pod created pod/bare-pod condition met the bare pod is running, on its own, with no Deployment above it deleting it directly -- the same action lesson 35 showed a Deployment's pod surviving: pod "bare-pod" deleted from default namespace still gone after waiting: yes ok a bare pod, with nothing watching it, does not come back
A Deployment: the same Pod spec, now with the loop from lesson 35 in front of it
A Deployment's YAML is not a different kind of container definition -- the
template: block is a Pod spec, verbatim. What a Deployment adds is
everything wrapped around that spec: the replica count, and the controller enforcing it.
cat > /tmp/cd-course-deployment.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-deployment
spec:
replicas: 3
selector:
matchLabels:
app: web-deployment
template:
metadata:
labels:
app: web-deployment
spec:
containers:
- name: web
image: nginx:alpine
YAML
kubectl apply -f /tmp/cd-course-deployment.yaml
kubectl wait --for=condition=Available deployment/web-deployment --timeout=30s
pod_count=$(kubectl get pods -l app=web-deployment --no-headers | wc -l | tr -d ' ')
echo "pods actually running for 1 Deployment, 3 replicas: $pod_count"
echo
echo "each one is a genuinely separate Pod, with its own name and IP:"
kubectl get pods -l app=web-deployment -o custom-columns=NAME:.metadata.name,IP:.status.podIP
check "the Deployment produced exactly the requested number of real pods" "$pod_count" "3"
What it printed, when the build ran it
deployment.apps/web-deployment created deployment.apps/web-deployment condition met pods actually running for 1 Deployment, 3 replicas: 3 each one is a genuinely separate Pod, with its own name and IP: NAME IP web-deployment-7c67c58c7f-btw85 10.244.0.14 web-deployment-7c67c58c7f-kfptl 10.244.0.15 web-deployment-7c67c58c7f-mblv9 10.244.0.16 ok the Deployment produced exactly the requested number of real pods
A Service: the address that survives every Pod it points to being replaced
Nothing should ever hardcode a Pod's own IP -- it is reassigned every time that Pod is replaced, which lesson 35 already showed happens whenever one dies. A Service gets one stable address and spreads real traffic across whichever Pods currently match its selector, live.
kubectl delete deployment web-deployment > /dev/null 2>&1 || true
cat > /tmp/cd-course-svc-demo.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami-deployment
spec:
replicas: 3
selector:
matchLabels:
app: whoami-deployment
template:
metadata:
labels:
app: whoami-deployment
spec:
containers:
- name: whoami
image: traefik/whoami
---
apiVersion: v1
kind: Service
metadata:
name: whoami-deployment
spec:
selector:
app: whoami-deployment
ports:
- port: 80
YAML
kubectl apply -f /tmp/cd-course-svc-demo.yaml
kubectl wait --for=condition=Available deployment/whoami-deployment --timeout=30s
cluster_ip=$(kubectl get svc whoami-deployment -o jsonpath='{.spec.clusterIP}')
echo "the Service's own, stable address: $cluster_ip"
echo
echo "a throwaway pod, from inside the cluster, hitting that ONE address repeatedly:"
echo "(whoami reports its own pod's hostname in every response body, which is how we can tell them apart)"
kubectl run curler --image=curlimages/curl --restart=Never --command -- sleep 300 > /dev/null
kubectl wait --for=condition=Ready pod/curler --timeout=30s > /dev/null
seen_hosts=""
for i in 1 2 3 4 5 6; do
answer=$(kubectl exec curler -- curl -s "http://whoami-deployment/" 2>/dev/null)
host=$(echo "$answer" | grep -o 'Hostname: [^ ]*' | cut -d' ' -f2)
seen_hosts="$seen_hosts $host"
done
echo "pod hostnames that answered, across 6 requests to the SAME address:"
echo "$seen_hosts" | tr ' ' '\n' | sort -u
distinct=$(echo "$seen_hosts" | tr ' ' '\n' | sort -u | grep -c '^whoami-deployment')
kubectl delete pod curler --now > /dev/null 2>&1
kubectl delete -f /tmp/cd-course-svc-demo.yaml
rm -f /tmp/cd-course-deployment.yaml /tmp/cd-course-svc-demo.yaml
check "more than one distinct pod answered through the single Service address" \
"$([ "$distinct" -gt 1 ] && echo yes)" "yes"
What it printed, when the build ran it
deployment.apps/whoami-deployment created service/whoami-deployment created deployment.apps/whoami-deployment condition met the Service's own, stable address: 10.96.225.152 a throwaway pod, from inside the cluster, hitting that ONE address repeatedly: (whoami reports its own pod's hostname in every response body, which is how we can tell them apart) pod hostnames that answered, across 6 requests to the SAME address: whoami-deployment-6676498684-8kwm7 whoami-deployment-6676498684-lchqp whoami-deployment-6676498684-mk5zq deployment.apps "whoami-deployment" deleted from default namespace service "whoami-deployment" deleted from default namespace ok more than one distinct pod answered through the single Service address
This is the exact mechanism from Part 6's load balancer lesson, one layer down. An ALB's target group tracks which instances are currently healthy and spreads traffic across them; a Service does the identical job for Pods, using a label selector instead of a manual register call. Neither one cares how the backends came to exist or how many there currently are -- that is precisely what makes them stable addresses.
The three objects, and which job each one actually does
- Pod: one or more containers that are always scheduled together, sharing a network namespace. The unit Kubernetes actually runs -- everything else exists to manage Pods, not to replace the idea of one.
- Deployment: a Pod template plus a replica count plus the reconciliation loop from lesson 35. What almost every real workload is wrapped in, specifically so a Pod dying is a non-event instead of an outage.
- Service: a stable address for a moving set of Pods, selected by label, not by remembering any individual Pod's identity -- which by design is not supposed to be remembered at all.
37 ConfigMaps, Secrets and the twelve-factor config split
A container image should never need to be rebuilt because a database hostname changed between staging and production -- that single idea is what "config outside the code" means, and Kubernetes gives it two real objects: ConfigMaps for values that are not sensitive, Secrets for values that are, even though (checked below) the difference between them is thinner than the names suggest.
The exact same image, behaving differently, because the config differs
Nothing about the container image below knows what environment it is in. The only thing that differs between these two pods is which ConfigMap is wired to it.
cat > /tmp/cd-course-envs.yaml << 'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
name: staging-config
data:
ENVIRONMENT: staging
---
apiVersion: v1
kind: ConfigMap
metadata:
name: production-config
data:
ENVIRONMENT: production
---
apiVersion: v1
kind: Pod
metadata:
name: staging-pod
spec:
containers:
- name: app
image: alpine
command: ["sh", "-c", "echo running as $ENVIRONMENT; sleep 300"]
envFrom:
- configMapRef:
name: staging-config
---
apiVersion: v1
kind: Pod
metadata:
name: production-pod
spec:
containers:
- name: app
image: alpine
command: ["sh", "-c", "echo running as $ENVIRONMENT; sleep 300"]
envFrom:
- configMapRef:
name: production-config
YAML
kubectl apply -f /tmp/cd-course-envs.yaml
kubectl wait --for=condition=Ready pod/staging-pod pod/production-pod --timeout=30s
echo "the exact same image and command, in the pod labelled staging:"
staging_log=$(kubectl logs staging-pod)
echo "$staging_log"
echo
echo "the exact same image and command, in the pod labelled production:"
production_log=$(kubectl logs production-pod)
echo "$production_log"
kubectl delete -f /tmp/cd-course-envs.yaml --grace-period=0 --force > /dev/null 2>&1
rm -f /tmp/cd-course-envs.yaml
check "the staging pod genuinely reported staging, from its injected config" "$staging_log" "running as staging"
check "the production pod genuinely reported production -- same image, different result" \
"$production_log" "running as production"
What it printed, when the build ran it
configmap/staging-config created configmap/production-config created pod/staging-pod created pod/production-pod created pod/staging-pod condition met pod/production-pod condition met the exact same image and command, in the pod labelled staging: running as staging the exact same image and command, in the pod labelled production: running as production ok the staging pod genuinely reported staging, from its injected config ok the production pod genuinely reported production -- same image, different result
What would have to change to add a THIRD environment. Nothing about the image, and nothing about the command inside it -- only a third ConfigMap. This is the concrete payoff of keeping config out of the image: adding an environment is a new YAML file, not a new build.
A Secret, read back honestly: base64, not encryption
The name "Secret" implies protection the object itself does not provide. What it actually stores is base64-encoded text -- reversible by anyone with read access to the object, with no key involved at all.
kubectl create secret generic db-credentials --from-literal=DB_PASSWORD=hunter2-but-real
echo "the Secret, exactly as Kubernetes stores it:"
stored=$(kubectl get secret db-credentials -o jsonpath='{.data.DB_PASSWORD}')
echo "$stored"
echo
echo "decoding it -- no key, no password, just base64, the same encoding curl or email attachments use:"
decoded=$(echo "$stored" | base64 --decode)
echo "$decoded"
kubectl delete secret db-credentials
check "what is stored is reversible base64, confirmed by decoding it back to the original" \
"$decoded" "hunter2-but-real"
What it printed, when the build ran it
secret/db-credentials created the Secret, exactly as Kubernetes stores it: aHVudGVyMi1idXQtcmVhbA== decoding it -- no key, no password, just base64, the same encoding curl or email attachments use: hunter2-but-real secret "db-credentials" deleted from default namespace ok what is stored is reversible base64, confirmed by decoding it back to the original
What a Secret actually protects against, and what it does not. A
Secret is NOT encrypted at rest by default, and base64 is not a security boundary --
anyone who can run kubectl get secret -o jsonpath against this cluster (the
exact command just run) can read it in one line. What a Secret DOES do differently from
a ConfigMap: Kubernetes avoids logging its value in places a ConfigMap's value might
appear, and RBAC (Part 13's IAM equivalent for Kubernetes) can restrict who is allowed to
get Secret objects specifically. Real protection -- encryption at rest,
short-lived credentials -- needs more than a Secret object alone; Part 13 returns to
this with the further, real consequences.
Same object, same mechanism, injected the same way -- on purpose
A Secret and a ConfigMap are consumed identically by a Pod (envFrom,
exactly as used above) -- Kubernetes deliberately does not make sensitive config harder to
wire up than non-sensitive config, because making the secure path the annoying path is how
secrets end up hardcoded instead.
cat > /tmp/cd-course-both.yaml << 'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
name: app-settings
data:
APP_MODE: production
LOG_LEVEL: info
---
apiVersion: v1
kind: Secret
metadata:
name: app-credentials
type: Opaque
stringData:
DB_PASSWORD: super-secret-db-pass
---
apiVersion: v1
kind: Pod
metadata:
name: config-and-secret-consumer
spec:
containers:
- name: app
image: alpine
command: ["sh", "-c", "env | grep -E 'APP_MODE|LOG_LEVEL|DB_PASSWORD' | sort; sleep 300"]
envFrom:
- configMapRef:
name: app-settings
- secretRef:
name: app-credentials
YAML
kubectl apply -f /tmp/cd-course-both.yaml
kubectl wait --for=condition=Ready pod/config-and-secret-consumer --timeout=30s
echo "what the running application actually sees, from BOTH sources, injected the same way:"
seen=$(kubectl logs config-and-secret-consumer)
echo "$seen"
kubectl delete -f /tmp/cd-course-both.yaml --grace-period=0 --force > /dev/null 2>&1
rm -f /tmp/cd-course-both.yaml
check "the non-sensitive setting from the ConfigMap reached the app" "$(echo "$seen" | grep -c 'APP_MODE=production')" "1"
check "the sensitive value from the Secret reached the app through the identical mechanism" \
"$(echo "$seen" | grep -c 'DB_PASSWORD=super-secret-db-pass')" "1"
What it printed, when the build ran it
configmap/app-settings created secret/app-credentials created pod/config-and-secret-consumer created pod/config-and-secret-consumer condition met what the running application actually sees, from BOTH sources, injected the same way: APP_MODE=production DB_PASSWORD=super-secret-db-pass LOG_LEVEL=info ok the non-sensitive setting from the ConfigMap reached the app ok the sensitive value from the Secret reached the app through the identical mechanism
What this actually replaces
- A config value baked into the image at build time -- meaning a different image per environment, and a rebuild for every config change.
- A password committed to a repo because "it's just for staging" -- Part 13 returns to why that specific habit is one of the most common real breach causes, not a theoretical risk.
- The distinction that matters is not "ConfigMap vs Secret" mechanically -- it is which category of value you are handling, and Part 13's least-privilege lesson is where that distinction actually earns its keep.
38 Ingress: exposing a service to the world
Every Service used in lesson 36 had an address only reachable from inside the cluster -- correct for one microservice talking to another, useless for an actual visitor. Ingress is a real HTTP router sitting at the cluster's edge, deciding, by hostname (and optionally path), which Service a request should reach.
Why this lesson installs Traefik, not the ingress controller most older
tutorials use. The community ingress-nginx project -- for years
the default choice bundled into countless getting-started guides -- was retired by
Kubernetes SIG Network in March 2026 and stopped receiving security patches. This course
will not teach installing software with no path to a fix for its next CVE. Traefik is
one of several currently maintained alternatives that speak the same Ingress API.
Installing a real ingress controller with Helm
Helm is to a Kubernetes application what a package manager is to an OS: a chart packages a set of manifests (here, Traefik's own Deployment, Service, RBAC rules and CRDs) so installing it is one real command instead of hand-assembling a dozen YAML files.
helm repo add traefik https://traefik.github.io/charts > /dev/null
helm repo update > /dev/null
echo "chart repository added"
helm install traefik traefik/traefik --wait --timeout 90s
echo
echo "confirming it is a real, running workload on the cluster -- not just a Helm record:"
kubectl get pods -l app.kubernetes.io/name=traefik
kubectl get ingressclass
check "Traefik installed and reports as deployed" "$(helm list --short | grep -c traefik)" "1"
check "installing it registered a real IngressClass Ingress objects can reference" \
"$(kubectl get ingressclass -o name | grep -c traefik)" "1"
What it printed, when the build ran it
chart repository added NAME: traefik LAST DEPLOYED: Tue Sep 15 21:27:39 2026 NAMESPACE: default STATUS: deployed REVISION: 1 DESCRIPTION: Install complete TEST SUITE: None NOTES: traefik with docker.io/traefik:v3.7.13 has been deployed successfully on default namespace! confirming it is a real, running workload on the cluster -- not just a Helm record: NAME READY STATUS RESTARTS AGE traefik-768cc4d6c9-ttnkf 1/1 Running 0 28s NAME CONTROLLER PARAMETERS AGE traefik (default) traefik.io/ingress-controller <none> 28s ok Traefik installed and reports as deployed ok installing it registered a real IngressClass Ingress objects can reference
Two backends, one entry point, routed by hostname -- proven with real HTTP requests
Two completely separate Deployments and Services exist below. The only thing making them reachable from outside the cluster is one Ingress object each, naming a different host. What follows is not reading the YAML back -- it is sending real HTTP requests through Traefik and checking which backend actually answered.
cat > /tmp/cd-course-ingress.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: site-a
spec:
replicas: 1
selector:
matchLabels:
app: site-a
template:
metadata:
labels:
app: site-a
spec:
containers:
- name: whoami
image: traefik/whoami
args: ["-name=site-a"]
---
apiVersion: v1
kind: Service
metadata:
name: site-a
spec:
selector:
app: site-a
ports:
- port: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: site-a
spec:
ingressClassName: traefik
rules:
- host: site-a.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: site-a
port:
number: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: site-b
spec:
replicas: 1
selector:
matchLabels:
app: site-b
template:
metadata:
labels:
app: site-b
spec:
containers:
- name: whoami
image: traefik/whoami
args: ["-name=site-b"]
---
apiVersion: v1
kind: Service
metadata:
name: site-b
spec:
selector:
app: site-b
ports:
- port: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: site-b
spec:
ingressClassName: traefik
rules:
- host: site-b.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: site-b
port:
number: 80
YAML
kubectl apply -f /tmp/cd-course-ingress.yaml
kubectl wait --for=condition=Available deployment/site-a deployment/site-b --timeout=30s
echo
echo "reaching Traefik itself, from inside the cluster, the same edge a real visitor's request hits:"
kubectl run curler --image=curlimages/curl --restart=Never --command -- sleep 300 > /dev/null
kubectl wait --for=condition=Ready pod/curler --timeout=30s > /dev/null
echo
echo "a request with Host: site-a.localhost:"
a_response=$(kubectl exec curler -- curl -s -H "Host: site-a.localhost" http://traefik/)
echo "$a_response" | head -2
echo
echo "the SAME address, a DIFFERENT Host header -- site-b.localhost:"
b_response=$(kubectl exec curler -- curl -s -H "Host: site-b.localhost" http://traefik/)
echo "$b_response" | head -2
echo
echo "a host neither Ingress named at all:"
unmatched_code=$(kubectl exec curler -- curl -s -o /dev/null -w '%{http_code}' -H "Host: nowhere.localhost" http://traefik/)
echo "status code for an unmatched host: $unmatched_code"
kubectl delete pod curler --grace-period=0 --force > /dev/null 2>&1
kubectl delete -f /tmp/cd-course-ingress.yaml > /dev/null 2>&1
rm -f /tmp/cd-course-ingress.yaml
check "the site-a host reached the site-a backend specifically" "$(echo "$a_response" | grep -c 'Name: site-a')" "1"
check "the site-b host, same address, reached a DIFFERENT backend" "$(echo "$b_response" | grep -c 'Name: site-b')" "1"
check "an unrecognised host is refused, not silently routed somewhere" "$unmatched_code" "404"
What it printed, when the build ran it
deployment.apps/site-a created service/site-a created ingress.networking.k8s.io/site-a created deployment.apps/site-b created service/site-b created ingress.networking.k8s.io/site-b created deployment.apps/site-a condition met deployment.apps/site-b condition met reaching Traefik itself, from inside the cluster, the same edge a real visitor's request hits: a request with Host: site-a.localhost: Name: site-a Hostname: site-a-6cb9b55b6c-rstqd the SAME address, a DIFFERENT Host header -- site-b.localhost: Name: site-b Hostname: site-b-55bc7d84cc-4xdqd a host neither Ingress named at all: status code for an unmatched host: 404 ok the site-a host reached the site-a backend specifically ok the site-b host, same address, reached a DIFFERENT backend ok an unrecognised host is refused, not silently routed somewhere
What just proved itself, concretely. Two isolated Deployments never
exchanged any information -- the ONLY thing connecting a request to a specific backend
was the Host header Traefik read and matched against each Ingress's
host: field. This is the real mechanism behind hosting many unrelated
applications behind one load balancer address, which is exactly how a single ALB in
Part 6 could plausibly front an entire company's set of services.
What Ingress replaces, and what it does not
- Without Ingress: every Service needing external traffic would need its own cloud load balancer (a real, per-service cost) or a manual port-forward -- not the pattern this lesson just proved is possible with one address.
- With Ingress: one entry point, one IP or DNS name, routing by hostname and path to as many backends as there are Ingress objects -- each team owning their own Ingress resource without touching anyone else's.
- What this lesson did NOT cover: TLS termination (a real Ingress also handles HTTPS certificates, commonly via cert-manager) and the Gateway API, the newer, more expressive standard SIG Network is steering new projects toward -- both real and worth knowing exist, outside this course's scope.
Part 11, end to end
A Deployment keeps a Pod's replica count true (lesson 35). A Service gives a moving set of Pods one stable, internal address (lesson 36). A ConfigMap or Secret lets the exact same image behave differently per environment (lesson 37). An Ingress takes one of those Services and makes it reachable from outside at all (this lesson). None of the four exists without the others actually mattering.