Containerization Lesson 91 of 113

Production-Grade AKS: Networking, Ingress, and Observability

A demo az aks create gets you a cluster; it does not get you a production cluster. This guide covers the four decisions that actually matter at scale: networking model, identity, ingress, and observability — with the commands and manifests to implement each.

In a nutshell

Level: Advanced · Time: ~34 min

Here is the whole idea in one breath: a production AKS cluster is decided by two things you cannot easily change later — the shape of its network and the depth of its observability. Compute you can add. Nodes you can scale. But the networking model is baked in at create time, and if you cannot see the cluster when it misbehaves at 3 a.m., every other decision was academic. Get the network model, the front door, and the metrics/logs/traces stack right on day one, and everything else — GitOps, mesh, progressive delivery — layers on cleanly.

Think of the cluster as a new office building. The networking model is the plumbing and the address plan: how many rooms (pods) you can fit before you run out of addresses, whether rooms can be reached directly from the street, and which corridors connect to which. Pick the wrong plan and you discover, halfway through fit-out, that the building is “full” even though most rooms are empty — not because you ran out of space, but because you ran out of addresses. Ingress is the single guarded front door — one reception desk (with a TLS badge check) that routes every visitor to the right floor, instead of a hundred unlocked side doors. And observability is the building’s sensors and CCTV: the metrics (is the AC running hot?), the logs (who opened which door, and what broke), and the traces (follow one visitor’s whole journey through the building). A building with great plumbing and no sensors is one you cannot operate; a building with sensors and bad plumbing is one you cannot grow.

The three observability signals are worth naming up front because people conflate them. Metrics are cheap numbers over time (CPU %, request rate, error rate) — great for dashboards and alerts. Logs are expensive lines of text (what exactly happened) — great for forensics, but they cost real money per gigabyte. Traces stitch one request’s path across many services — great for “where did the latency come from?”. Managed Azure gives you a first-party home for all three: managed Prometheus for metrics, Container Insights / Log Analytics for logs, and Application Insights (via OpenTelemetry) for traces.

If managed Kubernetes as a category is new to you, skim Managed Kubernetes compared: AKS vs EKS vs GKE first, and if pod networking fundamentals are hazy, the CNI pod-networking model is the prerequisite for the IP-planning math below. This lesson assumes you can already read a Deployment, a Service, and an Ingress manifest.

After this lesson you will be able to:

Production AKS architecture

Read the diagram left-to-right as the life of one request: (1) a client hits the managed NGINX ingress where TLS is terminated; (2) ingress routes to a Service, which lands on pods running on an autoscaling user node pool; (3) those pods pull images from ACR and fetch Entra tokens via Workload Identity with no stored secrets; (4) managed Prometheus + Grafana scrape metrics and fire alerts on the golden signals; and (5) east-west traffic between pods is refused by default-deny NetworkPolicy unless explicitly allowed, with HA from PodDisruptionBudgets and topology spread. Every section below implements one arrow of that path.

1. Networking: choose Azure CNI Overlay

Three models, one right default for most:

Model Pod IPs When
kubenet NAT’d, not routable legacy; avoid
Azure CNI (classic) from VNet subnet when pods must be directly routable, but burns VNet IPs fast
Azure CNI Overlay private overlay CIDR default — VNet-scale without exhausting subnet IPs

Before the commands, understand why this is the highest-stakes decision. In AKS the CNI (Container Network Interface) plugin decides where every pod’s IP address comes from, and that choice determines how many pods your cluster can ever hold, whether those pods are reachable from outside, and how much of your precious VNet address space you burn. It is set at create time and, with one exception, cannot be changed without disruption. So the full picture, expanded:

Property kubenet Azure CNI (classic / node-subnet) Azure CNI Overlay
Pod IP source fake overlay, node-routed via UDRs real IP from the VNet subnet private overlay CIDR (--pod-cidr)
VNet IPs consumed 1 per node 1 per node + 1 per pod (pre-reserved) 1 per node only
Pod directly routable in VNet? no (SNAT’d behind node) yes no (SNAT’d via node)
Max pods / node (typical) 110 (up to 250) 30 default, up to 250 250 default
Network policy support limited (Calico only) Azure NPM / Calico / Cilium Azure NPM / Calico / Cilium
Windows node pools no yes yes
Status on a deprecation path — migrate off supported; use only when direct routability is required recommended default

The reason Overlay is the default is the IP math, and it is worth doing by hand once so it never surprises you.

IP planning: the math that causes InsufficientSubnetSize

With classic Azure CNI, every node pre-reserves maxPods IP addresses from the node subnet plus one for itself — whether or not those pods exist yet. So the addresses a node subnet must hold is roughly:

subnet IPs needed ≈ (max nodes during scale + upgrade surge) × (1 + maxPods)

Work a concrete case. A /24 subnet has 256 addresses, of which Azure reserves 5, leaving 251 usable. Provision classic CNI with the common maxPods = 110:

251 usable ÷ (1 + 110) = 2.26  →  only 2 nodes fit

Two nodes. On a /24. That is the trap the enterprise scenario below hit — the cluster reports it is “full” while CPU sits idle, because the subnet, not compute, is exhausted. Even at the CLI default maxPods = 30, a /24 tops out at 251 ÷ 31 ≈ 8 nodes. And you must leave headroom for the upgrade surge (AKS adds an extra node while rolling), so the real ceiling is lower still.

