Skip to main content

Kubernetes Fundamentals on a Local Cluster

This chapter teaches the core Kubernetes object model on a free local cluster before any cloud spend. You'll build the minimal Express application that the rest of the course will deploy and see every object you need to run it. By the end the application runs in a local cluster, you can watch its pods scale up under load, and you understand each object in your own manifests.

Prerequisites

Mental model in one paragraph

A container is a single process tree, isolated by Linux namespaces and limited by cgroups. A pod is one or more containers that share a network namespace and a filesystem mount space; they're scheduled together and addressed by one IP. Kubernetes does not schedule containers; it schedules pods. You almost never write Pod objects directly. You write a Deployment which manages a ReplicaSet which manages pods, so rolling updates and self-healing happen for you. A Service gives a stable name and IP for the moving target of pods that match a label selector. A ConfigMap is non-secret configuration; a Secret is the same shape but base64-encoded and treated more carefully by the API server. The whole API is declarative: you submit the desired state, controllers reconcile reality toward it.

Step 1 — Create the kind cluster

kind runs Kubernetes inside containers on your laptop. Create a single-node cluster with a port mapping so you can reach a NodePort later:

cat <<'EOF' > /tmp/kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 30080
protocol: TCP
EOF
kind create cluster --name k8slearn --config /tmp/kind-config.yaml

Expected output finishes with:

Set kubectl context to "kind-k8slearn"
Have a nice day!

Verify:

kubectl cluster-info --context kind-k8slearn
kubectl get nodes
# k8slearn-control-plane Ready control-plane ...

Step 2 — Scaffold the Express application

Create the project on disk. This is what will eventually live in the expressapp repository.

mkdir -p ~/code/expressapp/app && cd ~/code/expressapp/app
npm init -y
npm install express prom-client
npm install --save-dev jest supertest eslint prettier

Replace package.json's scripts block with:

"scripts": {
"start": "node server.js",
"test": "jest --ci",
"lint": "eslint .",
"format": "prettier --check ."
}

Split the app into two files: app.js is the Express app (testable in-process with no port) and server.js is the HTTP listener and SIGTERM handler (the actual entrypoint).

Create app/app.js:

'use strict';
const express = require('express');
const promClient = require('prom-client');

function buildApp({ readyAfterMs = 1500 } = {}) {
const app = express();

const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });

const httpRequests = new promClient.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests, labelled by route, method, and status.',
labelNames: ['route', 'method', 'status'],
registers: [register],
});

const httpDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds, labelled by route and method.',
labelNames: ['route', 'method'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
registers: [register],
});

let ready = readyAfterMs <= 0;
if (!ready) setTimeout(() => { ready = true; }, readyAfterMs);

app.use((req, res, next) => {
const stop = httpDuration.startTimer({ route: req.path, method: req.method });
res.on('finish', () => {
stop();
httpRequests.inc({ route: req.path, method: req.method, status: res.statusCode });
});
next();
});

app.get('/', (_req, res) => {
res.json({ app: 'expressapp', version: process.env.APP_VERSION || 'dev' });
});

app.get('/healthz', (_req, res) => res.status(200).send('ok'));

app.get('/readyz', (_req, res) => {
if (!ready) return res.status(503).send('starting');
res.status(200).send('ready');
});

app.get('/burn', (_req, res) => {
const deadline = Date.now() + 200;
while (Date.now() < deadline) {}
res.send('done');
});

app.get('/metrics', async (_req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});

return app;
}

module.exports = { buildApp };

Create app/server.js. This is what containers run:

'use strict';
const { buildApp } = require('./app');

const port = process.env.PORT || 3000;
const app = buildApp();

const server = app.listen(port, () => {
console.log(`expressapp listening on :${port}`);
});

