Introduction
A managed Kubernetes cluster from the Lightspeed Cloud Kubernetes Service, attached to a VPC tier. The service provisions and maintains the node VMs; this architecture wraps them in the same network structure as RA-03 through RA-05, so the cluster gets a router-enforced ACL, the ability to add tiers later, and a clean split between the management path and the application path.
Three control plane nodes give the API and etcd a quorum, so any single control node can fail or be upgraded without taking the API down. Two workers carry the applications, behind an ingress controller published through a load balancer rule. Worker count can be changed after creation without any change to this design.
This suits teams who have outgrown docker-compose on a single VM, or an RA-05 style platform, and want conformant Kubernetes without operating kubeadm themselves. It also suits agencies running many small applications for different clients on one cluster, and anyone who wants the deployment tooling ecosystem (Helm, ArgoCD, plain manifests) against a standard conformant API.
What you deploy
- One VPC (
k8s,10.40.0.0/16) onvpc-std-v1. - One network tier (
k8s-cluster,10.40.10.0/24) onnet-vpctier-std-v1, governed by network ACLk8s-cluster-acl. - One CKS cluster (
prod01, Kubernetes 1.35.4): three control plane nodes and two workers, all ons-medium-gen2, provisioned and lifecycle-managed by the service. - Public IP A - management: the Kubernetes API on 6443 and SSH port forwards from 2222 up, one per node. This is the VPC's source NAT address, where CKS publishes its rules.
- Public IP B - application traffic: load balancer rules
80 → 30080and443 → 30443across the worker pool, on the one address this build acquires.
Why two public IPs
Same argument as RA-05. Public IP A is the management plane: 6443 and SSH, restricted to your admin range at the ACL. Public IP B is the traffic plane: anonymous internet HTTPS. Separating them keeps each IP to a single-purpose rule set, and a DDoS against your applications does not sit on the same rules that carry your kubectl session.
As in RA-05, only one address is acquired. CKS publishes the API and SSH rules on the VPC's source NAT address, which the VPC already holds - that address becomes Public IP A. Public IP B is acquired for the application load balancer.
The split also states the failure argument this architecture is built on, which is why it sits on the diagram legend. Losing one control node does not stop the API - the load balancer health checks route around it and etcd keeps quorum. Losing the entire control plane stops scheduling, kubectl and reconciliation, but pods already running on the workers keep serving through Public IP B. The management path and the request path fail independently, which is the property the whole series is built around.
Architecture
The management path
The operator uses kubectl and SSH against public IP A. The Kubernetes API is published on 6443 across the control plane, so a control node can drop out without breaking kubectl. SSH is published as port forwards from 2222 up, one per node, all landing on port 22 inside the tier - log in as user cloud with the SSH key pair named at cluster creation.
Once the cluster is provisioned, the kubeconfig for this API endpoint is available for download on the cluster's detail page in the portal - see accessing the cluster - and from the getKubernetesClusterConfig API, as the CloudMonkey section shows. That file is the credential for the management path: distribute it as deliberately as you would an SSH key.
The traffic path
End users reach public IP B. Load balancer rules send 80 and 443 to NodePorts 30080 and 30443 on the workers, where the ingress controller terminates the request and routes it by hostname to the right Service. The VPC router applies the tier's ingress ACL on the way in.
In-tier traffic
The whole cluster lives in the one k8s-cluster tier, so scheduling, etcd replication and kubelet traffic never leave the tier - and, as RA-02 explains, traffic between VMs in the same tier never reaches the VPC router. The ACL governs what enters and leaves the cluster, not what the cluster says to itself. Isolation between workloads inside the cluster is Kubernetes' job, via NetworkPolicy.
Resource plan
| Resource | Value | Notes |
|---|---|---|
| Zone | au-mel01 | |
| VPC | k8s, 10.40.0.0/16 | Offering vpc-std-v1 |
| Network tier | k8s-cluster, 10.40.10.0/24 | Offering net-vpctier-std-v1, ACL k8s-cluster-acl |
| CKS cluster | prod01, Kubernetes 1.35.4 | 3 control nodes, 2 workers |
| Control plane nodes | 3x s-medium-gen2 | 2 vCPU / 8 GB each. kube-apiserver, etcd (stacked), scheduler, controller-manager |
| Worker nodes | 2x s-medium-gen2 | 2 vCPU / 8 GB each. kubelet, containerd, ingress controller |
| Node root disks | 32 GB (noderootdisksize) | Container images and logs live here. Optional; defaults to the CKS template's disk size |
| SSH key pair | ops-key | Passed to CKS at creation and installed on every node; the private half is your node login |
| Public IP A | Management | The VPC's source NAT address. 6443 to the control plane and SSH 2222+ per node, rules created by CKS |
| Public IP B | Load balancer | 80 → 30080, 443 → 30443 across the worker pool |
| Public IPs | 2 | The source NAT address, held by the VPC automatically, plus one acquired for the load balancer |
| Admin source range | 203.0.113.4/32 | Placeholder - substitute your own |
control01 at 10.40.10.11 and so on show the shape of the cluster; CKS names and addresses the node VMs itself. Check the cluster detail page for the real values after creation.Every node uses the same compute offering deliberately, so the portal build and the scripted builds below produce the same cluster. CloudStack 4.22 can size node types differently via the nodeofferings map - see the note in the CloudMonkey section - but a reference architecture should produce the same result every time it is built, whichever tool built it.
Check these against your account's resource limits before you start.
Network ACL plan
One ACL, k8s-cluster-acl. Rules are numbered in bands so there is room to insert without renumbering, and egress uses a separate band from ingress because rule numbers are unique per ACL list, not per direction. Anything not matched is denied.
| # | Direction | Protocol | Port | Source or destination | Why |
|---|---|---|---|---|---|
| 100 | Ingress | TCP | 6443 | 203.0.113.4/32 | Kubernetes API via Public IP A |
| 110 | Ingress | TCP | 22 | 203.0.113.4/32 | Node SSH (arrives via port forwards 2222+) |
| 120 | Ingress | TCP | 30080 | 0.0.0.0/0 | Load balancer to the ingress NodePort, HTTP |
| 130 | Ingress | TCP | 30443 | 0.0.0.0/0 | Load balancer to the ingress NodePort, HTTPS |
| 140 | Ingress | All | All | 10.40.10.0/24 | Intra-cluster traffic that traverses the router |
| 200 | Egress | All | All | 0.0.0.0/0 | Image pulls, CKS bootstrap, updates |
Rule 140 is mostly precautionary: VM-to-VM traffic inside the tier never reaches the router, so the rule matters only for tier-addressed traffic that originates at the router, such as load balancer health checks.
egressdefaultpolicy=true): a custom ACL list replaces that default behaviour rather than adding to it, so without rule 200 the cluster cannot pull an image.Design decisions
Why a VPC tier and not an isolated network
CKS runs on an isolated network without any of this structure, and will provision API access for you there. The reason this architecture uses a VPC tier is the same reason RA-03 exists: the ACL at the virtual router, and the ability to add tiers later without re-platforming. When the cluster eventually needs a database VM that should not be a pod, it lands in an adjacent k8s-data tier with its own ACL, exactly as RA-03 places db01.
Why three control nodes
etcd needs a quorum, and a quorum of one is a single point of failure that also goes down for every upgrade. Two is worse than one for etcd - two nodes cannot form a majority if either fails. Three is the minimum that tolerates one node being down, whether from failure or planned maintenance. This matches the standing recommendation on the Kubernetes Service page: dev/test clusters can run one control node, production runs at least three.
Why an explicit load balancer to fixed NodePorts, not Service type LoadBalancer
The CloudStack cloud provider inside the cluster can create load balancer rules on your behalf when a Service of type LoadBalancer is declared, but the behaviour depends on the cluster's network type and release. This architecture instead pins the ingress controller to NodePorts 30080 and 30443 and declares the load balancer rules in the plan, because a reference architecture should produce the same result every time it is built. If you later verify the in-cluster provider works against your VPC tier, adopting it is an additive change, not a rebuild.
Stateful workloads
This architecture makes no assumption about persistent volume provisioning. Treat the cluster as stateless: databases belong on a VM in an adjacent tier (the RA-03 pattern), and object storage belongs in the S3 service. If you need in-cluster PersistentVolumes backed by cloud volumes, that is a storage driver decision to make deliberately, not a default - CKS exposes it as the enablecsi option at cluster creation.
Portal walkthrough
- Register your SSH key. Compute, SSH Key Pairs, Create SSH key pair. Name it
ops-keyand paste your public key. CKS installs it on every node it builds, for usercloud. - Create the VPC. Network, VPC, Add VPC. Name
k8s, CIDR10.40.0.0/16, offeringvpc-std-v1, zoneau-mel01. - Create the ACL before the tier. Open the VPC, Network ACL Lists, Add ACL List. Name
k8s-cluster-acl, then add the six rules from the plan above.Create the ACL first and attach it when you create the tier. A tier created without one gets no usable ACL: through the API the tier is left with no ACL list at all (ingress blocked; egress open only because the tier offerings setegressdefaultpolicy=true), and the portal steers you to the built-indefault_deny. CKS refuses to build on adefault_denytier, and the refusal surfaces as a genericHTTP 530 Internal error- the real message appears only in the management server log. - Create the tier. Within the VPC, add network tier
k8s-cluster, gateway10.40.10.1, netmask255.255.255.0, ACLk8s-cluster-acl. - Create the cluster. Containers, Kubernetes, Create Cluster. Name
prod01, Kubernetes version1.35.4, control node count3and worker count2, both with offerings-medium-gen2, node root disk 32 GB, SSH key pairops-key, network: thek8s-clustertier. Creation takes a few minutes while the VMs provision and the cluster bootstraps. - Download the kubeconfig from the cluster detail page and confirm
kubectl get nodesshows five nodesReady. If kubectl times out, do step 7 first - the API is only reachable once the 6443 rule exists. - Check what CKS published. CKS publishes the API and SSH rules on the VPC's source NAT address - that address is Public IP A. Reconcile it against the plan: 6443 to the control plane (a load balancer rule across the three control nodes where the release creates one, otherwise a port forward) and SSH 2222 and up, one per node. Add anything missing rather than duplicating.
- Acquire Public IP B on the
k8s-clustertier and create two load balancer rules:80 → 30080and443 → 30443, algorithm round robin, assigned to the two worker VMs. - Deploy an ingress controller pinned to those NodePorts, for example ingress-nginx with
service.type=NodePort,nodePorts.http=30080,nodePorts.https=30443.
Deploy with CloudMonkey
Same requirements as RA-01. The script builds everything up to and including the cluster, then the application load balancer on Public IP B. Public IP A is deliberately not scripted - CKS publishes those rules on the source NAT address itself; reconcile them per step 7 of the walkthrough.
#!/usr/bin/env bash
# ra06-kubernetes-vpc.sh - deploy RA-06
set -euo pipefail
ADMIN=203.0.113.4/32 # your admin CIDR
K8S_VERSION=1.35.4
# 1. Register your SSH key
cmk register sshkeypair name=ops-key publickey="$(cat ~/.ssh/id_ed25519.pub)"
# 2. Create the VPC
ZONE=$(cmk list zones name=au-mel01 filter=id | jq -r '.zone[0].id')
VPCOFF=$(cmk list vpcofferings name=vpc-std-v1 filter=id | jq -r '.vpcoffering[0].id')
VPC=$(cmk create vpc name=k8s displaytext=k8s zoneid=$ZONE \
cidr=10.40.0.0/16 vpcofferingid=$VPCOFF | jq -r '.vpc.id')
# 3. Create the ACL and its rules. CKS refuses to build on a tier using the
# default_deny ACL (as a generic HTTP 530), so the custom ACL is created
# first and attached to the tier at creation.
ACL=$(cmk create networkacllist name=k8s-cluster-acl \
description="CKS cluster tier ACL" vpcid=$VPC | jq -r '.networkacllist.id')
rule () { # number ingress|egress proto startport endport cidr
cmk create networkacl aclid=$ACL number=$1 action=allow traffictype=$2 \
protocol=$3 startport=$4 endport=$5 cidrlist=$6
}
rule 100 ingress tcp 6443 6443 $ADMIN # Kubernetes API via public IP A
rule 110 ingress tcp 22 22 $ADMIN # node SSH, arrives via forwards on 2222+
rule 120 ingress tcp 30080 30080 0.0.0.0/0 # LB to the ingress NodePort, HTTP
rule 130 ingress tcp 30443 30443 0.0.0.0/0 # LB to the ingress NodePort, HTTPS
cmk create networkacl aclid=$ACL number=140 action=allow traffictype=ingress \
protocol=all cidrlist=10.40.10.0/24 # intra-cluster via the router
cmk create networkacl aclid=$ACL number=200 action=allow traffictype=egress \
protocol=all cidrlist=0.0.0.0/0 # image pulls, CKS bootstrap
# 4. Create the tier, with the ACL attached at creation
TIEROFF=$(cmk list networkofferings name=net-vpctier-std-v1 filter=id | jq -r '.networkoffering[0].id')
TIER=$(cmk create network name=k8s-cluster displaytext=k8s-cluster zoneid=$ZONE \
networkofferingid=$TIEROFF vpcid=$VPC gateway=10.40.10.1 netmask=255.255.255.0 \
aclid=$ACL | jq -r '.network.id')
# 5. Create the cluster
K8SVER=$(cmk list kubernetessupportedversions | jq -r --arg v "$K8S_VERSION" \
'.kubernetessupportedversion[] | select(.semanticversion==$v) | .id')
SVCOFF=$(cmk list serviceofferings name=s-medium-gen2 filter=id | jq -r '.serviceoffering[0].id')
CLUSTER=$(cmk create kubernetescluster name=prod01 description="Production CKS" \
zoneid=$ZONE kubernetesversionid=$K8SVER serviceofferingid=$SVCOFF \
controlnodes=3 size=2 noderootdisksize=32 keypair=ops-key \
networkid=$TIER | jq -r '.kubernetescluster.id')
# 6. The kubeconfig - the same file the portal offers on the cluster detail
# page. configdata is the plain-text kubeconfig. The call fails with "config
# not available at this moment" while the cluster is still Starting.
cmk get kubernetesclusterconfig id=$CLUSTER \
| jq -r '.clusterconfig.configdata' > prod01.kubeconfig
# 8. Public IP B and the application load balancer
IPB=$(cmk associate ipaddress vpcid=$VPC networkid=$TIER | jq -r '.ipaddress.id')
lb () { # publicport nodeport name
cmk create loadbalancerrule name=$3 description=$3 \
publicipid=$IPB networkid=$TIER algorithm=roundrobin protocol=tcp \
publicport=$1 privateport=$2 | jq -r '.loadbalancer.id'
}
LBHTTP=$(lb 80 30080 ingress-http)
LBHTTPS=$(lb 443 30443 ingress-https)
# CKS names the node VMs itself. List them, then assign the two workers.
cmk list virtualmachines networkid=$TIER filter=id,name
cmk assign toloadbalancerrule id=$LBHTTP virtualmachineids=<worker01-id>,<worker02-id>
cmk assign toloadbalancerrule id=$LBHTTPS virtualmachineids=<worker01-id>,<worker02-id>
masternodes is deprecated in favour of controlnodes - the 4.22 API's own parameter description says so. All five nodes here use one serviceofferingid deliberately; to size node types differently, 4.22 takes a nodeofferings map (nodeofferings[0].node=worker nodeofferings[0].offering=<id> - the second key is offering, not the serviceoffering the upstream CKS documentation shows), with serviceofferingid as the fallback for unmapped node types. Run cmk sync and check cmk create kubernetescluster -h before scripting this.Deploy with Ansible
Same requirements as RA-01.
ngine_io.cloudstack 3.x. Every module was renamed in 3.0.0 - the cs_ prefix was dropped. Note the --upgrade flag: several distributions bundle 2.5.0, and a plain install will see it and report "Nothing to do".ansible-galaxy collection install ngine_io.cloudstack --upgrade
pip install 'cs>=3.4.0' sshpubkeys
export CLOUDSTACK_ENDPOINT=https://cloud.lightspeedhosting.com.au/client/api
export CLOUDSTACK_KEY=<api key>
export CLOUDSTACK_SECRET=<secret key>
The collection covers the SSH key, VPC, ACL and tier, but has no module for CKS clusters, so the cluster step shells out to cmk behind a guard on the error output - re-runs tolerate an existing cluster, but there is no true state comparison.
---
# ra06-kubernetes-vpc.yml - deploy RA-06
- name: RA-06 Kubernetes Service in a VPC tier
hosts: localhost
connection: local
gather_facts: false
vars:
zone: au-mel01
admin_cidr: 203.0.113.4/32 # your admin CIDR
tier_cidr: 10.40.10.0/24
acl_rules:
# Ingress - the 100 band
- {n: 100, t: ingress, p: tcp, s: 6443, e: 6443, cidr: "{{ admin_cidr }}"}
- {n: 110, t: ingress, p: tcp, s: 22, e: 22, cidr: "{{ admin_cidr }}"}
- {n: 120, t: ingress, p: tcp, s: 30080, e: 30080, cidr: 0.0.0.0/0}
- {n: 130, t: ingress, p: tcp, s: 30443, e: 30443, cidr: 0.0.0.0/0}
- {n: 140, t: ingress, p: all, cidr: "{{ tier_cidr }}"}
# Egress - the 200 band, because numbers are unique per ACL LIST, not
# per direction, and an egress rule reusing an ingress number silently
# rewrites it.
- {n: 200, t: egress, p: all, cidr: 0.0.0.0/0}
tasks:
- name: Register SSH key
ngine_io.cloudstack.ssh_key:
name: ops-key
public_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
- name: VPC
ngine_io.cloudstack.vpc:
name: k8s
cidr: 10.40.0.0/16
vpc_offering: vpc-std-v1
zone: "{{ zone }}"
# The ACL list first - CKS refuses to build on a tier using default_deny,
# which is where a tier created without an ACL can end up.
- name: Network ACL list
ngine_io.cloudstack.network_acl:
name: k8s-cluster-acl
vpc: k8s
zone: "{{ zone }}"
- name: Network ACL rules
ngine_io.cloudstack.network_acl_rule:
network_acl: k8s-cluster-acl
vpc: k8s
zone: "{{ zone }}"
rule_position: "{{ item.n }}"
traffic_type: "{{ item.t }}"
protocol: "{{ item.p }}"
start_port: "{{ item.s | default(omit) }}"
end_port: "{{ item.e | default(omit) }}"
cidrs: ["{{ item.cidr }}"]
action_policy: allow
loop: "{{ acl_rules }}"
- name: Tier
ngine_io.cloudstack.network:
name: k8s-cluster
vpc: k8s
network_offering: net-vpctier-std-v1
zone: "{{ zone }}"
gateway: 10.40.10.1
netmask: 255.255.255.0
acl: k8s-cluster-acl
register: tier
# No collection module exists for CKS clusters, so this shells out.
# zone_id, k8s_version_id and offering_id are UUIDs - resolve them with
# the cmk list lookups from the CloudMonkey section and pass them in
# with -e, or register them from api_request lookups.
- name: CKS cluster (no collection module - via cmk)
ansible.builtin.command: >
cmk create kubernetescluster name=prod01 description="Production CKS"
zoneid={{ zone_id }} kubernetesversionid={{ k8s_version_id }}
serviceofferingid={{ offering_id }} controlnodes=3 size=2
noderootdisksize=32 keypair=ops-key
networkid={{ tier.id }}
register: cks
changed_when: cks.rc == 0
failed_when: cks.rc != 0 and 'already exists' not in (cks.stderr + cks.stdout)
ngine_io.cloudstack.api_request, a generic module that executes any CloudStack API call, so createKubernetesCluster can be driven without shelling out. It is explicitly not idempotent - it would need a listKubernetesClusters guard and a when, the same pattern RA-05 uses for its source NAT lookup.The play stops at the cluster deliberately. Public IP B and its load balancer rules need the worker VM names, and CKS generates those at creation time - assign them from the portal or CloudMonkey (walkthrough step 8) once the cluster reports Running.
Deploy with Terraform
Recent versions of the CloudStack Terraform provider include a Kubernetes cluster resource, so unlike Ansible the whole build to the cluster is declarative.
# ra06-kubernetes-vpc.tf - deploy RA-06
terraform {
required_providers {
cloudstack = {
source = "cloudstack/cloudstack"
version = "~> 0.6.0"
}
}
}
variable "api_url" { default = "https://cloud.lightspeedhosting.com.au/client/api" }
variable "api_key" { sensitive = true }
variable "secret_key" { sensitive = true }
variable "admin_cidr" { default = "203.0.113.4/32" }
provider "cloudstack" {
api_url = var.api_url
api_key = var.api_key
secret_key = var.secret_key
}
locals {
zone = "au-mel01"
tier_cidr = "10.40.10.0/24"
}
resource "cloudstack_ssh_keypair" "ops" {
name = "ops-key"
public_key = file("~/.ssh/id_ed25519.pub")
}
resource "cloudstack_vpc" "k8s" {
name = "k8s"
display_text = "k8s"
cidr = "10.40.0.0/16"
vpc_offering = "vpc-std-v1"
zone = local.zone
}
# The ACL is declared before the tier that references it. CKS refuses to
# build on a tier using the default_deny ACL - see the portal walkthrough.
resource "cloudstack_network_acl" "cluster" {
name = "k8s-cluster-acl"
description = "CKS cluster tier"
vpc_id = cloudstack_vpc.k8s.id
}
# One rule block per port, mirroring the numbered ACL plan exactly.
# 0.6.0 removed the plural `ports` list; it remains in the schema so validate
# and plan pass, and apply fails. Use singular `port`, and set rule_number
# explicitly because rules are created concurrently.
resource "cloudstack_network_acl_rule" "cluster" {
acl_id = cloudstack_network_acl.cluster.id
rule {
rule_number = 100
action = "allow"
cidr_list = [var.admin_cidr]
protocol = "tcp"
port = "6443"
traffic_type = "ingress"
}
rule {
rule_number = 110
action = "allow"
cidr_list = [var.admin_cidr]
protocol = "tcp"
port = "22"
traffic_type = "ingress"
}
rule {
rule_number = 120
action = "allow"
cidr_list = ["0.0.0.0/0"]
protocol = "tcp"
port = "30080"
traffic_type = "ingress"
}
rule {
rule_number = 130
action = "allow"
cidr_list = ["0.0.0.0/0"]
protocol = "tcp"
port = "30443"
traffic_type = "ingress"
}
rule {
rule_number = 140
action = "allow"
cidr_list = [local.tier_cidr]
protocol = "all"
traffic_type = "ingress"
}
rule {
rule_number = 200
action = "allow"
cidr_list = ["0.0.0.0/0"]
protocol = "all"
traffic_type = "egress"
}
}
resource "cloudstack_network" "cluster_tier" {
name = "k8s-cluster"
display_text = "k8s-cluster"
cidr = local.tier_cidr
gateway = "10.40.10.1"
network_offering = "net-vpctier-std-v1"
vpc_id = cloudstack_vpc.k8s.id
acl_id = cloudstack_network_acl.cluster.id
zone = local.zone
}
resource "cloudstack_kubernetes_cluster" "prod01" {
name = "prod01"
description = "Production CKS"
zone = local.zone
kubernetes_version = "1.35.4"
service_offering = "s-medium-gen2"
control_nodes_size = 3
size = 2
noderootdisksize = 32
keypair = cloudstack_ssh_keypair.ops.name
network_id = cloudstack_network.cluster_tier.id
}
terraform init
terraform validate
terraform apply -var api_key=... -var secret_key=...
cloudstack_kubernetes_cluster arguments against the provider version you pin before building on this.As in the Ansible version, Public IP B and its load balancer rules are left outside the configuration: cloudstack_loadbalancer_rule takes member_ids, and the worker VMs are created and named by CKS rather than by Terraform. Assign them from the portal or CloudMonkey (walkthrough step 8) once the cluster is Running.
Verify
Check the two paths independently - the failure argument only holds if each one is known to work on its own.
The management path
# Five nodes, all Ready, versions matching 1.35.4
kubectl get nodes
# CNI, kube-proxy and core components running on every node
kubectl get pods -n kube-system
# SSH to the first node - user is cloud, key is the ops-key pair
ssh -i ~/.ssh/id_ed25519 -p 2222 cloud@<public-ip-a>
Control node failure
Stop one control node VM from the portal, confirm kubectl get nodes still answers, start it again. This demonstrates the three-node quorum working, and it is worth observing once before relying on it during an upgrade. While you are in the portal, confirm the three control node VMs report different hypervisor hosts - see control plane placement under Day 2.
The traffic path
# Deploy the ingress controller, then a test workload with an Ingress
curl -H "Host: test.example.com" http://<public-ip-b>/
The split
From an address outside your admin range, confirm 6443 on Public IP A times out and 443 on Public IP B answers. That pair of results is the whole design working: management closed to the world, applications open to it.
Day 2 operations
- Scaling workers. Change the worker count on the cluster detail page - see scaling a cluster. New workers join the cluster automatically, but they do not join your load balancer rules: assign the new VM to both rules on Public IP B, and remove it before scaling down. This is the one place the explicit-LB decision costs you a manual step. CKS can also autoscale the worker pool (
scaleKubernetesClusterwithautoscalingenabled=true, applied after the cluster isRunning- 4.22 has no creation-time autoscaling parameters); leave it off in this design, because autoscaled workers join the cluster but never join the load balancer rules. - Control plane placement. CKS cannot apply anti-affinity groups at creation, so nothing guarantees the three control nodes land on different hypervisor hosts - and three on one host is a quorum lost to a single host failure. Check placement after creation, and if a pair shares a host, apply a host anti-affinity group by hand:
updateVMAffinityGrouprequires each VM to be stopped and restarted, so roll it one control node at a time and the API stays up throughout. Nodes added later by scaling do not inherit the group. - Upgrades. One minor version at a time (1.34.x → 1.35.x), rolled node by node from the portal - see upgrading Kubernetes versions. With three control nodes the API stays up throughout; schedule it anyway for anything customer-facing, because workloads reschedule as each worker drains.
- Node sizing. Moving nodes to a larger offering is supported and one-directional. To go smaller, scale the worker count down and redeploy workers at the smaller offering instead.
- Stop and start. A cluster you are not using can be stopped from the portal; state is preserved, workloads are unreachable while stopped. See stopping and starting a cluster.
- Backups. The control plane's etcd state is managed by the service, but your manifests are your responsibility - keep them in git and treat the cluster as rebuildable. Anything stateful lives outside the cluster by design in this architecture, so it is backed up wherever it actually lives.
- Monitoring. Watch 6443 on Public IP A from your admin range and 443 on Public IP B from anywhere. Those two checks cover the failure argument this architecture is built on: the first tells you the control plane is healthy, the second tells you your users are being served, and they are allowed to fail independently.