Now the same /24 with Azure CNI Overlay: nodes draw from the VNet subnet, but pods draw from a separate /16 overlay CIDR that never touches the VNet.

node subnet: 251 usable ÷ 1 per node ≈ 250 nodes
pod space:   10.244.0.0/16 = 65,536 pod IPs, entirely off-VNet

Roughly 250 nodes on the exact same subnet, versus 2. The overlay CIDR (--pod-cidr) can be any private range that does not overlap the VNet, on-prem, or peered networks, and it is reused per cluster — pod IPs are only meaningful inside the cluster. That is the whole pitch: VNet-scale node counts without spending VNet IPs on pods. For the datapath details of how packets actually move in each model, see the CNI pod-networking model internals.

The one real cost of Overlay: pod IPs are not routable from outside the cluster. If an on-prem system must reach a pod by its IP (rare, and usually a design smell), Overlay breaks that path — put the workload behind an internal load balancer or the ingress instead. For everything else, Overlay is correct.

Provision with Overlay + Cilium data plane (eBPF) via Terraform:

resource "azurerm_kubernetes_cluster" "prod" {
  name                = "aks-prod-eus"
  location            = "eastus"
  resource_group_name = azurerm_resource_group.aks.name
  dns_prefix          = "aksprod"
  oidc_issuer_enabled       = true   # required for workload identity
  workload_identity_enabled = true

  default_node_pool {
    name                 = "system"
    vm_size              = "Standard_D4ds_v5"
    auto_scaling_enabled = true
    min_count            = 3
    max_count            = 6
    only_critical_addons_enabled = true   # taint system pool; run apps elsewhere
  }

  network_profile {
    network_plugin      = "azure"
    network_plugin_mode = "overlay"
    network_policy      = "cilium"
    network_data_plane  = "cilium"
    pod_cidr            = "10.244.0.0/16"
    service_cidr        = "10.0.0.0/16"
    dns_service_ip      = "10.0.0.10"
  }
  identity { type = "SystemAssigned" }
}

Three fields here decide the network and deserve a plain-language gloss. network_plugin_mode = "overlay" is what turns on Overlay. pod_cidr is the off-VNet pool pods draw from. service_cidr is a separate, virtual range for ClusterIP Services — it never appears on the wire, it is just the pool kube-proxy/Cilium hands out for stable service VIPs — and dns_service_ip (here 10.0.0.10) is the address of the in-cluster DNS service, which must sit inside service_cidr. Pod CIDR, service CIDR, and your VNet must not overlap each other or anything peered.

Add a separate user node pool for workloads so system add-ons never compete with apps:

az aks nodepool add -g rg-aks --cluster-name aks-prod-eus \
  --name apps --mode User --node-vm-size Standard_D8ds_v5 \
  --enable-cluster-autoscaler --min-count 3 --max-count 20

The only_critical_addons_enabled = true on the system pool taints it (CriticalAddonsOnly=true:NoSchedule) so CoreDNS, metrics-server, and the CSI drivers get guaranteed room and your app pods land on the apps pool. This separation is the difference between “a node pressure event took out CoreDNS and my app” and “it only touched the app.”

DNS inside the cluster

Every name lookup a pod makes — postgres, payments.svc, api.stripe.com — goes to CoreDNS, which runs as a Deployment in kube-system and answers on the dns_service_ip you set above. Two AKS-specific facts matter in production. First, CoreDNS is a shared, load-bearing dependency: if it is starved for CPU or evicted under node pressure, the whole cluster looks broken with confusing timeouts — which is exactly why the system-pool taint above exists. Second, for high-QPS clusters enable NodeLocal DNSCache, a per-node DNS cache that answers most lookups on the node itself, cutting CoreDNS load and tail latency. Custom stub domains and upstream forwarders (e.g., to a private DNS zone or on-prem resolver) are configured through the coredns-custom ConfigMap, not by editing the managed CoreDNS config directly — AKS reconciles the managed config and would revert your edits.

2. Identity: Workload Identity, not secrets

Stop mounting service-principal secrets. Microsoft Entra Workload Identity federates a Kubernetes service account to a managed identity — pods get Entra tokens with no secrets to rotate.

# 1) create a user-assigned managed identity and give it access (e.g., Key Vault)
az identity create -g rg-aks -n id-payments
az role assignment create --assignee <id-client-id> \
  --role "Key Vault Secrets User" --scope <keyvault-resource-id>

# 2) federate it to the k8s service account
az identity federated-credential create --name payments-fed \
  --identity-name id-payments -g rg-aks \
  --issuer "$(az aks show -g rg-aks -n aks-prod-eus --query oidcIssuerProfile.issuerUrl -o tsv)" \
  --subject system:serviceaccount:payments:payments-sa
apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-sa
  namespace: payments
  annotations:
    azure.workload.identity/client-id: "<id-client-id>"
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: payments, namespace: payments }
spec:
  template:
    metadata:
      labels: { azure.workload.identity/use: "true" }   # injects the token
    spec:
      serviceAccountName: payments-sa

