Basic Docker & Kubernetes Hands-On
▶ Open the runnable notebook for this episode — every command below is a Shift+Enter cell; the YAML manifests are in the workspace's yamls/ folder.
Session 2 · 70 min
This episode is the core Kubernetes hands-on: scheduling pods and jobs, persistent storage, multi-container pods, ConfigMaps and Secrets, Deployments, exposing an HTTPS service, steering pods with taints/tolerations and node affinity, and launching a GPU pod.
Conventions. Hands-on examples use the nrp-training-k8s namespace. In any YAML or command, replace <username> with a short version of your name or username to avoid collisions with other participants. Manifests live in the workspace yamls/ folder.
📘 Docs: Kubernetes basics · GPU pods · Run jobs · Storage · Live resources
kubectl flags you'll reach for constantly
| Flag | Purpose |
|---|---|
-n <namespace> | Target a specific namespace. |
-l key=value | Filter resources by label. |
-w / --watch | Stream live updates instead of a one-shot list. Ctrl-C to stop. |
-o wide | Add columns: node, pod IP, container image, etc. |
-o yaml / -o json | Print the full resource manifest. |
-o jsonpath='{...}' | Extract one field. |
--show-labels | Append a column with every label a resource carries. |
--previous (on kubectl logs) | Logs from the previous container instance — essential for crashloops. |
Hands-on: a simple pod
Open yamls/test-pod.yaml and replace <username> in metadata.name:
apiVersion: v1
kind: Pod
metadata:
name: test-pod-<username>
namespace: nrp-training-k8s
spec:
containers:
- name: mypod
image: ubuntu:22.04
command: ["sh", "-c", "echo 'Hello from NRP!' && sleep 3600"]
resources:
limits: { memory: 100Mi, cpu: 100m }
requests: { memory: 100Mi, cpu: 100m }Notice requests and limits are identical — the Gatekeeper-safe default from Episode 1.
Launch and inspect:
kubectl apply -n nrp-training-k8s -f yamls/test-pod.yaml
kubectl wait --for=condition=Ready pod/test-pod-<username> -n nrp-training-k8s --timeout=60s
kubectl get pods -n nrp-training-k8ssleep 5
kubectl logs test-pod-<username> -n nrp-training-k8sThe kubectl wait line pauses until the pod is actually running. We still sleep briefly before reading logs since Ready doesn't guarantee the container has flushed its first line of stdout.
Expected output
pod/test-pod-<username> created
NAME READY STATUS RESTARTS AGE
test-pod-<username> 1/1 Running 0 12s
Hello from NRP!Run a command inside it, then open an interactive shell (Ctrl-D to exit):
kubectl exec test-pod-<username> -n nrp-training-k8s -- echo 'Command executed successfully'kubectl exec -it test-pod-<username> -n nrp-training-k8s -- /bin/bashWhen something doesn't behave the way you expect:
kubectl describe pod test-pod-<username> -n nrp-training-k8s # status + last eventskubectl get events -n nrp-training-k8s --sort-by=.metadata.creationTimestamp | tail -20describe shows scheduling decisions and container state; get events shows the namespace timeline. For a crashlooping pod, add --previous to read the dead container's logs — kubectl logs <pod> -n nrp-training-k8s --previous. (On a healthy pod it just says "previous terminated container not found" — there's no prior crash to show, so only reach for it when a pod is actually restarting.)
Clean up:
kubectl delete pod test-pod-<username> -n nrp-training-k8s⏳ Bare pods on NRP are reaped after 6 hours. The cluster stamps every pod that isn't managed by a Deployment or Job with a 6-hour
activeDeadlineSeconds. A pod you start this morning will be gone by mid-afternoon — that's expected, not a failure, andcheck.shreports it as "already cleaned up". Anything that must outlive that (or survive a node reboot) belongs in a Deployment, and its data belongs on a PVC — which is exactly where we go next.
Hands-on: persistent storage with a PVC
Pods are ephemeral — anything written to the container filesystem disappears when the pod terminates. PersistentVolumeClaims ask Kubernetes for long-lived storage you can mount into pods. On NRP we typically use rook-ceph-block-east for general-purpose ReadWriteOnce block storage.
Open yamls/pvc.yaml — it contains a 1 GiB PVC and a writer pod that mounts it at /data. Replace <username> in both names, then:
kubectl apply -n nrp-training-k8s -f yamls/pvc.yaml
kubectl get pvc -n nrp-training-k8skubectl get pod pvc-pod-<username> -n nrp-training-k8sExpected output (Ceph provisioning takes ~30–60s on first claim)
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
pvc-<username> Bound pvc-99a63070-eb3d-490a-82fd-4e5811e4a5df 1Gi RWO rook-ceph-block-east 45s
NAME READY STATUS RESTARTS AGE
pvc-pod-<username> 1/1 Running 0 47sProve the data survives pod deletion — delete only the pod, re-apply, and read the file back:
kubectl wait --for=condition=Ready pod/pvc-pod-<username> -n nrp-training-k8s --timeout=90s
kubectl exec pvc-pod-<username> -n nrp-training-k8s -- cat /data/log.txtkubectl delete pod pvc-pod-<username> -n nrp-training-k8s
kubectl apply -n nrp-training-k8s -f yamls/pvc.yaml
kubectl wait --for=condition=Ready pod/pvc-pod-<username> -n nrp-training-k8s --timeout=90skubectl exec pvc-pod-<username> -n nrp-training-k8s -- cat /data/log.txt # previous line still thereDon't delete the PVC yet — the next section reuses it.
Hands-on: multi-container pod (sidecar pattern)
A pod can hold more than one container — they share the network namespace (same localhost) and any volumes mounted into both. This is the classic sidecar pattern: a main container plus a supporting one (log shipping, file syncing, format conversion).
yamls/multicontainer.yaml defines a pod whose writer container appends a tick line to a shared file every 5 seconds while a reader container tails the same file. It reuses pvc-<username> from the previous section — first delete the writer pod so the RWO volume detaches:
kubectl delete pod pvc-pod-<username> -n nrp-training-k8s --ignore-not-found
kubectl apply -n nrp-training-k8s -f yamls/multicontainer.yamlkubectl get pod sidecar-<username> -n nrp-training-k8sRead each container's log stream separately with -c:
sleep 5
kubectl logs sidecar-<username> -c writer -n nrp-training-k8s --tail=5kubectl logs sidecar-<username> -c reader -n nrp-training-k8s --tail=5Expected output
# writer:
writer-tick 1 04:55:58
writer-tick 2 04:56:04
# reader (tailing the shared file from a different process):
reader started, tailing /shared/data.log
writer-tick 1 04:55:58
writer-tick 2 04:56:04Every line the writer appends shows up in the reader's stream — both containers see the same volume. Clean up (this also releases the PVC):
kubectl delete -n nrp-training-k8s -f yamls/multicontainer.yaml
kubectl delete -n nrp-training-k8s -f yamls/pvc.yamlHands-on: ConfigMap, Secret, and env vars
Hard-coding paths, hostnames, or API tokens into images is a recipe for pain. Kubernetes gives you two purpose-built objects:
- ConfigMap — non-sensitive key/value config, stored as plain text.
- Secret — sensitive values (tokens, passwords, TLS keys), stored base64-encoded with separate RBAC.
yamls/configmap-secret.yaml ships a ConfigMap, a Secret, and a Pod that pulls ConfigMap keys in bulk via envFrom and the Secret via secretKeyRef. Replace <username> in all names and apply:
kubectl apply -n nrp-training-k8s -f yamls/configmap-secret.yaml
kubectl wait --for=condition=Ready pod/env-pod-<username> -n nrp-training-k8s --timeout=60ssleep 5
kubectl logs env-pod-<username> -n nrp-training-k8sExpected output
GREETING=Hello from NRP
SERVER_PORT=8080
API_TOKEN starts with: tutorial…Look inside each object:
kubectl get configmap app-config-<username> -n nrp-training-k8s -o yaml | grep -A2 '^data:'kubectl get secret app-secret-<username> -n nrp-training-k8s -o jsonpath='{.data.API_TOKEN}' | base64 -d ; echoBase64 is storage format, not encryption — anyone who can get secret in your namespace can read it. Clean up:
kubectl delete -n nrp-training-k8s -f yamls/configmap-secret.yamlwriter-tick lines. What do containers in one pod share?localhost, and see any volume mounted into each of them — that's what makes the sidecar pattern work.--previous reads the logs of the instance that crashed. Pair it with describe and get events: the debugging trio.env-pod printed GREETING=Hello from NRP. Where did that value come from?envFrom pulls all keys in bulk); sensitive values live in Secrets referenced via secretKeyRef. Neither is baked into the image.Hands-on: Deployment
A Deployment keeps a set of identical pods running: it restarts them when they fail and rolls out new versions without downtime. Open yamls/deployment.yaml, replace <username>, apply:
kubectl apply -n nrp-training-k8s -f yamls/deployment.yamlkubectl get deploy,rs,pod -n nrp-training-k8s -l app=hello-deploy-<username>Try the basic operations:
# scale to 4 replicas
kubectl scale deployment hello-deploy-<username> -n nrp-training-k8s --replicas=4
# delete one pod and watch the Deployment immediately recreate it
VICTIM=$(kubectl get pod -n nrp-training-k8s -l app=hello-deploy-<username> -o jsonpath='{.items[0].metadata.name}')
kubectl delete pod "$VICTIM" -n nrp-training-k8s
kubectl get pods -n nrp-training-k8s -l app=hello-deploy-<username> # still 4
# rolling update to a different image
kubectl set image deployment/hello-deploy-<username> -n nrp-training-k8s hello=nginx:alpine
kubectl rollout status deployment/hello-deploy-<username> -n nrp-training-k8sWorking with running pods: cp, port-forward, patch
Pick one pod from the Deployment:
POD=$(kubectl get pod -n nrp-training-k8s -l app=hello-deploy-<username> -o jsonpath='{.items[0].metadata.name}')
echo "$POD"Copy files in and out:
echo "training data v1" > /tmp/dataset.txt
kubectl cp /tmp/dataset.txt nrp-training-k8s/"$POD":/tmp/dataset.txtkubectl exec "$POD" -n nrp-training-k8s -- cat /tmp/dataset.txtTunnel a pod port to your terminal (foreground; use a second terminal for curl):
kubectl port-forward "$POD" -n nrp-training-k8s 8080:80
# second terminal:
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8080Patch a single field without re-applying YAML:
kubectl patch deployment hello-deploy-<username> -n nrp-training-k8s -p '{"spec":{"replicas":2}}'Clean up:
kubectl delete -n nrp-training-k8s -f yamls/deployment.yamlHands-on: batch Job
A Job runs pods until a target number complete successfully. Open yamls/job.yaml, replace <username>, apply, and watch π get computed:
kubectl apply -n nrp-training-k8s -f yamls/job.yaml
kubectl get jobs -n nrp-training-k8skubectl wait --for=condition=complete job/pi-<username> -n nrp-training-k8s --timeout=180ssleep 5
kubectl logs -n nrp-training-k8s -l job-name=pi-<username>Computing 2000 digits of π takes 50–120s of CPU, so kubectl wait holds until the Job reports complete before we read the logs.
Expected output (after 50–120s of CPU work)
3.14159265358979323846264338327950288419716939937510582097494459230781640628620…
NAME STATUS COMPLETIONS DURATION AGE
pi-<username> Complete 1/1 53s 57sThe Job auto-deletes 10 minutes after completion (ttlSecondsAfterFinished: 600).
Hands-on: exposing a service over HTTPS
To expose an HTTP application publicly you need three objects: a Deployment (runs the pods), a Service (stable in-cluster name), and an Ingress on the haproxy class that routes a public hostname to the Service. NRP runs HAProxy as the ingress controller, and Cert Manager issues a free Let's Encrypt TLS certificate automatically for any *.nrp-nautilus.io hostname.
Open yamls/ingress-demo.yaml and replace every <username> (the hostname hello-<username>.nrp-nautilus.io must be globally unique):
kubectl apply -n nrp-training-k8s -f yamls/ingress-demo.yamlkubectl get deploy,svc,ingress -n nrp-training-k8s -l k8s-app=hello-web-<username>Wait ~60 seconds for HAProxy and the certificate, then:
curl -sI https://hello-<username>.nrp-nautilus.io | head -5Expected output
HTTP/2 200
server: nginx/1.29.1
content-type: text/plainOpen the URL in your browser and reload a few times — the Server name line cycles between the two replicas. Clean up (this releases the public hostname):
kubectl delete -n nrp-training-k8s -f yamls/ingress-demo.yamlScheduling: labels, affinity, taints, and tolerations
NRP is a heterogeneous shared cluster — 500+ nodes, many GPU SKUs, and pools reserved for specific projects. Scheduling primitives are how you say "put my pod here, not there":
| Primitive | Lives on | Asks the question |
|---|---|---|
| Node label | Node | "What is this node? (GPU type, region, owner…)" |
nodeSelector / nodeAffinity | Pod | "Which nodes am I willing to land on?" |
| Taint | Node | "Who is allowed to land here?" |
| Toleration | Pod | "I have permission to land on those tainted nodes." |
Labels + affinity are an attraction; taints + tolerations are a repulsion. You usually need both: a toleration to be allowed onto a reserved node, plus an affinity rule so the scheduler actually picks it.
The PEARC26 reserved GPU pool
For the tutorial, NRP has a pool of NVIDIA A10 GPU nodes reserved:
- Label
nrp-training=true— marks the tutorial nodes. - Taint
nautilus.io/reservation=nrp:NoSchedule— keeps other workloads off them.
Explore the pool:
kubectl get nodes -l nrp-training=true -L nvidia.com/gpu.productkubectl get nodes -l nrp-training=true \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.taints}{"\n"}{end}'Expected output
NAME STATUS ROLES AGE VERSION GPU.PRODUCT
hcc-nrp-shor-c5825.unl.edu Ready <none> 3y300d v1.33.12 NVIDIA-A10
hcc-nrp-shor-c5905.unl.edu Ready <none> 3y300d v1.33.8 NVIDIA-A10
…
hcc-nrp-shor-c5825.unl.edu [{"effect":"NoSchedule","key":"nautilus.io/reservation","value":"nrp"}, …]To land on the pool, a pod spec needs both blocks — this pattern appears in every GPU manifest in the workspace:
spec:
tolerations:
- key: nautilus.io/reservation
operator: Equal
value: nrp
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: nrp-training
operator: In
values: ["true"]preferred… is a soft hint — the scheduler picks a reserved node if one is free but won't strand your pod if all are busy. The required… variant blocks scheduling until a matching node frees up. Beyond this tutorial the same pattern targets specific GPU models (nvidia.com/gpu.product=NVIDIA-A100-PCIE-40GB), CUDA versions (nvidia.com/cuda.runtime.major=12), or regions (topology.kubernetes.io/region=us-west).
Hands-on: your first GPU pod
yamls/gpu-pod.yaml requests one GPU via resource limits:
resources:
limits:
nvidia.com/gpu: 1
requests:
nvidia.com/gpu: 1Resource keys by hardware type:
- NVIDIA GPUs (generic):
nvidia.com/gpu: <count> - Qualcomm Cloud AI 100:
qualcomm.com/qaic: <count>— Nautilus has 8 Cloud AI 100 Ultra cards × 4 SoCs = 32 devices; each runs LLMs up to ~25B parameters - Specific products:
nvidia.com/a100,nvidia.com/rtxa6000, etc. — see GPU pods docs
Launch it, exec in, and run nvidia-smi:
kubectl apply -n nrp-training-k8s -f yamls/gpu-pod.yaml
kubectl get pods -n nrp-training-k8skubectl exec -it tutorial-<username>-gpu-pod -n nrp-training-k8s -- nvidia-smiExpected output
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.126.09 Driver Version: 580.126.09 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| 0 NVIDIA A10 On | 00000000:06:00.0 Off | 0 |
+-----------------------------------------+------------------------+----------------------+Important — GPUs are scarce shared resources. Delete the pod as soon as you're done:
kubectl delete pod tutorial-<username>-gpu-pod -n nrp-training-k8sSame operations from Python: the Kubernetes API
kubectl is just a client for the Kubernetes REST API — every get, apply, and delete you ran is an HTTP call to the API server. Anything you can do with kubectl, you can do from code, which is how automation, CI pipelines, dashboards, and operators drive a cluster. The official kubernetes Python client is the most common way to do it.
Install it once (a small, ~20-second pure-Python install):
pip install --quiet kubernetesThe script below does a full round-trip — list → create → wait → read logs → delete — against your namespace, entirely from Python. It reuses the same kubeconfig kubectl uses, so you're already authenticated; and it builds the Pod out of Python objects (V1Pod, V1Container, …) that map one-to-one to the YAML fields you saw earlier.
import os, time
from kubernetes import client, config
config.load_kube_config() # the same config kubectl uses — already authenticated
v1 = client.CoreV1Api()
ns = "nrp-training-k8s"
user = os.environ["NRP_USER"] # set by the ⚙️ setup cell
name = f"pyapi-{user}"
# LIST — like `kubectl get pods -n nrp-training-k8s`
for p in v1.list_namespaced_pod(ns).items:
print(p.metadata.name, "->", p.status.phase)
# CREATE — the same Pod you'd write in YAML, built as Python objects
pod = client.V1Pod(
metadata=client.V1ObjectMeta(name=name, labels={"app": "pyapi-demo"}),
spec=client.V1PodSpec(
restart_policy="Never",
tolerations=[client.V1Toleration(
key="nautilus.io/reservation", operator="Equal",
value="nrp", effect="NoSchedule")],
containers=[client.V1Container(
name="main", image="busybox:1.36",
command=["sh", "-c", "echo hello from the kubernetes python client"],
resources=client.V1ResourceRequirements(
requests={"cpu": "200m", "memory": "128Mi"},
limits={"cpu": "200m", "memory": "128Mi"}))]))
v1.create_namespaced_pod(ns, pod)
print("created", name)
# WAIT + READ LOGS — like `kubectl logs`
for _ in range(60):
if v1.read_namespaced_pod(name, ns).status.phase in ("Succeeded", "Failed"):
break
time.sleep(2)
# _preload_content=False returns the raw HTTP response, giving clean log text
resp = v1.read_namespaced_pod_log(name, ns, _preload_content=False)
print("logs:", resp.read().decode().strip())
# DELETE — like `kubectl delete pod`
v1.delete_namespaced_pod(name, ns)
print("deleted", name)Same three verbs (list / create / delete), same objects, just expressed in Python instead of YAML + kubectl. Every resource type has a matching API group — CoreV1Api for pods/services/configmaps, AppsV1Api for deployments, BatchV1Api for jobs — so the patterns from the whole morning carry straight over into code.
End of episode — cleanup
kubectl delete pod test-pod-<username> -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/multicontainer.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/pvc.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/configmap-secret.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/deployment.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/job.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete -f yamls/ingress-demo.yaml -n nrp-training-k8s --ignore-not-found
kubectl delete pod tutorial-<username>-gpu-pod -n nrp-training-k8s --ignore-not-foundThen verify: bash check.sh 2 in the workspace (or the last cell of the notebook).
cat /data/log.txt after the delete/re-apply cycle.kubectl get secret … | base64 -d printed your token in plain text. Is a Secret encrypted?get secret in the namespace can read the value — protect Secrets with RBAC and separate namespaces.https://hello-<you>.nrp-nautilus.io took three objects. Which set, doing what?haproxy class maps the hostname to the Service, which load-balances across the Deployment's pods — and Cert Manager issued the Let's Encrypt certificate without you asking.preferredDuringScheduling… affinity for the reserved pool. Every reserved node is busy — what happens?preferred… trades placement for availability; the required… variant would leave your pod Pending until a matching node frees up. Pick per how strict your hardware needs are.