Terraform reference for the AKS cluster¶
The modules and Terragrunt stacks that built the cluster, its VNet, its routes and its peerings,
reproduced in full with identifiers replaced. A root root.hcl (not shown) generated the
azurerm provider block with subscription_id taken from an environment file, and an azurerm
state backend per environment.
Layout¶
terraform/azure
├── root.hcl # backend + generated provider (azurerm ~> 4.50)
├── _modules/
│ ├── aks/ # resource group, Log Analytics, cluster, subnet role assignments
│ ├── aks-routes/ # extra routes in the AKS-managed kubenet route table
│ ├── vnet/ # VNet, subnets, optional route tables, inline peerings
│ ├── vnet-peering/ # both legs of one peering
│ └── afwpolicy-rule-collection/ # rule collection group on an existing firewall policy
└── <env>/westeurope/
├── aks-vnet/ # vnet module
├── aks/ # aks module, depends on the VNet existing
├── aks-routes/ # aks-routes module, dependency on aks
├── aks-peering-hub/ # vnet-peering module, dependency on aks-vnet
├── aks-peering-{dev,tst,sdev}/
└── afwpolicy-rule-collection/ # VPN clients → Kafka load balancers
Apply order: aks-vnet, then aks and the peerings, then aks-routes.
aks module¶
The module creates its own resource group and Log Analytics workspace, looks up the subnets of an existing VNet, builds the cluster, and grants the cluster's system-assigned identity Network Contributor on the node, pod and internal-LB subnets. That grant is what lets AKS attach NICs and create internal load balancer frontends in a VNet it does not own.
resource "azurerm_resource_group" "this" {
name = var.resource_group_name
location = var.location
tags = var.common_tags
}
data "azurerm_subnet" "aks" {
name = var.aks_subnet_name
resource_group_name = var.vnet_resource_group
virtual_network_name = var.vnet_name
}
data "azurerm_subnet" "pods" {
name = var.pod_subnet_name
resource_group_name = var.vnet_resource_group
virtual_network_name = var.vnet_name
}
data "azurerm_subnet" "internal_lb" {
count = var.internal_lb_subnet_name != null ? 1 : 0
name = var.internal_lb_subnet_name
resource_group_name = var.vnet_resource_group
virtual_network_name = var.vnet_name
}
resource "azurerm_log_analytics_workspace" "aks" {
name = "${var.cluster_name}-logs"
location = var.location
resource_group_name = azurerm_resource_group.this.name
sku = "PerGB2018"
retention_in_days = 30
tags = var.common_tags
}
resource "azurerm_kubernetes_cluster" "this" {
name = var.cluster_name
location = var.location
resource_group_name = azurerm_resource_group.this.name
dns_prefix = var.cluster_name
kubernetes_version = var.kubernetes_version
private_cluster_enabled = var.private_cluster_enabled
private_dns_zone_id = var.private_dns_zone_id
private_cluster_public_fqdn_enabled = var.private_cluster_public_fqdn_enabled
default_node_pool {
name = "system"
node_count = var.node_count
vm_size = var.vm_size
os_disk_size_gb = var.os_disk_size_gb
vnet_subnet_id = data.azurerm_subnet.aks.id
pod_subnet_id = var.network_plugin == "azure" && var.network_plugin_mode != "overlay" ? data.azurerm_subnet.pods.id : null
zones = var.availability_zones
type = "VirtualMachineScaleSets"
temporary_name_for_rotation = "systemtemp"
}
identity {
type = "SystemAssigned"
}
network_profile {
network_plugin = var.network_plugin
network_plugin_mode = var.network_plugin_mode # "overlay" for Azure CNI Overlay
network_data_plane = var.network_data_plane # "cilium" for the eBPF dataplane
network_policy = var.network_plugin == "kubenet" ? "calico" : (var.network_data_plane == "cilium" ? "cilium" : "azure")
pod_cidr = var.network_plugin == "kubenet" || var.network_plugin_mode == "overlay" ? "10.244.0.0/16" : null
service_cidr = var.service_cidr
dns_service_ip = var.dns_service_ip
load_balancer_sku = "standard"
outbound_type = var.outbound_type
}
oms_agent {
log_analytics_workspace_id = azurerm_log_analytics_workspace.aks.id
}
tags = var.common_tags
lifecycle {
# The node count belongs to the autoscaler / manual scaling, not to the plan.
ignore_changes = [default_node_pool[0].node_count]
}
}
resource "azurerm_role_assignment" "aks_network_contributor" {
scope = data.azurerm_subnet.aks.id
role_definition_name = "Network Contributor"
principal_id = azurerm_kubernetes_cluster.this.identity[0].principal_id
}
resource "azurerm_role_assignment" "pods_network_contributor" {
scope = data.azurerm_subnet.pods.id
role_definition_name = "Network Contributor"
principal_id = azurerm_kubernetes_cluster.this.identity[0].principal_id
}
resource "azurerm_role_assignment" "internal_lb_network_contributor" {
count = var.internal_lb_subnet_name != null ? 1 : 0
scope = data.azurerm_subnet.internal_lb[0].id
role_definition_name = "Network Contributor"
principal_id = azurerm_kubernetes_cluster.this.identity[0].principal_id
}
The inputs worth knowing, with their module defaults:
| Input | Default | Notes |
|---|---|---|
network_plugin |
azure |
kubenet in this deployment |
network_plugin_mode |
null |
overlay for Azure CNI Overlay |
network_data_plane |
azure |
cilium switches the policy engine to Cilium too |
outbound_type |
loadBalancer |
userDefinedRouting sends egress through a firewall UDR instead |
private_cluster_enabled |
true |
Overridden to false here |
private_dns_zone_id |
System |
Only meaningful for a private cluster |
vm_size |
Standard_D2s_v3 |
|
node_count |
2 |
|
os_disk_size_gb |
128 |
|
internal_lb_subnet_name |
null |
When set, the identity is granted Network Contributor on it |
Outputs: cluster_id, cluster_name, node_resource_group (consumed by aks-routes),
kube_config (sensitive) and kubelet_identity.
The cluster stack¶
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../../../_modules/aks"
}
inputs = {
resource_group_name = "rg-aks-nonprod"
cluster_name = "aks-nonprod"
kubernetes_version = "1.32"
node_count = 2
vm_size = "Standard_D4s_v6"
os_disk_size_gb = 128
availability_zones = ["1", "2"]
# Bring-your-own VNet
vnet_name = "vnet-aks"
vnet_resource_group = "rg-aks-network"
aks_subnet_name = "snet-aks-nodes"
pod_subnet_name = "snet-aks-pods"
internal_lb_subnet_name = "snet-aks-internal-lb"
external_lb_subnet_name = "snet-aks-external-lb"
private_cluster_enabled = false
private_dns_zone_id = null
private_cluster_public_fqdn_enabled = false
network_plugin = "kubenet"
network_plugin_mode = null
network_data_plane = "azure"
service_cidr = "192.168.0.0/16"
dns_service_ip = "192.168.0.10"
outbound_type = "loadBalancer"
}
aks-routes module¶
Manages routes inside the route table AKS creates for kubenet. The table is looked up rather than created, because AKS owns it and recreates it with the node resource group.
data "azurerm_route_table" "aks" {
name = var.aks_route_table_name
resource_group_name = var.aks_resource_group_name
}
# Default route to the internet rather than the hub firewall, so public load balancers and
# API-server access keep working.
resource "azurerm_route" "default_to_internet" {
name = "default"
resource_group_name = var.aks_resource_group_name
route_table_name = var.aks_route_table_name
address_prefix = "0.0.0.0/0"
next_hop_type = "Internet"
}
# Keep the on-prem VPN client range going through the hub firewall.
resource "azurerm_route" "onprem" {
name = "onprem"
resource_group_name = var.aks_resource_group_name
route_table_name = var.aks_route_table_name
address_prefix = "172.16.42.0/24"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.40.1.4"
}
The module also carried commented-out experiments, kept as a record of what was tried: a default
route via the firewall (VirtualAppliance → 10.40.1.4), VnetLocal routes for each
application spoke, and /32 routes with next hop Internet for each public load balancer
address (192.0.2.10 to 192.0.2.13). At least one of those /32 routes had been applied at
some point: it was still in the stack's state, outside the configuration, when the cluster was
deleted.
Its stack takes the node resource group from the cluster and hardcodes the table name:
dependency "aks" {
config_path = "../aks"
mock_outputs = {
node_resource_group = "MC_rg-aks-nonprod_aks-nonprod_westeurope"
}
}
inputs = {
aks_resource_group_name = dependency.aks.outputs.node_resource_group
# Generated by AKS and not exposed as an output. Format: aks-agentpool-<random-id>-routetable
aks_route_table_name = "aks-agentpool-12345678-routetable"
}
vnet module¶
A general-purpose VNet: subnets with optional delegation and service endpoints, optional route tables and routes, subnet-to-route-table associations that accept either a key of a table declared here or the full ID of an external one, and optional inline peerings.
resource "azurerm_resource_group" "this" {
name = var.resource_group_name
location = var.location
tags = var.common_tags
}
resource "azurerm_virtual_network" "this" {
name = var.vnet_name
location = var.location
resource_group_name = azurerm_resource_group.this.name
address_space = var.address_space
dns_servers = var.dns_servers
tags = var.common_tags
}
resource "azurerm_subnet" "subnets" {
for_each = var.subnets
name = each.value.name
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [each.value.address_prefix]
dynamic "delegation" {
for_each = each.value.delegation != null ? [each.value.delegation] : []
content {
name = delegation.value.name
service_delegation {
name = delegation.value.service_name
actions = delegation.value.actions
}
}
}
service_endpoints = each.value.service_endpoints
private_endpoint_network_policies = each.value.private_endpoint_network_policies
private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled
}
resource "azurerm_route_table" "this" {
for_each = var.route_tables
name = each.value.name
location = var.location
resource_group_name = azurerm_resource_group.this.name
bgp_route_propagation_enabled = !each.value.disable_bgp_route_propagation
tags = var.common_tags
}
resource "azurerm_route" "this" {
for_each = merge([
for rt_key, rt in var.route_tables : {
for route_key, route in rt.routes :
"${rt_key}__${route_key}" => merge(route, { route_table_name = rt.name, route_name = route_key })
}
]...)
name = each.value.route_name
resource_group_name = azurerm_resource_group.this.name
route_table_name = each.value.route_table_name
address_prefix = each.value.address_prefix
next_hop_type = each.value.next_hop_type
next_hop_in_ip_address = each.value.next_hop_in_ip_address
depends_on = [azurerm_route_table.this]
}
resource "azurerm_subnet_route_table_association" "subnets" {
for_each = { for k, v in var.subnets : k => v if v.route_table_id != null }
subnet_id = azurerm_subnet.subnets[each.key].id
# Accept a key of a table declared above, or the full ID of an external table.
route_table_id = can(azurerm_route_table.this[each.value.route_table_id].id) ? azurerm_route_table.this[each.value.route_table_id].id : each.value.route_table_id
depends_on = [azurerm_route_table.this]
}
resource "azurerm_virtual_network_peering" "this" {
for_each = var.vnet_peerings
name = each.key
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
remote_virtual_network_id = each.value.remote_vnet_id
allow_virtual_network_access = each.value.allow_virtual_network_access
allow_forwarded_traffic = each.value.allow_forwarded_traffic
allow_gateway_transit = each.value.allow_gateway_transit
use_remote_gateways = each.value.use_remote_gateways
}
The AKS VNet stack¶
inputs = {
resource_group_name = "rg-aks-network"
vnet_name = "vnet-aks"
address_space = ["10.40.80.0/21"]
dns_servers = [] # Azure default DNS: firewall DNS broke with loadBalancer egress
# No route tables: AKS manages its own for kubenet.
route_tables = {}
# Conflicts with the standalone aks-peering-hub stack; see the VNet peering page.
vnet_peerings = {
aks-to-hub = {
remote_vnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-connectivity/providers/Microsoft.Network/virtualNetworks/vnet-hub"
allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = true
}
}
subnets = {
pods = {
name = "snet-aks-pods"
address_prefix = "10.40.80.0/22"
service_endpoints = []
private_endpoint_network_policies = "Disabled"
# No route table on the pod subnet: one there breaks Service-CIDR access.
delegation = {
name = "aks-delegation"
service_name = "Microsoft.ContainerService/managedClusters"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
aks = {
name = "snet-aks-nodes"
address_prefix = "10.40.84.0/24"
service_endpoints = ["Microsoft.Storage"]
private_endpoint_network_policies = "Disabled"
# The AKS-managed route table in the node resource group.
route_table_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MC_rg-aks-nonprod_aks-nonprod_westeurope/providers/Microsoft.Network/routeTables/aks-agentpool-12345678-routetable"
}
internal_lb = {
name = "snet-aks-internal-lb"
address_prefix = "10.40.85.0/24"
service_endpoints = []
private_endpoint_network_policies = "Disabled"
}
external_lb = {
name = "snet-aks-external-lb"
address_prefix = "10.40.86.0/24"
service_endpoints = []
private_endpoint_network_policies = "Disabled"
}
}
}
A VNet that points at a table inside the cluster's resource group
The node subnet's route_table_id names a route table that lives in the cluster's node
resource group, which Azure deletes along with the cluster. The VNet stack therefore depends
on the cluster existing, even though it is applied first. See
Decommissioning.
vnet-peering module¶
data "azurerm_virtual_network" "source" {
name = var.source_vnet_name
resource_group_name = var.source_resource_group_name
}
data "azurerm_virtual_network" "destination" {
name = var.destination_vnet_name
resource_group_name = var.destination_resource_group_name
}
resource "azurerm_virtual_network_peering" "source_to_destination" {
name = var.peering_name_source_to_destination
resource_group_name = var.source_resource_group_name
virtual_network_name = data.azurerm_virtual_network.source.name
remote_virtual_network_id = data.azurerm_virtual_network.destination.id
allow_virtual_network_access = var.allow_virtual_network_access
allow_forwarded_traffic = var.allow_forwarded_traffic_source_to_destination
allow_gateway_transit = var.allow_gateway_transit_source_to_destination
use_remote_gateways = var.use_remote_gateways_source_to_destination
}
resource "azurerm_virtual_network_peering" "destination_to_source" {
name = var.peering_name_destination_to_source
resource_group_name = var.destination_resource_group_name
virtual_network_name = data.azurerm_virtual_network.destination.name
remote_virtual_network_id = data.azurerm_virtual_network.source.id
allow_virtual_network_access = var.allow_virtual_network_access
allow_forwarded_traffic = var.allow_forwarded_traffic_destination_to_source
allow_gateway_transit = var.allow_gateway_transit_destination_to_source
use_remote_gateways = var.use_remote_gateways_destination_to_source
}
The stacks that call it are on VNet peering.
Firewall rule collection for VPN clients¶
A rule collection group on the hub's existing firewall policy, meant to let the on-prem VPN client range reach the Kafka load balancers:
inputs = {
resource_group_name = "rg-connectivity"
firewall_policy_name = "afwp-hub"
rule_collection_group_name = "vpn-to-aks-kafka"
priority = 210
network_rule_collections = [
{
name = "allow-vpn-to-aks-kafka"
priority = 100
action = "Allow"
rules = [
{
name = "vpn-to-aks-kafka"
protocols = ["TCP"]
source_addresses = ["172.16.42.0/24"]
destination_addresses = ["10.224.0.0/24"]
destination_ports = ["9092-9094"]
}
]
}
]
application_rule_collections = []
nat_rule_collections = []
}
Check the destination against where the load balancers really are
10.224.0.0/24 is in Azure's default AKS VNet range, the addresses the Kafka load
balancers had while the cluster ran on the AKS-managed network (the bastion forwarder
targets the same range). It is not the bring-your-own VNet the Terraform describes
(10.40.80.0/21). The stack was never applied, and on-prem traffic to the AKS VNet would
not have reached the firewall anyway (see
Why VNet-peering routes win).