The mechanism, in one paragraph, because it is the crux of secret-free Azure access. AKS exposes an OIDC issuer — a public endpoint that signs a short-lived token proving “I am the payments-sa service account in the payments namespace.” You register that claim (the --subject system:serviceaccount:payments:payments-sa) as a federated credential on a managed identity. Now when the pod presents its projected service-account token, Entra ID checks the signature and the subject, sees the federation you configured, and hands back an Entra access token for the managed identity — which has the RBAC you granted (here, Key Vault Secrets User). There is no secret in the cluster: nothing to leak in a git commit, nothing to rotate, nothing that outlives the pod. The mutating webhook from the workload-identity add-on injects the token file and the AZURE_* environment variables whenever it sees the azure.workload.identity/use: "true" pod label — which is why that label, not just the service account, is required.

3. Ingress: managed app routing + cert-manager

Enable the AKS-managed NGINX ingress (app routing add-on) so you don’t operate the controller yourself:

az aks approuting enable -g rg-aks -n aks-prod-eus

Then expose a service with TLS (cert-manager issuing Let’s Encrypt certs):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payments
  namespace: payments
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: webapprouting.kubernetes.azure.com
  tls:
    - hosts: ["pay.kloudvin.com"]
      secretName: pay-tls
  rules:
    - host: pay.kloudvin.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: payments, port: { number: 80 } } }

Choosing an ingress: managed NGINX vs App Gateway vs AGC

“Ingress” is the cluster’s front door, and Azure gives you several. The right pick depends on where you want TLS terminated, whether you need a WAF, and whether you have adopted the Gateway API. The comparison:

Option What it is TLS / WAF Reach for it when
App Routing add-on (managed NGINX) AKS-operated ingress-nginx, upgraded for you TLS in-cluster; cert-manager for certs default — you want a standard, low-ops ingress and control routing with familiar NGINX annotations
Self-managed ingress-nginx you install/operate the controller via Helm TLS in-cluster; full control of the config you need a controller version or config the add-on does not expose, and accept the upgrade burden
AGIC (App Gateway Ingress Controller) a controller that programs an Azure Application Gateway L7 LB TLS + WAF at the gateway, outside the cluster you want WAF and TLS offloaded to a managed Azure L7 in front of the cluster, using the classic Ingress API
AGC (Application Gateway for Containers) the next-gen managed L7, driven by the Gateway API TLS + WAF, header/traffic-based routing, weighted splits new builds wanting Gateway API, fine-grained traffic splitting, and a fully managed data plane

The mental split: NGINX-family (add-on or self-managed) terminates TLS and routes inside the cluster — simplest, most portable. App Gateway family (AGIC, and its successor AGC) puts a managed Azure L7 load balancer with a WAF in front of the cluster, offloading TLS and shielding the origin. AGIC uses the older Ingress API; AGC uses the Gateway API and is where Azure is investing — if you are choosing today and want traffic splitting or WAF, AGC is the forward-looking pick.

4. Network policy: default-deny

Lock down east-west traffic. With Cilium/Azure network policy, start every namespace at default-deny and open only what’s needed:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: payments }
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-from-ingress, namespace: payments }
spec:
  podSelector: { matchLabels: { app: payments } }
  ingress:
    - from:
        - namespaceSelector: { matchLabels: { kubernetes.azure.com/managed: "true" } }
      ports: [{ port: 80 }]

The default posture in Kubernetes is default-allow: any pod can reach any other pod, the API server, and the cloud metadata endpoint. The two objects above invert that for the payments namespace — the first flips every pod to deny-all (an empty podSelector: {} selects them all, and listing both policyTypes with no rules denies both directions), the second re-permits exactly one flow: ingress from the AKS-managed ingress namespace to the payments app on port 80. The moment you enable egress deny, remember DNS is an egress call to CoreDNS on port 53 — you must allow it or every name lookup fails. The full default-deny playbook, including the DNS gotcha and Cilium L7/FQDN rules, lives in zero-trust pod networking.

Which policy engine: Azure NPM, Calico, or Cilium

A NetworkPolicy object only does something if the cluster’s network plugin enforces it. On AKS you choose the engine at create time (network_policy above), and the choice caps what you can express:

Engine Enforces L7 (HTTP/DNS) rules? Notes
Azure NPM stock NetworkPolicy (L3/L4) no Azure’s built-in policy manager; fine for basic segmentation
Calico stock NetworkPolicy + GlobalNetworkPolicy no (L3/L4 + limited) cross-platform, richer than NPM; extra component to run
Cilium (Azure CNI powered by Cilium) stock NetworkPolicy + CiliumNetworkPolicy yes — HTTP method/path, DNS FQDN egress eBPF datapath; identity-based (survives pod IP churn); the strongest option

For a new production cluster, Azure CNI Overlay powered by Cilium (as in the Terraform above) is the strongest default: you get identity-based enforcement that survives pod churn, L7 rules (HTTP method/path and DNS FQDN egress), and Hubble flow visibility. Whichever you pick, always prove enforcement (apply a deny, confirm it drops) rather than assume the YAML did anything.

5. Observability: managed Prometheus + Grafana

Don’t self-host the metrics stack. Enable Azure Monitor managed Prometheus, Azure Managed Grafana, and Container Insights in one shot:

az aks update -g rg-aks -n aks-prod-eus \
  --enable-azure-monitor-metrics \
  --enable-azure-monitor-app-monitoring

# link a managed Grafana instance
az grafana create -g rg-aks -n graf-kloudvin
az aks update -g rg-aks -n aks-prod-eus --enable-azure-monitor-metrics \
  --grafana-resource-id $(az grafana show -g rg-aks -n graf-kloudvin --query id -o tsv)

Scrape your own app metrics by annotating pods and adding a PodMonitor:

apiVersion: azmonitoring.coreos.com/v1
kind: PodMonitor
metadata: { name: payments, namespace: payments }
spec:
  selector: { matchLabels: { app: payments } }
  podMetricsEndpoints: [{ port: metrics, interval: 30s }]

The four signals to alert on first (the “golden signals”): latency, traffic, errors, saturation. Wire alerts in Grafana → Azure Monitor action groups.

The three signals: metrics, logs, and traces

Beginners reach for “logs” for everything and then drown in cost. Production observability is three different tools for three different questions:

Signal Answers Azure home Cost shape
Metrics “Is it healthy right now, and what is the trend?” managed Prometheus → Grafana cheap; billed per time-series sample
Logs “What exactly happened to this one request/pod?” Container Insights → Log Analytics expensive; billed per GB ingested + retention
Traces “Where did the latency go across services?” Application Insights (via OpenTelemetry) per-span sampling; usually sampled

The winning pattern: alert on metrics (cheap, real-time), drill into logs only when an alert fires (expensive, so keep them lean), and use traces to localize which service in a call chain is slow. Sending everything to logs and eyeballing dashboards is how observability bills balloon into the tens of thousands.

Managed Prometheus + Managed Grafana

--enable-azure-monitor-metrics deploys the managed Prometheus collector: an ama-metrics replica and ama-metrics-node DaemonSet land in kube-system, scrape targets per a Data Collection Rule (DCR), and remote-write into an Azure Monitor workspace (not a Log Analytics workspace — metrics and logs have separate stores). You do not run Prometheus servers, manage its storage, or page yourself when its PVC fills. Managed Grafana is the query/dashboard front end, pre-wired to that workspace with the standard Kubernetes dashboards. Your app metrics get in by the PodMonitor/ScrapeConfig CRDs above — note the CRD group is azmonitoring.coreos.com/v1, the Azure-managed flavor, not the community monitoring.coreos.com. If you already know the Prometheus/Grafana stack, the concepts are identical to self-hosted Prometheus & Grafana — you are just outsourcing the servers and storage.

Container Insights, and controlling its log cost

Container Insights (enabled via the monitoring add-on) deploys the ama-logs DaemonSet, which tails every container’s stdout/stderr plus node inventory into Log Analytics, billed per GB ingested. On a chatty cluster this is the line item that surprises finance:

# enable Container Insights, pointed at an existing Log Analytics workspace
az aks enable-addons -a monitoring -g rg-aks -n aks-prod-eus \
  --workspace-resource-id $(az monitor log-analytics workspace show \
     -g rg-aks -n la-kloudvin --query id -o tsv)

Three levers keep the bill sane: (1) use a cost-preset / data collection setting to exclude noisy namespaces (kube-system, log spam) and drop stdout you do not need; (2) put high-volume, rarely-queried tables like ContainerLogV2 on the Basic Logs tier, which trades ad-hoc query power for a fraction of the ingestion price; and (3) set retention deliberately — 30 days hot, archive beyond. Turning on Container Insights with defaults and no exclusions is the single most common way to overspend on an AKS cluster.

Control-plane and diagnostic logs

Managed Prometheus and Container Insights cover your nodes and pods. The control plane — the API server, scheduler, controller-manager, and the audit log — is managed by Azure and its logs are off by default. Turn them on with a diagnostic setting so you can answer “who deleted that deployment?” and “why did the scheduler refuse to place this pod?”:

az monitor diagnostic-settings create \
  --name aks-diag --resource $(az aks show -g rg-aks -n aks-prod-eus --query id -o tsv) \
  --workspace $(az monitor log-analytics workspace show -g rg-aks -n la-kloudvin --query id -o tsv) \
  --logs '[
    {"category":"kube-apiserver","enabled":true},
    {"category":"kube-audit-admin","enabled":true},
    {"category":"kube-controller-manager","enabled":true},
    {"category":"kube-scheduler","enabled":true},
    {"category":"cluster-autoscaler","enabled":true},
    {"category":"guard","enabled":true}
  ]'

Prefer kube-audit-admin over the full kube-audit category in production — the latter includes every read (get/list/watch) and is enormous; the admin variant keeps the writes and privileged reads that matter for forensics at a fraction of the volume.

Traces and OpenTelemetry

Metrics and logs tell you a service is slow; traces tell you which. Instrument apps with OpenTelemetry (the vendor-neutral standard), emit OTLP, and export to Application Insights — either directly from the SDK or through an OpenTelemetry Collector DaemonSet that batches and routes. The Collector pattern is worth the extra hop at scale: it decouples your app from the backend, does tail-based sampling, and can fan the same spans to Application Insights for traces and managed Prometheus for the metrics OTel also produces. A single traceparent header propagated across services is what lets Application Insights draw the end-to-end map from ingress to database.

Alerting and SLOs