function shutdown(signal) {
console.log(`${signal} received, draining`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Now write a real test at app/__tests__/app.test.js that exercises every endpoint the spec calls out:

const request = require('supertest');
const { buildApp } = require('../app');

describe('expressapp', () => {
const app = buildApp({ readyAfterMs: 0 }); // immediately ready for tests

test('/ returns app metadata as JSON', async () => {
const res = await request(app).get('/');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ app: 'expressapp' });
expect(typeof res.body.version).toBe('string');
});

test('/healthz returns 200 ok', async () => {
const res = await request(app).get('/healthz');
expect(res.status).toBe(200);
expect(res.text).toBe('ok');
});

test('/readyz returns 200 ready when initialized', async () => {
const res = await request(app).get('/readyz');
expect(res.status).toBe(200);
expect(res.text).toBe('ready');
});

test('/readyz returns 503 before initialization completes', async () => {
const slow = buildApp({ readyAfterMs: 60_000 });
const res = await request(slow).get('/readyz');
expect(res.status).toBe(503);
expect(res.text).toBe('starting');
});

test('/metrics exposes prom-client text format', async () => {
// Drive a request first so the histogram has a sample.
await request(app).get('/');
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/plain/);
expect(res.text).toMatch(/^# HELP http_requests_total/m);
expect(res.text).toMatch(/^# TYPE http_request_duration_seconds histogram/m);
});
});

The Dockerfile (Step 3) needs to copy both app.js and server.js.

Run it locally:

node server.js &
sleep 1
curl -s localhost:3000/ # {"app":"expressapp","version":"dev"}
curl -i localhost:3000/readyz # 503 then 200 after ~1.5s
curl -s localhost:3000/metrics | head
kill %1

Step 3 — Multi-stage, non-root Dockerfile

Create ~/code/expressapp/app/Dockerfile:

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev

FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY --from=deps /app/node_modules ./node_modules
COPY app.js server.js ./
USER app
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

A small .dockerignore keeps build context tight:

node_modules
npm-debug.log
.git
.vscode
__tests__

Build and load into kind:

cd ~/code/expressapp/app
docker build -t expressapp:dev .
kind load docker-image expressapp:dev --name k8slearn

kind load docker-image is how images reach a kind node without a registry. The next chapter switches to a real registry.

Step 4 — Write the manifests

Create ~/code/expressapp/k8s/base/ and the four objects you need to learn:

namespace.yaml carves out a logical slice of the cluster so this app's objects don't collide with anything else and you can scope kubectl commands with -n expressapp. You'll remove this file in Part 3 when cluster-bootstrap owns namespaces; on kind it's just the easiest way to keep things tidy.

apiVersion: v1
kind: Namespace
metadata:
name: expressapp

configmap.yaml holds non-secret runtime configuration as plain key/value pairs. The Deployment below pulls these in as environment variables via envFrom, so changing a value here and re-applying is how you reconfigure the app without rebuilding the image. Anything sensitive belongs in a Secret instead (covered in Part 8).

apiVersion: v1
kind: ConfigMap
metadata:
name: expressapp
namespace: expressapp
data:
APP_VERSION: "0.1.0"
LOG_LEVEL: "info"

deployment.yaml is the workload itself. It tells Kubernetes "I want N pods running this image, matching these labels, with these probes and resource bounds" — and a controller works to keep reality matching that spec. Rolling updates, restarts on crash, and rescheduling on node failure all come from this one object. The selector and the pod template labels must match, that's how the Deployment finds the pods it owns.

A few fields inside are worth knowing the first time you see them:

  • resources.requests is what the scheduler reserves for the pod (what it's promised). resources.limits is the ceiling the kernel enforces — go over the CPU limit and you get throttled; go over the memory limit and you get OOM-killed. Always set both.
  • startupProbe runs first and gives slow-starting apps room to come up without the other probes failing prematurely. Once it passes, it's never run again.
  • readinessProbe decides whether a pod gets traffic from the Service. Fail it and the pod is quietly pulled out of the load-balancing pool, no restart.
  • livenessProbe decides whether a pod gets killed. Fail it enough times and the kubelet restarts the container.
  • imagePullPolicy: IfNotPresent tells the kubelet to use the local copy of the image if it already has one. On kind you loaded the image with kind load docker-image, so this avoids a registry lookup that would fail.
  • Named ports (name: http on the container, targetPort: http on the Service) let you rename a port number in one place without updating every reference.
apiVersion: apps/v1
kind: Deployment
metadata:
name: expressapp
namespace: expressapp
labels:
app.kubernetes.io/name: expressapp
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: expressapp
template:
metadata:
labels:
app.kubernetes.io/name: expressapp
spec:
containers:
- name: app
image: expressapp:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: http
envFrom:
- configMapRef:
name: expressapp
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
startupProbe:
httpGet: { path: /readyz, port: http }
failureThreshold: 30
periodSeconds: 1
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
failureThreshold: 3

service.yaml gives the pods a stable name and IP. Pods are cattle, their IPs change every time one restarts, so nothing inside or outside the cluster should ever talk to a pod IP directly. The Service watches for pods matching its selector and load-balances traffic across them. type: NodePort exposes it on a fixed port on every node, which pairs with the extraPortMappings you set up in the kind config so localhost:30080 reaches the app. In a real cluster you'd front this with an Ingress instead (Part 4).

apiVersion: v1
kind: Service
metadata:
name: expressapp
namespace: expressapp
spec:
type: NodePort
selector:
app.kubernetes.io/name: expressapp
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080

Apply:

kubectl apply -f ~/code/expressapp/k8s/base/namespace.yaml
kubectl apply -f ~/code/expressapp/k8s/base/configmap.yaml
kubectl apply -f ~/code/expressapp/k8s/base/deployment.yaml
kubectl apply -f ~/code/expressapp/k8s/base/service.yaml

Watch it come up:

kubectl -n expressapp get pods -w
# expressapp-7d…-abcd 0/1 Pending …
# expressapp-7d…-abcd 0/1 ContainerCreating
# expressapp-7d…-abcd 0/1 Running (readiness still failing)
# expressapp-7d…-abcd 1/1 Running (readinessProbe passed)

Reach it through the NodePort that kind mapped to the host:

curl -s http://127.0.0.1:30080/
# {"app":"expressapp","version":"dev"}
curl -s http://127.0.0.1:30080/metrics | grep http_requests_total | head

Step 5 — Walk through each object

With the app running, use these commands to look at the live state:

kubectl -n expressapp get deploy,rs,pod,svc,configmap
kubectl -n expressapp describe deploy expressapp | sed -n '1,40p'
kubectl -n expressapp get pod -o jsonpath='{.items[0].spec.containers[0].env}' | jq .
  • Deployment expressapp owns a ReplicaSet with the desired replica count.
  • The ReplicaSet owns the Pods. kubectl get rs -n expressapp shows it.
  • Each Pod has its own IP (kubectl -n expressapp get pod -o wide) but you don't call it directly; the Service does.
  • The Service has a stable ClusterIP and a DNS name expressapp.expressapp.svc.cluster.local. From any pod in the cluster, that hostname always resolves to the current pods.

Open k9s and use :deploy, :rs, :pod, :svc, :configmap to navigate. Hit d on a resource to describe, l to tail logs, s to shell in.

Step 6 — Probes, demonstrated

You wrote a slow /readyz. To see what readiness does in practice, scale the Deployment up and watch the new pod take 1.5s before it gets traffic:

kubectl -n expressapp scale deploy/expressapp --replicas=3
kubectl -n expressapp get pod -w
# new pod is 0/1 Running for ~1.5s, then 1/1 Running once /readyz returns 200

Now break liveness and watch the pod restart:

kubectl -n expressapp patch deploy expressapp --type=json -p='[
{"op":"replace","path":"/spec/template/spec/containers/0/livenessProbe/httpGet/path","value":"/does-not-exist"}
]'
kubectl -n expressapp get pod -w
# CrashLoopBackOff after a few liveness failures

Revert:

kubectl -n expressapp patch deploy expressapp --type=json -p='[
{"op":"replace","path":"/spec/template/spec/containers/0/livenessProbe/httpGet/path","value":"/healthz"}
]'

