View on GitHub

Containers & Kubernetes Tutorial

Lab VMs on Azure — one ready-made Linux machine per student

Terraform that provisions the cohort’s lab fleet: one Ubuntu 24.04 VM per student (plus one for the instructor), fully configured on first boot with everything labs/lab00-setup.md asks for, the course repo cloned and the lab images pre-pulled. Students receive an SSH line and a password; Lab 00 becomes “verify and go”.

                 ┌───────────────── <prefix>-containers-lab-rg ─────────────────┐
 laptop ── ssh ──┤  NSG (22, 80, 443, 8080, 30000-32767 from allowed CIDRs)      │
                 │  vnet 10.60.0.0/16 / subnet vms 10.60.1.0/24                  │
                 │  ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
                 │  │ ana-s      │ │ joao-m     │ │ maria-p    │ │ instructor │ │
                 │  │ D4s_v5     │ │ D4s_v5     │ │ D8s_v5     │ │ D8s_v5     │ │
                 │  │ pip + DNS  │ │ pip + DNS  │ │ pip + DNS  │ │ pip + DNS  │ │
                 │  └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
                 │  auto-shutdown 21:00 Lisbon · Premium SSD 64 GiB · cloud-init │
                 └───────────────────────────────────────────────────────────────┘

Why cloud-init (and not a provisioner or a bash script over SSH)

  cloud-init (custom_data) remote-exec / SSH script Custom Script Extension
Needs Terraform to reach the VM over SSH no yes (NSG, keys, retries) no
Runs before anyone logs in, survives a Terraform crash yes no yes
Logs /var/log/cloud-init-output.log Terraform output only extension logs, awkward
Re-runnable by hand sudo /usr/local/sbin/lab-setup.sh re-apply with taint re-deploy extension
Works with 30 VMs in parallel yes, the VM does the work Terraform holds 30 SSH sessions yes

So: cloud-init.yaml carries a small #cloud-config (packages, a few config files) and writes the real provisioning logic as a normal bash script (/usr/local/sbin/lab-setup.sh) which it then runs once. You get the best of both — first-boot automation with no inbound dependency, and a plain script you can read, re-run or adapt. It deliberately mirrors labs/setup.sh (the students’ own install path) so both stay in sync.

What the first boot does (≈ 6–9 minutes on a D4s_v5):

  1. Docker Engine 28+, buildx, compose from the official apt repo; student in the docker group; BuildKit + log rotation in daemon.json; inotify limits for kind.
  2. kubectl (pkgs.k8s.io channel kubectl_channel), kind (latest), helm, k9s, kubectx/kubens, bash completion, k alias.
  3. Clones the course repo into ~/docker-kubernetes-training (variable course_repo_url), creates ~/pinboard-labs.
  4. Pre-pulls the images used in Labs 01–05 (postgres:17-alpine, nginx:1.28-alpine, alpine, busybox, golang:1.25-alpine, distroless, metrics-server) and warms the kind node image by creating and deleting a throw-away cluster.
  5. On the instructor VM only: pre-builds pinboard-api:1.0/1.1/1.2-broken and pinboard-web:1.0.
  6. Writes /var/lib/lab-setup/status = READY (or FAILED: <step>), shown in the MOTD at login.

Usage

cd infra/azure-lab-vms
az login && az account set --subscription <id>

cp terraform.tfvars.example terraform.tfvars   # edit: prefix, students, your SSH public key
terraform init
terraform plan -out lab.tfplan
terraform apply lab.tfplan

terraform output vms                           # host names + ssh commands
column -s, -t < out/<prefix>-credentials.csv   # one line per student (git-ignored)
./scripts/status.sh                            # READY / RUNNING: … / FAILED: … per VM

Then follow scripts/send-credentials.md to hand out access.

During the course

Need Command
Start every VM before a session (they auto-shutdown at 21:00) ./scripts/start-all.sh
Stop paying between sessions ./scripts/start-all.sh stop
Add a late student add a key to students, terraform apply — only the new VM is created
A student broke their machine terraform taint 'azurerm_linux_virtual_machine.vm["ana-s"]' && terraform apply → fresh VM, same DNS name, same password
Help a student ssh student@<prefix>-<handle>.<region>.cloudapp.azure.com with your key
See why provisioning failed ssh … 'sudo tail -50 /var/log/cloud-init-output.log' then sudo /usr/local/sbin/lab-setup.sh to retry
End of course terraform destroy

Opening the browser on a student’s Pinboard

Labs say http://localhost / http://localhost:8080. On a remote VM students have two options, both documented in labs/lab00-setup.md (Step 01 now covers the VM case):

Sizing and cost

Item Default Note
VM Standard_D4s_v5 (4 vCPU, 16 GiB) a 3-node kind cluster + builds + ingress + metrics-server stays under 8 GiB; 2 vCPU is noticeably slow on builds
Disk Premium SSD 64 GiB node image ≈ 1 GiB, build caches, many image tags
Region westeurope change location; DNS suffix changes accordingly
Auto-shutdown 21:00 Lisbon auto_shutdown_time = "" to disable

Rough West Europe list price (2026): D4s_v5 ≈ €0.19/h, P6 disk ≈ €10/month, public IP ≈ €3/month. A 12-student cohort (13 VMs) running only during the 10 sessions plus homework (say 40 h each) costs ≈ €100 of compute + ≈ €170 of disks/IPs if the fleet lives one month. ./scripts/start-all.sh stop between sessions is what keeps the compute part small.

Security notes

Files

infra/azure-lab-vms/
├── versions.tf            providers (azurerm ~> 4, random, local), optional azurerm backend
├── variables.tf           prefix, students, sizes, NSG CIDRs, repo, auto-shutdown
├── main.tf                RG, VNet/subnet/NSG, per-VM IP/NIC/VM (for_each), shutdown schedule, credentials CSV
├── outputs.tf             vms (fqdn/ssh), passwords (sensitive), credentials_csv
├── cloud-init.yaml        #cloud-config template → writes and runs /usr/local/sbin/lab-setup.sh
├── terraform.tfvars.example
└── scripts/               status.sh · start-all.sh · send-credentials.md