Dashboards are for humans watching; alerts are for humans not watching. Do not alert on raw CPU — alert on SLO burn. Define a Service Level Objective (e.g., “99.9% of payments requests succeed under 300 ms over 30 days”), then alert when your error budget is burning fast enough to blow the objective. Managed Prometheus supports Prometheus rule groups (recording + alerting rules) defined as Azure resources; wire the alerts to Azure Monitor action groups (PagerDuty, email, webhook) and/or Grafana alerting. Start with the four golden signals per service — latency, traffic, errors, saturation — as recording rules, then layer SLO burn-rate alerts on top. Alerting on symptoms users feel (latency, errors) rather than causes (CPU, memory) is what separates a pager that means something from one everyone mutes.

Hands-on lab

No cluster? Everything below is schema-correct and safe to author now; the kubectl/az commands run against a real AKS cluster when you have one. The goal: provision an Overlay+Cilium cluster, front an app with managed ingress + TLS, lock it down, and prove observability is on. Outputs are representative (from a comparable cluster), labelled as such.

Step 1 — provision (or inspect) the cluster. Confirm the network model is what you think it is:

az aks show -g rg-aks -n aks-prod-eus \
  --query '{plugin:networkProfile.networkPlugin, mode:networkProfile.networkPluginMode, \
            dataplane:networkProfile.networkDataplane, podCidr:networkProfile.podCidr}' -o table
# representative output
Plugin    Mode     Dataplane    PodCidr
--------  -------  -----------  ---------------
azure     overlay  cilium       10.244.0.0/16

Step 2 — deploy a demo app on the user node pool and expose it via the app-routing ingress with cert-manager TLS (reuse the Ingress manifest from section 3). Verify placement and address:

kubectl get pods -n payments -o wide            # land on the 'apps' pool, not 'system'
kubectl get ingress -n payments                 # ADDRESS populated, HOSTS pay.kloudvin.com
# representative output
NAME                        READY   STATUS    NODE
payments-6c9f5d4b7c-n2k8p   1/1     Running   aks-apps-2245...

NAME       CLASS                                    HOSTS               ADDRESS         PORTS
payments   webapprouting.kubernetes.azure.com       pay.kloudvin.com    20.55.x.x       80, 443

Step 3 — apply default-deny + the ingress allow (the manifests from section 4), then prove enforcement:

# same namespace, no allow rule → should be refused
kubectl -n payments run probe --image=nicolaka/netshoot --rm -it --restart=Never \
  -- curl -m 5 http://payments  ; echo "exit=$?"
# expect a timeout / non-zero exit once default-deny is on

Step 4 — confirm observability is live:

kubectl -n kube-system get ds ama-logs ama-metrics-node   # Container Insights + managed Prometheus agents
kubectl get podmonitor -n payments                        # your app scrape target exists
az aks show -g rg-aks -n aks-prod-eus --query azureMonitorProfile  # metrics profile enabled

Step 5 — validate the YAML before it ships (works with no cluster, catches typos):

kubectl apply --dry-run=server -f ingress.yaml -f netpol.yaml -f podmonitor.yaml

If step 3’s curl succeeds after you applied default-deny, either the policy did not select the pod or your engine is not enforcing — go back to section 4. If the ama-* DaemonSets are missing in step 4, the monitoring add-on/metrics profile did not enable; re-run the az aks update from section 5.

Going deeper

The sections above get you a correct cluster. This is for when you must defend the design in an architecture review, reason about failure modes, or keep the bill and the blast radius under control at scale.

The Overlay datapath, and why pods are not routable

In classic Azure CNI, a pod’s veth is bridged so its VNet IP is a first-class address the fabric routes — which is why pods are directly reachable and why they each cost a VNet IP. In Overlay, each node owns a slice of the pod CIDR and the CNI programs the datapath (with Cilium, eBPF at the node) to encapsulate/route pod traffic between nodes while pods draw from the off-VNet CIDR. Outbound to the VNet or internet is SNAT’d behind the node’s IP, so from the VNet’s perspective traffic appears to come from the node, never the pod. That single fact explains both Overlay’s superpower (pods cost nothing in VNet IPs) and its one limitation (nothing outside the cluster can dial a pod IP directly). It also means your subnet sizing shifts from pods to nodes: size the node subnet for max nodes + upgrade surge + a margin, and size the overlay /16 for pods — two independent budgets instead of one contended pool.

Private clusters and controlled egress

Two hardening steps turn a demo cluster into one a security team will sign off on: closing the front door of the control plane, and controlling what the cluster can talk out to.

Private API server. By default the Kubernetes API server has a public FQDN. --enable-private-cluster replaces it with a private endpoint (a private IP in your VNet via Private Link) resolved through a private DNS zone, so kubectl only works from inside the network:

az aks create -g rg-aks -n aks-prod-eus --enable-private-cluster \
  --network-plugin azure --network-plugin-mode overlay --network-dataplane cilium \
  # ...node pool + identity flags...

The trade-off is connectivity: your CI runners, jump host, or laptop must reach that private IP — via VPN, ExpressRoute, a peered management VNet, Azure Bastion + a jumpbox, or the escape hatch az aks command invoke (which runs a command server-side without network line-of-sight). If a private endpoint is too heavy, the middle ground is a public endpoint restricted by --api-server-authorized-ip-ranges to your egress IPs — less airtight than private, but far better than open-to-the-internet.

