This post is part of a series on deploying JupyterHub on Jetstream2:
- Deploy Kubernetes on Jetstream2
- Install Traefik Ingress Controller
- Deploy JupyterHub
- Setup HTTPS with cert-manager
Jetstream2 uses the Cluster API as the backend for Magnum, making it faster and more straightforward to launch Kubernetes clusters. This guide walks through creating a cluster, configuring autoscaling, and verifying the deployment.
Advantages of Magnum-Based Deployments
Magnum-based clusters offer several benefits over Kubespray:
- Faster Deployment: Instead of using Ansible to configure each VM, Magnum uses pre-prepared images. Clusters typically deploy in about 10 minutes, and workers can scale up in around 5 minutes.
- Load Balancer Integration: The OpenStack load balancer service provides easy support for multiple master nodes, ensuring high availability.
- Autoscaling: The Cluster Autoscaler can automatically add or remove worker nodes based on workload.
Prerequisites
Install OpenStack and Magnum Clients:
pip install python-openstackclient python-magnumclient python-octaviaclient python-designateclientThe OpenStack client creates and manages the cluster, the Magnum client manages cluster templates, the Octavia client manages load balancers, and the Designate client manages DNS records.
This tutorial used python-openstackclient 9.0.0, python-magnumclient 4.8.1, python-octaviaclient 3.14.0, and python-designateclient 6.3.0.
Create an App Credential: Create an application credential through Horizon under Identity → Application credentials. Choose “Unrestricted” and include all permissions, notably “loadbalancer”, in the project where you will create the cluster. Download the
openrcfile and source it:source app-cred-XXXX-openrc.shInstall Kubernetes Tooling: Once the cluster is launched, manage it using standard Kubernetes tools:
kubectl: see https://kubernetes.io/docs/tasks/tools/, this tutorial used 1.35.helm: see https://helm.sh/docs/intro/install/, this tutorial used 3.18.
Create the Cluster with Magnum
Check the available cluster templates:
openstack coe cluster template listClone the repository with all the configuration files:
git clone https://github.com/zonca/jupyterhub-deploy-kubernetes-jetstream
cd jupyterhub-deploy-kubernetes-jetstream/kubernetes_magnumCreate a cluster:
export K8S_CLUSTER_NAME=k8s
bash create_cluster.shThe script uses the kubernetes-1-33-jammy-fixed-labels template, creates 1 control-plane node and 1 worker (both m3.small flavor), enables autoscaling with min 1 / max 5 workers, and polls until the cluster status is CREATE_COMPLETE. Cluster creation typically takes about 10 minutes.
Template choice: We use the
kubernetes-1-33-jammy-fixed-labelstemplate rather thankubernetes-1-33-jammy. The default template deploys a Kubernetes dashboard app that is now defunct, which causes Magnum’s post-create bookkeeping to get stuck atCREATE_IN_PROGRESSeven though the cluster is fully functional. The-fixed-labelstemplate omits the dashboard and completes cleanly. Alternatively, you can use any template with--labels kube_dashboard_enabled=false.
Note: The first time you create a cluster (or after a long period of inactivity), deployment can take 2–2.5 hours, likely because the images are not cached in OpenStack. After that, clusters should deploy in about 10 minutes.
In case of errors, check the error message with:
openstack coe cluster show $K8S_CLUSTER_NAME -f json | jq '.status_reason'The cluster consumes resources when active. To delete it:
bash delete_cluster.shWarning: This deletes all Jetstream virtual machines and any data stored in JupyterHub.
Once the nodes are ready, retrieve the Kubernetes config file:
openstack coe cluster config $K8S_CLUSTER_NAME --force
export KUBECONFIG=$(pwd)/config
chmod 600 configVerify that kubectl commands work:
kubectl get nodesYou should see output like:
NAME STATUS ROLES AGE VERSION
k8s-wamiv264xiet-control-plane-p2zj6 Ready control-plane 8m22s v1.33.2
k8s-wamiv264xiet-default-worker-x8cxp-vj9db Ready <none> 5m45s v1.33.2
Check storage classes (Magnum provides Cinder-backed persistent volumes by default):
kubectl get storageclassNAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
default (default) cinder.csi.openstack.org Retain WaitForFirstConsumer true 12m
replicated-hdd cinder.csi.openstack.org Retain WaitForFirstConsumer true 12m
Autoscaling
The create_cluster.sh script creates the cluster with the label auto_scaling_enabled=true and max_node_count=5, which activates the Cluster Autoscaler. You can override the maximum before creating the cluster:
export MAX_NODE_COUNT=10
bash create_cluster.shAfter the cluster is created, verify the label on the nodegroup:
openstack coe nodegroup show $K8S_CLUSTER_NAME default-worker -c labels -c min_node_count -c max_node_count| Field | Value |
+----------------+--------------------------------------------------------------------------------+
| labels | {'auto_scaling_enabled': 'true', 'min_node_count': '1', 'max_node_count': '5'} |
| max_node_count | None |
| min_node_count | 1 |
Note that max_node_count on the nodegroup shows None even though the label is set to 5. This is a Magnum quirk — the nodegroup field and the label are separate. The autoscaler reads the label value, not the nodegroup field, so it is active and capped at 5 regardless. This means there is no need to run openstack coe nodegroup update to set max_node_count — the label set at cluster creation time is sufficient. To change the maximum, set MAX_NODE_COUNT before running create_cluster.sh.
Test Scale Up
Create a deployment that requests enough memory to trigger the autoscaler:
kubectl create -f high_mem_dep.yaml
kubectl scale deployment high-memory-deployment --replicas=6Each replica requests 4 GB of memory, so 6 replicas cannot fit on a single m3.small worker. The autoscaler detects the pending pods and adds nodes. Check the scale-up event:
kubectl get events --field-selector reason=TriggeredScaleUpLAST SEEN TYPE REASON OBJECT MESSAGE
2m19s Normal TriggeredScaleUp pod/high-memory-deployment-844964899f-ngldm pod triggered scale-up: [{MachineDeployment/.../k8s-...-default-worker 1->5 (max: 5)}]
Within a few minutes, new worker nodes appear:
kubectl get nodesNAME STATUS ROLES AGE VERSION
k8s-2yo5qznljser-control-plane-g7nk4 Ready control-plane 15m v1.33.2
k8s-2yo5qznljser-default-worker-m2pp7-2mq6z Ready <none> 2m40s v1.33.2
k8s-2yo5qznljser-default-worker-m2pp7-bc6jv Ready <none> 2m37s v1.33.2
k8s-2yo5qznljser-default-worker-m2pp7-kp28n Ready <none> 2m30s v1.33.2
k8s-2yo5qznljser-default-worker-m2pp7-t2nsg Ready <none> 15m v1.33.2
k8s-2yo5qznljser-default-worker-m2pp7-tzjdn Ready <none> 2m31s v1.33.2
The autoscaler scaled from 1 to 5 workers (the maximum set in the label). One pod remains pending because all 6 replicas cannot fit within the 5-worker limit.
Clean Up and Observe Scale Down
Delete the test deployment so the autoscaler can return the worker pool to its minimum size:
kubectl delete deployment high-memory-deploymentThe autoscaler takes several minutes to identify idle nodes, drain them, and terminate them. Watch the node count drop:
kubectl get nodes -wYou can inspect the autoscaler’s internal status to see whether a scale-down is in progress:
kubectl -n kube-system get configmap cluster-autoscaler-status -o jsonpath='{.data.status}'When scaleDown.status shows CandidatesPresent, the autoscaler has identified idle nodes and is draining them. Once the process completes, the cluster returns to 1 worker node.
Scale Manually
If you prefer not to use the autoscaler, you can scale the worker pool manually. Resize the nodegroup:
openstack coe cluster resize --nodegroup default-worker $K8S_CLUSTER_NAME 3Confirm the change:
kubectl get nodesIssues and Feedback
Please open an issue on the repository to report any issue or give feedback.