From 27dec6cb27b0338b5336130d5b7c7653925b1ed7 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Mon, 27 Apr 2026 11:59:28 +0200 Subject: [PATCH 01/18] Add chapter 3 alternative: install cluster with kind instead of k3s Drop-in replacement for chapters/03-install-k3s.md aimed at machines that already have other services on host ports 80/443. Uses kind (Kubernetes in Docker) with the official local-registry sidecar pattern, ingress on 8080/8443, and an unchanged cert-manager setup. Chapters 4+ work unchanged aside from swapping the registry name. Co-Authored-By: Claude Opus 4.7 (1M context) --- chapters/03-install-kind.md | 428 ++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 chapters/03-install-kind.md diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md new file mode 100644 index 0000000..8cfc5ad --- /dev/null +++ b/chapters/03-install-kind.md @@ -0,0 +1,428 @@ +# Chapter 3 (alternative): Installing the cluster with **kind** instead of k3s + +> This is a drop-in replacement for [03-install-k3s.md](03-install-k3s.md). Use **this** chapter if you already have other services running on host ports 80/443 (a reverse proxy, a hosted site, anything that listens on the standard HTTP/HTTPS ports) and you want a Kubernetes cluster that doesn't fight them. From chapter 4 onwards everything is identical — Helm and Skaffold don't care which distro is underneath. + +## What we're going to build + +The end-state is the same as the original chapter 3: + +* a real Kubernetes cluster on the local machine +* an **ingress controller** (nginx) so we can later route HTTP traffic +* a **container registry** so docker images we build are reachable from inside the cluster +* a **certificate manager** with a local CA so we can hand out TLS certs +* the client tools to drive all of this (`kubectl`, `helm`, `skaffold`, `k9s`, `kubectx`) + +The only thing that changes is *how the cluster is hosted*: + +| | Original (k3s) | This chapter (kind) | +|---|---|---| +| Where it runs | Host-level systemd service | Inside Docker containers | +| Binds host ports 80/443 | Yes (via nginx-ingress) | No (we map to 8080/8443) | +| Conflicts with existing :80/:443 services | Yes | No | +| Image registry | In-cluster `registry.kube-public` over TLS | Sidecar `kind-registry` over plain HTTP | +| Persistent across reboots | Yes (systemd) | No (you re-create the cluster) | + +For a learning environment that's a great trade. The cluster is fully throwaway: when something breaks, `kind delete cluster && kind create cluster` and you're back in 30 seconds. + +> Why "kind"? It stands for **K**ubernetes **IN** **D**ocker. Each cluster node is just a Docker container running a real kubelet. It's the upstream Kubernetes project's own way to test Kubernetes itself. + +--- + +## 1. Install the client tools + +These are the same tools the original chapter installs — they talk to *any* cluster, not just k3s. Skip any you already have. + +```shell +# kubectl: the canonical CLI for the Kubernetes API +curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +sudo install kubectl /usr/local/bin +rm -f kubectl + +# helm: package manager for Kubernetes manifests +curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash + +# skaffold: the build/push/deploy glue we'll meet in chapter 7 +curl -Lo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 +sudo install skaffold /usr/local/bin/ +rm -f skaffold + +# k9s: a TUI for Kubernetes +curl -Lo k9s.tgz https://github.com/derailed/k9s/releases/download/v0.32.5/k9s_Linux_amd64.tar.gz +tar -xf k9s.tgz k9s && sudo install k9s /usr/local/bin/ +rm -f k9s.tgz k9s + +# kubectx: quick context/namespace switcher +curl -Lo kubectx https://github.com/ahmetb/kubectx/releases/download/v0.9.3/kubectx +sudo install kubectx /usr/local/bin/ +rm -f kubectx + +# kind: the cluster itself +curl -Lo kind https://kind.sigs.k8s.io/dl/v0.24.0/kind-linux-amd64 +sudo install kind /usr/local/bin/ +rm -f kind +``` + +Skaffold's file-watching mode (`skaffold dev`) opens a *lot* of inotify handles. Bump the kernel limits so we don't hit them later: + +```shell +cat << END | sudo tee -a /etc/sysctl.conf +fs.inotify.max_user_watches=1048576 +fs.inotify.max_user_instances=1000000 +END +sudo sysctl --system +``` + +Sanity check: + +```shell +kubectl version --client +helm version +skaffold version +kind version +``` + +--- + +## 2. Decide the cluster's port strategy + +This is the one decision that differs from the original chapter. + +A kind cluster is a regular Docker container. By default it doesn't publish anything to the host. We have to tell it which container ports to map onto host ports — same rules as `docker run -p`. + +For this tutorial we want: + +* **8080** on the host → 80 inside the cluster (HTTP for nginx-ingress) +* **8443** on the host → 443 inside the cluster (HTTPS for nginx-ingress) + +Why those numbers? Host ports 80 and 443 are likely already taken on any machine that hosts other web services — a reverse proxy, a website, a media server, anything with an HTTP front-end. Picking the `+8000` variants keeps the two stacks side-by-side without conflict, and they're a common convention for "secondary HTTP listener on this box." + +Later on, if you want to expose tutorial workloads publicly, you can have your existing reverse proxy forward selected hostnames into `127.0.0.1:8080`. We'll get to that in chapter 9 if you care about it. + +--- + +## 3. Create a kind config file + +Create `~/kind-tutorial.yaml`: + +```yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: tutorial +nodes: + - role: control-plane + # This label is what nginx-ingress uses to find a node it's allowed to schedule on. + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + # Map host ports onto the node's network namespace. + # Anything inside the cluster listening on these container ports + # becomes reachable on the host at 8080 / 8443. + extraPortMappings: + - containerPort: 80 + hostPort: 8080 + protocol: TCP + - containerPort: 443 + hostPort: 8443 + protocol: TCP +# Tell containerd (the container runtime inside the kind node) that whenever +# it sees an image starting with `localhost:5001/...`, it should fetch it +# from the registry container we'll create in step 5. +containerdConfigPatches: + - |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5001"] + endpoint = ["http://kind-registry:5000"] +``` + +A few things to point out: + +* `name: tutorial` → this becomes the kubectl context name. You'll see it as `kind-tutorial`. +* The `node-labels: "ingress-ready=true"` line lets us pin the ingress controller pod to this exact node — important because `extraPortMappings` only works for the node that has them. +* The `containerdConfigPatches` block is the magic that lets us pretend the registry is at `localhost:5001` from both host and cluster. We'll set up the actual registry container next. +* You may notice we use port **5001**, not 5000, for the registry. That's a convention from kind's official docs to dodge a conflict on macOS, where Apple's AirPlay Receiver service binds 5000 by default since macOS Monterey. On Linux 5000 would work fine, but we keep 5001 here so this chapter matches what you'll find in upstream kind documentation if you ever need to look something up. + +--- + +## 4. Create the cluster + +```shell +kind create cluster --config ~/kind-tutorial.yaml +``` + +This spins up: + +* one Docker container called `tutorial-control-plane` running the API server, scheduler, controller-manager, etcd, and kubelet +* it auto-writes a kubeconfig stanza to `~/.kube/config` and switches your current context to `kind-tutorial` + +Verify: + +```shell +kubectl cluster-info +kubectl get nodes +``` + +You should see one node, `tutorial-control-plane`, in `Ready` state. + +> **kubeconfig already-exists case:** if you already have a `.kube/config` from another cluster, kind will *merge* its stanza in (good). To hop between clusters, use `kubectx` (`kubectx kind-tutorial`). + +--- + +## 5. Set up the in-cluster image registry + +We need somewhere to store the docker images we'll build in chapters 4 and 7. Kind doesn't ship a registry — instead the standard pattern (from kind's own docs) is to run a tiny `registry:2` container next to the cluster. + +```shell +# Run a registry, only listening on the host's loopback so it isn't exposed publicly. +# Container name is "kind-registry" so containerd in the cluster can resolve it. +docker run -d --restart=always \ + -p 127.0.0.1:5001:5000 \ + --name kind-registry \ + registry:2 + +# Connect the registry to the same docker network the kind cluster uses. +# Without this, the kind node container can't reach the registry container. +docker network connect kind kind-registry || true +``` + +Now both sides can reach the registry through the same name `localhost:5001`: + +* **From the host** (when you `docker push`): hits `127.0.0.1:5001` → forwarded to the registry container's port 5000. +* **From inside the cluster** (when containerd pulls): the `containerdConfigPatches` we wrote earlier rewrites `localhost:5001` to `http://kind-registry:5000`, which it resolves through docker's embedded DNS on the kind network. + +It looks weird but it's a tested pattern. The end result is that **both sides use the exact same image reference**, e.g. `localhost:5001/myfrontend:latest` — no fiddling with two names. + +(Optional, mostly cosmetic: tell tools like Skaffold that this registry exists, by writing the standard `local-registry-hosting` ConfigMap.) + +```shell +cat < **If you see** `http: server gave HTTP response to HTTPS client` — you have an older Docker that doesn't auto-trust localhost. Edit `/etc/docker/daemon.json` (create it if missing): +> ```json +> { +> "insecure-registries": ["localhost:5001"] +> } +> ``` +> Then `sudo systemctl restart docker`. Be aware: restarting the Docker daemon briefly stops every container on this host. They'll auto-restart if their compose files use `restart: unless-stopped`, but expect a few seconds of downtime on anything else running on this machine. Schedule it for a quiet moment. + +Verify the cluster can pull the image you just pushed (this is the real test of the redirect trick): + +```shell +kubectl run busybox-test --image=localhost:5001/busybox:test --rm -it --restart=Never --image-pull-policy=Always -- sh -c 'echo "hello from inside the cluster"' +``` + +If it prints `hello from inside the cluster`, the cluster pulled your image through the redirect successfully. If it errors with `ErrImagePull` or hangs, run `kubectl describe pod busybox-test` and look at the `Events:` section. + +--- + +## 6. Install the nginx ingress controller + +Kind has its own preset manifest for nginx-ingress that already knows about the `ingress-ready=true` node label and the host port mapping we set in step 3. So we don't even need helm here: + +```shell +kubectl apply -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml +``` + +Wait for it to come up: + +```shell +kubectl wait --namespace ingress-nginx \ + --for=condition=ready pod \ + --selector=app.kubernetes.io/component=controller \ + --timeout=180s +``` + +When that returns, ingress is alive on the host at: + +* `http://localhost:8080` +* `https://localhost:8443` (will use a self-signed cert for now) + +Quick sanity probe: + +```shell +curl -I http://localhost:8080/ +# should answer 404 from nginx — that's correct, no ingress rules exist yet +``` + +You'll also see two `ingress-nginx-admission-*` pods in `Completed` state — that's expected. They're one-shot Kubernetes Jobs that bootstrap the validating webhook and exit. A `Completed` Job is healthy; it should *not* be `Running` long-term. + +--- + +## 7. Install cert-manager + a local CA + a ClusterIssuer + +This part is **identical** to the original chapter 3 — it's a Kubernetes concept, not a k3s concept. We need it because: + +* later chapters create TLS certificates for ingress hostnames +* chapter 10 demonstrates operators, and cert-manager *is* an operator (CRD + controller) — a great real-world specimen + +```shell +helm repo add jetstack https://charts.jetstack.io +helm repo update +helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager --create-namespace \ + --set installCRDs=true +``` + +Wait until the three cert-manager pods are running: + +```shell +kubectl get pods -n cert-manager +``` + +Now the local CA (generates a key pair you'll use to sign certificates issued inside the cluster): + +```shell +mkdir -p $HOME/kubeca && cd $HOME/kubeca +[[ -f ca.key ]] || openssl genrsa -out ca.key 2048 +[[ -f ca.crt ]] || openssl req -x509 -new -nodes -key ca.key -subj "/CN=local_kind" -days 3650 \ + -reqexts v3_req -extensions v3_ca -out ca.crt +``` + +Upload the CA key+cert into the cluster as a `tls` secret, then create a `ClusterIssuer` that references it: + +```shell +kubectl create secret tls ca-key-pair \ + --cert=ca.crt \ + --key=ca.key \ + --namespace=cert-manager + +cat << END | kubectl apply -f - +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned-ca-issuer + namespace: cert-manager +spec: + ca: + secretName: ca-key-pair +END +``` + +If you get an "Admission Web Hook" error, the cert-manager pods aren't fully up yet — wait 20 seconds and re-apply. + +> Same as the original chapter: this is a *self-signed* CA, so browsers and Docker won't trust it without an extra step. Trust it on the host with: +> ``` +> sudo cp ca.crt /usr/local/share/ca-certificates/selfsigned-kind.crt +> sudo update-ca-certificates +> ``` + +You don't need to restart Docker for this CA (we're using HTTP for the registry, not TLS). + +--- + +## 8. Verify the whole stack + +```shell +# every pod across every namespace should be Running or Completed +kubectl get pods -A + +# the cluster issuer should report Ready=True +kubectl get clusterissuer + +# the ingress controller should be Running +kubectl get pods -n ingress-nginx + +# the registry container should be reachable +curl http://localhost:5001/v2/_catalog +# should answer: {"repositories":[...]} +``` + +Open k9s once just to get the feel: + +```shell +k9s +``` + +* `:` then type `pods` → list all pods +* `:` then `ns` → list namespaces, hit enter to pin one +* `0` → show all namespaces +* `?` for the keybindings cheatsheet +* `Ctrl-C` to quit + +--- + +## 9. Differences vs the original chapter — important to know going forward + +When you read chapter 4 onwards, replace the following on the fly: + +| Original (k3s) | This setup (kind) | +|---|---| +| `registry.kube-public/myfrontend` | `localhost:5001/myfrontend` | +| `registry.kube-public/myapi` | `localhost:5001/myapi` | +| `imagePullSecrets: [registry-creds]` | **omit it** — our registry is plain HTTP, no auth | +| `host: frank-test.duckdns.org` (chapter 9) | `host: tutorial.localhost`, accessed via `curl http://localhost:8080 -H "Host: tutorial.localhost"` | +| `kubectl create secret docker-registry registry-creds ...` | not needed | + +Everything else (Deployments, StatefulSets, Services, Helm charts, Skaffold config, Flux gitops) is **byte-for-byte identical**. + +When chapter 7 introduces skaffold's `-d registry.kube-public` flag, you'll write: + +``` +skaffold run -d localhost:5001 +``` + +instead. + +--- + +## 10. Cleanup / restart cheat sheet + +Things go wrong sometimes. These commands are safe to run any time: + +```shell +# nuke the cluster, keep the registry contents +kind delete cluster --name tutorial + +# re-create from the same config file +kind create cluster --config ~/kind-tutorial.yaml + +# nuke the registry too (also wipes the docker images stored in it) +docker rm -f kind-registry + +# bring the registry back +docker run -d --restart=always -p 127.0.0.1:5001:5000 --name kind-registry registry:2 +docker network connect kind kind-registry +``` + +If the host reboots, the kind container won't auto-start. Bring it back with: + +```shell +docker start tutorial-control-plane +docker start kind-registry +``` + +--- + +## What you should now understand (chapter 3's real learning goals) + +Before moving on to chapter 4, make sure you can answer: + +* What is a **namespace** and why do we have one per software component? +* What does a **ClusterIssuer** do, and how is it different from a `Certificate`? +* What is an **ingress controller** and why doesn't a bare `Ingress` object work without one? +* Why does the cluster need its own way to *pull* images, separate from the way the host *pushes* them? +* What does `kubectl apply` actually do at the API level — and how does that differ from `kubectl create`? + +These are the questions the original chapter 3 leaves dangling on purpose. The k3s-vs-kind choice doesn't change any of them. From d7a6722902d2dd03e92362a16d080f4f26819130 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 11:58:25 +0200 Subject: [PATCH 02/18] Switch chapter 3 kind alt to Traefik ingress controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the nginx-ingress install with a Traefik helm install, configured for kind's single-node control-plane + extraPortMappings setup. Same Kubernetes `Ingress` resource concept, no nginx-specific annotations carried through to later chapters — keeps the YAML in chapters 4-9 portable across controllers. Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 46 +++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 8cfc5ad..115a331 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -7,7 +7,7 @@ The end-state is the same as the original chapter 3: * a real Kubernetes cluster on the local machine -* an **ingress controller** (nginx) so we can later route HTTP traffic +* an **ingress controller** (Traefik) so we can later route HTTP traffic * a **container registry** so docker images we build are reachable from inside the cluster * a **certificate manager** with a local CA so we can hand out TLS certs * the client tools to drive all of this (`kubectl`, `helm`, `skaffold`, `k9s`, `kubectx`) @@ -91,8 +91,8 @@ A kind cluster is a regular Docker container. By default it doesn't publish anyt For this tutorial we want: -* **8080** on the host → 80 inside the cluster (HTTP for nginx-ingress) -* **8443** on the host → 443 inside the cluster (HTTPS for nginx-ingress) +* **8080** on the host → 80 inside the cluster (HTTP for the ingress controller) +* **8443** on the host → 443 inside the cluster (HTTPS for the ingress controller) Why those numbers? Host ports 80 and 443 are likely already taken on any machine that hosts other web services — a reverse proxy, a website, a media server, anything with an HTTP front-end. Picking the `+8000` variants keeps the two stacks side-by-side without conflict, and they're a common convention for "secondary HTTP listener on this box." @@ -110,7 +110,7 @@ apiVersion: kind.x-k8s.io/v1alpha4 name: tutorial nodes: - role: control-plane - # This label is what nginx-ingress uses to find a node it's allowed to schedule on. + # This label is what the ingress controller (Traefik) uses to find a node it's allowed to schedule on. kubeadmConfigPatches: - | kind: InitConfiguration @@ -237,36 +237,48 @@ If it prints `hello from inside the cluster`, the cluster pulled your image thro --- -## 6. Install the nginx ingress controller +## 6. Install the Traefik ingress controller -Kind has its own preset manifest for nginx-ingress that already knows about the `ingress-ready=true` node label and the host port mapping we set in step 3. So we don't even need helm here: +We use Traefik as the cluster's ingress controller. If you already run Traefik elsewhere as a standalone reverse proxy, it's the **same binary** — only the config source is different: this one reads Kubernetes `Ingress` resources from the API server, where the standalone one reads Docker labels or static files. Two independent processes, no shared state. + +Install via helm. Single line so it copy-pastes cleanly: + +```shell +helm repo add traefik https://traefik.github.io/charts && helm repo update +``` ```shell -kubectl apply -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml +helm install traefik traefik/traefik -n traefik --create-namespace --set "nodeSelector.ingress-ready=true" --set "tolerations[0].key=node-role.kubernetes.io/control-plane" --set "tolerations[0].operator=Exists" --set "tolerations[0].effect=NoSchedule" --set "ports.web.hostPort=80" --set "ports.websecure.hostPort=443" --set "service.type=ClusterIP" ``` +What each `--set` does: + +* `nodeSelector.ingress-ready=true` → schedule Traefik on the node we labeled in step 3, the one whose `extraPortMappings` actually publishes ports to the host. +* `tolerations[0...]` → kind's single-node cluster has only a control-plane node, and control-plane nodes carry a `NoSchedule` taint that normally blocks workload pods. The toleration tells Traefik "I'm fine landing on a control-plane node." +* `ports.web.hostPort=80` / `ports.websecure.hostPort=443` → bind directly to the kind node container's ports 80/443. Kind's `extraPortMappings` then surfaces those as host `localhost:8080` / `localhost:8443`. +* `service.type=ClusterIP` → don't request a `LoadBalancer` (we have no cloud LB provider). External traffic enters via `hostPort` instead. + Wait for it to come up: ```shell -kubectl wait --namespace ingress-nginx \ - --for=condition=ready pod \ - --selector=app.kubernetes.io/component=controller \ - --timeout=180s +kubectl wait --namespace traefik --for=condition=ready pod --selector=app.kubernetes.io/name=traefik --timeout=180s ``` -When that returns, ingress is alive on the host at: +When that returns, Traefik listens on: * `http://localhost:8080` -* `https://localhost:8443` (will use a self-signed cert for now) +* `https://localhost:8443` (Traefik's auto-generated self-signed cert; we'll replace it with the cert-manager-issued one in chapter 9) -Quick sanity probe: +Sanity probe: ```shell curl -I http://localhost:8080/ -# should answer 404 from nginx — that's correct, no ingress rules exist yet +# should answer 404 — correct, no Ingress rules defined yet ``` -You'll also see two `ingress-nginx-admission-*` pods in `Completed` state — that's expected. They're one-shot Kubernetes Jobs that bootstrap the validating webhook and exit. A `Completed` Job is healthy; it should *not* be `Running` long-term. +A 404 here is **good**. It proves the request reached Traefik but Traefik has nothing to route it to. Once chapter 9 creates an `Ingress` resource, the same URL will resolve. + +> **Why Traefik over nginx-ingress?** Both are valid `Ingress` controllers. Traefik makes the standard k8s `Ingress` object work without nginx-specific annotations (`nginx.ingress.kubernetes.io/use-regex`, etc.), keeping later chapters' YAML portable across controllers. nginx-ingress remains a fine choice — the swap is one helm install away. --- @@ -342,7 +354,7 @@ kubectl get pods -A kubectl get clusterissuer # the ingress controller should be Running -kubectl get pods -n ingress-nginx +kubectl get pods -n traefik # the registry container should be reachable curl http://localhost:5001/v2/_catalog From 0e07e4fcec30b4f16903134c12af742f5ff07399 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 13:29:16 +0200 Subject: [PATCH 03/18] Add sleep 1 to busybox verification to avoid TTY attach race Without the sleep, the container exits before kubectl finishes its TTY attach handshake, producing a cosmetic "couldn't attach... falling back to streaming logs" warning. Adding `sleep 1` to the echo command makes the verification output clean while not affecting correctness. Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 115a331..f62d277 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -230,11 +230,13 @@ If the push prints `test: digest: sha256:... size: ...` you're done. Modern Dock Verify the cluster can pull the image you just pushed (this is the real test of the redirect trick): ```shell -kubectl run busybox-test --image=localhost:5001/busybox:test --rm -it --restart=Never --image-pull-policy=Always -- sh -c 'echo "hello from inside the cluster"' +kubectl run busybox-test --image=localhost:5001/busybox:test --rm -it --restart=Never --image-pull-policy=Always -- sh -c 'sleep 1; echo "hello from inside the cluster"' ``` If it prints `hello from inside the cluster`, the cluster pulled your image through the redirect successfully. If it errors with `ErrImagePull` or hangs, run `kubectl describe pod busybox-test` and look at the `Events:` section. +> The `sleep 1;` before the `echo` exists only so `kubectl ... -it` has time to wire up its TTY attach before the container exits. Without it, the command still works but kubectl prints a `couldn't attach... falling back to streaming logs` warning. Cosmetic. + --- ## 6. Install the Traefik ingress controller From 429f506053b04df7538032adb8d8d9a00b084b95 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 13:30:43 +0200 Subject: [PATCH 04/18] Use --set-string for nodeSelector.ingress-ready to fix install error Helm's --set auto-converts unquoted true/false into Go booleans, but Kubernetes' nodeSelector schema requires string values. Without --set-string the install fails with: Deployment in version "v1" cannot be handled as a Deployment: json: cannot unmarshal bool into Go struct field PodSpec.spec.template.spec.nodeSelector of type string Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index f62d277..74eece4 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -250,12 +250,12 @@ helm repo add traefik https://traefik.github.io/charts && helm repo update ``` ```shell -helm install traefik traefik/traefik -n traefik --create-namespace --set "nodeSelector.ingress-ready=true" --set "tolerations[0].key=node-role.kubernetes.io/control-plane" --set "tolerations[0].operator=Exists" --set "tolerations[0].effect=NoSchedule" --set "ports.web.hostPort=80" --set "ports.websecure.hostPort=443" --set "service.type=ClusterIP" +helm install traefik traefik/traefik -n traefik --create-namespace --set-string "nodeSelector.ingress-ready=true" --set "tolerations[0].key=node-role.kubernetes.io/control-plane" --set "tolerations[0].operator=Exists" --set "tolerations[0].effect=NoSchedule" --set "ports.web.hostPort=80" --set "ports.websecure.hostPort=443" --set "service.type=ClusterIP" ``` What each `--set` does: -* `nodeSelector.ingress-ready=true` → schedule Traefik on the node we labeled in step 3, the one whose `extraPortMappings` actually publishes ports to the host. +* `--set-string "nodeSelector.ingress-ready=true"` → schedule Traefik on the node we labeled in step 3, the one whose `extraPortMappings` actually publishes ports to the host. **Why `--set-string` instead of `--set`:** helm's `--set` auto-converts unquoted `true`/`false` into Go booleans, but Kubernetes' `nodeSelector` schema requires the value to be a string. `--set-string` forces string interpretation. * `tolerations[0...]` → kind's single-node cluster has only a control-plane node, and control-plane nodes carry a `NoSchedule` taint that normally blocks workload pods. The toleration tells Traefik "I'm fine landing on a control-plane node." * `ports.web.hostPort=80` / `ports.websecure.hostPort=443` → bind directly to the kind node container's ports 80/443. Kind's `extraPortMappings` then surfaces those as host `localhost:8080` / `localhost:8443`. * `service.type=ClusterIP` → don't request a `LoadBalancer` (we have no cloud LB provider). External traffic enters via `hostPort` instead. From ede302073d3947e6a05b3aaa2a2967dd890391b2 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 13:36:28 +0200 Subject: [PATCH 05/18] Use `helm upgrade --install` for Traefik so retries don't error Plain `helm install` errors with "cannot re-use a name that is still in use" if a previous attempt left a release in helm's history (even a failed one). `helm upgrade --install` is the standard idempotent pattern: install on first run, update on subsequent runs. Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 74eece4..ed0cf10 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -243,14 +243,14 @@ If it prints `hello from inside the cluster`, the cluster pulled your image thro We use Traefik as the cluster's ingress controller. If you already run Traefik elsewhere as a standalone reverse proxy, it's the **same binary** — only the config source is different: this one reads Kubernetes `Ingress` resources from the API server, where the standalone one reads Docker labels or static files. Two independent processes, no shared state. -Install via helm. Single line so it copy-pastes cleanly: +Install via helm. We use `helm upgrade --install` instead of plain `helm install` so the command stays re-runnable — if a previous attempt left a failed release behind, this updates it in place instead of erroring with "cannot re-use a name." ```shell helm repo add traefik https://traefik.github.io/charts && helm repo update ``` ```shell -helm install traefik traefik/traefik -n traefik --create-namespace --set-string "nodeSelector.ingress-ready=true" --set "tolerations[0].key=node-role.kubernetes.io/control-plane" --set "tolerations[0].operator=Exists" --set "tolerations[0].effect=NoSchedule" --set "ports.web.hostPort=80" --set "ports.websecure.hostPort=443" --set "service.type=ClusterIP" +helm upgrade --install traefik traefik/traefik -n traefik --create-namespace --set-string "nodeSelector.ingress-ready=true" --set "tolerations[0].key=node-role.kubernetes.io/control-plane" --set "tolerations[0].operator=Exists" --set "tolerations[0].effect=NoSchedule" --set "ports.web.hostPort=80" --set "ports.websecure.hostPort=443" --set "service.type=ClusterIP" ``` What each `--set` does: From 310dff18c3e4cd206bfa8aa6b8e1975169b5390b Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 13:40:20 +0200 Subject: [PATCH 06/18] Add cert-manager concept intro; fix installCRDs deprecation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expands chapter 3 alt §7 with a short conceptual intro explaining what cert-manager is (operator that issues/renews TLS certs from a Kubernetes-native API), the Issuer/ClusterIssuer/Certificate object triad, and how the result lands in a regular tls Secret. - Switches the helm install to `helm upgrade --install` (idempotent on retry) and replaces the deprecated `--set installCRDs=true` with `--set crds.enabled=true` (the new flag in cert-manager v1.15+). Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index ed0cf10..80219d8 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -286,17 +286,27 @@ A 404 here is **good**. It proves the request reached Traefik but Traefik has no ## 7. Install cert-manager + a local CA + a ClusterIssuer -This part is **identical** to the original chapter 3 — it's a Kubernetes concept, not a k3s concept. We need it because: +### What cert-manager is -* later chapters create TLS certificates for ingress hostnames -* chapter 10 demonstrates operators, and cert-manager *is* an operator (CRD + controller) — a great real-world specimen +**cert-manager** is the Kubernetes-native way to issue and renew TLS certificates automatically. If you've used Let's Encrypt with `certbot` on a regular Linux server, cert-manager is the same idea — turn a *request* for "I need an HTTPS cert for `foo.example.com`" into a real, signed certificate file — but driven by Kubernetes objects instead of cron jobs and shell scripts. + +The pieces: + +* **`Issuer` / `ClusterIssuer`** — *who* should sign certs. Could be Let's Encrypt over ACME, a self-signed CA you control, HashiCorp Vault, etc. `Issuer` is namespace-scoped; `ClusterIssuer` is cluster-wide. In this tutorial we make one `ClusterIssuer` backed by a local self-signed CA, so every namespace can request certs from it. +* **`Certificate`** — *what* cert you want. Hostname(s), validity, which Issuer to ask. cert-manager reconciles each `Certificate` by asking the referenced Issuer for a signed cert. +* **The result** — cert-manager writes the signed cert + private key into a normal Kubernetes **`Secret`** (`type: kubernetes.io/tls`). Your Ingress / Service / Pod consumes that Secret the usual way. Renewal happens automatically before expiry. + +Why it matters for this tutorial: + +* Later chapters create TLS certificates for ingress hostnames — cert-manager is what makes that one-line. +* Chapter 10 introduces **operators**, and cert-manager *is* a textbook operator: it ships CRDs (`Certificate`, `Issuer`, `ClusterIssuer`, `CertificateRequest`, `Challenge`, `Order`) plus a controller that reconciles them. Installing it now also means by chapter 10 you already have a real operator running to dissect. + +This part is **identical** to the original chapter 3 — cert-manager is a Kubernetes concept, not a k3s concept. ```shell helm repo add jetstack https://charts.jetstack.io helm repo update -helm install cert-manager jetstack/cert-manager \ - --namespace cert-manager --create-namespace \ - --set installCRDs=true +helm upgrade --install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set crds.enabled=true ``` Wait until the three cert-manager pods are running: From 7a33e696c106ca7131b58fa00a623511a5aead1d Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 13:50:39 +0200 Subject: [PATCH 07/18] Drop on-the-fly translation table from chapter 3 alt Parallel kind-flavored versions of chapters 4+ will exist alongside the originals and already use the right image refs, hostnames, etc., so a translation table in chapter 3 is redundant. Removed section 9 (translation table + skaffold -d note); renumbered the cleanup section to 9. Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 80219d8..66d7ae5 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -387,31 +387,7 @@ k9s --- -## 9. Differences vs the original chapter — important to know going forward - -When you read chapter 4 onwards, replace the following on the fly: - -| Original (k3s) | This setup (kind) | -|---|---| -| `registry.kube-public/myfrontend` | `localhost:5001/myfrontend` | -| `registry.kube-public/myapi` | `localhost:5001/myapi` | -| `imagePullSecrets: [registry-creds]` | **omit it** — our registry is plain HTTP, no auth | -| `host: frank-test.duckdns.org` (chapter 9) | `host: tutorial.localhost`, accessed via `curl http://localhost:8080 -H "Host: tutorial.localhost"` | -| `kubectl create secret docker-registry registry-creds ...` | not needed | - -Everything else (Deployments, StatefulSets, Services, Helm charts, Skaffold config, Flux gitops) is **byte-for-byte identical**. - -When chapter 7 introduces skaffold's `-d registry.kube-public` flag, you'll write: - -``` -skaffold run -d localhost:5001 -``` - -instead. - ---- - -## 10. Cleanup / restart cheat sheet +## 9. Cleanup / restart cheat sheet Things go wrong sometimes. These commands are safe to run any time: From bf21588a5c4ddcaa3850780a3668acbd16f69d42 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 14:04:34 +0200 Subject: [PATCH 08/18] Add spoiler answers for the 5 chapter-3 review questions Each end-of-chapter question now has a collapsible
block with a teaching-style answer: namespaces, ClusterIssuer vs Certificate, ingress controller vs Ingress object, push/pull asymmetry through a registry, and apply vs create semantics. Lets learners self-check without revealing answers until they've tried. Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 99 ++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 66d7ae5..247666b 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -417,12 +417,99 @@ docker start kind-registry ## What you should now understand (chapter 3's real learning goals) -Before moving on to chapter 4, make sure you can answer: +Before moving on to chapter 4, make sure you can answer the following. Try first; then peek at the spoiler for a check. -* What is a **namespace** and why do we have one per software component? -* What does a **ClusterIssuer** do, and how is it different from a `Certificate`? -* What is an **ingress controller** and why doesn't a bare `Ingress` object work without one? -* Why does the cluster need its own way to *pull* images, separate from the way the host *pushes* them? -* What does `kubectl apply` actually do at the API level — and how does that differ from `kubectl create`? +### 1. What is a **namespace** and why do we have one per software component? + +
+ Answer + +A **namespace** is a virtual partition inside a single Kubernetes cluster. Resource names (a Deployment called `frontend`, a Service called `db`, etc.) are unique only **within** a namespace, not across the cluster. Most workload-type resources (Pods, Deployments, Services, ConfigMaps, Secrets, …) live inside a namespace; a small set of resources are cluster-scoped (Nodes, ClusterIssuer, ClusterRole, StorageClass, …). + +Reasons to put each software component (cert-manager, ingress controller, registry, your app) in its own namespace: + +* **No name collisions** — two unrelated charts can both name their main Deployment `controller` without conflict. +* **Scoped cleanup** — `kubectl delete namespace foo` deletes everything in that namespace in one shot. Great for "let me just rip out this experiment." +* **RBAC boundaries** — a Role/RoleBinding can grant access to one namespace only. Devs see their own; ops see all. +* **Quotas and limits** — CPU/memory/PVC quotas attach per namespace. +* **Listing hygiene** — `kubectl get pods -n traefik` shows you only Traefik's pods, not 50 unrelated ones. + +You *could* put everything in `default`. You shouldn't; on day 30 you won't know what's what. + +
+ +### 2. What does a **ClusterIssuer** do, and how is it different from a `Certificate`? + +
+ Answer + +These are two cert-manager CRDs that play different roles. + +* **`ClusterIssuer`** describes *who* will sign certificates and *how*. It encapsulates a certificate authority — a self-signed CA backed by a secret (our case), Let's Encrypt over ACME, HashiCorp Vault, etc. It's cluster-scoped, so any namespace can reference it. You usually have one per CA you trust. +* **`Certificate`** describes *what* you want — hostnames, key type, validity, and **which Issuer/ClusterIssuer to ask**. cert-manager reconciles each `Certificate` by asking the named issuer to sign a CSR, and stores the resulting cert+key in a regular `kubernetes.io/tls` Secret. + +Analogy: a `ClusterIssuer` is a passport office; a `Certificate` is a passport application that names the office it should be sent to. The output (a passport / a TLS secret) is what other resources consume. + +There's also `Issuer` (namespace-scoped variant of `ClusterIssuer`). Same shape, just narrower visibility. + +
+ +### 3. What is an **ingress controller** and why doesn't a bare `Ingress` object work without one? + +
+ Answer + +An **`Ingress`** resource is *data*: a YAML object stored in etcd that says "route `foo.example.com/api` to the `api` Service on port 80, with this TLS secret." It has no behavior of its own. + +An **ingress controller** is *code*: a Pod (often a Deployment) running an actual reverse proxy — nginx, Traefik, HAProxy, Istio gateway, etc. — that: + +1. Watches the Kubernetes API for `Ingress` objects (and the Services and Endpoints they reference). +2. Translates them into its own routing configuration on the fly. +3. Listens on real ports (host ports via `hostPort`, or a `LoadBalancer` service) so external traffic can actually arrive. + +Without a controller, `Ingress` objects sit in etcd, nobody reads them, no traffic flows. Some k8s distros bundle a controller (k3s ships Traefik); cloud-managed clusters often ship one tied to the cloud's load balancer. On kind you install one yourself, which is exactly what step 6 of this chapter does. + +
+ +### 4. Why does the cluster need its own way to *pull* images, separate from the way the host *pushes* them? + +
+ Answer + +The host's Docker daemon and the cluster nodes' container runtime (containerd, in kind's case) are **two different processes with two different image caches and two different network views**. They don't share state. + +* **Host build**: `docker build` stores the image in the *host* daemon's local cache. Cluster nodes have no idea it exists. +* **Cluster pull**: when a Pod is scheduled, the node's containerd reads the image reference, resolves it to a registry URL, downloads via HTTPS, and stores it in *containerd*'s own cache. + +The bridge between the two is a **registry** — a process both sides can reach over HTTP(S). The host *pushes* to it (uploads layers); cluster nodes *pull* from it (download layers). + +Why the URLs aren't identical from both sides: in our setup the host reaches the registry through Docker's port-publish mapping (`127.0.0.1:5001` → registry container `:5000`); cluster nodes reach the same registry container directly over the `kind` Docker network (`kind-registry:5000`), bypassing port publishing entirely. The `containerdConfigPatches` block in step 3 rewrites `localhost:5001` → `http://kind-registry:5000` on the cluster side so we can use *one* image reference everywhere. + +In a real cloud setup, the registry might be ECR / GCR / a private Harbor; same shape — both sides need network access to the same registry URL, possibly with credentials. + +
+ +### 5. What does `kubectl apply` actually do at the API level — and how does that differ from `kubectl create`? + +
+ Answer + +Both end up POSTing or PATCHing to the same `/api/...` endpoint, but with very different semantics: + +* **`kubectl create`** is **imperative**. It says "make this new object now." If the named object already exists, the call fails (409 Conflict). Good for one-shot operations; bad for "I'm going to re-run this script tomorrow." +* **`kubectl apply`** is **declarative**. It says "make the cluster's state match this YAML." Internally: + 1. It computes a diff between the YAML you passed, the **last-applied configuration** stored as an annotation on the live object (`kubectl.kubernetes.io/last-applied-configuration`), and the current live state. + 2. It builds a strategic merge patch from those three inputs and PATCHes the API. + 3. It updates the last-applied annotation to your new YAML. + +Consequences: + +* `apply` is **idempotent** — re-run with no changes, no-op. Re-run with changes, patches the diff. +* `apply` is the right verb for everything that lives in git (your helm charts, your flux manifests, your CI scripts). It survives manual edits in the middle reasonably well. +* `create` is great in scripts where you genuinely want to fail if something already exists (e.g. one-shot secret creation). + +Server-Side Apply (`--server-side`) is the newer variant that lets multiple actors co-own different fields of the same object — relevant for operators and GitOps tools that touch the same resource. + +
These are the questions the original chapter 3 leaves dangling on purpose. The k3s-vs-kind choice doesn't change any of them. From 7d4cf9294de6bc6067925ca17b3d6c696e55bda9 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 14:10:53 +0200 Subject: [PATCH 09/18] Add chapter 3 question 6: CPU/memory limits hierarchy Adds a sixth review question with a detailed spoiler answer covering: - Pod-level resources.requests vs resources.limits (scheduler vs cgroups) - Namespace-level ResourceQuota and LimitRange (API admission) - Node-level allocatable capacity (kubelet) - Cluster-level aggregation (sum of nodes, not a separate knob) - Who enforces what at which stage - What happens when you over-declare at each level (Pending vs admission-rejected vs legal oversubscription) Co-Authored-By: Claude Opus 4.7 --- chapters/03-install-kind.md | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/chapters/03-install-kind.md b/chapters/03-install-kind.md index 247666b..f85b98b 100644 --- a/chapters/03-install-kind.md +++ b/chapters/03-install-kind.md @@ -512,4 +512,97 @@ Server-Side Apply (`--server-side`) is the newer variant that lets multiple acto
+### 6. If I set CPU/memory limits at the **pod**, the **namespace**, and the **cluster** level — what's the hierarchy, who enforces what, and can I declare more than physically exists? + +
+ Answer + +There are really **three different concepts** going by the name "limit" in Kubernetes — they don't form a single nested chain, they interact at different stages of a pod's life. Worth separating them. + +**1. Pod-level (`resources.requests` / `resources.limits`) — declared in the pod spec, per container.** + +```yaml +resources: + requests: { cpu: "100m", memory: "128Mi" } # what I need to schedule + limits: { cpu: "500m", memory: "256Mi" } # what I'm allowed to use at runtime +``` + +* `requests` are used by the **scheduler** to decide which node has room. Sum of all pods' requests on a node must fit in that node's *allocatable* capacity. +* `limits` are enforced by the **container runtime** (cgroups). CPU over the limit = throttled; memory over the limit = OOM-killed. +* No request set = pod scheduled in `BestEffort` class — first to be killed under pressure. + +**2. Namespace-level (`ResourceQuota` + `LimitRange`).** + +A `ResourceQuota` object caps **the sum** of requests/limits across all pods in a namespace: + +```yaml +kind: ResourceQuota +spec: + hard: + requests.cpu: "10" + requests.memory: "16Gi" + limits.cpu: "20" + limits.memory: "32Gi" + pods: "50" +``` + +Enforced by the **API server admission plugin**: if creating a new pod would push the sum over the quota, the pod is rejected at submission time — it never even reaches the scheduler. + +A `LimitRange` is a different beast: it sets default / min / max **per container or per pod** inside a namespace. Used to fill in missing requests on pods that didn't declare them, or to reject obviously-broken values like a 1-byte memory limit. + +**3. Node-level — physical/kubelet-imposed, not configured per workload.** + +Each node has a `capacity` (everything the hardware reports) and an `allocatable` (capacity minus reservations for kubelet, system daemons, and the eviction threshold). The scheduler treats `allocatable` as the ceiling. You don't "set a limit on a node" — you might reduce `allocatable` by reserving more for system, but the node's physical RAM/CPU is what it is. + +**Cluster-level** isn't a single configured number — it's just `sum(allocatable across all nodes)`. There's no `kubectl set cluster-cpu 32`. + +### Hierarchy diagram + +``` ++-------------------- Cluster -----------------------+ +| Capacity = Σ node.allocatable | +| | +| +--------- Namespace foo ----------+ | +| | ResourceQuota.hard.cpu = 10 | ← API admission +| | LimitRange caps per-container | | +| | | | +| | +-- Pod A (requests=2, limits=4) + | +| | +-- Pod B (requests=1, limits=2) + | +| +----------------------------------+ | +| | +| +------ Node n1 (allocatable cpu=4) ------+ | +| | Σ pod.requests scheduled here ≤ 4 | | +| +-----------------------------------------+ | ++----------------------------------------------------+ +``` + +### Can I declare more than what exists? + +* **Pod limits > node allocatable** → API accepts the pod, but the scheduler can never place it. Pod stays `Pending` forever with `0/N nodes available: insufficient cpu/memory`. +* **Pod limits > namespace ResourceQuota** → API admission rejects the pod immediately. You'll see `exceeded quota` in the error. +* **Sum of namespace quotas > cluster capacity** → totally legal, K8s won't stop you. It's how multi-tenant clusters oversubscribe ("each team can request 10 CPU, but the cluster only has 8 — they never all use it at once"). Some quotas will admit pods that then sit Pending until capacity frees up. +* **Pod limits > pod requests** → fine and normal — that's how burstable workloads work. +* **Container running over its memory limit** → OOMKill by the kernel, pod restarts (unless restartPolicy says no). +* **Container running over its CPU limit** → throttled (slowed down), not killed. + +### Who enforces what, when + +| Layer | Enforced by | When | +|---|---|---| +| `ResourceQuota` (namespace) | API server admission plugin | Pod creation/update — pod rejected if over | +| `LimitRange` (namespace) | API server admission plugin | Pod creation — defaults filled in, mins/maxes checked | +| `requests` (pod) vs node `allocatable` | Scheduler | Pod placement | +| `limits` (pod) | Container runtime (cgroups) | At runtime | +| Node `allocatable` | kubelet config | Always; reduces visible capacity | +| Cluster total | (none, just an aggregate) | n/a | + +### Practical advice + +* Always set `requests` on production pods. Without them the scheduler is guessing and your cluster will look fuller than it is. +* Set `limits` to prevent one rogue pod eating a whole node. Memory limit especially. +* `ResourceQuota` belongs on shared clusters where teams should not be able to eat everyone else's room. +* `LimitRange` is good defense-in-depth: it gives any unlabeled pod a sane default, so a developer who forgot to set requests doesn't accidentally schedule a giant best-effort blob. + +
+ These are the questions the original chapter 3 leaves dangling on purpose. The k3s-vs-kind choice doesn't change any of them. From 8942caa07eaf0341d8c90ed22b881f1fbd91899e Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 14:44:07 +0200 Subject: [PATCH 10/18] Add chapter 4 kind variant: Deployments + StatefulSet adapted to kind Drop-in replacement for chapters/04-kubernetes.md aimed at learners using the kind cluster from 03-install-kind.md. Differences from the original: - Image refs use `localhost:5001/...` (the kind local-registry sidecar) - No `imagePullSecrets` block (registry is plain HTTP, no auth) - Skips the `kubectl create secret docker-registry registry-creds` step - Pins `postgres:16` instead of `:latest` (data-compat reason explained) - Adds notes on where PVC data actually lives on kind (local-path-provisioner in the node container's filesystem) Includes six review questions with collapsible spoiler answers covering scaling semantics for Deployment vs StatefulSet, port-forward, pod deletion vs StatefulSet deletion (data persistence), latest vs pinned images, rollout ordering rationale, and the localhost-from-inside-pod gotcha. Co-Authored-By: Claude Opus 4.7 --- chapters/04-kubernetes-kind.md | 350 +++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 chapters/04-kubernetes-kind.md diff --git a/chapters/04-kubernetes-kind.md b/chapters/04-kubernetes-kind.md new file mode 100644 index 0000000..268a776 --- /dev/null +++ b/chapters/04-kubernetes-kind.md @@ -0,0 +1,350 @@ +# Chapter 4 (alternative): Going to Kubernetes with our application — kind variant + +> Drop-in replacement for [04-kubernetes.md](04-kubernetes.md). Assumes you ran [03-install-kind.md](03-install-kind.md) instead of the k3s install. The two chapters are 95% identical — the only meaningful differences are: +> +> * image references use `localhost:5001/...` (kind's local-registry sidecar) +> * we don't need an `imagePullSecrets` block — our registry has no auth +> * we skip the `kubectl create secret docker-registry registry-creds ...` step entirely + +## 1. Pushing our container images to the in-cluster registry + +So far the `myapi` and `myfrontend` images you built in chapters 1 and 2 live only in the host's Docker daemon. The kind cluster can't see them. We need to push them to a registry that the cluster *can* reach. We already installed one in chapter 3 — `kind-registry`, addressable as `localhost:5001` from both the host and the cluster (thanks to the containerd redirect). + +In Docker, "push to registry X" means **tagging the image so its name starts with X**, then running `docker push`. The image name *is* its registry address. + +```shell +docker tag myfrontend localhost:5001/myfrontend +``` + +```shell +docker push localhost:5001/myfrontend +``` + +```shell +docker tag myapi localhost:5001/myapi +``` + +```shell +docker push localhost:5001/myapi +``` + +Verify both arrived: + +```shell +curl http://localhost:5001/v2/_catalog +``` + +Should return `{"repositories":["busybox","myapi","myfrontend"]}` (busybox is left over from the chapter 3 verification — harmless). + +> **Why no `docker login`?** The chapter-3 registry runs without authentication: no htpasswd, no TLS. That's fine on a single-host kind setup that listens only on loopback. In production you'd bolt cert-manager + basic auth on top — covered conceptually in the original chapter 3. + +## 2. Creating a Deployment for the frontend + +A **Deployment** is the most common k8s workload object. It says "I want N copies of this pod template running, all of the time, and I want a rolling update when the template changes." + +Create `frontend-deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: localhost:5001/myfrontend +``` + +Note what's **not** here compared to the original chapter: + +* No `imagePullSecrets:` block. Our registry doesn't require credentials. +* No prior `kubectl create secret docker-registry registry-creds ...` step. Skip it. + +Apply it: + +```shell +kubectl apply -f frontend-deployment.yaml +``` + +Check what got created: + +```shell +kubectl get deployment frontend +``` + +```shell +kubectl get pod +``` + +If the pod is stuck in `ErrImagePull` or `ImagePullBackOff`, run `kubectl describe pod ` and look at `Events:`. The most likely cause is that you forgot the `localhost:5001/` prefix when tagging the image, or you typed `localhost:5000` (off by one — the host port is 5001). + +You can also browse the deployment in k9s: hit `:` then type `deploy`. + +## 3. Creating a StatefulSet for the database + +Our backend needs a database to count visits. We'll run a tiny single-instance PostgreSQL. There's a real difference between this and the frontend: + +* **Stateless apps** (frontend, api) keep no data on disk. If a pod dies, a replacement pod is identical. Order doesn't matter. Deployments handle them. +* **Stateful apps** (databases) keep data on disk. If pod `db-0` dies, the new pod must be **the same `db-0`**, with the same PersistentVolume reattached, or you've lost data. + +For this we use a **StatefulSet**. Key behaviors vs a Deployment: + +| | Deployment | StatefulSet | +|---|---|---| +| Pod identity | Random suffixes, interchangeable | Stable: `name-0`, `name-1`, ... | +| Storage | Pods share, or none | Each pod gets its **own** PVC via `volumeClaimTemplates` | +| Update order (during rollout) | New pod created **before** old terminated (zero-downtime preference) | Old terminated **before** new created (preserves at-most-one-writer for DBs) | +| Use case | API servers, web frontends, workers | Databases, brokers, anything that owns a disk | + +We also want the database to come up with our `counter` table already created. Postgres reads any `*.sql` files placed in `/docker-entrypoint-initdb.d/` on first start. We'll mount a **ConfigMap** there. + +### The init script as a ConfigMap + +A **ConfigMap** is just a named bag of key/value pairs stored in etcd. You can mount its keys as files inside pods. + +Save as `postgres-initdb-configmap.yaml`: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgresql-initdb-config +data: + init.sql: | + CREATE TABLE IF NOT EXISTS counter ( + counterId SERIAL PRIMARY KEY, + api TEXT NOT NULL, + counter INTEGER NOT NULL default 0 + ); + + INSERT INTO counter (api) VALUES ('myapi'); +``` + +Apply: + +```shell +kubectl apply -f postgres-initdb-configmap.yaml +``` + +### The StatefulSet itself + +Save as `postgres-statefulset.yaml`: + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgresql-db +spec: + selector: + matchLabels: + app: postgresql-db + replicas: 1 + template: + metadata: + labels: + app: postgresql-db + spec: + containers: + - name: postgresql-db + image: postgres:16 + volumeMounts: + - name: postgresql-db-disk + mountPath: /data + - name: postgresql-initdb + mountPath: /docker-entrypoint-initdb.d + env: + - name: POSTGRES_PASSWORD + value: astrongdatabasepassword + - name: PGDATA + value: /data/pgdata + volumes: + - name: postgresql-initdb + configMap: + name: postgresql-initdb-config + volumeClaimTemplates: + - metadata: + name: postgresql-db-disk + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi +``` + +Apply: + +```shell +kubectl apply -f postgres-statefulset.yaml +``` + +A few details worth understanding: + +* `volumeClaimTemplates` (note the *template* — plural-ish): the StatefulSet creates a **new PVC per replica** by following this template. If you set `replicas: 3`, you'd get three PVCs: `postgresql-db-disk-postgresql-db-0`, `-1`, `-2`. Each pod gets its own disk, sticky for its lifetime. +* `accessModes: ["ReadWriteOnce"]` — only one node can mount this volume at a time (correct for a database). Other modes: `ReadOnlyMany`, `ReadWriteMany` (rare, needs a network filesystem like NFS). +* `PGDATA: /data/pgdata` — Postgres writes data here. The mount at `/data` is backed by the PVC, so the data survives pod restarts. +* `image: postgres:16` — pinned major version. The original chapter uses `postgres:latest`; pinning is safer because a major version bump (16 → 17) can require a manual data migration. + +### Where the actual data lives (kind-specific) + +Kind ships with a `local-path-provisioner` (one of the pods you saw in step 8 of chapter 3, under `local-path-storage` namespace). When a PVC is created, the provisioner picks a folder on the node container's filesystem (under `/var/local-path-provisioner/`) and binds it. So your database's data is a directory inside the `tutorial-control-plane` docker container. `kind delete cluster` = data gone. Fine for tutorial; in production you'd point a `StorageClass` at real cloud or NAS storage. + +Verify everything came up: + +```shell +kubectl get statefulset +``` + +```shell +kubectl get pod +``` + +```shell +kubectl get pvc +``` + +You should see `postgresql-db-disk-postgresql-db-0` as a `Bound` PVC. + +## 4. Wrapping up + +You now have a **Deployment** managing one stateless pod (frontend) and a **StatefulSet** managing one stateful pod (postgres), plus a **ConfigMap** holding the database init script. None of these pieces know how to talk to each other yet — Pods only know their own IPs, and those IPs change. That's the job of **Services**, covered in chapter 5. + +![frontend-deployment](../imgs/frontend-deployment.png) + +## Review questions + +### 1. Try to scale the **deployment** up and down — using k9s, then using kubectl. What did you observe? + +
+ Answer + +With kubectl: + +```shell +kubectl scale deployment frontend --replicas=3 +``` + +```shell +kubectl get pods -w +``` + +You'll see two new `frontend-...` pods appear in `Pending`, then `ContainerCreating`, then `Running`. Random suffixes. Each pod's name is independent — they're interchangeable. + +```shell +kubectl scale deployment frontend --replicas=1 +``` + +Two pods get `Terminating`. The pod that **remains** is the same one that was running before — Deployments try to keep existing pods where possible, but the survivor's name is essentially arbitrary; if you'd scaled back to zero and then back up, you'd get fresh names. + +In k9s: navigate with `:deploy`, highlight the row, press `s` (scale), type a number, enter. Same effect, less typing. + +The key takeaway: scaling a Deployment is fast and stateless. The replicas don't have identities you can rely on. + +If you try the same on the StatefulSet (`kubectl scale statefulset postgresql-db --replicas=3`), the result is different: pods come up **sequentially** (`postgresql-db-0`, then `-1`, then `-2`), each one gets its own PVC, and scaling back down terminates them in reverse order (`-2`, `-1`, …). PVCs are *not* deleted when you scale down — they're kept around in case you scale back up. That's intentional safety for databases. + +
+ +### 2. Can you port-forward the frontend deployment to your host so you can browse it? + +
+ Answer + +```shell +kubectl port-forward deployment/frontend 8888:80 +``` + +Then visit `http://localhost:8888` from a browser on the same machine, or use VS Code's remote port-forwarding if you're SSH'd in. + +Notes: + +* The `8888:80` form is `localPort:containerPort`. Container port 80 is what the frontend image listens on (set by the Dockerfile `CMD`). +* Targeting a **deployment** picks one of its pods automatically. You can also target a specific pod (`pod/frontend-abc123`), a service (`service/frontend`), or a statefulset. +* `kubectl port-forward` is a debug tool, not a production exposure mechanism. It runs through your kubeconfig (kube-apiserver tunnels the traffic). Closing the terminal kills the tunnel. +* In k9s: select the pod, press `Shift-F`, choose a port mapping. Same thing. + +
+ +### 3. What happens if you delete the postgres pod directly? What if you delete the StatefulSet? + +
+ Answer + +```shell +kubectl delete pod postgresql-db-0 +``` + +The StatefulSet controller notices its desired state (one replica) doesn't match the actual state (zero pods), and **immediately re-creates** `postgresql-db-0`. The new pod re-attaches to the same PVC `postgresql-db-disk-postgresql-db-0` — same data. You'll see it briefly go through `Pending` → `ContainerCreating` → `Running`, but the data inside (the `counter` table) is intact. + +```shell +kubectl delete statefulset postgresql-db +``` + +The StatefulSet is gone. The pod terminates. **But the PVC is not deleted** — by default, StatefulSets leave PVCs behind so you don't accidentally lose data. If you re-create the StatefulSet with the same name, it will re-bind to the same PVC and you'll see your old data. + +To actually delete the data: + +```shell +kubectl delete pvc postgresql-db-disk-postgresql-db-0 +``` + +This is the difference between **persistent** state (lives with the PVC) and **ephemeral** state (lives with the pod). Knowing which is which prevents some really bad days. + +
+ +### 4. The original tutorial uses `image: postgres:latest`. This chapter uses `image: postgres:16`. Why? + +
+ Answer + +Three reasons to pin to a major version: + +1. **Postgres major versions are not data-compatible.** Going from 16.x to 17.x is a manual operation (`pg_upgrade` or a dump/restore). If your StatefulSet was running `postgres:latest` when 16 was current and you re-deploy six months later, you'll suddenly pull 17, the pod will start, refuse to read 16's data files, crash-loop, and your database is offline. Pinning to `16` prevents the surprise. +2. **`latest` is implicit, not explicit.** Two clusters deployed a week apart using `latest` may actually be running different versions. Pinning makes the manifest self-documenting. +3. **Image caching is more predictable.** With `latest`, the `imagePullPolicy` defaults to `Always`, meaning the cluster pulls from the registry on every pod start. With a real tag like `16`, the default is `IfNotPresent`, which is faster. + +The exception: in *development* you may genuinely want `latest` for the frontend/backend you're iterating on. Skaffold (chapter 7) handles this by tagging each build uniquely (e.g. `inputDigest`), so neither `latest` nor a fixed tag — every change gets its own tag. + +
+ +### 5. Why does the database StatefulSet update its pod by *terminating the old pod before* creating the new one, while Deployments do the opposite? + +
+ Answer + +Different invariants. Both behaviors are correct for their target workloads. + +**Deployments** assume **stateless, interchangeable, replaceable** workloads. They optimize for **availability**: during a rollout, create the new pod first; only when it's `Ready` do they terminate an old one. This is the rolling-update strategy. End-users see zero downtime because there's always at least one healthy old or new pod serving. + +**StatefulSets** assume **stateful, identity-bound, often single-writer** workloads (databases, brokers). They optimize for **safety**: never have two pods with the same identity (same name, same disk) running at the same time, even briefly. So they kill the old `db-0` first, wait until it's fully gone, then bring up the new `db-0`. The cost is brief downtime per pod during a rolling update; the benefit is correctness for software that assumes "I'm the only one writing to this disk right now." + +For Postgres specifically, this matters because two postgres processes writing to the same `PGDATA` directory at the same time will corrupt the data. The StatefulSet rollout strategy makes that impossible by design. + +
+ +### 6. The frontend pod's image reference is `localhost:5001/myfrontend`. From inside the pod, can you `curl localhost:5001`? + +
+ Answer + +**No.** And this is one of the most common k8s gotchas, so worth burning into memory. + +Inside the pod, `localhost` refers to **the pod's own network namespace** — i.e. the frontend container itself. There's no registry listening inside the pod, so `curl localhost:5001` errors with connection refused. + +The image reference `localhost:5001/myfrontend` is read by **containerd on the kind node**, not by the pod. Containerd is on the node container's network, and the `containerdConfigPatches` redirect (from chapter 3 step 3) rewrites `localhost:5001` → `http://kind-registry:5000`. The pod never sees that URL. + +This is the same trap as the chapter 3 puzzle ("why is the redirect rewriting `localhost` to `kind-registry`"). Pattern to internalize: **`localhost` is contextual — always ask "from whose perspective?".** + +If you actually wanted to reach the registry from inside a pod, you would `curl kind-registry:5000` (if the pod's DNS is correctly set up — which on kind, by default, it is, because the kind node's CoreDNS forwards through to Docker's embedded DNS). + +
From 2839cd5cfddaf756f2366808f1bab350e3b257d9 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 15:54:17 +0200 Subject: [PATCH 11/18] Add chapter 5 kind variant: Services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in replacement for chapters/05-service.md. Service is a pure Kubernetes concept so the content is near-identical to the original; this variant exists to keep chapter numbering parallel for the kind+Traefik track and to add six spoiler-answer review questions: - Why pod IPs change on every restart (CNI pool + per-node CIDR) - What the Service ClusterIP actually is (virtual IP, kube-proxy iptables/IPVS rewrite — not a real listener) - Service types compared (ClusterIP / NodePort / LoadBalancer / ExternalName) with practical "which one" guidance - Headless Services (`clusterIP: None`) for per-pod DNS and client-side load balancing - Selector matching: zero matches vs NotReady pods, plus diagnosing via `kubectl get endpoints` - Why Services don't need host:container port mappings the way `docker -p` does Co-Authored-By: Claude Opus 4.7 --- chapters/05-service-kind.md | 282 ++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 chapters/05-service-kind.md diff --git a/chapters/05-service-kind.md b/chapters/05-service-kind.md new file mode 100644 index 0000000..a4b9ccc --- /dev/null +++ b/chapters/05-service-kind.md @@ -0,0 +1,282 @@ +# Chapter 5 (alternative): Services — kind variant + +> Drop-in replacement for [05-service.md](05-service.md). The actual content is **near-identical** — Services are a pure Kubernetes concept, untouched by which distro you run. This chapter exists in the kind variant only to keep the chapter numbering parallel and to add spoiler-answer review questions at the end. + +## 1. The problem Services solve + +After chapter 4 you have: + +* a **Deployment** running `frontend` +* a **StatefulSet** running `postgresql-db` (one replica) +* matching pods, each with its own IP address + +The frontend container would like to reach the database. It needs an **address** — a hostname or IP. The pod's IP looks like it should work: + +```shell +kubectl get pod postgresql-db-0 --template '{{printf "%s\n" .status.podIP}}' +``` + +Use that IP to connect from inside the frontend pod. Open a shell: + +```shell +kubectl exec -it deployment/frontend -- /bin/sh +``` + +Then inside the shell: + +```shell +apk add postgresql-client +``` + +```shell +PGPASSWORD=astrongdatabasepassword psql -h -U postgres -c '\dt' +``` + +You should see the `counter` table from the init script. So pod-to-pod IP routing works. + +### Now the problem + +Delete the postgres pod: + +```shell +kubectl delete pod postgresql-db-0 +``` + +The StatefulSet immediately replaces it. Look at the new pod's IP: + +```shell +kubectl get pod postgresql-db-0 --template '{{printf "%s\n" .status.podIP}}' +``` + +**Different IP.** The frontend now can't find the database — its hardcoded IP is stale. + +This isn't a bug, it's how pods work. Every pod lifecycle event (delete, evict, reschedule, node restart) gets a fresh IP. Building anything on top of pod IPs directly = broken design. + +![statefulset](../imgs/statefulset.png) + +## 2. The Service object + +A **Service** gives a group of pods a stable name + stable virtual IP. It also load-balances across them if there are multiple matches. Save as `postgres-service.yaml`: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: postgres-db +spec: + selector: + app: postgresql-db + ports: + - port: 5432 +``` + +```shell +kubectl apply -f postgres-service.yaml +``` + +Three things going on: + +* `selector: { app: postgresql-db }` — find every pod with the label `app: postgresql-db` (set by our StatefulSet in chapter 4). Live tracked: pods that come and go get added/removed automatically. +* `ports[].port: 5432` — the Service listens on TCP 5432. With no `targetPort` set, traffic forwards to **the same port** on each matching pod. (You can override with `targetPort: 5433` if the pod listens elsewhere.) +* Service type defaults to **`ClusterIP`** — reachable only from inside the cluster. The Service gets a virtual IP from a cluster-internal range. CoreDNS auto-creates a DNS record `postgres-db..svc.cluster.local`, abbreviated as just `postgres-db` from pods in the same namespace. + +![statefulset-with-service](../imgs/statefulset-with-service.png) + +## 3. Use it + +Re-open a shell in the frontend pod: + +```shell +kubectl exec -it deployment/frontend -- /bin/sh +``` + +```shell +apk add postgresql-client +``` + +```shell +PGPASSWORD=astrongdatabasepassword psql -h postgres-db -U postgres -c '\dt' +``` + +It works. Now delete the database pod again: + +```shell +kubectl delete pod postgresql-db-0 +``` + +Wait a few seconds, retry the `psql` call. **Still works.** The Service tracks the new pod's IP automatically. + +This is the right way for components to find each other in k8s: **by Service name, never by Pod IP.** + +## 4. Cleanup before chapter 6 + +In chapter 6 we re-create everything via Helm. Wipe the manual yaml objects first: + +```shell +kubectl delete deployment frontend +``` + +```shell +kubectl delete statefulset postgresql-db +``` + +```shell +kubectl delete configmap postgresql-initdb-config +``` + +```shell +kubectl delete service postgres-db +``` + +Don't delete the PVC unless you want to wipe the database data: + +```shell +kubectl get pvc +``` + +If you want a fully fresh start for chapter 6: + +```shell +kubectl delete pvc postgresql-db-disk-postgresql-db-0 +``` + +## Review questions + +### 1. Why does a pod get a new IP every time it restarts? Couldn't Kubernetes just keep the old one? + +
+ Answer + +Two reasons rooted in how the networking is implemented: + +1. **Pod IPs come from a CNI plugin's address pool.** When a pod is destroyed, its IP goes back to the pool. When a new pod is created, the next IP in the pool is handed out — there's no "remember which pod had which IP" logic, because pods are designed to be ephemeral. +2. **Pods can be rescheduled to different nodes.** Each node typically gets its own CIDR slice of the cluster's pod IP range (e.g. node A has `10.244.0.0/24`, node B has `10.244.1.0/24`). A pod that lands on a different node *must* get an IP from that node's slice. Keeping the old IP across nodes would require routing tricks no CNI does by default. + +The whole design assumes IPs are throwaway. That's exactly why Services exist — they layer a stable virtual IP + DNS name on top of the ephemeral pod IPs. + +
+ +### 2. The Service has no IP of its own when you create it — kubectl shows a `CLUSTER-IP` like `10.96.123.45`. Where does that come from, and what listens on it? + +
+ Answer + +When a `Service` of type `ClusterIP` is created, the API server allocates one IP from the cluster's `service-cluster-ip-range` (set by the cluster admin, e.g. `10.96.0.0/12`). This is a **virtual** IP — no network interface has it. + +The IP becomes reachable because **kube-proxy on every node** programs iptables (or IPVS) rules saying: + +> "Any packet to `10.96.123.45:5432` should be DNAT'd to one of these pod IPs: `[10.244.0.5:5432, 10.244.1.7:5432, ...]`" + +Pick-one logic = simple round-robin (or hash, in IPVS mode). When a pod is added or removed from the Service's endpoint set (because a pod was created/deleted/became Ready/became NotReady), kube-proxy rewrites the iptables rules within seconds. + +So nothing actually *listens* on the Service IP. It's a virtual address that the kernel rewrites at packet time. That's why it's blazing fast and survives any number of pod restarts. + +You can see the underlying pod IPs the Service forwards to: + +``` +kubectl get endpoints postgres-db +``` + +Or its newer name: + +``` +kubectl get endpointslices -l kubernetes.io/service-name=postgres-db +``` + +
+ +### 3. There are four Service types: `ClusterIP`, `NodePort`, `LoadBalancer`, `ExternalName`. Which one would you use to expose a development tool to your team over the office network? + +
+ Answer + +Most likely **`LoadBalancer`** (cloud) or **`NodePort`** (bare metal), depending on where the cluster runs. + +Recap: + +| Type | What it does | Reachable from | +|---|---|---| +| `ClusterIP` (default) | Virtual IP inside the cluster only | Pods, host with `kubectl port-forward` | +| `NodePort` | Opens a port (30000–32767 by default) on **every node** | Anyone who can reach a node's IP on that port | +| `LoadBalancer` | Asks the cloud provider for an external LB → routes to NodePorts | Public internet (or VPC, depending on annotations) | +| `ExternalName` | DNS CNAME to an outside host | Pods (resolves to an external DNS name, no proxying) | + +For a team tool on the office network: + +* On a cloud cluster → `LoadBalancer` is one line, gets you a real external IP/DNS, locked down via security groups. +* On bare-metal → `NodePort` exposes a high port on every node. Hand out `http://any-node-ip:31234/`. Or install MetalLB which lets `LoadBalancer` work on bare metal. +* In **this tutorial's** kind setup → neither works directly. We use `hostPort` on the ingress controller (chapter 3 step 3) and route through Traefik, which serves the same purpose. + +`ClusterIP` is wrong here — it's intentionally invisible to anyone outside the cluster. + +`ExternalName` is for a different problem: making an external host (e.g. `db.amazonaws.com`) look like an in-cluster Service so pods can use a friendly name. + +
+ +### 4. A "headless Service" is created by setting `clusterIP: None`. When is that useful? + +
+ Answer + +Two main cases. + +**Stable per-pod DNS for a StatefulSet.** + +A headless Service has no virtual IP and no kube-proxy magic. Instead, CoreDNS returns the **set of pod IPs** directly to the client when it resolves the Service name. Combined with a StatefulSet, you also get one DNS A record *per pod*: `postgresql-db-0.postgres-db.default.svc.cluster.local`, `postgresql-db-1.postgres-db...`, etc. + +Why care? Some software needs to talk to a specific replica (e.g. write to the primary postgres, read replicas separately; or each node of a clustered cache needs to be addressed individually). A normal Service load-balances; a headless Service preserves identity. + +The Postgres operator (chapter 10) uses headless Services for exactly this reason. + +**Client-side load balancing.** + +If the app embeds its own load-balancing logic (typical for gRPC, where channels keep persistent connections), you don't want kube-proxy interfering — you want a list of all backend IPs and the client picks. A headless Service exposes the raw list via DNS. + +
+ +### 5. What happens if a Service's selector matches **zero** pods? Or **pods that aren't Ready**? + +
+ Answer + +**Zero matches** → the Service is created and gets a Cluster IP, but its endpoints object is empty. Any packet sent to the Service IP is dropped (connection refused). DNS still resolves, traffic just goes nowhere. + +This is a common bug source. Diagnosis: + +``` +kubectl get endpoints +``` + +If `ENDPOINTS` column shows ``, your selector doesn't match what you think it matches. Common causes: + +* typo in label key/value (`app: postgresql-db` vs `app: postgresql_db`) +* selector pointing at the wrong namespace (Services and the Pods they select must be in the same namespace) +* pods exist but in `Pending` (never got scheduled) — they have no IP yet +* pods exist but all `NotReady` (failing their readiness probe) — by default, they're excluded from the endpoint set + +**Pods Running but NotReady** → they appear in `endpoints` (under `notReadyAddresses`), but kube-proxy still excludes them. Traffic only goes to `Ready` pods. This is intentional: readiness probes let your app tell k8s "I'm alive but not yet ready to serve" (e.g. while warming a cache, loading config). The Service stops sending traffic until you're Ready again. + +If you want traffic to also reach not-ready pods (rare), set `spec.publishNotReadyAddresses: true` on the Service. + +
+ +### 6. Why doesn't a Service need a port mapping like `8080:80`? It just says `port: 5432`. + +
+ Answer + +Because **the Service IP is not the host's IP** — there's no host-vs-container port translation to do. + +When you `docker run -p 8888:80`, you're mapping a *host* port (the host has many other ports) to a *container* port (the container has its own private namespace). That mapping is needed because both sides have port number scarcity to worry about. + +A `Service` is just a name + virtual IP that maps to pod IPs. The Service can listen on **any port** without conflicting with the pods' ports or anything else — the Service IPs are a private range carved out for this purpose. If the pod's app happens to listen on 5432, you can expose the Service on 5432 (`port: 5432`, `targetPort` defaults to the same), or on any other port: + +```yaml +ports: + - port: 80 # Service listens on :80 + targetPort: 5432 # forwards to pod's :5432 +``` + +This is useful when you want a clean front-door port (`80` for everything) regardless of what the pods actually run on internally. Different problem from `docker -p`. + +
From 8554aa653ed18009a1add3f8f1a4c98bcf6b412c Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 16:02:17 +0200 Subject: [PATCH 12/18] Add chapter 6 kind variant: Helm chart Drop-in replacement for chapters/06-helm.md. Walks through creating a Helm chart for the demo app, adapted for the kind+Traefik track: - Image refs use `localhost:5001/...` - No `imagePullSecrets` block in the Deployment template - No registry-creds secret step - Uses `helm upgrade --install` as the idiomatic re-runnable command - Preserves the original chapter's two-deliberate-bugs puzzle in the API yaml (selector/labels mismatch, values-section name mismatch), with the same collapsible "click to reveal" hint - Database StatefulSet becomes optional via `db.enabled` flag Adds six spoiler-answer review questions covering: repository/tag split rationale, helm upgrade --install semantics, helm template use cases, replicas change vs rolling update, manual kubectl edit being overwritten by Helm, and what happens when you delete Helm's release secrets directly. Co-Authored-By: Claude Opus 4.7 --- chapters/06-helm-kind.md | 470 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 chapters/06-helm-kind.md diff --git a/chapters/06-helm-kind.md b/chapters/06-helm-kind.md new file mode 100644 index 0000000..aeb5a64 --- /dev/null +++ b/chapters/06-helm-kind.md @@ -0,0 +1,470 @@ +# Chapter 6 (alternative): Creating a Helm chart — kind variant + +> Drop-in replacement for [06-helm.md](06-helm.md). Differences from the original: image references use `localhost:5001/...`, no `imagePullSecrets` block, no `kubectl create secret docker-registry registry-creds` step. The Helm concepts are otherwise byte-for-byte the same. + +## 1. What a Helm chart is + +So far you've been writing raw YAML files and `kubectl apply`-ing them one at a time. That works for two or three objects; it starts hurting around ten. A **Helm chart** is: + +* a folder of YAML **templates** (each a Kubernetes resource manifest with placeholders for values), +* a `values.yaml` with the default settings those placeholders draw from, +* a `Chart.yaml` with metadata (chart name, version, app version, dependencies). + +`helm install` renders the templates with the values, produces ordinary YAML, and submits it to the cluster as a **release**. Helm remembers the release in the cluster (as a Secret), so a later `helm upgrade` knows what was installed last time and can diff against your new templates. + +In one picture: **Helm = templating engine + release tracker on top of `kubectl apply`**. + +## 2. Bootstrapping a chart + +From the tutorial root (`/home/dev/skaffold-helm-tutorial` or wherever you cloned it): + +```shell +helm create myapp +``` + +This drops a `myapp/` folder loaded with example files for a generic web app. Most of it is noise for our purposes. Clean it up: + +* Empty `myapp/values.yaml` (delete every line — keep the file). +* Delete every file under `myapp/templates/` but **keep the `templates/` folder itself**. +* Leave `myapp/Chart.yaml` alone. + +Now move the **frontend deployment yaml** from chapter 4 into `myapp/templates/frontend.yaml`. For now, paste it in unchanged (we'll templatize in a moment): + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: localhost:5001/myfrontend +``` + +(No `imagePullSecrets` block — our registry is anonymous HTTP.) + +Try installing it: + +```shell +helm install myapp-deployment-1 myapp +``` + +`myapp-deployment-1` = the **release name** (your choice). `myapp` = the chart path (the folder). + +Verify: + +```shell +helm list +``` + +```shell +kubectl get deployment +``` + +Wipe it: + +```shell +helm uninstall myapp-deployment-1 +``` + +`helm list` is namespace-scoped. If you put releases in a non-default namespace, pass `-n `. + +## 3. Make the image configurable + +Hardcoding `localhost:5001/myfrontend` in the template defeats the point of Helm. Make it a value. + +Put this in `myapp/values.yaml`: + +```yaml +frontend: + image: + repository: localhost:5001/myfrontend + tag: null +``` + +The structure is free-form YAML. Helm reads `values.yaml` into a single Go map; templates reference it as `.Values`. + +Now rewrite `myapp/templates/frontend.yaml`. The only change is the `image:` line: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ default .Chart.AppVersion .Values.frontend.image.tag }}" +``` + +Two things going on inside the `{{ ... }}` curlies: + +* `{{ .Values.frontend.image.repository }}` → the string from `values.yaml`. +* `{{ default .Chart.AppVersion .Values.frontend.image.tag }}` → Helm's `default` function. Returns `.Values.frontend.image.tag` **unless** it's nil/empty/null, in which case it falls back to `.Chart.AppVersion` from `Chart.yaml`. + +The idea: when the tutorial author releases a new version of the chart, they bump `appVersion` in `Chart.yaml` and re-publish. Consumers don't need to override anything to get the new image — the default updates itself. Power users can still override the tag at install time with `--set`. + +Try it: + +```shell +helm install myapp-deployment-1 myapp --set frontend.image.repository=some.invalid/image --set frontend.image.tag=latest +``` + +Watch what kubernetes does with the (invalid) image — `kubectl get pod`, `kubectl describe pod` to see `ImagePullBackOff`. Then clean up and reinstall with proper values: + +```shell +helm uninstall myapp-deployment-1 +``` + +```shell +helm install myapp-deployment-1 myapp +``` + +## 4. Add a Service for the frontend + +Append this to `myapp/templates/frontend.yaml`. The `---` separator is YAML's way of putting multiple documents in one file; Helm just submits each one to the API: + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend +spec: + ports: + - port: 80 + targetPort: 80 + name: frontend + selector: + app: frontend +``` + +Re-deploy: + +```shell +helm upgrade --install myapp-deployment-1 myapp +``` + +`helm upgrade --install` is the idiomatic "create if missing, update if exists" pattern. Use it everywhere — no need to manually track which call gets `install` vs `upgrade`. + +## 5. Deploy the API + +In `values.yaml` add a backend section: + +```yaml +frontend: + image: + repository: localhost:5001/myfrontend + tag: null + +backend: + image: + repository: localhost:5001/myapi + tag: null +``` + +Create `myapp/templates/api.yaml`: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: api +spec: + ports: + - port: 80 + targetPort: 80 + name: http + selector: + app: backend +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: api + labels: + app: api +spec: + serviceName: api + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + imagePullPolicy: Always + image: "{{ .Values.api.image.repository }}:{{ default .Chart.AppVersion .Values.api.image.tag }}" +``` + +Try to install: + +```shell +helm upgrade --install myapp-deployment-1 myapp +``` + +Inspect carefully. **There are two bugs** in this `api.yaml`. The chart may install but the resulting workload won't function. Hunt them. + +
+ Click to reveal the two bugs (try first!) + +1. The template references `.Values.api.image.*` but `values.yaml` defines `backend.image.*`. One of the two names is wrong — pick which one you want to canonicalize on, then fix both spots so they agree. (The original tutorial inherits "backend" from chapter 4; sticking with that is a sensible choice.) +2. The Service `selector: { app: backend }` doesn't match the StatefulSet's pod label `app: api`. Either change the Service selector to `app: api`, or change the pod label to `app: backend`. They have to agree, or the Service has zero endpoints. + +Both are common real-world mistakes, exactly the kind that a `helm template` dry-run wouldn't catch — the YAML is valid, the meaning is wrong. + +
+ +Once it's fixed, redeploy. Check: + +```shell +kubectl get pod +``` + +```shell +kubectl logs deployment/frontend +``` + +```shell +kubectl get endpoints api +``` + +If `endpoints/api` shows the api pod's IP, the Service is wired correctly. + +## 6. Add the database to the chart + +The chapter-4 StatefulSet, ConfigMap, and Service for postgres also belong in the chart. Drop the three yamls into `myapp/templates/db.yaml` (one file, separated with `---`). Image references for postgres need no templating since `postgres:16` doesn't change between releases. But there's a worthwhile improvement: make the database **optional**. + +In `values.yaml`: + +```yaml +db: + enabled: true +``` + +Wrap the entire `db.yaml` content in a guard: + +```yaml +{{- if .Values.db.enabled }} +apiVersion: v1 +kind: ConfigMap +# ... rest of postgres-initdb-config +--- +apiVersion: apps/v1 +kind: StatefulSet +# ... rest of postgresql-db +--- +apiVersion: v1 +kind: Service +# ... rest of postgres-db service +{{- end }} +``` + +Now `helm upgrade --install myapp-deployment-1 myapp --set db.enabled=false` will skip the database entirely. Useful in production where you'd connect to an external RDS / CrunchyDB cluster instead of an in-chart postgres. + +## 7. How Helm actually works under the hood + +`helm install` does three things: + +1. **Reads the chart**: Chart.yaml, values.yaml, templates/. +2. **Renders templates**: substitutes `.Values`, `.Chart`, `.Release`, etc. into placeholder slots. Output = plain YAML. +3. **Submits to the API server**: same as `kubectl apply -f`. + +You can run step 2 alone: + +```shell +helm template myapp-installation myapp > myapp-rendered.yaml +``` + +```shell +cat myapp-rendered.yaml | less +``` + +Excellent debugging tool. If you can't tell whether Helm is reading your values correctly, render and look at the output. + +Helm also stores **release history** in the cluster (as Secrets in the release's namespace, named `sh.helm.release.v1..v`). Each `helm upgrade` creates a new entry. You can roll back: + +```shell +helm history myapp-deployment-1 +``` + +```shell +helm rollback myapp-deployment-1 1 +``` + +> Helm only knows about *what it created*. If you `kubectl apply` an extra Service on top of a Helm release, Helm won't see it on the next upgrade and won't touch it. Conversely, if you `kubectl edit` a Deployment that Helm owns, Helm will overwrite your edit on the next upgrade. Stay disciplined about which tool owns which objects. + +![helm-chart](../imgs/helm-chart.png) + +![helm-render](../imgs/helm-render.png) + +## 8. Troubleshooting + +### "Another operation is in progress" + +If you `Ctrl-C` Helm mid-install, the release ends up in a half-state stored in those release-secret. Next `helm install` complains. + +```shell +helm list -a +``` + +`-a` shows all releases including failed/pending ones. Look for your release. Then either roll back: + +```shell +helm rollback myapp-deployment-1 +``` + +…or, if this is a first install that nothing depends on: + +```shell +helm uninstall myapp-deployment-1 +``` + +…and retry. + +## Review questions + +### 1. Why split the image into `repository` and `tag` in `values.yaml`? Wouldn't a single `image: "localhost:5001/myfrontend:0.0.1"` string be simpler? + +
+ Answer + +Both work, but the split is more flexible **because consumers rarely want to override the whole image, only the tag**. + +With a split: + +``` +helm install myapp-deployment-1 myapp --set frontend.image.tag=v2.3.0 +``` + +The repository defaults to the chart's value (`localhost:5001/myfrontend`); the user only specifies what changed. + +With a monolithic string: + +``` +helm install myapp-deployment-1 myapp --set frontend.image="localhost:5001/myfrontend:v2.3.0" +``` + +The user has to repeat the repository every time. Worse, if they forget the repo, they typo it, or copy from an older example, the override breaks silently. + +Coupling the **default tag** to `.Chart.AppVersion` (which the chart author bumps with each release) is the second half of the trick: end users get the new image automatically when they upgrade the chart, with no `--set` needed. + +
+ +### 2. When does `helm upgrade --install` actually call `install` vs `upgrade`? + +
+ Answer + +Helm checks whether a release with that name already exists in that namespace: + +* **Doesn't exist** → behaves like `helm install`: creates the release-secret, renders the templates, submits to the API server. +* **Exists** → behaves like `helm upgrade`: renders new templates, computes a diff against the *last applied* release, and patches what changed. Bumps the release revision (`v2`, `v3`, …). + +The flag is idempotent: re-running with no changes is a no-op (well, it creates a new revision marker but doesn't touch any resources). It's the safe default for scripts, CI, and Skaffold (chapter 7 wires Skaffold to call exactly this). + +
+ +### 3. What does `helm template` actually produce, and when should you reach for it instead of `helm install`? + +
+ Answer + +`helm template` runs only the **rendering** step — substitutes values into templates and emits the resulting YAML to stdout. It **never touches the cluster**. + +Use cases: + +* **Debugging values**: "did my override actually land?" → render and look. +* **Reviewing changes before apply**: render, `diff` against the previous render, eyeball the change. +* **Generating manifests for GitOps tools** that prefer raw YAML over Helm releases (Argo CD's "render then apply" mode, or Flux's Kustomization layered on top of `helm template`). +* **CI pipelines** that want to lint the YAML with kubeval or kube-linter before deploying. +* **Air-gapped clusters** where Helm can't reach the API server but you still want chart-driven manifests — render locally, ship the YAML to the operator who applies it. + +What it loses vs `helm install`: + +* No release history. Can't `helm rollback` something that was applied via `helm template | kubectl apply`. +* Helm doesn't track ownership, so cleanup is manual (`kubectl delete -f rendered.yaml`). + +For learning Helm, `helm template` is the single best debugging tool. Use it liberally. + +
+ +### 4. You change `replicas: 1` to `replicas: 3` in your chart and run `helm upgrade`. Do all three pods restart, or just the two new ones spin up? + +
+ Answer + +Just the two new ones spin up. The existing pod is **untouched**. + +Helm computes a strategic merge patch: the `spec.replicas` field changes from `1` to `3`. The Deployment controller sees that, creates two more pods to reach the new desired count, and leaves the first one running. + +If you'd changed something inside the pod template (image, env, command), the Deployment's rolling-update strategy would replace pods one at a time (or N at a time, configurable via `maxSurge` / `maxUnavailable`). + +Field-level surgery, not "restart everything." That's why `helm upgrade` is generally safe — it only disturbs what actually changed in the rendered YAML. + +
+ +### 5. You run `kubectl edit deployment frontend` and change the image. Then later `helm upgrade --install myapp-deployment-1 myapp`. What happens to your manual edit? + +
+ Answer + +**Overwritten.** Helm re-renders the templates from the chart, computes a diff against the previous Helm-stored manifest, and applies the patch. Your manual edit is not in the chart, so Helm has no reason to keep it. + +Worse: Helm doesn't *know* your edit existed, so it didn't even try to merge. The image quietly reverts to whatever the chart says. + +This is one of the most common "but I changed that yesterday and it's gone today!" surprises in k8s. Rule: **either Helm owns the object, or you do — never both.** + +If you legitimately need a per-environment override (different image in staging vs production), the right answer is: + +* Pass `--set` or `-f values-production.yaml` to Helm so the override is part of the chart input. +* Or wrap the chart in another tool (Helmfile, Argo CD, Flux) that records overrides as code. + +Server-Side Apply (newer, `--server-side`) does a smarter 3-way merge that can sometimes preserve manual edits to fields the chart doesn't manage — but it's an advanced topic. + +
+ +### 6. Helm stores release history as Secrets in the namespace. What happens if you `kubectl delete` those secrets directly? + +
+ Answer + +Helm "forgets" the release. The deployed resources (Deployment, Service, etc.) remain in the cluster — they're real Kubernetes objects, not owned by Helm — but `helm list` won't show the release anymore. + +Side effects: + +* You can't `helm rollback` to a previous revision (the history is gone). +* You can't `helm uninstall` to clean up (Helm doesn't know what to delete). You'd have to `kubectl delete` each object by hand. +* You **can** still `helm install` a fresh release with the same name — Helm sees no conflict, since from its perspective nothing exists. + +Practical use: this is one (heavy-handed) way to recover from a corrupt release state, when `helm history`/`helm rollback` themselves are broken. Don't reach for it unless `helm history --max=N` and `helm rollback` have failed. + +A gentler equivalent: `helm secrets list -n ` and inspect what's there before deleting. + +
From 6b8aa88bfe6562f82da92e131ece487b49c12654 Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 16:20:36 +0200 Subject: [PATCH 13/18] Clarify file-creation vs file-edit steps in chapter 6 kind variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original wording was ambiguous about which files already exist and which ones the learner should create vs edit. Tightened §2, §3, and §6: - §2: "Now create a new file myapp/templates/frontend.yaml" instead of "Now move the frontend deployment yaml..." - §3: explicit "open the already-existing myapp/values.yaml (you emptied it in §2)" and "open the already-existing myapp/templates/frontend.yaml (the file you wrote in §2)". Also added an explicit `helm uninstall` step so the user doesn't fight a stale install when re-running. - §6: "Create a new file myapp/templates/db.yaml" and "open the already-existing myapp/values.yaml and add" — disambiguates the file-creation chain. No content changes, only clarifications. Co-Authored-By: Claude Opus 4.7 --- chapters/06-helm-kind.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/chapters/06-helm-kind.md b/chapters/06-helm-kind.md index aeb5a64..80c8f0b 100644 --- a/chapters/06-helm-kind.md +++ b/chapters/06-helm-kind.md @@ -28,7 +28,7 @@ This drops a `myapp/` folder loaded with example files for a generic web app. Mo * Delete every file under `myapp/templates/` but **keep the `templates/` folder itself**. * Leave `myapp/Chart.yaml` alone. -Now move the **frontend deployment yaml** from chapter 4 into `myapp/templates/frontend.yaml`. For now, paste it in unchanged (we'll templatize in a moment): +Now create a **new file** `myapp/templates/frontend.yaml`. Paste the content below into it — this is the same Deployment YAML you used in chapter 4 (with the image ref already adapted for kind). For now leave it un-templated; we'll templatize the image in §3: ```yaml apiVersion: apps/v1 @@ -84,7 +84,13 @@ helm uninstall myapp-deployment-1 Hardcoding `localhost:5001/myfrontend` in the template defeats the point of Helm. Make it a value. -Put this in `myapp/values.yaml`: +First, uninstall the release from §2 so you don't fight a stale install: + +```shell +helm uninstall myapp-deployment-1 +``` + +Now open the **already-existing** `myapp/values.yaml` (you emptied it in §2). Add this: ```yaml frontend: @@ -95,7 +101,7 @@ frontend: The structure is free-form YAML. Helm reads `values.yaml` into a single Go map; templates reference it as `.Values`. -Now rewrite `myapp/templates/frontend.yaml`. The only change is the `image:` line: +Now open the **already-existing** `myapp/templates/frontend.yaml` (the file you wrote in §2). Replace its contents with the version below — only one line changes (the `image:` line at the bottom), but it's easiest to copy-paste the whole file: ```yaml apiVersion: apps/v1 @@ -259,33 +265,39 @@ If `endpoints/api` shows the api pod's IP, the Service is wired correctly. ## 6. Add the database to the chart -The chapter-4 StatefulSet, ConfigMap, and Service for postgres also belong in the chart. Drop the three yamls into `myapp/templates/db.yaml` (one file, separated with `---`). Image references for postgres need no templating since `postgres:16` doesn't change between releases. But there's a worthwhile improvement: make the database **optional**. +The chapter-4 ConfigMap, StatefulSet, and Service for postgres also belong in the chart. + +Create a **new file** `myapp/templates/db.yaml`. Paste the three YAMLs from chapter 4 into it (the `postgresql-initdb-config` ConfigMap, the `postgresql-db` StatefulSet, and the `postgres-db` Service), separated by `---` between each. No templating needed yet — `postgres:16` doesn't change between releases. -In `values.yaml`: +Now add a worthwhile improvement: make the database **optional**. + +Open the **already-existing** `myapp/values.yaml` and add: ```yaml db: enabled: true ``` -Wrap the entire `db.yaml` content in a guard: +Then edit the **already-existing** `myapp/templates/db.yaml`. Wrap the entire file's content with a Helm conditional — one line at the top, one at the bottom: ```yaml {{- if .Values.db.enabled }} apiVersion: v1 kind: ConfigMap -# ... rest of postgres-initdb-config +# ... your existing ConfigMap content --- apiVersion: apps/v1 kind: StatefulSet -# ... rest of postgresql-db +# ... your existing StatefulSet content --- apiVersion: v1 kind: Service -# ... rest of postgres-db service +# ... your existing Service content {{- end }} ``` +(The `{{- if ... }}` / `{{- end }}` are template directives, not YAML. They wrap the whole file.) + Now `helm upgrade --install myapp-deployment-1 myapp --set db.enabled=false` will skip the database entirely. Useful in production where you'd connect to an external RDS / CrunchyDB cluster instead of an in-chart postgres. ## 7. How Helm actually works under the hood From 8986a0c4cde29ded0ed4d4f29e3e6d801644b99b Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Thu, 21 May 2026 16:23:29 +0200 Subject: [PATCH 14/18] Rewrite chapter 6 file-edit instructions in diff style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier "already-existing" phrasing was clumsy and didn't say WHERE in each file the new content should go. Now uses a clearer pattern: - §3: replaces "rewrite the whole file" with a single-line find/replace ("find this line, replace it with that line"), with the full file available as a collapsible sanity-check rather than the primary instruction. - §4 (Service for frontend): "At the end of the file, append..." - §5 (backend): "Add a backend block alongside the frontend one — order doesn't matter, Helm reads it all into one map", and shows the file end-state instead of vague "add this". - §6 (database): numbered list of which YAMLs go in the new file, explicit "alongside the existing frontend: and backend: blocks", explicit "the entire content of the file" for the conditional wrap. No content changes, only clearer where-to-put-what instructions. Co-Authored-By: Claude Opus 4.7 --- chapters/06-helm-kind.md | 53 ++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/chapters/06-helm-kind.md b/chapters/06-helm-kind.md index 80c8f0b..414951d 100644 --- a/chapters/06-helm-kind.md +++ b/chapters/06-helm-kind.md @@ -90,7 +90,9 @@ First, uninstall the release from §2 so you don't fight a stale install: helm uninstall myapp-deployment-1 ``` -Now open the **already-existing** `myapp/values.yaml` (you emptied it in §2). Add this: +### Edit `myapp/values.yaml` + +The file is empty (you emptied it in §2). Paste the content below into it — order doesn't matter since the file is empty, just save these four lines: ```yaml frontend: @@ -101,7 +103,24 @@ frontend: The structure is free-form YAML. Helm reads `values.yaml` into a single Go map; templates reference it as `.Values`. -Now open the **already-existing** `myapp/templates/frontend.yaml` (the file you wrote in §2). Replace its contents with the version below — only one line changes (the `image:` line at the bottom), but it's easiest to copy-paste the whole file: +### Edit `myapp/templates/frontend.yaml` + +Open the file. Find this line near the bottom (line 16 if you pasted §2's content verbatim): + +```yaml + image: localhost:5001/myfrontend +``` + +Replace **only that one line** with: + +```yaml + image: "{{ .Values.frontend.image.repository }}:{{ default .Chart.AppVersion .Values.frontend.image.tag }}" +``` + +Indentation must stay the same (10 spaces). Everything else in the file is untouched. + +
+ Click for the full file as a sanity check ```yaml apiVersion: apps/v1 @@ -125,7 +144,11 @@ spec: image: "{{ .Values.frontend.image.repository }}:{{ default .Chart.AppVersion .Values.frontend.image.tag }}" ``` -Two things going on inside the `{{ ... }}` curlies: +
+ +### What the templating does + +Two things inside the `{{ ... }}` curlies: * `{{ .Values.frontend.image.repository }}` → the string from `values.yaml`. * `{{ default .Chart.AppVersion .Values.frontend.image.tag }}` → Helm's `default` function. Returns `.Values.frontend.image.tag` **unless** it's nil/empty/null, in which case it falls back to `.Chart.AppVersion` from `Chart.yaml`. @@ -150,7 +173,7 @@ helm install myapp-deployment-1 myapp ## 4. Add a Service for the frontend -Append this to `myapp/templates/frontend.yaml`. The `---` separator is YAML's way of putting multiple documents in one file; Helm just submits each one to the API: +Open `myapp/templates/frontend.yaml`. At the **end of the file**, append the block below. The `---` separator is YAML's way of putting multiple documents in one file; Helm just submits each one to the API: ```yaml --- @@ -177,7 +200,9 @@ helm upgrade --install myapp-deployment-1 myapp ## 5. Deploy the API -In `values.yaml` add a backend section: +### Edit `myapp/values.yaml` + +Right now the file holds the frontend block from §3. Add a backend block **alongside** the frontend one (order doesn't matter — Helm reads it all into one map). The file should end up containing both: ```yaml frontend: @@ -191,7 +216,7 @@ backend: tag: null ``` -Create `myapp/templates/api.yaml`: +### Create `myapp/templates/api.yaml` (new file) ```yaml apiVersion: v1 @@ -267,18 +292,26 @@ If `endpoints/api` shows the api pod's IP, the Service is wired correctly. The chapter-4 ConfigMap, StatefulSet, and Service for postgres also belong in the chart. -Create a **new file** `myapp/templates/db.yaml`. Paste the three YAMLs from chapter 4 into it (the `postgresql-initdb-config` ConfigMap, the `postgresql-db` StatefulSet, and the `postgres-db` Service), separated by `---` between each. No templating needed yet — `postgres:16` doesn't change between releases. +### Create `myapp/templates/db.yaml` (new file) + +Paste the three YAMLs from chapter 4 into it, separated by `---` between each (so it's one file with three documents): + +1. the `postgresql-initdb-config` ConfigMap +2. the `postgresql-db` StatefulSet +3. the `postgres-db` Service + +No templating needed yet — `postgres:16` doesn't change between releases. -Now add a worthwhile improvement: make the database **optional**. +### Make the database optional -Open the **already-existing** `myapp/values.yaml` and add: +Open `myapp/values.yaml`. Add a `db:` block alongside the existing `frontend:` and `backend:` blocks (order doesn't matter): ```yaml db: enabled: true ``` -Then edit the **already-existing** `myapp/templates/db.yaml`. Wrap the entire file's content with a Helm conditional — one line at the top, one at the bottom: +Now edit `myapp/templates/db.yaml`. Wrap the **entire content** of the file with a Helm conditional — one directive line at the very top, one at the very bottom: ```yaml {{- if .Values.db.enabled }} From 3c63c6ade8105b5bea5dd1d311e1657e9fb3e46c Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Fri, 22 May 2026 11:47:12 +0200 Subject: [PATCH 15/18] =?UTF-8?q?Inline=20full=20db.yaml=20content=20in=20?= =?UTF-8?q?chapter=206=20=C2=A76=20instead=20of=20cross-ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous wording told the learner to "paste the three YAMLs from chapter 4" which forced flipping between chapters. Now §6 gives the complete file content (ConfigMap + StatefulSet + Service already wrapped in the if/end conditional) so the learner can copy-paste a single block without bouncing between files. Also flips the order so values.yaml comes before db.yaml — by the time the learner pastes the templated file they already have the db.enabled value defined. Co-Authored-By: Claude Opus 4.7 --- chapters/06-helm-kind.md | 89 ++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/chapters/06-helm-kind.md b/chapters/06-helm-kind.md index 414951d..c7a3d48 100644 --- a/chapters/06-helm-kind.md +++ b/chapters/06-helm-kind.md @@ -290,27 +290,92 @@ If `endpoints/api` shows the api pod's IP, the Service is wired correctly. ## 6. Add the database to the chart -The chapter-4 ConfigMap, StatefulSet, and Service for postgres also belong in the chart. +The chapter-4 ConfigMap, StatefulSet, and Service for postgres also belong in the chart. We're going to put all three in one file, **and** wrap them in a Helm conditional so the database can be turned off via a value (useful in production where you'd connect to an external managed Postgres instead). -### Create `myapp/templates/db.yaml` (new file) - -Paste the three YAMLs from chapter 4 into it, separated by `---` between each (so it's one file with three documents): +### Edit `myapp/values.yaml` -1. the `postgresql-initdb-config` ConfigMap -2. the `postgresql-db` StatefulSet -3. the `postgres-db` Service +Add a `db:` block at the end (alongside `frontend:` and `backend:`, order doesn't matter): -No templating needed yet — `postgres:16` doesn't change between releases. +```yaml +db: + enabled: true +``` -### Make the database optional +### Create `myapp/templates/db.yaml` (new file) -Open `myapp/values.yaml`. Add a `db:` block alongside the existing `frontend:` and `backend:` blocks (order doesn't matter): +Paste this whole file in. It contains ConfigMap + StatefulSet + Service, all wrapped in `{{- if .Values.db.enabled }} ... {{- end }}`: ```yaml -db: - enabled: true +{{- if .Values.db.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgresql-initdb-config +data: + init.sql: | + CREATE TABLE IF NOT EXISTS counter ( + counterId SERIAL PRIMARY KEY, + api TEXT NOT NULL, + counter INTEGER NOT NULL default 0 + ); + + INSERT INTO counter (api) VALUES ('myapi'); +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgresql-db +spec: + selector: + matchLabels: + app: postgresql-db + replicas: 1 + serviceName: postgres-db + template: + metadata: + labels: + app: postgresql-db + spec: + containers: + - name: postgresql-db + image: postgres:16 + volumeMounts: + - name: postgresql-db-disk + mountPath: /data + - name: postgresql-initdb + mountPath: /docker-entrypoint-initdb.d + env: + - name: POSTGRES_PASSWORD + value: astrongdatabasepassword + - name: PGDATA + value: /data/pgdata + volumes: + - name: postgresql-initdb + configMap: + name: postgresql-initdb-config + volumeClaimTemplates: + - metadata: + name: postgresql-db-disk + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-db +spec: + selector: + app: postgresql-db + ports: + - port: 5432 +{{- end }} ``` +The `{{- if }}` / `{{- end }}` are Helm template directives, not YAML. They wrap the **entire file**. If `db.enabled` is `false`, Helm renders nothing for this file and none of the three resources get created. + Now edit `myapp/templates/db.yaml`. Wrap the **entire content** of the file with a Helm conditional — one directive line at the very top, one at the very bottom: ```yaml From 48eead83465f62e1f15e2d0b0ef93dce2bda625d Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Fri, 22 May 2026 11:59:19 +0200 Subject: [PATCH 16/18] Add chapter 7 kind variant: Skaffold Drop-in replacement for chapters/07-skaffold.md. Same skaffold.yaml content as the original (registry name lives in the -d flag, not in the yaml), with kind-specific notes: - `-d localhost:5001` flag throughout (vs `-d registry.kube-public`) - Note that Skaffold uses bare names `frontend`/`api`, not the `myfrontend`/`myapi` from chapters 4-6 (different image refs belong to different ownership boundaries) - setValueTemplates wired to `backend.image.*` to match the values structure canonicalized in the chapter 6 kind variant - Explanation that `default .Chart.AppVersion` only matters when Skaffold isn't driving the deploy Adds six spoiler-answer review questions: build/run/dev/delete distinction, setValueTemplates and IMAGE_REPO_/IMAGE_TAG_ wiring, why inputDigest beats latest/gitSHA for dev loops, why the registry name is outside skaffold.yaml, sync vs rebuild semantics (and when sync is wrong), and Ctrl-C vs kill -9 cleanup behaviour. Co-Authored-By: Claude Opus 4.7 --- chapters/07-skaffold-kind.md | 304 +++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 chapters/07-skaffold-kind.md diff --git a/chapters/07-skaffold-kind.md b/chapters/07-skaffold-kind.md new file mode 100644 index 0000000..a27bff1 --- /dev/null +++ b/chapters/07-skaffold-kind.md @@ -0,0 +1,304 @@ +# Chapter 7 (alternative): Skaffold — kind variant + +> Drop-in replacement for [07-skaffold.md](07-skaffold.md). The only meaningful change vs the original is the registry flag: `-d localhost:5001` instead of `-d registry.kube-public`. The `skaffold.yaml` itself is generic — it doesn't hardcode the registry. + +## 1. Why Skaffold + +After chapter 6 you have a working Helm chart, but every code change still requires: + +1. `docker build` for whichever app changed +2. `docker tag localhost:5001/` +3. `docker push localhost:5001/` +4. `helm upgrade --install myapp-deployment-1 myapp --set .image.tag=...` + +Four steps. Annoying after the third time, infuriating after the thirtieth. **Skaffold** is the glue layer that runs all four for you when you save a file. It doesn't replace Docker, Helm, or the cluster — it just chains them together. + +What Skaffold does, end to end: + +1. Watches your source code (only the directories you tell it). +2. On change → rebuilds the affected Docker image(s). +3. Tags each image with a content-addressable digest (so identical source = identical tag = cache hit next time). +4. Pushes to the configured registry. +5. Updates the Helm release with the new image tag(s) — patches whatever values the chart needs. +6. Auto port-forwards Services to your host so you can hit them in a browser. + +Optional bonus: live-sync. For some file types (Python, JS, Vue), Skaffold can copy the changed file directly into the running container instead of rebuilding the image. Faster than a full rebuild when the runtime supports it (Python's `uvicorn --reload`, Vue's webpack-dev-server hot reload). + +## 2. Clean up the manual Helm release + +Skaffold will take ownership of the Helm release. Wipe the one you installed by hand in chapter 6 so there's no conflict: + +```shell +helm uninstall myapp-deployment-1 +``` + +```shell +kubectl get pod +``` + +All `frontend`, `api`, `postgresql-db` pods should be `Terminating` or gone. + +## 3. Create `skaffold.yaml` + +Skaffold reads its config from `skaffold.yaml` at the project root. Create `/home/dev/skaffold-helm-tutorial/skaffold.yaml` (new file) with this content: + +```yaml +apiVersion: skaffold/v4beta10 +kind: Config +metadata: + name: myapp +build: + tagPolicy: + inputDigest: {} + local: + concurrency: 0 + artifacts: + - image: api + context: myapi + docker: + dockerfile: docker/Dockerfile + sync: + infer: + - "*.py" + - "**/*.py" + - "**/*.html" + - "**/*~" + - image: frontend + context: frontend + docker: + dockerfile: docker/Dockerfile.dev + sync: + infer: + - "*.js" + - "*.html" + - "*.vue" + - "**/*.vue" + - "**/*.js" + - "**/*~" + +deploy: + helm: + releases: + - name: myapp + chartPath: myapp + setValueTemplates: + frontend.image.repository: "{{.IMAGE_REPO_frontend}}" + frontend.image.tag: "{{.IMAGE_TAG_frontend}}" + backend.image.repository: "{{.IMAGE_REPO_api}}" + backend.image.tag: "{{.IMAGE_TAG_api}}" + +portForward: + - resourceType: service + resourceName: frontend + port: 80 + localPort: 8080 + - resourceType: service + resourceName: api + port: 80 + localPort: 9999 +``` + +Note: `setValueTemplates` references `backend.image.*` (not `api.image.*`) — matches the values.yaml structure you canonicalized on in chapter 6. If you picked `api.image.*` instead, adapt accordingly. + +### What each section does + +* **`build.tagPolicy.inputDigest`** — Skaffold tags each image with a hash of its build context. Identical inputs = identical tag = no rebuild, no push, no helm update. Other policies exist (`gitCommit`, `envTemplate`, `dateTime`). +* **`build.local.concurrency: 0`** — Build all artifacts in parallel (0 = unlimited). +* **`build.artifacts`** — each block describes one image: name, context dir, Dockerfile, file-sync patterns. +* **`deploy.helm`** — wraps `helm upgrade --install`. Skaffold calls it for you. +* **`setValueTemplates`** — wires Skaffold's per-image variables (`IMAGE_REPO_*`, `IMAGE_TAG_*`) into the chart's value paths. After building image `frontend`, Skaffold sets `frontend.image.repository = /frontend` and `frontend.image.tag = `. +* **`portForward`** — auto-forward cluster Services to your host while `skaffold dev` runs. Same as `kubectl port-forward`, just declarative. + +## 4. Build and push (without deploying) + +```shell +cd /home/dev/skaffold-helm-tutorial +``` + +```shell +skaffold build -d localhost:5001 +``` + +`-d localhost:5001` = the default registry. Skaffold combines this with the artifact name → `localhost:5001/frontend:` and `localhost:5001/api:`. Watch the output — it builds both, pushes both, prints the tags. + +Verify they landed: + +```shell +curl http://localhost:5001/v2/_catalog +``` + +You should see `frontend` and `api` repos with new digest tags (alongside the manually-pushed `myfrontend` / `myapi` from earlier). + +> Note: Skaffold builds the image with name `api` and `frontend`, then publishes as `localhost:5001/api` / `localhost:5001/frontend` — different names from the `myapi` / `myfrontend` you pushed by hand in chapters 4 and 6. Skaffold owns these images now. + +## 5. Build, push, and deploy + +```shell +skaffold run -d localhost:5001 +``` + +This runs the full pipeline: build → push → `helm upgrade --install`. When it exits, the Helm release `myapp` is on the cluster with the freshly-built images. + +```shell +kubectl get pod +``` + +You should see `frontend-...`, `api-0`, `postgresql-db-0` all `Running`. + +Open `http://localhost:8080` in a browser (matches the `portForward` block in `skaffold.yaml`). The frontend should load and the "request time" button should hit the API at `localhost:9999`. + +## 6. Live mode: `skaffold dev` + +```shell +skaffold dev -d localhost:5001 +``` + +This stays running. It watches your source files. Change anything matched by the `sync.infer` patterns in `skaffold.yaml` and: + +* For matched file types → **sync**: copy the new file into the running container. Vue's webpack-dev-server hot-reloads the browser. Uvicorn's `--reload` restarts the FastAPI process. Sub-second feedback loop. +* For files not matched (e.g. `Dockerfile`, `setup.py`, `package.json`) → **rebuild**: full image rebuild + push + Helm patch + pod rolling update. Slower, ~30–60s. + +Try it: open `frontend/src/components/HelloWorld.vue`, change the heading text, save. The change should appear in the browser within a couple seconds. + +Stop with `Ctrl-C`. Skaffold then runs the cleanup: it `helm uninstall`s the release. + +> If `skaffold dev` exits without cleaning up (`kill -9`, crash), the release stays. Use `helm uninstall myapp` to remove it. + +## 7. Clean up before chapter 8 + +```shell +skaffold delete -d localhost:5001 +``` + +Runs `helm uninstall myapp` for you. After this, `kubectl get pod` should show no `frontend`/`api`/`postgresql-db` pods. + +## Review questions + +### 1. What's the difference between `skaffold build`, `skaffold run`, `skaffold dev`, and `skaffold delete`? + +
+ Answer + +| Command | What it does | When to use | +|---|---|---| +| `skaffold build` | Builds + pushes images. **Doesn't deploy.** Prints the resulting tags to stdout. | CI pipelines that build artifacts and hand off to a separate deploy step. | +| `skaffold run` | Build + push + deploy via Helm. One-shot, then exits. The release stays. | Manual "deploy what I have right now" — like a heavier `helm upgrade --install`. | +| `skaffold dev` | Build + push + deploy + **watch source** + auto-rebuild on change + auto-cleanup on Ctrl-C. | Your default development loop. The headline feature. | +| `skaffold delete` | Run the cleanup step alone — `helm uninstall` the release Skaffold created. | When you finished `skaffold dev` cleanly but later want a manual cleanup, or when `skaffold run` left a release behind. | +| `skaffold debug` (bonus) | Like `dev` but configures language-specific debug ports (Java JDWP, Node inspector, etc.). | Attaching a debugger to a pod from your IDE. | + +`dev` = the bread and butter. The others are for specific moments. + +
+ +### 2. What is `setValueTemplates` actually doing, and what's the difference between `IMAGE_REPO_frontend` and `IMAGE_TAG_frontend`? + +
+ Answer + +`setValueTemplates` is Skaffold's wiring between **what it just built** and **what the Helm chart expects**. + +After building an image, Skaffold knows two things about it: + +* `IMAGE_REPO_` — the full registry+repo string, e.g. `localhost:5001/frontend` +* `IMAGE_TAG_` — the tag Skaffold assigned, e.g. `f3a9c2b...` (the inputDigest) + +`setValueTemplates` says: "for the Helm release, set these chart value paths to these Skaffold variables." So this block: + +```yaml +setValueTemplates: + frontend.image.repository: "{{.IMAGE_REPO_frontend}}" + frontend.image.tag: "{{.IMAGE_TAG_frontend}}" +``` + +…is equivalent to running: + +``` +helm upgrade --install myapp myapp \ + --set frontend.image.repository=localhost:5001/frontend \ + --set frontend.image.tag=f3a9c2b... +``` + +…where `localhost:5001/frontend` and `f3a9c2b...` change every build. + +This is why the chart's `values.yaml` can have `tag: null` as a placeholder — Skaffold fills it in. The `default .Chart.AppVersion` fallback you wrote in chapter 6 only matters when Skaffold isn't driving the deploy (e.g. someone installs the chart by hand). + +
+ +### 3. The `tagPolicy: inputDigest` means images are tagged with a hash of the build context. Why is that better than tagging with `latest` or with the git SHA? + +
+ Answer + +* **`latest`** is the worst option. Two builds with different code can share the same tag. The cluster sees `latest` already pulled and reuses the cached image, never picking up your new code. Also `imagePullPolicy` defaults to `Always` with `latest`, eating bandwidth even when nothing changed. +* **Git SHA** is fine for CI builds but bad locally. Every commit gets a unique tag, but a *change without a commit* (you edited a file, didn't commit yet) gets the same SHA as before. So during active development the cluster keeps pulling the same tag and seeing the same old image. +* **`inputDigest`** = hash of the actual build context. Edit a `.py` file → digest changes → new tag → new pull → new pod with new code. Don't edit anything → digest unchanged → no rebuild, no push, no pod restart. **Maps exactly onto "did the code actually change?"** + +inputDigest is the right policy for development loops. For production release pipelines, a deterministic policy tied to a release tag (`envTemplate` driven by `$VERSION`) is more readable in registry catalogs. + +
+ +### 4. Why doesn't `skaffold.yaml` mention `localhost:5001` anywhere? It uses bare names like `image: api`. + +
+ Answer + +The artifact `name` in `skaffold.yaml` is the **logical** image name. The actual registry prefix comes from the `-d ` flag at command time (or from a `default-repo` setting in `~/.skaffold/config`). + +This separation is intentional: + +* The same `skaffold.yaml` works against multiple registries — `localhost:5001` for kind, `your-team.azurecr.io` for staging, `myteam-prod.amazonaws.com` for production. +* Skaffold combines `default-repo + artifact-name` to compute the full image ref: `localhost:5001/api`, `your-team.azurecr.io/api`, etc. +* Helm chart values get the full ref from `{{.IMAGE_REPO_*}}` — no chart-side config needed for "which registry are we using today." + +Want to bake the registry in permanently? Run once: + +``` +skaffold config set default-repo localhost:5001 +``` + +Then you can drop the `-d` flag — Skaffold reads it from `~/.skaffold/config`. Fine for one-developer setups. + +
+ +### 5. `skaffold dev` says it does "live sync" for matching file types. What's the difference between sync and rebuild, and why is sync sometimes wrong? + +
+ Answer + +* **Rebuild** = full Docker build → push → Helm upgrade → rolling update. The cluster gets a new image. Always correct but slow (30–60s for our chart). +* **Sync** = Skaffold copies the changed file directly into the running container via `kubectl cp` (or its equivalent). No image rebuild, no push, no Helm upgrade. The container's filesystem now has the new file. Whether the *running process* notices depends on the app: + * Vue webpack-dev-server watches its filesystem → reloads page automatically. + * `uvicorn --reload` watches Python files → restarts the FastAPI process. + * A statically compiled binary (Go, Rust, C++) → **no effect**; the binary in memory is unchanged. You'd need a rebuild. + +When is sync wrong? When the change matters at build time: + +* `package.json` / `setup.py` — installed deps depend on these; only a rebuild picks up new deps. +* Dockerfile changes — obviously rebuild. +* Config files that the app only reads at startup — sync the file, but the app still uses the old version until restarted. + +Skaffold's `sync.infer` pattern list controls which files trigger sync. Files not matching → rebuild. The default patterns in our `skaffold.yaml` are conservative on purpose. + +If you suspect a sync gave you stale behavior, `kubectl delete pod ` forces a fresh pull and is the quick sanity check. + +
+ +### 6. What happens if you Ctrl-C `skaffold dev` vs `kill -9` it? + +
+ Answer + +* **Ctrl-C** = SIGINT. Skaffold's signal handler runs: it calls `helm uninstall myapp`, removes the port-forward, exits clean. Cluster goes back to pre-`skaffold dev` state. +* **kill -9** = SIGKILL. Process dies instantly, no signal handler runs. The Helm release stays. Port-forwards die (they were owned by Skaffold), but the deployed resources remain. Next `skaffold dev` will see the leftover release and try to upgrade it — usually fine, but occasionally causes "release in progress" lockouts. + +Recovery if `kill -9` left a mess: + +``` +helm list -a +helm uninstall myapp +``` + +…and try `skaffold dev` again. If `helm list -a` shows the release in `pending-upgrade` state, you may need `helm rollback myapp` first. + +
From dc7619c43e278cdafaf702ee18336318e4d475ac Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Fri, 22 May 2026 13:12:31 +0200 Subject: [PATCH 17/18] Add chapter 8 kind variant: production Dockerfile + Skaffold profiles Drop-in replacement for chapters/08-frontend-production.md. Walks through creating a multi-stage production Dockerfile for the frontend (node build stage + nginx serve stage), the matching nginx default.conf with SPA `try_files` fallback, and a Skaffold profile that swaps in the dev Dockerfile when `-p dev` is passed. Kind-specific bits: `-d localhost:5001` flag in all skaffold commands; node base image bumped from `node:14-alpine` to `node:20-alpine` to match the chapter 2 / Dockerfile.dev fix. Adds six spoiler-answer review questions covering: where the dev vs prod image size difference comes from, the SPA try_files trick, JSON-Patch index fragility (and the strategic-merge alternative), profile cleanup gotcha when release names diverge, adding a third profile (staging with replicas override), and PID 1 / signal handling implications of running webpack-dev-server vs nginx as the container's entry point. Co-Authored-By: Claude Opus 4.7 --- chapters/08-frontend-production-kind.md | 333 ++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 chapters/08-frontend-production-kind.md diff --git a/chapters/08-frontend-production-kind.md b/chapters/08-frontend-production-kind.md new file mode 100644 index 0000000..2d697d0 --- /dev/null +++ b/chapters/08-frontend-production-kind.md @@ -0,0 +1,333 @@ +# Chapter 8 (alternative): Production Dockerfile + Skaffold profiles — kind variant + +> Drop-in replacement for [08-frontend-production.md](08-frontend-production.md). The only kind-specific bit is the `-d localhost:5001` registry flag in the Skaffold commands; the rest (production Dockerfile, nginx config, Skaffold profile patches) is identical to the original. + +## 1. Why a second Dockerfile + +The `Dockerfile.dev` you built in chapter 2 runs the **webpack-dev-server** — Vue CLI's hot-reload dev server. Great for development, terrible for production: + +* Webpack-dev-server holds the entire source tree in memory and re-bundles on every request. +* It opens debugging endpoints (`/sockjs-node`, etc.) that leak source maps. +* The resulting image is huge (node + npm cache + source). +* It's slower than serving static files. + +For production we want the inverse: + +1. Compile the Vue source down to a static bundle (`dist/` folder: HTML + JS + CSS). +2. Serve those static files via a tiny webserver (nginx). +3. Throw away the build toolchain — the final image only needs nginx + the compiled assets. + +This is a textbook **multi-stage Docker build**: stage 1 = build, stage 2 = serve. The output image is ~30MB instead of ~1GB. + +## 2. Create the production Dockerfile + +### Create `frontend/docker/Dockerfile` (new file) + +```Dockerfile +# stage 1: build +FROM node:20-alpine as build-stage +LABEL org.opencontainers.image.authors="tutorial" + +RUN npm install -g @vue/cli + +ENV NODE_OPTIONS="--max-old-space-size=8192" +ADD package.json package-lock.json* /source/ +WORKDIR /source +RUN npm install +ADD . /source +WORKDIR /source +RUN npm install && cp .env.k8s .env && npm run build + +# stage 2: serve +FROM nginx:stable-alpine as production-stage +ADD docker/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build-stage /source/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +Walk-through: + +* `FROM ... as build-stage` — names the first stage so the second can copy from it. +* The build stage is essentially the same as `Dockerfile.dev`, but ends with `npm run build` (Vue CLI's "produce static dist/" command) instead of `npm run serve`. +* `FROM nginx:stable-alpine as production-stage` — fresh, minimal image. No node, no npm, no source. +* `COPY --from=build-stage /source/dist /usr/share/nginx/html` — the only thing that travels from stage 1 to stage 2: the compiled assets. +* `CMD ["nginx", "-g", "daemon off;"]` — nginx in foreground (containers need PID 1 to stay alive). + +### Create `frontend/docker/default.conf` (new file) + +Nginx needs a config telling it how to serve the SPA. Single-page apps have one HTML file; client-side routing handles the rest. The trick is the `try_files` line — for any URL nginx doesn't recognize as a real file, it falls back to `index.html` so the Vue Router can take over. + +```nginx +server { + listen 80; + listen [::]:80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} +``` + +### Quick local test (optional, just to see the prod image work) + +From `frontend/`: + +```shell +docker build -t myfrontend-prod -f docker/Dockerfile . +``` + +```shell +docker run --rm -p 8888:80 myfrontend-prod +``` + +Browse `http://localhost:8888`. Same UI as dev mode, served by nginx. Stop with Ctrl-C. + +## 3. Point Skaffold at the production Dockerfile + +Open `/home/dev/skaffold-helm-tutorial/skaffold.yaml`. Find the `frontend` artifact and change its `dockerfile:` line: + +**Before:** +```yaml + - image: frontend + context: frontend + docker: + dockerfile: docker/Dockerfile.dev +``` + +**After:** +```yaml + - image: frontend + context: frontend + docker: + dockerfile: docker/Dockerfile +``` + +Now `skaffold run -d localhost:5001` builds the production image. Live-sync of `.vue` files won't work anymore (the nginx image has no node, no webpack-dev-server) — you'd need a full rebuild on every change. That's why you wouldn't run `skaffold dev` against a production image during active development. + +## 4. Introduce a Skaffold profile for development + +You want **both modes available** without editing `skaffold.yaml` every time. Skaffold's solution is **profiles**: named overrides applied on top of the base config when you pass `-p `. + +Append to the end of `skaffold.yaml`: + +```yaml +profiles: + - name: dev + patches: + - op: replace + path: /build/artifacts/1/docker/dockerfile + value: docker/Dockerfile.dev +``` + +What this says: + +* `name: dev` — the profile name. Activated with `skaffold run -p dev` (or `skaffold dev -p dev`). +* `patches:` — JSON-Patch operations applied to the base config. +* `op: replace` — overwrite a field. +* `path: /build/artifacts/1/docker/dockerfile` — JSON Pointer into the config. `/build/artifacts/1` = the **second** artifact (zero-indexed; `0` = `api`, `1` = `frontend`). `/docker/dockerfile` drills into that artifact's `docker.dockerfile` setting. +* `value: docker/Dockerfile.dev` — what to set it to. + +End result: with no profile, you build the prod Dockerfile. With `-p dev`, the same Skaffold config switches to `Dockerfile.dev`. + +## 5. Use the modes + +Production-style build and deploy: + +```shell +skaffold run -d localhost:5001 +``` + +Development build (dev server + hot reload) — same chart, dev Dockerfile: + +```shell +skaffold run -d localhost:5001 -p dev +``` + +Live mode in dev: + +```shell +skaffold dev -d localhost:5001 -p dev +``` + +When you change a `.vue` file in `frontend/src/`, Skaffold syncs it into the running container, webpack-dev-server hot-reloads, the browser refreshes. Same loop as chapter 7, except now flipping `-p dev` on/off chooses which container architecture is running underneath. + +## 6. Why profiles instead of separate `skaffold-*.yaml` files + +You *could* have two complete files (`skaffold.yaml`, `skaffold-prod.yaml`) and pass `--filename`. Profiles are usually nicer because: + +* **DRY** — 95% of the config is shared, the diff is tiny. +* Multiple profiles compose. You can have `dev`, `staging`, `prod`, `prod-eu`, layering JSON-Patch ops. +* Single source of truth: one file in git, profile names listed in the README. + +Patches feel awkward at first (the JSON Pointer syntax especially). For changes that touch many fields, profiles also support `patchesStrategicMerge`-style override blocks, but the JSON-Patch form is more precise for small surgical edits like this one. + +## 7. Cleanup before chapter 9 + +```shell +skaffold delete -d localhost:5001 +``` + +Or `skaffold delete -d localhost:5001 -p dev` if you ended on the dev profile — pass the same flags as the run that created the release, so Skaffold knows which release name it owns. + +## Review questions + +### 1. The dev image is ~1 GB, the prod image is ~30 MB. Where does the size difference come from? + +
+ Answer + +The dev image carries everything needed to **build and serve**: + +* `node:20-alpine` base (~150 MB) +* `npm install -g @vue/cli` (~400 MB of dependencies for the CLI) +* `npm install` of project dependencies (~600 MB in `node_modules/`) +* All source code (`src/`, `public/`, etc.) +* Webpack-dev-server kept resident in memory at runtime + +The prod image carries only what's needed to **serve** static files: + +* `nginx:stable-alpine` base (~25 MB) +* The compiled `dist/` folder (your `src/` minified and bundled, typically a few MB for a small app) +* A 1-line nginx config + +The build toolchain stays in stage 1, which Docker discards once stage 2 is complete. Multi-stage = "use a big builder, ship a tiny runner." + +
+ +### 2. Why does `try_files $uri $uri/ /index.html;` in the nginx config exist? What breaks without it? + +
+ Answer + +Single-page apps put **all routing in the browser**. When the user navigates to `/about`, no `/about.html` file exists on the server — Vue Router intercepts the URL and renders the right component using JavaScript. + +But the first request to `/about` (typing the URL in the address bar, or hitting refresh while on that page) goes to the **server**. Without `try_files`, nginx looks for a file named `about` in `/usr/share/nginx/html`, doesn't find one, and returns `404 Not Found`. The user sees a broken page. + +`try_files $uri $uri/ /index.html;` says: + +1. First try `$uri` (e.g. `/about`) as a file. +2. If not found, try `$uri/` (treating it as a directory looking for an index). +3. If still not found, **fall back to `/index.html`**. + +Now any unknown path returns `index.html`, the Vue app boots, the Vue Router reads the URL, and renders `/about` client-side. + +Same trick is used by every SPA framework's nginx recipe (React, Angular, Svelte, …). Slight variations exist for sub-path deployments. + +
+ +### 3. The JSON Pointer `/build/artifacts/1/docker/dockerfile` looks fragile — what happens if you reorder the artifacts in your skaffold.yaml? + +
+ Answer + +The patch breaks. `/build/artifacts/1` literally means "the second item in the array." Reorder so `frontend` is first → the patch now points at `api`'s Dockerfile and silently corrupts your `api` artifact instead. + +This is a real footgun. Two safer alternatives: + +1. **Use `patchesStrategicMerge`-style blocks** instead of JSON-Patch. They reference artifacts by name, not index: + ```yaml + profiles: + - name: dev + build: + artifacts: + - image: frontend + docker: + dockerfile: docker/Dockerfile.dev + ``` + Slightly more verbose but order-independent and self-documenting. + +2. **Add a comment to the array** saying "do not reorder, profile patches reference indices." Defensive but ugly. + +For tiny configs with two artifacts, the JSON-Patch form is fine. For real projects with five+ artifacts and multiple profiles, the strategic-merge form is safer. + +
+ +### 4. You ran `skaffold run -p dev`, then later `skaffold delete -d localhost:5001` without `-p dev`. Did it clean up? + +
+ Answer + +**Probably yes**, but it's a footgun worth understanding. + +Profiles don't usually change the Helm release **name** — that's set in `deploy.helm.releases[].name`. So `skaffold delete` looks for a release called `myapp` (the name in your base config) and uninstalls it regardless of which profile is active. + +But profiles **can** patch the release name. If a profile changes `deploy.helm.releases[0].name` to `myapp-dev`, then deleting without `-p dev` would look for `myapp`, not find it, and silently do nothing — leaving `myapp-dev` orphaned. To catch this: + +``` +helm list -a +``` + +Shows all releases regardless of which Skaffold profile was active when they were installed. + +Habit: pass the same `-p` flag(s) to `skaffold delete` as you used for `skaffold run`. Or just use `helm uninstall ` directly if Skaffold's profile bookkeeping gets confusing. + +
+ +### 5. You're adding a third profile for "staging" — same as prod but with a different chart value (`replicas: 2`). What's the minimal change? + +
+ Answer + +Add the profile to the bottom of `skaffold.yaml`: + +```yaml +profiles: + - name: dev + patches: + - op: replace + path: /build/artifacts/1/docker/dockerfile + value: docker/Dockerfile.dev + + - name: staging + deploy: + helm: + releases: + - name: myapp + chartPath: myapp + setValueTemplates: + frontend.image.repository: "{{.IMAGE_REPO_frontend}}" + frontend.image.tag: "{{.IMAGE_TAG_frontend}}" + backend.image.repository: "{{.IMAGE_REPO_api}}" + backend.image.tag: "{{.IMAGE_TAG_api}}" + setValues: + frontend.replicas: "2" +``` + +Profile patches **overlay** the base config — fields you don't mention stay as-is. So this profile inherits the build section (prod Dockerfile) and only changes the Helm `setValues`. + +In `myapp/values.yaml` you'd need a corresponding `frontend.replicas: 1` default and the Deployment template would need to use `replicas: {{ .Values.frontend.replicas }}`. Plumb through whatever's actually configurable. + +For a real prod/staging split you'd also use different namespaces, different registries, different image-tag policies, etc. — that's why some teams have multiple `skaffold-.yaml` files instead of profiles. + +
+ +### 6. The dev image runs `npm run serve` as PID 1. The prod image runs nginx as PID 1. Why does that matter? + +
+ Answer + +Linux containers have a special contract for PID 1: **PID 1 is the init process**. It's responsible for: + +* Receiving signals (SIGTERM, SIGINT) and forwarding them to children. +* Reaping zombie child processes. +* Exiting cleanly when shutdown is requested. + +The container's *whole lifecycle* depends on PID 1 behaving correctly. If PID 1 ignores SIGTERM, `docker stop` and `kubectl delete pod` will hang until the 30-second timeout, then SIGKILL. + +* **nginx** is a well-behaved init process when given `-g "daemon off;"`. It catches signals, reaps children, exits cleanly. Production-grade. +* **Webpack-dev-server** is *not* especially well-behaved as PID 1 — it has signal-handling quirks. Fine in dev where you Ctrl-C interactively, but in production you don't want pods taking 30+ seconds to terminate during a rollout. + +Other common offenders: shell scripts (`CMD npm run serve` literally runs through `sh -c`, and shell signal handling is iffy), Python apps without explicit signal handlers, Java apps started via wrapper scripts. The fix is often a small init process like `tini` (`tini -- node server.js`) which docker provides via `--init` or `tini` baked into base images. + +For our tutorial it's mostly cosmetic — kind clusters tear down whole pods fast. In production it matters a lot. + +
From c1f1d50be44050655f7e7712bbd38921cea124de Mon Sep 17 00:00:00 2001 From: SirHephaistos Date: Fri, 22 May 2026 14:32:47 +0200 Subject: [PATCH 18/18] Add chapter 9 kind variant: Ingress on Traefik (no nginx annotations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in replacement for chapters/09-ingress.md. Uses the standard Kubernetes Ingress resource with `ingressClassName: traefik` and plain `pathType: Prefix` rules — no nginx-specific annotations like `nginx.ingress.kubernetes.io/use-regex`, keeping the YAML portable across ingress controllers. Key adaptations: - Hostname: `tutorial.localhost` (RFC 6761 reserved zone, resolves to 127.0.0.1 without DNS setup) instead of duckdns - Access via http://tutorial.localhost:8080 (kind's extraPortMappings surface ingress 80 -> host 8080) - Path matching: four separate prefix rules instead of one regex rule, with a note that longest-prefix wins regardless of YAML order - IngressClass mechanism (k8s 1.18+) instead of the deprecated kubernetes.io/ingress.class annotation - Optional §8 promotes the Ingress into the Helm chart with ingress.enabled / ingress.host values for env-specific overrides - Optional §9 adds TLS termination via the cert-manager ClusterIssuer from chapter 3 Preserves the original chapter's HelloWorld.vue URL gotcha — the hardcoded `localhost:9999` URL can't be fixed by Helm templating because the URL is frozen at docker-build time, not deploy time. Fix is relative URLs in the JS. Adds six spoiler-answer review questions: Ingress/Service namespace coupling, path-order independence (longest-prefix wins), why TLS terminates at the edge not at the pod, the build-time vs deploy-time vs runtime URL trap, IngressClass mechanics, and how to swap from Traefik to nginx-ingress without rewriting YAML. Co-Authored-By: Claude Opus 4.7 --- chapters/09-ingress-kind.md | 418 ++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 chapters/09-ingress-kind.md diff --git a/chapters/09-ingress-kind.md b/chapters/09-ingress-kind.md new file mode 100644 index 0000000..6e9bd7d --- /dev/null +++ b/chapters/09-ingress-kind.md @@ -0,0 +1,418 @@ +# Chapter 9 (alternative): Ingress on Traefik — kind variant + +> Drop-in replacement for [09-ingress.md](09-ingress.md). The original uses nginx-ingress with nginx-specific regex annotations. This variant uses Traefik (installed in chapter 3) with the **standard Kubernetes `Ingress` resource** — no controller-specific annotations, fully portable across ingress controllers. The HelloWorld.vue URL gotcha at the end is the same in both versions. + +## 1. What an Ingress is, again + +So far you've been reaching the frontend and backend via `kubectl port-forward` (or via Skaffold's `portForward:` block). That works for one developer on one laptop. It doesn't scale to "give my colleague a URL." + +An **`Ingress`** object is a routing rule: "for requests with hostname X and path Y, send to Service Z." The ingress controller (Traefik, in our setup) reads `Ingress` objects from the k8s API and rewires its proxy config accordingly. + +Recap from chapter 3: + +``` +Browser + ↓ +host port 8080 (mapped to kind node :80 via extraPortMappings) + ↓ +Traefik pod inside cluster + ↓ inspects Host header + path, looks up Ingress rules +Service (frontend or api) + ↓ +Pod +``` + +Until you create an `Ingress`, Traefik has no rules → answers 404 to everything (you saw this in chapter 3 §6 verification). + +## 2. Pick a hostname + +For a real public ingress you'd point a real DNS name at the cluster (e.g. via duckdns, route53, cloudflare). For tutorial purposes that's overkill — we'll use a hostname that resolves to loopback by convention. + +`*.localhost` is reserved by RFC 6761 and is supposed to always resolve to `127.0.0.1` on every machine. Most modern Linux distros honor this via NSS; if yours doesn't, you can always add an `/etc/hosts` entry. We'll use `tutorial.localhost`. + +Quick check: + +```shell +getent hosts tutorial.localhost +``` + +If it returns `127.0.0.1 tutorial.localhost` you're set. If it returns nothing, add the entry: + +```shell +echo '127.0.0.1 tutorial.localhost' | sudo tee -a /etc/hosts +``` + +## 3. Pre-flight: make sure the cluster is up + +Before creating the Ingress, you need the app actually running. From chapter 7/8: + +```shell +cd /home/dev/skaffold-helm-tutorial && skaffold run -d localhost:5001 -p dev +``` + +(Use `-p dev` if you want hot-reload via webpack-dev-server. Without it you run the production nginx-served build from chapter 8.) + +Wait for `kubectl get pod` to show `frontend-...`, `api-0`, `postgresql-db-0` all `Running`. + +## 4. Create the Ingress yaml + +The frontend lives at `/` (everything that's not an API call). The API answers on three paths: `/time`, `/counter`, `/settings`. Each path has to be a separate rule with `pathType: Prefix` (Traefik supports `Prefix`, `Exact`, and `ImplementationSpecific`; we stick with the portable `Prefix`). + +Create a new file `ingress.yaml` anywhere convenient (tutorial root or `/tmp`): + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: myapp +spec: + ingressClassName: traefik + rules: + - host: tutorial.localhost + http: + paths: + - path: /time + pathType: Prefix + backend: + service: + name: api + port: + number: 80 + - path: /counter + pathType: Prefix + backend: + service: + name: api + port: + number: 80 + - path: /settings + pathType: Prefix + backend: + service: + name: api + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: frontend + port: + number: 80 +``` + +Apply: + +```shell +kubectl apply -f ingress.yaml +``` + +A few things to call out: + +* **`ingressClassName: traefik`** — replaces the deprecated annotation `kubernetes.io/ingress.class: "nginx"` (or `"traefik"`) used in the original tutorial. The IngressClass approach has been the recommended way since k8s 1.18. +* **No nginx annotations** — the original `nginx.ingress.kubernetes.io/use-regex: "true"` is gone. Traefik doesn't understand it. We use prefix matching instead. +* **Path order matters when paths overlap** — Traefik (and most controllers) match the longest-prefix first, so `/time` beats `/` even though both technically match `/time`. Documented behaviour, no special config needed. + +## 5. Test it + +The host port mapping from chapter 3 was 8080 → 80 (inside the cluster node container). So the URL the browser sees is `http://tutorial.localhost:8080`. Quick test: + +```shell +curl -s http://tutorial.localhost:8080/time +``` + +Should print something like `{"current":"22/05/2026 11:32:14"}`. The request hit Traefik on `:8080`, Traefik matched `Host: tutorial.localhost` + path `/time` → forwarded to the `api` Service → which load-balances to the `api-0` pod. + +```shell +curl -sI http://tutorial.localhost:8080/ +``` + +Should answer `HTTP/1.1 200 OK` with `Content-Type: text/html` — the frontend's index.html. + +In a browser, `http://tutorial.localhost:8080/` should load the Vue app. (VS Code Remote-SSH forwards 8080 to your laptop automatically.) + +## 6. The HelloWorld.vue URL trap + +Click the "request time" button in the browser. Probably doesn't work. + +Open `frontend/src/components/HelloWorld.vue`. You'll see: + +```javascript +axios.get('http://localhost:9999/time') +``` + +The frontend hardcodes `localhost:9999` for the API. That URL is correct when using `skaffold dev`'s port-forward block, but not when accessing via the Ingress at `tutorial.localhost:8080`. + +The "obvious" fix is to change it to `http://tutorial.localhost:8080/time`. That works, but it's brittle — every developer's local hostname becomes a code edit. The **portable** fix is to use a **relative URL**: + +```javascript +axios.get('/time') +``` + +Now the browser makes the request relative to the page it loaded. If the page loaded from `http://tutorial.localhost:8080`, the API call goes to `http://tutorial.localhost:8080/time`. If it later loaded from `https://app.example.com`, the call goes to `https://app.example.com/time`. No URL is baked into the JS bundle. + +Apply that change in `HelloWorld.vue` for both `getTime` and `getCounter` calls (drop the `http://localhost:9999` prefix from both). + +```shell +skaffold dev -d localhost:5001 -p dev +``` + +…lets webpack-dev-server hot-reload the change. Click the button again → time should now show. + +## 7. Wait, why doesn't templating the URL in Helm fix it? + +A natural next thought: "I'll make `axios.get('http://{{ .Values.ingress.host }}/time')` and override it per environment." + +It doesn't work, and the reason is fundamental to how front-end deployment differs from back-end deployment. + +Helm templating runs at **`helm install` time**. It substitutes values into YAML manifests and ships them to Kubernetes. **Helm never touches your Vue source code.** Your `HelloWorld.vue` is baked into the frontend docker image at **`docker build` time** — long before Helm has anything to say about deployment. + +So: + +``` +Build time ─── Vue source compiled into static JS bundle + (URL strings are now baked in) + ↓ +Push time ─── Image pushed to registry + ↓ +Helm render ─── Templates → YAML (operating on Pod specs, not JS code) + ↓ +Cluster apply ─── Pods scheduled, frontend serves the pre-built JS + ↓ +Runtime ─── Browser executes the JS, sees the URL strings + that were baked in at build time +``` + +**The cluster never sees JS source code.** The Helm chart can't possibly templatize something that doesn't exist by the time it runs. + +Workarounds (each with trade-offs): + +* **Relative URLs** (what we did) — drop the host entirely, browser figures it out. +* **Runtime config injection** — have the frontend fetch a tiny `/config.json` from the server at startup; nginx serves it from a ConfigMap mounted as a file. Decouples build from runtime config, costs an extra HTTP round-trip on page load. +* **window.\_\_ENV\_\_ injection at HTML render** — the page template gets a small `