Controlled egress. By default AKS egresses through a public load balancer doing SNAT, which has two problems at scale: a limited pool of SNAT ports (exhaustion shows up as intermittent outbound connection failures under load) and no control over what the cluster can reach. The --outbound-type you choose fixes both:

Outbound type What it does Reach for it when
loadBalancer (default) SNAT via the cluster’s LB public IP(s) small clusters, no egress policy needed
managedNATGateway / userAssignedNATGateway NAT Gateway with far larger, scalable SNAT port pools you hit SNAT port exhaustion under outbound load
userDefinedRouting a UDR forces all egress through your firewall/NVA you need to inspect and allow-list egress (e.g., Azure Firewall with FQDN rules)

The production-hardened pattern is userDefinedRouting sending egress to an Azure Firewall, with application/network rules allowing exactly the FQDNs AKS needs (Microsoft publishes the required set, and the AzureKubernetesService FQDN tag covers the platform ones) plus your own dependencies. Now the cluster can only reach approved destinations — a hard requirement for regulated workloads and a strong control against data exfiltration from a compromised pod.

The managed-Prometheus and Grafana architecture

Worth knowing where your metrics actually live, because the stores are separate and independently billed. ama-metrics scrapes targets (kube-state-metrics, cAdvisor/node metrics, and your PodMonitors) per the Data Collection Rule, then remote-writes into an Azure Monitor workspace — a purpose-built time-series store, distinct from the Log Analytics workspace where Container Insights sends logs. Managed Grafana queries the Azure Monitor workspace over PromQL. So a full production cluster typically has three data stores: an Azure Monitor workspace (metrics), a Log Analytics workspace (Container Insights + control-plane logs), and an Application Insights resource (traces). Keeping them straight is what lets you reason about cost — you tune each independently: retention on Log Analytics, sampling on App Insights, series cardinality on Prometheus.

The real cost of logs, and cardinality

Two bills bite silently. On the logs side, Container Insights charges per GB into Log Analytics; a single over-logging service (debug logs left on, a health-check spamming stdout) can dominate the invoice. Control it with namespace exclusions, the Basic Logs tier for high-volume tables, and disciplined app log levels. On the metrics side, the killer is cardinality: every unique combination of label values is a separate time series, and a well-meaning label like user_id or a raw path with IDs in it explodes one metric into millions of series, driving cost and slowing queries. Keep metric labels bounded and low-cardinality (status code, route template, method — never raw IDs), and prefer logs/traces for high-cardinality detail. “Metrics for aggregates, logs/traces for specifics” is a cost rule as much as a design one.

Version and API caveats

Enterprise scenario

A fintech platform team migrated ~40 services onto a shared AKS cluster using classic Azure CNI. It worked in staging, then production node scale-out started failing with InsufficientSubnetSize. Classic CNI assigns every pod a real VNet IP, and with maxPods=110 per node a single /22 node subnet was exhausted well before the autoscaler hit its ceiling — the subnet, not compute, was the bottleneck. Re-IPing a peered hub-and-spoke VNet that other teams depended on was a non-starter.

The fix was migrating the data plane to Azure CNI Overlay, where pods draw from a private overlay CIDR and only nodes consume VNet IPs. This is an in-place cluster update, not a rebuild:

az aks update -g rg-aks -n aks-prod-eus \
  --network-plugin-mode overlay \
  --pod-cidr 10.244.0.0/16

Two gotchas bit them. First, the migration is one-way and requires draining and recycling every node, so they ran it during a window with PodDisruptionBudgets in place. Second, a legacy service depended on directly routable pod IPs from an on-prem caller; overlay pod IPs are not reachable outside the cluster, so that path had to move behind the internal load balancer. After the cutover the same /22 comfortably supported the full node count, and VNet IP consumption dropped from thousands of pod IPs to a few dozen node IPs — buying years of headroom without touching the hub network. Validate the new plane with kubectl get nodes -o wide and confirm pod CIDRs land in the overlay range.

Production readiness checklist

Verify

kubectl get nodes -o wide                          # node pools healthy
kubectl get networkpolicy -A                       # default-deny present
kubectl describe sa payments-sa -n payments        # workload-identity annotation
kubectl get ingress -n payments                    # address assigned, TLS secret created
az aks show -g rg-aks -n aks-prod-eus --query azureMonitorProfile  # metrics enabled

With these five pillars in place you have a cluster that’s routable at VNet scale, secret-free, TLS-terminated, segmented, and observable. Everything else — GitOps with Argo CD, progressive delivery, service mesh, day-2 upgrade and backup discipline — layers cleanly on top.

Troubleshooting