Step 7 — Install metrics-server on kind

kind's kubelet uses a self-signed certificate, so the default metrics-server chart fails to verify it. Install with the --kubelet-insecure-tls flag. This is a local-only workaround; on real clusters in Part 3 you'll install metrics-server without that flag.

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl -n kube-system patch deployment metrics-server --type=json -p='[
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}
]'
kubectl -n kube-system rollout status deploy/metrics-server

Wait ~30 seconds for the first scrape, then verify:

kubectl top nodes
kubectl -n expressapp top pods
# numbers appear instead of <unknown>

Note: kube-prometheus-stack (installed in Part 9) does not replace metrics-server for the HPA API. They're different APIs (metrics.k8s.io vs Prometheus). The built-in HPA needs metrics-server.

Step 8 — HorizontalPodAutoscaler under load

A HorizontalPodAutoscaler (HPA) is a controller that watches a metric (here, CPU utilization from metrics-server) and scales a Deployment's replicas up or down to keep that metric near a target. Horizontal means more pods; vertical autoscaling, which adjusts requests/limits, is a separate thing and not used in this course. The HPA respects minReplicas/maxReplicas so it can never run away.

Create ~/code/expressapp/k8s/base/hpa.yaml:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: expressapp
namespace: expressapp
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: expressapp
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50

Apply and watch:

kubectl apply -f ~/code/expressapp/k8s/base/hpa.yaml
kubectl -n expressapp get hpa expressapp -w

Generate load against /burn from another terminal:

kubectl -n expressapp run loader --rm -it --restart=Never --image=alpine -- sh -c '
apk add --no-cache curl >/dev/null
while true; do curl -s http://expressapp/burn > /dev/null & done
'

Within a minute the HPA REPLICAS column climbs from 2 toward 6. Stop the loader (Ctrl-C) and watch it back down after the cool-down window (~5 minutes for scale-down by default).

If REPLICAS stays at <unknown>/50%, metrics-server hasn't produced a sample yet; kubectl top pods -n expressapp should also show numbers. Recheck step 7.

Step 9 — Inspect everything in k9s

Launch k9s, point it at the kind-k8slearn context, and:

  • Filter by namespace (:expressapp).
  • Hit l on a pod to tail logs.
  • Hit s to open a shell and curl http://expressapp/ from inside the cluster. That proves the Service DNS name resolves.
  • Hit e on a Deployment to edit it inline and watch the rolling update happen.

Clean up (optional)

You'll keep using this cluster in Part 3 only as a comparison; you can delete it now if you want to free RAM:

kind delete cluster --name k8slearn

If you delete it, you must recreate it before re-running steps in this chapter.

Completion signal

  • kubectl --context kind-k8slearn -n expressapp get deploy,svc,hpa shows the Deployment ready, the Service with a NodePort, and the HPA reporting actual CPU percentages (not <unknown>).
  • curl http://127.0.0.1:30080/ returns the expected JSON.
  • Generating load against /burn makes the HPA REPLICAS value rise; removing the load brings it back down.
  • You can describe each object (Deployment, ReplicaSet, Pod, Service, ConfigMap, HPA) in one sentence each.

You're ready for 03. Cluster on DigitalOcean.