In Part 1 you got a local cluster running with kind. Now it is time to actually run something on it. This part cooks with Pods: the smallest unit Kubernetes knows how to run. You will create single- and multi-container Pods, label and select them, give them CPU and memory budgets, and wire up the health checks that tell Kubernetes when a Pod is alive and when it is ready for traffic.
If you skipped Part 1, go set up your kind cluster first: Kubernetes Cookbook Part 1: A Local Cluster with kind. Everything below assumes kubectl talks to that cluster.
What you need
- The kind cluster from Part 1, with
kubectlconfigured to reach it. - A directory for this part's files, for example
mkdir pods && cd pods. - Internet access so kind can pull the images
hashicorp/http-echo,nginx:1.27,busybox:1.36, andcurlimages/curl.
The running example for this series is clinerds-demo: the tiny hashicorp/http-echo web server that answers every HTTP request with a fixed text string on port 5678.
Recipe 1: Run a single-container Pod by the book
Problem: You know docker run. You want the Kubernetes equivalent: one container, running on the cluster, that you can inspect and poke at.
Create clinerds-demo-pod.yaml:
apiVersion: v1 kind: Pod metadata: name: clinerds-demo labels: app: clinerds-demo spec: containers: - name: web image: hashicorp/http-echo args: - "-text=hello from the clinerds demo pod" ports: - containerPort: 5678 Apply it and watch it come up:
kubectl apply -f clinerds-demo-pod.yaml kubectl get pods -w The -w flag watches, so you see the lifecycle in real time:
NAME READY STATUS RESTARTS AGE clinerds-demo 0/1 Pending 0 1s clinerds-demo 0/1 ContainerCreating 0 2s clinerds-demo 1/1 Running 0 8s Press Ctrl-C to stop watching. Now read the logs:
kubectl logs clinerds-demo To prove the server answers, note the Pod's IP with kubectl get pod clinerds-demo -o wide, then call it from a throwaway debug Pod. The http-echo image is stripped down and contains no shell, so kubectl exec into it does not work — a second Pod is the reliable trick:
kubectl run curl-test --rm -it --image=curlimages/curl --restart=Never -- http://CLINERDS_DEMO_POD_IP:5678 You get back hello from the clinerds demo pod. Clean up with kubectl delete pod curl-test if anything lingered, but keep clinerds-demo running — later recipes reuse it.
How it works: A Pod is a wrapper around one or more containers that always run together on the same machine. You never say "run this on node X"; you describe the Pod and the Kubernetes scheduler picks a node for you. Pending means the scheduler is deciding or the image is still downloading, ContainerCreating means the node is setting up the container, and Running means your process is up. kubectl logs streams the container's standard output, exactly like docker logs.
When it goes wrong:
-
ImagePullBackOffinkubectl get podsmeans the image name or tag is wrong, or the node cannot reach the registry. -
CrashLoopBackOffmeans the container starts and immediately exits. Checkkubectl logs clinerds-demofor the error.
Recipe 2: Run a two-container Pod that shares data
Problem: You want nginx to serve a small HTML file, and you want a second container to keep that file fresh without rebuilding any image.
Containers in one Pod share two things: the network and (if you ask for it) volumes. This recipe uses both. Create web-with-sidecar.yaml:
apiVersion: v1 kind: Pod metadata: name: web-with-sidecar labels: app: web-with-sidecar spec: volumes: - name: content emptyDir: {} containers: - name: nginx image: nginx:1.27 ports: - containerPort: 80 volumeMounts: - name: content mountPath: /usr/share/nginx/html - name: writer image: busybox:1.36 volumeMounts: - name: content mountPath: /content command: - sh - -c - | while true; do echo "<h1>updated at $(date)</h1>" > /content/index.html sleep 10 done kubectl apply -f web-with-sidecar.yaml kubectl get pod web-with-sidecar Note READY 2/2 — two containers, one Pod. Now verify the sharing from inside the Pod. busybox has a shell and wget, so kubectl exec works here if you pick the container with -c:
kubectl exec web-with-sidecar -c writer -- wget -qO- localhost:80 Run it again ten seconds later and the timestamp in the HTML has changed.
How it works: All containers in a Pod share one network namespace — one IP address, one set of ports. That is why the writer container can reach nginx as plain localhost:80: to it, nginx is literally a local process. The emptyDir volume is an empty directory Kubernetes creates when the Pod lands on a node, mounted into both containers at different paths (/content for the writer, /usr/share/nginx/html for nginx). The writer's loop overwrites index.html every 10 seconds, and nginx serves the new file immediately. One caveat: emptyDir lives and dies with the Pod. Delete the Pod and the data is gone — Part 8 covers storage that survives.
Variations:
- Two containers in a Pod that both try to bind port 80 will fail, because the network namespace is shared. Give each container its own port.
-
kubectl logs web-with-sidecar -c writershows one container's logs; without-c, kubectl refuses when a Pod has more than one container.
Recipe 3: Organize Pods with labels and selectors
Problem: Your cluster is filling up with Pods and you need to find, group, and act on them by role, not by random name.
Labels are key/value stickers you put on objects. The Pods from Recipes 1 and 2 already carry an app label. Add a second demo Pod with more labels, labeled-pod.yaml:
apiVersion: v1 kind: Pod metadata: name: clinerds-demo-v2 labels: app: clinerds-demo tier: frontend track: canary spec: containers: - name: web image: hashicorp/http-echo args: - "-text=hello from v2" ports: - containerPort: 5678 kubectl apply -f labeled-pod.yaml kubectl get pods --show-labels NAME READY STATUS RESTARTS AGE LABELS clinerds-demo 1/1 Running 0 20m app=clinerds-demo clinerds-demo-v2 1/1 Running 0 5s app=clinerds-demo,tier=frontend,track=canary web-with-sidecar 2/2 Running 0 15m app=web-with-sidecar Now filter with -l. Equality-based:
kubectl get pods -l app=clinerds-demo Set-based (quote it so your shell does not eat the parentheses):
kubectl get pods -l 'tier in (frontend,backend)' Labels are mutable — no need to recreate a Pod to re-tag it:
kubectl label pod clinerds-demo tier=backend kubectl label pod clinerds-demo-v2 track- # trailing minus removes the label kubectl get pods -l 'tier in (frontend,backend)' How it works: Labels are pure metadata; Kubernetes does not care what tier means. But almost everything else in the system selects on labels. Services find their Pods by label (Part 5), Deployments manage Pods by label (Part 4), and kubectl -l is your everyday filter. The set-based form key in (a, b) matches any of the listed values; key notin (a) and plain key (exists) work too. Think of labels as a tagging system where every tool in Kubernetes can query the tags.
Recipe 4: Budget CPU and memory with requests and limits
Problem: One greedy Pod can hog a node and starve everything else. You want to tell Kubernetes what your Pod needs and cap what it may take.
Update clinerds-demo-pod.yaml so the container has resources, then re-apply:
apiVersion: v1 kind: Pod metadata: name: clinerds-demo labels: app: clinerds-demo tier: backend spec: containers: - name: web image: hashicorp/http-echo args: - "-text=hello from the clinerds demo pod" ports: - containerPort: 5678 resources: requests: cpu: 100m memory: 64Mi limits: cpu: 200m memory: 128Mi kubectl apply -f clinerds-demo-pod.yaml Now watch a limit do its job. Create memory-hog.yaml, a Pod allowed 50Mi that tries to write 200Mi into /dev/shm (which counts against memory):
apiVersion: v1 kind: Pod metadata: name: memory-hog spec: containers: - name: hog image: busybox:1.36 command: ["sh", "-c", "dd if=/dev/zero of=/dev/shm/fill bs=1M count=200"] resources: requests: memory: 20Mi limits: memory: 50Mi kubectl apply -f memory-hog.yaml kubectl get pod memory-hog -w Within seconds the status flips to OOMKilled. Ask for the details:
kubectl describe pod memory-hog | grep -A3 "Last State" Last State: Terminated Reason: OOMKilled Exit Code: 137 Clean up: kubectl delete pod memory-hog.
How it works: A request is what the scheduler uses for placement: it adds up the requests on each node and only puts your Pod where it fits. 100m CPU means one tenth of one core (m is milli). A limit is enforced at runtime. Exceed the CPU limit and the container is throttled — slowed down, not killed. Exceed the memory limit and the kernel kills the process outright: OOMKilled, short for out-of-memory kill, exit code 137. Memory is incompressible — you cannot throttle a process out of the RAM it already holds — so killing is the only option. Set requests from real measurement (kubectl top pod once metrics are installed) and limits as a safety margin above that.
Recipe 5: Keep Pods honest with liveness and readiness probes
Problem: A running process is not the same as a working process. You want Kubernetes to detect a wedged app and restart it, and to keep traffic away from Pods that are not ready yet.
Kubernetes checks health with probes. Add both kinds to clinerds-demo-pod.yaml:
apiVersion: v1 kind: Pod metadata: name: clinerds-demo labels: app: clinerds-demo tier: backend spec: containers: - name: web image: hashicorp/http-echo args: - "-text=hello from the clinerds demo pod" ports: - containerPort: 5678 resources: requests: cpu: 100m memory: 64Mi limits: cpu: 200m memory: 128Mi readinessProbe: httpGet: path: / port: 5678 initialDelaySeconds: 2 periodSeconds: 5 livenessProbe: httpGet: path: / port: 5678 initialDelaySeconds: 5 periodSeconds: 10 kubectl apply -f clinerds-demo-pod.yaml Now break one on purpose to see the machinery. Create broken-liveness.yaml — identical, but the liveness probe points at port 8080, where nothing listens:
apiVersion: v1 kind: Pod metadata: name: broken-liveness spec: containers: - name: web image: hashicorp/http-echo args: - "-text=i keep getting restarted" ports: - containerPort: 5678 livenessProbe: httpGet: path: / port: 8080 periodSeconds: 5 kubectl apply -f broken-liveness.yaml kubectl get pod broken-liveness -w Watch the RESTARTS column climb:
NAME READY STATUS RESTARTS AGE broken-liveness 1/1 Running 3 (21s ago) 2m kubectl describe pod broken-liveness shows the event behind each restart:
Warning Unhealthy ... Liveness probe failed: Get "http://10.244.0.9:8080/": dial tcp ...: connect: connection refused Normal Killing ... Container web failed liveness probe, will be restarted Clean up: kubectl delete pod broken-liveness.
How it works: A probe is a health check the kubelet (the Kubernetes agent on each node) runs against your container: an HTTP GET, a TCP connect, or a command. A liveness probe answers "is this container stuck?" Fail it enough times and the kubelet restarts the container — that is the RESTARTS counter going up. A readiness probe answers "should this Pod receive traffic right now?" Fail it and the Pod is pulled out of every Service's rotation (you will see that in Part 5) but left running — no restart, because the fix might just be time. initialDelaySeconds gives a slow app room to boot before the checks start. Use liveness for "restart it, it is wedged", readiness for "wait, it is warming up".
When it goes wrong:
- Probing a port your app does not listen on (like our 8080) causes endless restarts — the classic typo.
- Setting
initialDelaySeconds: 0for a slow-starting app kills it before it finishes booting.
Where to go next
You can now run Pods, share data between containers, tag and select them, budget their resources, and health-check them. In Part 3: Namespaces you will learn to divide one cluster into tidy rooms so teams and environments do not step on each other. For the deep reference, the official Pods documentation is the place. The full series lives at Kubernetes Cookbook.