Symptom Likely cause Fix
InsufficientSubnetSize on scale-out classic Azure CNI pre-reserves maxPods IPs/node; subnet exhausted migrate to Azure CNI Overlay (--network-plugin-mode overlay) or enlarge the node subnet; do the IP math first
Pods stuck ContainerCreating, CNI IP errors node subnet out of free IPs (classic CNI) same as above; Overlay removes per-pod VNet IP pressure
All name resolution fails after enabling egress deny NetworkPolicy blocked egress to CoreDNS on port 53 add an egress allow to kube-system kube-dns on UDP and TCP 53 in the same change
Applied a NetworkPolicy but nothing is blocked engine not enforcing, or no policy selects the pod confirm network_policy engine (NPM/Calico/Cilium); check the podSelector matches the pod’s labels; prove with a deny
Ingress ADDRESS never populates app-routing add-on not enabled, or ingressClassName wrong az aks approuting enable; set ingressClassName: webapprouting.kubernetes.azure.com
TLS secret never created / cert stuck cert-manager not installed or the cluster-issuer annotation missing/misnamed install cert-manager; verify cert-manager.io/cluster-issuer names a real ClusterIssuer
Pod cannot get a Key Vault token (403 / no token) missing azure.workload.identity/use: "true" pod label, or federated-credential subject mismatch add the pod label; confirm the --subject system:serviceaccount:<ns>:<sa> exactly matches
kubectl times out after enabling private cluster no network path to the private API endpoint connect via VPN/ExpressRoute/Bastion+jumpbox, or use az aks command invoke
Intermittent outbound connection failures under load SNAT port exhaustion on the default LB outbound switch --outbound-type to a NAT Gateway; or route egress via UDR/firewall
Log Analytics bill spikes after enabling monitoring Container Insights ingesting all namespaces/stdout by default apply a cost-preset, exclude noisy namespaces, use Basic Logs tier, set retention
App metrics missing in Grafana wrong CRD group, or scrape port name mismatch use azmonitoring.coreos.com/v1 PodMonitor; ensure port: matches a named container port

Practice challenges

Author these against the manifests and commands above; each has a graded difficulty and a worked solution — try before you open the toggle.

Challenge 1 (beginner) — size the subnet. You must run up to 60 nodes on classic Azure CNI with maxPods = 30, plus upgrade surge. Which node subnet size do you need, and why is Overlay easier?

<details> <summary>Solution</summary>

Classic CNI needs ~(nodes + surge) × (1 + maxPods) IPs = ~61 × 31 ≈ 1,891 IPs, so a /21 (2,048 addresses, ~2,043 usable) — a /22 (1,019 usable) is too small. With Overlay the node subnet only needs ~61 IPs (a /25 is plenty) because pods live in the separate /16 overlay CIDR. Why: classic CNI charges VNet IPs per pod; Overlay charges per node. </details>

Challenge 2 (beginner) — don’t break DNS. You enable a namespace-wide default-deny including egress and the app starts timing out on every outbound call. Add the minimal rule to restore name resolution.

<details> <summary>Solution</summary>

Allow egress to CoreDNS in kube-system on UDP and TCP 53:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns, namespace: payments }
spec:
  podSelector: {}
  policyTypes: ["Egress"]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:
            matchLabels: { k8s-app: kube-dns }
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

Why: every name lookup is an egress call to CoreDNS on 53; both transports are needed because large answers fall back to TCP. </details>

Challenge 3 (intermediate) — pick the ingress. A regulated service must terminate TLS outside the cluster behind a WAF, and you want to adopt the Gateway API for weighted traffic splits. Which ingress option, and why not managed NGINX?

<details> <summary>Solution</summary>

Application Gateway for Containers (AGC) — a managed Azure L7 with WAF that terminates TLS in front of the cluster and is driven by the Gateway API, giving native weighted HTTPRoute splits. Managed NGINX terminates TLS inside the cluster and has no managed WAF, so it fails the “WAF outside the origin” requirement. (AGIC also offers WAF but uses the older Ingress API, not Gateway API.) Why: the requirement is edge WAF + Gateway API — exactly AGC’s remit. </details>

Challenge 4 (intermediate) — secret-free Key Vault access. A pod gets AuthorizationFailed reaching Key Vault despite a federated credential existing. Name the two most likely causes.

<details> <summary>Solution</summary>

(1) The pod is missing the azure.workload.identity/use: "true" label, so the webhook never injected the token/env. (2) The federated credential’s --subject does not exactly match system:serviceaccount:<namespace>:<serviceaccount>, or the RBAC role assignment (Key Vault Secrets User) is missing/scoped wrong. Why: workload identity needs the label and an exact subject match and the target RBAC — all three. </details>

Challenge 5 (advanced) — alert on SLO, not CPU. Your team pages on “node CPU > 80%” and everyone mutes it. Redesign the alert around a user-facing SLO for the payments service.

<details> <summary>Solution</summary>

Define an SLO (e.g., 99.9% of requests succeed under 300 ms over 30 days), record the golden signals as Prometheus rules (request rate, error rate, p99 latency), then alert on error-budget burn rate — page fast-burn (e.g., 2% of the 30-day budget in 1 hour) and ticket slow-burn. Wire to an Azure Monitor action group. Why: CPU is a cause, not a symptom users feel; burn-rate alerts fire only when the objective is genuinely at risk, so they stay trustworthy. </details>

Challenge 6 (advanced) — lock down egress. A compromised pod must not be able to exfiltrate to arbitrary internet hosts, and the cluster must only reach an approved set of FQDNs. Outline the AKS design.

<details> <summary>Solution</summary>

