Minikube
This activity puts into practice the concepts from the Container Orchestration lecture. You will start a local Kubernetes cluster with minikube, deploy a personalized web service using ConfigMaps, Secrets, Deployments, Services, Ingress, and a small StatefulSet with persistent storage, then watch the control loop heal and roll out your changes. By the end, you will have a personalized page reachable through Kubernetes on your laptop and a stateful workload whose data survives pod replacement.
What You Will Need
Section titled “What You Will Need”- A working Docker environment from the earlier Docker activity, or an equivalent local Docker Engine or Docker Desktop setup that already passes
docker version minikubeinstalled before class using the official minikube start guide and verified withminikube versionkubectlinstalled before class using the official Kubernetes tools guide and verified withkubectl version --client- Enough free RAM to run Docker plus a small local cluster comfortably
Start the Cluster
Section titled “Start the Cluster”Before you deploy anything, make the cluster itself visible. You will start minikube, enable the two addons this activity depends on, then inspect what Kubernetes placed on the node so you can tell the difference between cluster infrastructure and the application workloads you are about to add.
-
Verify the three tools this activity assumes are already installed:
Terminal window docker versionminikube versionkubectl version --clientAll three commands should print normal version information and return to the prompt. If
docker versionhangs or reports that the daemon is unavailable, start Docker before continuing. -
Create a clean working directory for the manifests you are about to write:
Terminal window mkdir -p ~/cs312-minikubecd ~/cs312-minikubeKeeping the manifests in one directory makes it easy to apply them again later, check them into Git, or reuse them in a different local cluster.
-
Start a single-node Kubernetes cluster with the Docker driver:
Terminal window minikube start --driver=dockerThis creates a local cluster and configures your
kubectlcontext to point at it. The command prints several setup lines and usually takes a minute or two on the first run. -
Enable the ingress and metrics-server addons:
Terminal window minikube addons enable ingressminikube addons enable metrics-serverThe ingress addon gives you a local HTTP entry point later in the activity. The metrics-server addon makes
kubectl topand the Horizontal Pod Autoscaler possible. -
Inspect the cluster at a high level:
Terminal window kubectl cluster-infokubectl get nodes -o widekubectl get pods -Akubectl get nskubectl get node -o 'jsonpath={.items[0].status.nodeInfo.containerRuntimeVersion}'; echodocker ps --filter name=minikube --format 'table {{.Names}}\t{{.Status}}'You should see one node in
Readystate. The pod list will show a handful of system pods, butdocker psshows only one container on your host: the minikube node itself. Every pod runs inside that node rather than as a sibling host container. Minikube uses the Docker driver to create a node that looks like a real VM to Kubernetes, but from your host it is just a Docker container. ThecontainerRuntimeVersioncommand shows which runtime Kubernetes sees inside that node. You can change it withminikube start --container-runtime=cri-oor--container-runtime=containerdif you want to see a different runtime in action.You may also see one or more pods in
Completedstatus. In many setups, these are Jobs an addon ran once, such as ingress admission setup. They finished cleanly, and Kubernetes keeps them so you can read their logs. You will create your own Job later in this activity and see the same lifecycle. -
Look specifically at the system namespace:
Terminal window kubectl get pods -n kube-systemkube-systemis where Kubernetes and addon components live. Your own application objects should not go here. -
Identify which pods are DaemonSet-managed:
Terminal window kubectl get daemonset -AYou should see at least
kube-proxyin the list. A DaemonSet schedules exactly one Pod per matching node, which is why each DaemonSet shows one Pod in this single-node cluster but would show one Pod per node in a production cluster. Compare that with the Deployment-managed pods you saw above: those are placed wherever the scheduler finds capacity.
Create a Namespace and Configuration
Section titled “Create a Namespace and Configuration”Now create the first pieces of desired state. A namespace scopes all the objects you are about to create to one named bucket, which makes cleanup at the end a single command. A ConfigMap and a Secret store the two values the web workload will render into its page.
-
Create
namespace.yaml:apiVersion: v1kind: Namespacemetadata:name: cs312-minikubeA namespace gives this activity its own naming scope. The name
cs312-minikubewill appear on every object you create from here on. -
Apply the namespace and make it your current default:
Terminal window kubectl apply -f namespace.yamlkubectl config set-context --current --namespace=cs312-minikubekubectl config view --minify -o 'jsonpath={..namespace}'; echoThe final command should print
cs312-minikube. From now on,kubectlcommands that use this current context and do not specify-ntarget that namespace until you change the context again. Kubernetes also automatically creates adefaultServiceAccount in the new namespace; every Pod runs as some service account even when you do not specify one. -
Create
web-config.yaml:apiVersion: v1kind: ConfigMapmetadata:name: orchestra-web-confignamespace: cs312-minikubedata:message: |This page is coming from a Deployment behind a Service.---apiVersion: v1kind: Secretmetadata:name: orchestra-web-ownernamespace: cs312-minikubetype: OpaquestringData:owner: YOUR_ONIDReplace
YOUR_ONIDwith your own ONID or another short identifier before saving the file. The ConfigMap holds normal configuration text; the Secret uses Kubernetes’ secret-handling path inside the cluster, but this manifest file still contains the value in plain text on your machine until you remove or ignore it. -
Apply the ConfigMap and Secret:
Terminal window kubectl apply -f web-config.yamlKubernetes stores both objects immediately. Nothing is using them yet, but the desired state now exists in the API.
-
Inspect what was stored:
Terminal window kubectl get configmap,secretkubectl get secret orchestra-web-owner -o jsonpath='{.data.owner}'; echokubectl get secret orchestra-web-owner -o jsonpath='{.data.owner}' | base64 --decode; echoThe first secret lookup prints the base64-encoded form. The second decodes it back to the original text. A Kubernetes Secret is an access-control boundary, not encryption: anyone who can read the Secret object can decode the value in one command. The real protection comes from RBAC limiting which service accounts and users can read which Secrets, and from optional encryption at rest in etcd.
Deploy a Stateless Web Workload
Section titled “Deploy a Stateless Web Workload”With namespace and configuration in place, you can now create the web workload itself. A Deployment keeps two replica Pods running at all times, an init container inside each Pod renders a personal HTML page from the ConfigMap and Secret, and a ClusterIP Service gives the group a stable address inside the cluster. You will also inspect how the Deployment, ReplicaSet, Pods, probes, and volumes fit together.
-
Create
web-deployment.yaml:apiVersion: apps/v1kind: Deploymentmetadata:name: orchestra-webnamespace: cs312-minikubespec:replicas: 2strategy:type: RollingUpdaterollingUpdate:maxUnavailable: 0maxSurge: 1selector:matchLabels:app: orchestra-webtemplate:metadata:labels:app: orchestra-webspec:initContainers:- name: render-pageimage: busybox:1.36command:- sh- -c- |OWNER="$(cat /secrets/owner)"MESSAGE="$(cat /config/message)"cat > /work/index.html <<EOF<!doctype html><html><head><meta charset="utf-8"><title>CS 312 Orchestration</title></head><body><h1>CS 312 Container Orchestration</h1><p><strong>Owner:</strong> ${OWNER}</p><p>${MESSAGE}</p></body></html>EOFvolumeMounts:- name: rendered-sitemountPath: /work- name: app-configmountPath: /configreadOnly: true- name: app-secretmountPath: /secretsreadOnly: truecontainers:- name: nginximage: nginx:1.27-alpineports:- containerPort: 80resources:requests:cpu: 50mmemory: 64Milimits:cpu: 250mmemory: 128MistartupProbe:httpGet:path: /port: 80periodSeconds: 2failureThreshold: 15readinessProbe:httpGet:path: /port: 80periodSeconds: 5livenessProbe:httpGet:path: /port: 80periodSeconds: 10volumeMounts:- name: rendered-sitemountPath: /usr/share/nginx/htmlreadOnly: truevolumes:- name: rendered-siteemptyDir: {}- name: app-configconfigMap:name: orchestra-web-config- name: app-secretsecret:secretName: orchestra-web-owner---apiVersion: v1kind: Servicemetadata:name: orchestra-webnamespace: cs312-minikubespec:type: ClusterIPselector:app: orchestra-webports:- port: 80targetPort: 80The Pod in this Deployment has two phases: the init container renders the HTML file from the ConfigMap and Secret into an
emptyDirvolume, then the nginx container serves that file. AnemptyDiris scratch space tied to the Pod’s lifetime: it is created when the Pod starts and deleted when it is removed, which is fine here because the init container regenerates it from durable config objects on each start. The rolling update strategy setsmaxUnavailable: 0(never drop below the desired replica count during an update) andmaxSurge: 1(allow one extra Pod above the desired count while the new version starts up). -
Apply the Deployment and wait for it to become ready:
Terminal window kubectl apply -f web-deployment.yamlkubectl rollout status deployment/orchestra-webThe rollout status should end with
successfully rolled out. Kubernetes does not just start a container once here; it keeps reconciling until the desired number of ready replicas exists. -
Inspect the workload hierarchy:
Terminal window kubectl get deployment,replicaset,pods,service -o wideYou should see one Deployment, one ReplicaSet created by that Deployment, two Pods created by that ReplicaSet, and one ClusterIP Service selecting those Pods.
-
Capture one Pod name and confirm its owner:
Terminal window WEB_POD=$(kubectl get pods -l app=orchestra-web -o jsonpath='{.items[0].metadata.name}')echo "$WEB_POD"kubectl get pod "$WEB_POD" -o jsonpath='{.metadata.ownerReferences[0].kind}{"/"}{.metadata.ownerReferences[0].name}{"\n"}'The owner reference points to a ReplicaSet, not directly to the Deployment. That is the Deployment/ReplicaSet/Pod hierarchy made concrete: the Deployment manages the ReplicaSet, and the ReplicaSet manages the Pods.
-
Describe the Pod and look for the init container, probes, and volumes:
Terminal window kubectl describe pod "$WEB_POD"Scroll until you find the
Init Containers,Containers,Mounts,Liveness,Readiness, andStartupsections. This is the Pod model made concrete: you can see exactly which containers Kubernetes created, which volumes are mounted where, and how each probe is configured. -
Prove the Service works from inside the cluster by DNS name:
Terminal window kubectl run curl-test --rm -i --restart=Never --image=busybox:1.36 -- wget -qO- http://orchestra-webThis command starts a temporary Pod named
curl-testrunningbusybox, executeswget -qO- http://orchestra-webinside it, prints the output to your terminal, and then deletes the Pod. The flags do the following:--rmdeletes the Pod when it exits,-iconnects your terminal to the Pod’s output,--restart=Nevermakes it a one-shot Pod rather than one that Kubernetes would restart on exit, and--separateskubectlarguments from the command to run inside the container. The URLhttp://orchestra-webworks because CoreDNS resolves short Service names within the same namespace.You should see the HTML page including your personal identifier. The workload is reachable by Service name, not by hardcoded Pod IP.
Make Stateful Storage Visible
Section titled “Make Stateful Storage Visible”Stateless workloads can be replaced freely because they carry no persistent data. Stateful ones need storage that survives a Pod restart. This section adds a second workload backed by a PersistentVolumeClaim (PVC) so you can prove that deleting a Pod does not destroy the data on its volume.
-
Create
stateful-note.yaml:apiVersion: v1kind: Servicemetadata:name: note-padnamespace: cs312-minikubespec:clusterIP: Noneselector:app: note-padports:- port: 8080targetPort: 8080---apiVersion: apps/v1kind: StatefulSetmetadata:name: note-padnamespace: cs312-minikubespec:serviceName: note-padreplicas: 1selector:matchLabels:app: note-padtemplate:metadata:labels:app: note-padspec:containers:- name: note-padimage: busybox:1.36command:- sh- -c- |mkdir -p /dataif [ ! -f /data/index.html ]; thenecho '<h1>Stateful note pad</h1><p>Edit /data/index.html to make persistence visible.</p>' > /data/index.htmlfihttpd -f -p 8080 -h /dataports:- containerPort: 8080volumeMounts:- name: notesmountPath: /datavolumeClaimTemplates:- metadata:name: notesspec:accessModes:- ReadWriteOnceresources:requests:storage: 100MiSetting
clusterIP: Nonemakes this a headless Service: it does not get a virtual ClusterIP, so DNS queries resolve directly to individual Pod IPs instead of a load-balanced virtual address. Combined with the StatefulSet’s stable pod names, each replica gets a predictable DNS name in the form<pod-name>.<service-name>, herenote-pad-0.note-pad. ThevolumeClaimTemplatesfield tells Kubernetes to create one PVC per replica and bind it to that replica’s ordinal identity, sonote-pad-0always reattaches to the same volume even after rescheduling. On stock minikube, the default StorageClass and local provisioner usually bind this claim automatically. If the PVC staysPending, inspectkubectl get storageclassandkubectl describe pvc notes-note-pad-0before continuing. -
Apply the StatefulSet and wait for it to be ready:
Terminal window kubectl apply -f stateful-note.yamlkubectl rollout status statefulset/note-padThis should create one Pod named
note-pad-0and one PVC named something likenotes-note-pad-0. -
Inspect the objects Kubernetes created for you:
Terminal window kubectl get statefulset,pod,pvcNotice that the Pod name is ordinal and stable. That is the first sign that a StatefulSet is not treating its Pods as interchangeable.
-
Write your own content into the mounted volume:
Terminal window kubectl exec note-pad-0 -- sh -c 'printf "<h1>Persisted from YOUR_ONID</h1>\n<p>This file lives on a PVC.</p>\n" > /data/index.html && cat /data/index.html'Replace
YOUR_ONIDwith your own identifier before you run the command. You are writing directly into the persistent storage mounted at/data. -
Read that same file back through the Pod’s stable DNS name:
Terminal window kubectl exec note-pad-0 -- wget -qO- http://note-pad-0.note-pad:8080/The DNS name
note-pad-0.note-padcomes from the StatefulSet plus the headless Service. That naming pattern is what lets clients target a specific replica when ordering matters. -
Delete the Pod and watch the StatefulSet replace it:
Terminal window kubectl delete pod note-pad-0kubectl get pod note-pad-0 -wWait until the replacement Pod returns to
Running, then stop the watch with Ctrl+C. -
Confirm that the file survived the Pod replacement:
Terminal window kubectl exec note-pad-0 -- cat /data/index.htmlThe Pod was replaced, but the file should still be there. That is the operational distinction between Pod identity and storage identity.
Run a One-Off Job
Section titled “Run a One-Off Job”Deployments and StatefulSets manage workloads that should always be running. Some work is finite: a database migration, a data import, a nightly report. A Job runs one or more Pods to completion and then stops. This section runs a Job so you can see what a Pod in the Succeeded phase looks like.
-
Create
batch-job.yaml:apiVersion: batch/v1kind: Jobmetadata:name: hello-jobnamespace: cs312-minikubespec:completions: 1template:spec:restartPolicy: Nevercontainers:- name: helloimage: busybox:1.36command:- sh- -c- 'echo "Job complete from YOUR_ONID"; sleep 2'Replace
YOUR_ONIDbefore saving. TherestartPolicy: Nevertells Kubernetes not to restart the container after it exits; the Job controller tracks completion separately. Withcompletions: 1, the Job is finished when one Pod exits with status 0. -
Apply the Job and watch the Pod move through its lifecycle:
Terminal window kubectl apply -f batch-job.yamlkubectl get pods -wYou should see a Pod move through
Pending→Running→Completed. Stop the watch with Ctrl+C once the Pod showsCompleted. -
Read the output and inspect the Job’s final state:
Terminal window kubectl logs job/hello-jobkubectl get job hello-jobkubectl get pod -l job-name=hello-jobThe log should show your message. The Job’s
COMPLETIONScolumn should read1/1. The Pod statusCompletedmeans all containers exited cleanly with status 0, which Kubernetes calls theSucceededphase. Kubernetes keeps the completed Pod around by default so you can read its logs. -
Delete the Job:
Terminal window kubectl delete job hello-jobDeleting a Job cascades to its Pods. In production, the
ttlSecondsAfterFinishedfield on a Job spec can automate this cleanup; without it, completed Pods accumulate until you remove them manually.
Watch the Control Loop Publish and Update Your App
Section titled “Watch the Control Loop Publish and Update Your App”You now have a stateless and a stateful workload running in the cluster. This section makes the control loop visible: you will delete Pods and watch them heal, change replica counts, roll out and roll back an update, add an HPA, and finally publish the page through an Ingress so it is reachable from your browser.
Self-Healing, Scaling, and Rollouts
Section titled “Self-Healing, Scaling, and Rollouts”This section puts the control loop directly in front of you. Delete one Pod, change the desired replica count, and roll out a configuration change while Kubernetes keeps the Deployment available throughout.
-
Delete one web Pod and watch the Deployment heal the gap:
Terminal window kubectl get pods -l app=orchestra-webWEB_POD=$(kubectl get pods -l app=orchestra-web -o jsonpath='{.items[0].metadata.name}')kubectl delete pod "$WEB_POD"kubectl get pods -l app=orchestra-web -wOne Pod disappears, then a replacement appears automatically. Stop the watch with Ctrl+C after the replica count returns to the desired number. When deletion begins, Kubernetes marks the Pod unready for normal Service traffic and updates EndpointSlice conditions so load balancers stop using it as a ready backend while it is shutting down. Applications that need connection draining still need to handle shutdown gracefully.
-
Scale the Deployment manually to three replicas:
Terminal window kubectl scale deployment orchestra-web --replicas=3kubectl get pods -l app=orchestra-webThis is still declarative. You are not starting a third Pod directly. You are changing the desired replica count and letting Kubernetes do the work.
-
Check that metrics-server is feeding live resource data:
Terminal window kubectl top podsIf you get a metrics API error, wait one minute and run the command again. The addon can take a short time to become ready after the cluster starts.
-
Create
web-edge.yamlwith an HPA and an Ingress:apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:name: orchestra-webnamespace: cs312-minikubespec:scaleTargetRef:apiVersion: apps/v1kind: Deploymentname: orchestra-webminReplicas: 2maxReplicas: 5metrics:- type: Resourceresource:name: cputarget:type: UtilizationaverageUtilization: 50---apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: orchestra-webnamespace: cs312-minikubespec:ingressClassName: nginxrules:- http:paths:- path: /pathType: Prefixbackend:service:name: orchestra-webport:number: 80An HPA (Horizontal Pod Autoscaler) watches a metric and automatically increases or decreases a Deployment’s replica count to keep that metric near a target. Here the target is 50% average CPU utilization, with a floor of 2 replicas and a ceiling of 5. The Ingress object declares how HTTP traffic entering the cluster should be routed to the Service.
-
Apply the HPA and Ingress, then inspect the autoscaler object:
Terminal window kubectl apply -f web-edge.yamlkubectl get hpaThe current utilization might be
0%, very low, or temporarily unknown. The important point is the target: the HPA is now another controller watching the same Deployment. -
Update the web message and apply it:
Open
web-config.yamland replace themessagevalue with:Rolling updates keep this page online while the desired state changes.Then apply:
Terminal window kubectl apply -f web-config.yamlNothing visible changes yet because the init container only re-renders the page when a Pod starts. The Deployment template has not changed, so there is no automatic rollout from this ConfigMap update by itself.
-
Roll the Deployment so new Pods pick up the new desired content:
Terminal window kubectl rollout restart deployment/orchestra-webkubectl rollout status deployment/orchestra-webBecause the Deployment strategy allows one surge Pod and zero unavailable Pods, Kubernetes can replace the old replicas without dropping the whole service at once.
-
Now make one Pod-template change that the Deployment can actually roll back:
Edit
web-deployment.yamland change the nginx image fromnginx:1.27-alpinetonginx:1.28-alpine, then apply it again:Terminal window kubectl apply -f web-deployment.yamlkubectl rollout status deployment/orchestra-webThis creates a new Deployment revision because the pod template changed. Unlike the ConfigMap edit above, this change is tracked in the ReplicaSet history.
-
Roll back to the previous revision:
Terminal window kubectl rollout undo deployment/orchestra-webkubectl rollout status deployment/orchestra-webKubernetes reverts the Deployment’s pod template to the previous ReplicaSet. The same rolling strategy applies in reverse: new Pods from the older spec replace the current ones while keeping the Deployment available throughout.
-
Confirm the image rolled back and the page still shows the updated message:
Terminal window kubectl get deployment orchestra-web -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'kubectl run curl-test --rm -i --restart=Never --image=busybox:1.36 -- wget -qO- http://orchestra-webThe image should be back to
nginx:1.27-alpine, while the page should still show the updated rolling-update message. The rollback changed the Deployment template, but it did not revert the separate ConfigMap object.
Publish the Page Through Ingress
Section titled “Publish the Page Through Ingress”The Service is still internal to the cluster. This section first shows you NodePort, which opens a port directly on the node’s IP address, then replaces it with the ingress controller for a cleaner HTTP entry point.
-
Temporarily expose the Service as a NodePort:
Terminal window kubectl patch service orchestra-web -p '{"spec":{"type":"NodePort"}}'minikube service orchestra-web -n cs312-minikube --urlCopy the URL printed and curl it (on macOS/WSL with the Docker driver,
minikube service --urlmay keep a tunnel open, so run the curl in a second terminal):Terminal window curl <the-url-from-above>/You should see the HTML page. Notice the port number in the URL: by default, NodePort allocates from the 30000-32767 range. This is a usable but blunt entry point: every node in the cluster exposes that port whether or not it is running one of the Pods, and clients need to know both the node address and the high-numbered port.
-
Switch back to ClusterIP and use Ingress instead:
Terminal window kubectl patch service orchestra-web -p '{"spec":{"type":"ClusterIP"}}'Ingress is the cleaner production pattern: one external entry point, path-based or hostname-based routing to many Services, and no high-numbered ports visible to clients. The steps below set that up.
-
Start the minikube tunnel in a second terminal and leave it running:
Terminal window minikube tunnelLeave that command running while you finish this section. On macOS it may ask for your password because it needs permission to create network routes.
-
In your original terminal, wait for the Ingress to report an address:
Terminal window kubectl get ingressOn Linux, an IP address will appear in the
ADDRESScolumn within a few seconds. On macOS, theADDRESScolumn may stay empty even while the tunnel is working correctly; the address to use in the next step is127.0.0.1regardless. -
Fetch the page through the Ingress endpoint:
Terminal window curl http://127.0.0.1/Terminal window curl "http://$(kubectl get ingress orchestra-web -o jsonpath='{.status.loadBalancer.ingress[0].ip}')/"You should now see the same personalized HTML page you generated earlier, this time reached through the ingress controller rather than from inside the cluster.
-
Open the same URL in a browser and leave the page visible:
The page should show your identifier and the updated rolling-update message. This is the clean, visible end state for the activity: a real page, served through Kubernetes, with your own text on it.
Clean Up
Section titled “Clean Up”Once you have verified the Ingress page, remove the activity resources so your next minikube exercise starts from a known state. The fastest cleanup is to stop any helper tunnels, delete the namespace, and reset your default kubectl namespace.
-
Stop any helper terminals you started for local access:
If
minikube service ... --urlorminikube tunnelis still running in another terminal, press Ctrl+C there first.This closes the local helper tunnel and releases any routes or forwarded ports it created.
-
Delete the activity namespace and everything in it:
Terminal window kubectl delete namespace cs312-minikube --wait=trueKubernetes cascades that delete to the Deployment, StatefulSet, Service, PVC, ConfigMap, Secret, HPA, Ingress, and any completed Job objects you created in this activity.
-
Reset your current
kubectlnamespace todefault:Terminal window kubectl config set-context --current --namespace=defaultkubectl config view --minify -o 'jsonpath={..namespace}'; echoThe final command should print
default. This keeps laterkubectlcommands from pointing at a namespace that no longer exists.
Going Further
Section titled “Going Further”You now have the core Kubernetes object model running locally: namespaces, Services, Deployments, StatefulSets, PVCs, probes, an HPA, and an Ingress. The natural next step is to move one layer up the stack and start working with the ecosystem that grows around those raw manifests.
If you want to keep going, repackage the three web manifest files as a Helm chart so the owner, message, and replica counts move into values.yaml. Build a separate staging overlay with Kustomize so you can change the message and replica count without copying the whole manifest set. To see the operator pattern become concrete, install cert-manager on a throwaway minikube cluster and compare kubectl get crd before and after the install. When the later lab arrives, take the same mental model to k3s on EC2 and pay attention to what changes: the control plane packaging, the storage defaults, the ingress controller, and the operational cost of exposing the cluster to real users.