3.4. AKS Cluster
In this lab you will introduce a Pulumi ComponentResource that owns its own resource group and
models the full AKS cluster lifecycle, add node pool configuration to the stack config, deploy
a running Kubernetes cluster, and grant yourself admin access via a role assignment.
Step 1 — Create the Kubernetes Component
Create a new file kubernetes.py next to __main__.py:
Explanation
A ComponentResource is a Pulumi construct that groups related resources under a single logical
node. It has two responsibilities:
| Responsibility | Code |
|---|---|
| Register the component type | super().__init__("workshop:azure:Kubernetes", name, {}, opts) |
| Scope child resources | pulumi.ResourceOptions(parent=self) on every child resource |
parent=self tells Pulumi that the ResourceGroup, ManagedCluster, and every AgentPool
belong to this component. They appear indented underneath Kubernetes in pulumi up output,
and are destroyed together when the component is destroyed.
The resource group is created inside the component — it is an implementation detail, not a
caller concern. __main__.py just instantiates Kubernetes(f"aks-{suffix}") and the component
owns the rest. Node pools are added via add_node_pool, keeping the cluster definition stable
while pools can be added or changed independently.
node_resource_group explicitly names the resource group where AKS places node infrastructure
(VMs, disks, NICs). Without it Azure generates a name like MC_rg-lab_aks-lab_westeurope,
which is hard to predict. Pass it as a plain string, not a ResourceGroup Output — it is an
instruction to AKS to use that name when it creates the group on your behalf. Declaring it as a
Pulumi resource would cause a conflict because Azure would try to create the same group twice.
Three resource groups, three lifecycles
AKS uses two resource groups by design:
| Resource group | Contents | Managed by |
|---|---|---|
rg-{suffix} | VNet, subnet — shared networking | You |
rg-aks-{suffix} | ManagedCluster resource | You |
rg-nodes-aks-{suffix} | VMs, disks, NICs — node infrastructure | AKS |
The node resource group is owned and managed by AKS. Avoid modifying resources inside it directly — changes will be reconciled away by the control plane.
SystemAssigned identity
ResourceIdentityType.SYSTEM_ASSIGNED tells Azure to create and manage a service principal for
the cluster automatically. AKS uses it to pull images from ACR, write to disk, and communicate
with the Azure control plane — no manual credential rotation required.
Azure RBAC for Kubernetes
aad_profile with managed=True, enable_azure_rbac=True enables Azure RBAC on the cluster’s
Kubernetes API. Role assignments on the cluster resource then control who can kubectl — no
separate RoleBinding YAML required. Without this flag, role assignments are silently ignored
by the Kubernetes API server.
Step 2 — Add a Static Egress IP
Extend the Kubernetes constructor in kubernetes.py to allocate a static public IP for AKS outbound
traffic and wire it into the load balancer profile.
Add the IP resource after the resource group declaration:
Then update the network_profile inside ManagedCluster:
Finally, after the cluster declaration, grant the cluster’s managed identity Network Contributor on the egress IP:
Explanation
By default AKS routes outbound traffic through a randomly-assigned Azure-managed public IP. That address can change if the cluster is recreated, making it impossible to reliably whitelist in database firewall rules, NSGs, or third-party allowlists. A static IP resolves this: the egress address is known in advance and stable across redeployments.
The IP is created in the cluster’s own resource group (self.rg). It cannot go in the node resource group
because AKS creates that group during cluster provisioning — it does not exist yet when Pulumi evaluates
the constructor.
load_balancer_sku="standard" is required. The Basic SKU does not support custom outbound IP assignment;
Standard is the default for new clusters but must be stated explicitly when load_balancer_profile is
used. outbound_ips tells AKS to route all egress through your IP instead of its automatically
provisioned one.
The role assignment is necessary because the egress IP lives in rg-{name} (the cluster’s resource
group), not in the node resource group where AKS’s managed identity already holds Network Contributor.
Without it the cloud-controller-manager is denied Microsoft.Network/publicIPAddresses/join/action when
it tries to attach the IP to the load balancer. Microsoft documents this requirement explicitly: “if you
customized your outbound IP, make sure your cluster identity has permissions to both the outbound public
IP and the inbound public IP.”
Assigning to self.egress_ip exposes the resource as a component property. Callers can read
aks.egress_ip.ip_address to discover the address without reaching into Azure directly.
Step 3 — Add Node Pool Config
config/Pulumi.dev.yaml already exists with the provider keys from the previous lab. Append
the aks block under the existing config: key:
Explanation
Pulumi namespaces config keys under the project name (cloud-native-lab:aks) to avoid
collisions when multiple projects share a stack. config.require_object("aks") strips the
prefix and returns the value.
kubernetes_version pins the control-plane version explicitly. Without it, Azure selects a
default version that can change between regions and over time, making cluster behaviour
non-reproducible. Pinning ensures every pulumi up — regardless of who runs it or when —
produces a cluster at the same version, and forces version upgrades to be a deliberate config
change rather than a side-effect of redeployment.
availability_zones is a list of zone numbers ("1", "2", "3") applied to every node pool.
When a pool has more nodes than zones, AKS distributes them evenly; with a single node AKS picks
one zone from the list. Without this field, Azure chooses a zone implicitly — which zone is
unspecified and may differ between deployments. Pinning zones makes placement reproducible and
enables multi-zone clusters where workloads survive a zone failure.
The field sits at the aks level rather than inside each node entry because all pools in the same
cluster must use the same zone set — splitting pools across different zone sets would prevent
zone-aware scheduling from working correctly. Switzerland North supports zones 1, 2, and 3.
Moving pool parameters into config keeps the program code environment-agnostic. A staging stack
can run a single-node pool; production can define multiple pools — without touching __main__.py.
Step 4 — Wire the Component into __main__.py
Add the import and the cluster block after the subnet declaration:
Explanation
AKS requires at least one node pool at cluster creation time — it cannot be added after the
fact via AgentPool. The first entry in nodes is therefore passed as agent_pool_profiles
directly to ManagedCluster through the component’s **kwargs. Any additional entries
(nodes[1:]) are attached afterwards via add_node_pool.
nodes[0] is passed as a whole dict to system_pool. Inside the component, **system_pool
unpacks it so every key (name, count, vm_size, os_type, mode) is forwarded to
ManagedClusterAgentPoolProfileArgs. vnet_subnet_id is injected separately inside the
component because it is a cross-resource Output, not a static config value.
mode="System" marks this pool as the system pool. AKS requires exactly one system pool; it
runs core cluster components such as CoreDNS and the metrics server. User workloads should run
on separate user pools to keep system services isolated.
Step 5 — Deploy
The preview will show four new resources: the AKS resource group, the Kubernetes component,
the ManagedCluster, and the AgentPool. Confirm with yes to deploy.
Provisioning an AKS cluster takes several minutes. Once complete, verify with the Azure CLI:
Explanation
CLUSTER=$(pulumi stack output cluster_name) captures the deployed cluster name into a shell
variable. The component names the resource group rg-{cluster_name}, so the same variable
covers both arguments without any manual lookup.
Step 6 — Grant Cluster Admin Access
Add grant_role to the Kubernetes component in kubernetes.py:
Then call it from __main__.py:
Then run:
Explanation
grant_role takes a role definition UUID and a principal ID, then creates a RoleAssignment
scoped to the cluster. get_client_config_output() is called internally to retrieve the
subscription ID needed to build the full role definition resource path the Azure API requires —
the caller only needs to know the UUID.
get_client_config_output() in __main__.py returns the object ID of the currently
authenticated principal — the user who ran az login. Passing it to grant_role grants you
Azure Kubernetes Service RBAC Cluster Admin access on this cluster only.
Azure built-in role GUIDs are globally stable
Built-in role IDs are defined by Microsoft and are identical in every Azure tenant and
subscription worldwide — they never change between environments. The UUID passed to grant_role
is always the same regardless of who deploys or where. It is safe and intentional to hardcode
them. The authoritative list is at
Built-in roles for Azure RBAC.
Role scope: cluster, not subscription
The role assignment is scoped to the cluster resource ID, not the subscription. This follows
the principle of least privilege: your identity gets kubectl admin access on this cluster
only, not on every cluster in the subscription.
Step 7 — Verify Cluster Access
Fetch credentials using the Azure CLI:
Then verify that the role assignment grants access to the Kubernetes API:
Explanation
az aks get-credentials writes a kubeconfig context to ~/.kube/config. Because Azure RBAC
is enabled on the cluster, the Kubernetes API authenticates your request using your Entra ID
identity — the same one the role assignment was granted to. kubectl get pods -A lists all
pods across all namespaces; a successful response confirms both connectivity and authorization.
kubectl fails with an authentication error?
Some environments require kubelogin to perform the Entra ID token exchange. If kubectl get pods
returns an authentication error, install it and convert the kubeconfig:
–overwrite-existing
If a context for this cluster already exists in ~/.kube/config from a previous run,
--overwrite-existing replaces it silently instead of prompting.