Set the cluster’s --outbound-type userDefinedRouting so a UDR forces all egress through an Azure Firewall; on the firewall, allow the AKS platform FQDNs (the AzureKubernetesService FQDN tag) plus your explicit application FQDN allow-list, and deny the rest. Layer a Cilium egress NetworkPolicy (FQDN/toFQDNs) inside the cluster for defense in depth. Why: the firewall gives a central, inspectable choke point that no single namespace policy can bypass, and egress is now allow-listed rather than open. </details>

Common beginner mistakes

Cheat-sheet

# --- Networking / cluster ---
az aks show -g rg-aks -n aks-prod-eus \
  --query 'networkProfile.{plugin:networkPlugin,mode:networkPluginMode,dataplane:networkDataplane}'
az aks update -g rg-aks -n aks-prod-eus --network-plugin-mode overlay --pod-cidr 10.244.0.0/16  # 1-way!
kubectl get nodes -o wide                                  # node pools + IPs
az aks nodepool list -g rg-aks --cluster-name aks-prod-eus -o table

# --- Identity (workload identity) ---
az aks show -g rg-aks -n aks-prod-eus --query oidcIssuerProfile.issuerUrl -o tsv
az identity federated-credential list --identity-name id-payments -g rg-aks -o table
kubectl describe sa payments-sa -n payments                # check client-id annotation

# --- Ingress ---
az aks approuting enable -g rg-aks -n aks-prod-eus
kubectl get ingress -A                                     # ADDRESS + HOSTS + PORTS
kubectl get ingressclass                                   # webapprouting.kubernetes.azure.com

# --- Network policy ---
kubectl get networkpolicy -A
kubectl get ciliumnetworkpolicy -A                         # if Cilium engine

# --- Observability ---
az aks update -g rg-aks -n aks-prod-eus --enable-azure-monitor-metrics   # managed Prometheus
az aks enable-addons -a monitoring -g rg-aks -n aks-prod-eus \
  --workspace-resource-id <la-id>                          # Container Insights
kubectl -n kube-system get ds ama-logs ama-metrics-node    # agents present
kubectl get podmonitor -A                                  # scrape targets (azmonitoring.coreos.com/v1)

# --- Private cluster / egress ---
az aks create ... --enable-private-cluster                 # private API endpoint
az aks command invoke -g rg-aks -n aks-prod-eus -c "kubectl get nodes"  # reach private API
az aks create ... --outbound-type userDefinedRouting       # egress via firewall/NVA

# --- Always: validate before shipping ---
kubectl apply --dry-run=server -f manifest.yaml

Interview Q&A

Q: Why is Azure CNI Overlay the default networking recommendation over classic Azure CNI and kubenet? A: Overlay draws pod IPs from an off-VNet overlay CIDR, so only nodes consume VNet addresses — the same subnet that holds ~2 nodes under classic CNI (with maxPods=110) holds ~250 under Overlay. kubenet is on a deprecation path and has limited policy support. The only thing Overlay gives up is direct pod-IP routability from outside the cluster, which is rarely needed.

Q: How does Workload Identity remove secrets from the cluster? A: AKS exposes an OIDC issuer; a Kubernetes service account’s projected token proves its identity, and a federated credential on a managed identity trusts that token’s subject. Entra ID exchanges the SA token for an Entra access token — so the pod gets Azure RBAC with no stored secret to leak or rotate. The azure.workload.identity/use: "true" pod label triggers the injection.

Q: What’s the difference between managed NGINX ingress and Application Gateway for Containers? A: Managed NGINX (app-routing add-on) terminates TLS and routes inside the cluster — simple and portable. AGC is a managed Azure L7 with a WAF that sits in front of the cluster, driven by the Gateway API, offloading TLS and enabling weighted traffic splits. Choose AGC when you need an edge WAF and Gateway-API traffic management; NGINX for a straightforward low-ops ingress.

Q: You enabled a default-deny egress policy and the app broke. What happened? A: Egress deny also blocks DNS, which is an egress call to CoreDNS on port 53. Every name lookup fails, and the app surfaces confusing connection timeouts. Fix: allow egress to kube-system kube-dns on UDP and TCP 53 in the same change as the default-deny.

Q: Metrics, logs, or traces — which do you alert on, and why? A: Alert on metrics (cheap, real-time) — specifically user-facing SLO burn rate on the golden signals. Use logs for forensic drill-down once an alert fires (expensive per GB, so keep them lean), and traces to localize which service in a call chain is slow. Alerting on logs or raw CPU is noisy and costly.

Q: How do you keep an AKS observability bill under control? A: Two levers. Logs: Container Insights cost presets, exclude noisy namespaces, Basic Logs tier for high-volume tables, deliberate retention. Metrics: keep label cardinality bounded (no raw IDs/paths as labels) since every label combo is a separate time series. And separate the stores — Azure Monitor workspace (metrics), Log Analytics (logs), App Insights (traces) — so each is tuned independently.

Q: How do you harden the control plane and egress on AKS? A: Make the API server private (--enable-private-cluster) or restrict it with authorized IP ranges, and route egress with --outbound-type userDefinedRouting through an Azure Firewall that allow-lists required FQDNs — plus a NAT Gateway if you hit SNAT port exhaustion. That closes the inbound control-plane surface and turns egress from open into allow-listed.

Glossary

KubernetesAKSNetworkingObservabilityHelmPrometheusIngress
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments