Kubernetes conducts the show.
Two tools, two jobs, one idea behind both. This is the story of how a .NET monolith becomes a set of microservices on Azure, told in five acts, with diagrams you can play with.
A single monitor is glowing. On it, a graph climbs like a heartbeat that will not come down. Contoso's online store, one big Contoso.Web.dll on one big IIS server, is drowning in traffic that wants only one thing: the shoe on sale.
The catalogue page is the part that is melting. But checkout, cart, invoicing and the emails all live inside the same binary, on the same machine. To give the catalogue more power, you have to give everything more power. There is no volume knob for one instrument. There is only the whole orchestra, louder.
Someone opens the deploy script. It is 400 lines of PowerShell. At the top there is a comment: "do not run twice."
Two different things are failing in that room, and it is worth being clear about which is which, because each has its own hero, and most articles introduce only one.
The first failure is the venue. One server, built by hand, that nobody can rebuild. Four people put it together over eighteen months of clicking in the portal, and two of them have left. That machine is not infrastructure; it is a museum piece. Nobody dares to touch it and nobody can recreate it.
The second failure is the show. Even with a perfect venue, someone still has to decide what runs where, notice when a process dies, add capacity when the queue grows, and swap instruments in the middle of the performance without the audience hearing a gap. At 2 a.m., that someone is a person holding a laptop.
Terraform fixes the first one. It builds the venue from a written plan, exactly the same way every time, and it will build you a second one on Tuesday if you ask.
Kubernetes fixes the second. It reads the music sheet, watches every section, and keeps the show going whether or not anyone is awake.
Neither is enough on its own. A perfect cluster full of hand-deployed chaos is still chaos. Beautiful Terraform pointing at nothing is just a very well-documented empty room.
We will follow one company, Contoso Commerce, as it breaks its monolith into four .NET microservices and gets them running properly. Four services is enough to be real, and small enough to keep in your head:
| Service | Job | Why it's separate |
|---|---|---|
| catalog-api | Products, search, images | Mostly reads. Traffic jumps 40× on sale days. Needs to scale on its own. |
| cart-api | Basket state per shopper | Talks a lot, uses Redis. Fails in a completely different way. |
| orders-api | Checkout, payment, fulfilment | Must never lose a write. Deployed carefully and rarely. |
| identity-api | Sign-in, tokens | A security boundary. Its own team, its own release schedule. |
The main text is the story, in plain English, with no background needed. Wherever you see an Under the hood panel, click it for the real Terraform, YAML and pipeline code. Skip all of them and the story still makes sense.
Before the acts begin, one idea makes the rest of this article easy. Terraform and Kubernetes look like completely different tools. Really, they are the same tool, running at two different speeds, in two different worlds.
Neither of them takes orders. You cannot tell either one to "create a VM" or "start a container". You can only tell them what should be true. Then they go and make it true, and, this is the important part, they keep making it true afterwards.
This behaviour has a name: a reconciliation loop. Four steps, again and again, forever. Switch between the two heroes below and see the same loop in two different costumes.
…
Same four boxes. What changes is the clock and the world. Terraform's loop runs when you run it, takes minutes, and its world is your Azure subscription. Kubernetes' loop runs a few times a second, forever, and its world is the inside of the cluster.
| Terraform | Kubernetes | |
|---|---|---|
| Builds | The theatre — clusters, networks, registries, identities | Nothing. It manages what is already there. |
| Its world | Azure Resource Manager | The cluster's own state store (etcd) |
| Memory | A state file, in blob storage | etcd, managed for you |
| Loop speed | On demand — minutes | Continuous — seconds |
| Changes are | Reviewed by a human first | Applied instantly, then watched |
| Failure looks like | A plan that won't apply | A pod stuck in Pending |
Learn the loop once and both heroes stop being a pile of commands to memorise. Every confusing thing either tool does, like drift, Pending pods, "no changes" or surprise re-creations, is just the loop doing exactly what it promised, using information you did not know it had.
Our first hero walks in with a simple but bold idea: your environment is a text file in your repository. If it is not in the file, it does not exist. If someone clicks something in the portal, the next Terraform run will notice and offer to undo it.
The Azure Portal is a conversation. Terraform is a contract. Conversations get forgotten. Contracts get reviewed, versioned and signed, and someone who was not in the room can run them again next year.
Remember the loop. Terraform's "observe" step is unusually careful, because it compares three things, not two, and knowing which three explains almost every surprising plan you will ever read.
| Source | What it is | Where it lives |
|---|---|---|
| Your .tf files | What you want | Git — reviewed in pull requests |
| The state file | What Terraform believes it built | An Azure Storage blob, locked while Terraform runs |
| Azure itself | What actually exists right now | Read live through the Azure API on every plan |
When all three agree, the plan is empty and Terraform says the most comforting words in infrastructure: No changes. Your infrastructure matches the configuration. When they disagree, the difference is the plan.
Never keep the state file on a laptop. Put it in an Azure Storage account with blob versioning switched on, and let Terraform use the blob lease as its lock. That lock is the only thing stopping two pipelines from applying changes to the same cluster at the same time, which damages the state file in ways that take a very bad afternoon to fix.
Below is what a real terraform apply looks like from the outside. Press play. Watch the steps sort themselves out: you never told Terraform that the subnet must exist before the cluster. It worked that out because one refers to the other.
Ten resources, one command, in the right order, with the independent ones running in parallel. You described relationships; Terraform worked out the steps. That flip is the whole value of Terraform, and it is why the same file works on an empty subscription and on one where nine of the ten resources already exist.
The theatre is now standing: a virtual network, a container registry, a Log Analytics workspace, an AKS cluster with two node pools, and the identities that let them talk to each other without a single password. What all of that means is Act II's job; this act only cares that it was built from a file you can read, review and run again.
Fix your provider versions. A provider without a fixed version is a time bomb that goes off on a Friday. The azurerm provider is on version 4.x, and several setting names changed from 3.x, which is the most common reason a copied snippet does not apply.
terraform {
required_version = "~> 1.9"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.30" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
}
# remote state: shared, locked, versioned
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "stcontosotfstate"
container_name = "tfstate"
key = "prod/aks.tfstate"
use_azuread_auth = true # no storage keys anywhere
}
}
provider "azurerm" {
features {}
use_oidc = true # federated auth from CI — no client secret
}
resource "azurerm_virtual_network" "main" {
name = "vnet-contoso-prod"
address_space = ["10.40.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "aks" {
name = "snet-aks-nodes"
resource_group_name = azurerm_resource_group.main.name
# ↓ THIS reference is what creates the dependency edge.
# No depends_on needed — Terraform reads the graph from your code.
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.40.0.0/20"]
}
resource "azurerm_kubernetes_cluster" "main" {
name = "aks-contoso-prod"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = "contoso-prod"
# Standard tier buys a financially-backed control-plane SLA.
# Free tier gives you an SLO and no money back. Use Standard in prod.
sku_tier = "Standard"
kubernetes_version = "1.35" # AKS keeps 3 GA minors in support
automatic_upgrade_channel = "patch" # CVEs patch themselves
node_os_upgrade_channel = "NodeImage"
# passwordless everything
oidc_issuer_enabled = true
workload_identity_enabled = true
local_account_disabled = true # no static kubeconfig admin creds
identity { type = "SystemAssigned" }
azure_active_directory_role_based_access_control {
azure_rbac_enabled = true
admin_group_object_ids = [var.platform_admins_group_id]
}
default_node_pool {
name = "system"
vm_size = "Standard_D4as_v5"
vnet_subnet_id = azurerm_subnet.aks.id
auto_scaling_enabled = true # was enable_auto_scaling in azurerm 3.x
min_count = 2
max_count = 4
only_critical_addons_enabled = true # keep apps off the system pool
zones = ["1", "2", "3"]
upgrade_settings { max_surge = "33%" }
}
network_profile {
# Overlay: pods get IPs from a private space, not your VNet.
# You stop running out of subnet at 400 pods. Do this.
network_plugin = "azure"
network_plugin_mode = "overlay"
network_policy = "cilium"
network_data_plane = "cilium"
pod_cidr = "192.168.0.0/16"
load_balancer_sku = "standard"
}
web_app_routing { # managed NGINX ingress
dns_zone_ids = [azurerm_dns_zone.shop.id]
}
oms_agent {
log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
msi_auth_for_monitoring_enabled = true
}
lifecycle {
# the cluster autoscaler owns node_count at runtime — don't fight it
ignore_changes = [default_node_pool[0].node_count]
}
}
# app workloads live in their own pool, scaled independently
resource "azurerm_kubernetes_cluster_node_pool" "apps" {
name = "apps"
kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
vm_size = "Standard_D4as_v5"
vnet_subnet_id = azurerm_subnet.aks.id
auto_scaling_enabled = true
min_count = 3
max_count = 30
zones = ["1", "2", "3"]
}
# let the cluster pull images without a single password
resource "azurerm_role_assignment" "acr_pull" {
scope = azurerm_container_registry.main.id
role_definition_name = "AcrPull"
principal_id = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
}
Three lines worth discussing in code review: network_plugin_mode = "overlay" (stops you running out of VNet IP addresses), local_account_disabled = true (removes the shared admin kubeconfig that ends up pasted in Teams), and ignore_changes on node_count. Without that last one, Terraform and the cluster autoscaler will fight forever, each one "fixing" the other's work.
The old way: a connection string in a Kubernetes Secret, base64-encoded (which is encoding, not encryption), and sooner or later someone commits it. The new way: the pod proves who it is with a short-lived token, and Azure gives it access. Nothing to leak, nothing to rotate, nothing to find in Git history two years from now.
# 1. the pod's Azure identity
resource "azurerm_user_assigned_identity" "catalog" {
name = "id-catalog-api"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
# 2. the trust: "the catalog-sa account in namespace 'shop' IS this identity"
resource "azurerm_federated_identity_credential" "catalog" {
name = "fic-catalog-api"
resource_group_name = azurerm_resource_group.main.name
parent_id = azurerm_user_assigned_identity.catalog.id
audience = ["api://AzureADTokenExchange"]
issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url
subject = "system:serviceaccount:shop:catalog-sa"
}
# 3. and what it's allowed to touch — nothing more
resource "azurerm_role_assignment" "catalog_kv" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.catalog.principal_id
}
On the .NET side, that is the whole change: one line, no secret.
builder.Configuration.AddAzureKeyVault(
new Uri("https://kv-contoso.vault.azure.net/"),
new DefaultAzureCredential()); // picks up workload identity automatically
One folder per environment, one shared module. Dev and prod differ only by a variables file, never by a separate copy of the code. The moment they drift apart, "it works in dev" stops meaning anything.
infra/
├── modules/
│ └── aks-platform/ # cluster + network + acr + identities
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── envs/
├── dev/
│ ├── main.tf # module "platform" { source = "../../modules/aks-platform" }
│ └── dev.tfvars # node_min=1 node_max=3 sku_tier="Free"
└── prod/
├── main.tf
└── prod.tfvars # node_min=3 node_max=30 sku_tier="Standard"
# envs/prod/main.tf
module "platform" {
source = "../../modules/aks-platform"
environment = "prod"
location = "westeurope"
node_min = 3
node_max = 30
sku_tier = "Standard"
}
The theatre is built. Our second hero walks on, and here is the most useful thing to know about AKS, which almost nobody says clearly: a cluster has two halves, and Microsoft runs only one of them.
The half Microsoft runs is the control plane, the brain. It holds the desired state of your world ("there should always be six catalogue pods"), decides which machine each container runs on, and keeps comparing what is with what should be. You do not patch it, you do not log in to it, and on the Free tier you do not pay for it. Terraform created it in about three minutes and then had nothing more to do with it.
The half you own is the node pools: normal Azure virtual machines that do the actual work. Your containers run here. You pay for them by the second, like any other VM.
The control plane is the conductor. It reads the music sheet, watches every section and points. It plays no notes. The nodes are the musicians. They make all the sound and take none of the decisions. The moment a violinist walks off stage, the conductor notices, and a replacement is already sitting down.
Click through the diagram. Terraform created every box you just saw, and each one is something you will either configure in HCL or shout about during an incident.
You met this at the start. Here it is doing the job that makes Kubernetes feel alive:
You: "There should be six catalogue pods."
Kubernetes: "There are six. Fine."
A node catches fire. Two pods die.
Kubernetes: "There are four. That is not six."
Kubernetes: schedules two more, somewhere healthy, in about twenty seconds.
You: still asleep.
Nobody wrote "if a pod dies, start another one". There is no if. There is only the gap between what was declared and what exists, being closed again and again. Terraform does this when asked, in minutes, against Azure. Kubernetes does it all the time, in seconds, against the cluster. Same hero, different shift.
This is the declaration. replicas: 6 is the "what you want". Everything else tells the scheduler how to keep that promise safely.
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
spec:
replicas: 6
selector:
matchLabels: { app: catalog-api }
template:
metadata:
labels:
app: catalog-api
azure.workload.identity/use: "true"
spec:
serviceAccountName: catalog-sa # ← matches the federated credential
containers:
- name: api
image: contosoacr.azurecr.io/catalog-api:1.14.0
ports: [{ containerPort: 8080 }]
# requests = what the scheduler reserves. limits = the ceiling.
resources:
requests: { cpu: "150m", memory: "192Mi" }
limits: { cpu: "800m", memory: "512Mi" }
# "am I alive?" — fail and I get restarted
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 10
# "am I ready for traffic?" — fail and I'm pulled from the load balancer
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
The two probes are the most under-used feature in Kubernetes. Liveness answers "should I be killed?" Readiness answers "should I get traffic?" Get readiness wrong and every deploy drops requests, because traffic arrives before your .NET app has finished setting up its DI container and EF Core model.
In ASP.NET Core, the endpoints are three lines:
// Program.cs
builder.Services.AddHealthChecks()
.AddDbContextCheck<CatalogContext>("db", tags: ["ready"]);
app.MapHealthChecks("/healthz", new() { Predicate = _ => false }); // process is up
app.MapHealthChecks("/readyz", new() { Predicate = r => r.Tags.Contains("ready") });
Terraform owns the cluster. Kubernetes owns what runs inside it. Keep that line sharp. Do not let Terraform apply your application manifests (it will fight the controllers, and its state file will go out of date every time a pod moves), and do not let Kubernetes create Azure infrastructure it cannot understand. Each hero in its own world.
Terraform built the theatre. Kubernetes is ready to conduct. But something has to carry the music from a developer's laptop to the stage, reliably, in minutes, many times a day, with the right people checking at the right moments. That is the pipeline.
Start from the idea, not the tools. Both our heroes accept only one kind of input: a written description of what should be true. So the question "how do we deploy?" becomes a much better question: how does a change to that description get proposed, reviewed and accepted?
Git already answers that. A branch is a proposal. A pull request is a review. A merge is acceptance. CI/CD is the machinery that makes a merge mean something in the real world.
You are not "pushing code to servers". You are proposing a change to the description of your production system, getting it reviewed, and once it is accepted, letting two reconciliation loops make the real world match the new description.
Here is the difference that unlocks everything, and mixing it up is the most common mistake teams make. One hero's description changes weekly. The other's changes hourly. They should not share a pipeline, a repository or a set of permissions.
| Infrastructure pipeline | Application pipeline | |
|---|---|---|
| Changes | Cluster, network, ACR, identities | The four .NET services |
| Tool | Terraform | Docker + Helm/kubectl |
| Cadence | Weekly, maybe | Several times a day |
| Approval | A person always reads the plan difference | Several layers of checks, mostly automatic, with a person only where judgement is needed |
| If it goes wrong, it affects | Everything | One service |
| Repo | contoso-platform | contoso-catalog, -cart, … |
If deploying a one-line text fix to the catalogue page means running Terraform, then every deploy carries the risk of accidentally re-creating a node pool. Keep the thing that changes hourly far away from the thing that can delete a database.
There is a myth here worth killing: that continuous deployment means removing human approval. It does not, and teams that chase that goal end up either reckless or, more often, bringing back a Thursday-afternoon change board and feeling bad about it.
Mature pipelines have more checks than manual processes had, not fewer. What changes is who does them and when. The old model had one check at the end, done by a person reading a change ticket about software they had never seen. The new model has six or seven checks, most of them automatic, and the human ones sit exactly where a person adds judgement a machine cannot.
| Gate | The question it asks | Who answers | What it blocks |
|---|---|---|---|
| Pull request review | Is this change correct and well-designed? | A human teammate | The merge |
| Tests & coverage | Did we break anything we knew about? | The pipeline | The build |
| Image scan & signing | Are we shipping a known security hole (CVE)? | The pipeline | The push to ACR |
| Staging smoke tests | Does it run when assembled? | The pipeline | Promotion |
| Canary analysis | Does real traffic behave? Response time, server errors, how close we are to limits | Automated metric analysis | The full rollout |
| Environment approval | Should this ship now, given what else is happening? | Named humans — on high-risk services | Production deploy |
| Change freeze | Are we inside a protected window? | Policy | Everything |
Look at what the humans are being asked. Not "did 214 tests pass"; a person is worse at that than the machine, and asking them turns the approval into a rubber stamp. They are asked the one thing the pipeline cannot know: is this a good time, given everything happening outside this repository? A sale starting in an hour. A payment provider already having a bad morning. An audit window. That is judgement, and it belongs to a person.
Group your services by risk and let the pipeline behave differently for each group. Giving catalog-api the same heavy process as orders-api slows everyone down for nothing; giving orders-api the speed of catalog-api is how you refund people by mistake.
Every human approval is a queue. It adds hours of waiting, it bundles unrelated changes together (which makes the eventual failure harder to find), and if it fires on every deploy, within about three weeks people approve it without thinking. So add approvals carefully: an approval that is always given is not a control, just a delay with paperwork. If your approver has never once said no, that approval is telling you something.
Run the pipeline below. The first button deploys catalog-api, which promotes itself based on canary health numbers. The second deploys orders-api, which stops and waits for you. The third pushes a commit with a vulnerable library, so you can watch an automatic check do its job.
There is a hand-over worth naming, where our two heroes swap places one last time. The pipeline's job ends at helm upgrade: it changes the desired state and walks away. It does not "deploy" in the old sense of copying files onto servers. It submits a change. Kubernetes then does the real work: pulling images, starting pods, waiting until they are ready, moving traffic and removing the old ones.
So the pipeline's last real task is waiting and judging: kubectl rollout status watches the rollout and fails the build if the new pods never become ready. Without that line, a broken image gets a green tick and you find out from a customer.
Everything above is push: the pipeline holds credentials and reaches into the cluster. The other way is pull: an agent inside the cluster (Argo CD, or the AKS GitOps add-on based on Flux) watches a Git repo and applies changes itself. Then CI never touches the cluster at all; it just writes a new image tag into a manifests repo, and approving a deploy becomes approving a pull request. Push is easier to start with. Pull works better beyond a handful of clusters, because the cluster's real state is always a commit you can compare, which is the reconciliation loop applied to the delivery process itself.
See that there is no password anywhere. OIDC federation lets the workflow prove it is this repo, this branch and get a short-lived Azure token.
name: catalog-api
on:
push:
branches: [main]
paths: ['src/Catalog/**'] # only build what changed
permissions:
id-token: write # ← required for OIDC login
contents: read
env:
ACR: contosoacr.azurecr.io
IMAGE: catalog-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test
run: dotnet test ./src/Catalog --logger trx
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Build & push image
run: |
az acr login --name contosoacr
# tag with the commit SHA — never 'latest' in production
TAG=${GITHUB_SHA::7}
docker build -f src/Catalog/Dockerfile \
-t $ACR/$IMAGE:$TAG .
docker push $ACR/$IMAGE:$TAG
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.ACR }}/${{ env.IMAGE }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: '1' # fail the build, don't just warn
And the Dockerfile: multi-stage, not running as root, with a chiselled runtime. Chiselled images have no shell and no package manager, which removes most of what a security scanner would complain about:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln .
COPY src/Catalog/*.csproj src/Catalog/
RUN dotnet restore src/Catalog # cached layer: restore before copying code
COPY . .
RUN dotnet publish src/Catalog -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID # non-root by default
ENTRYPOINT ["dotnet", "Contoso.Catalog.dll"]
In GitHub Actions, the approval is an environment with protection rules. The job names the environment, and GitHub handles the pause, the notification, the audit trail and the "you cannot approve your own deploy" rule. Azure DevOps environments have the same idea as Approvals and Checks.
deploy-canary:
needs: build
runs-on: ubuntu-latest
environment: production-canary # no reviewers — automatic
steps:
- run: az aks get-credentials -g rg-contoso-prod -n aks-contoso-prod
- run: |
helm upgrade --install orders-canary ./charts/orders \
--namespace shop --set image.tag=${GITHUB_SHA::7} \
--set replicaCount=1 --set canary=true --atomic
- name: Analyse canary for 10 minutes
run: ./scripts/canary-analysis.sh --window 10m \
--max-error-rate 0.5 --max-p95-regression 20
deploy-prod:
needs: deploy-canary
runs-on: ubuntu-latest
# ↓ THIS is the human gate. Configured in repo settings:
# Required reviewers: @contoso/payments-oncall (2)
# Wait timer: 0 · Prevent self-review: on
# Deployment branches: main only
environment:
name: production
url: https://shop.contoso.com
steps:
- run: |
helm upgrade --install orders ./charts/orders \
--namespace shop --set image.tag=${GITHUB_SHA::7} \
--atomic --timeout 5m
- run: kubectl rollout status deploy/orders-api -n shop --timeout=180s
Two details that make the approval real, not just for show. --atomic means a rollout that does not become healthy within the timeout is rolled back automatically, so the approver is allowing an attempt, not a one-way door. And prevent self-review means the person who wrote the change is not the person who approves it, which is the whole point of having an approval.
Run plan on the pull request so reviewers see the difference. Apply only after a person clicks approve.
jobs:
plan:
runs-on: ubuntu-latest
steps:
- run: terraform init
- run: terraform plan -out=tfplan -lock-timeout=5m
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/github-script@v7 # post the plan as a PR comment
with: { script: '...comment plan.txt on the PR...' }
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: tfplan }
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: prod-infra # ← required reviewers live here too
steps:
- uses: actions/download-artifact@v4
with: { name: tfplan }
# apply the SAVED plan, not a fresh one — what was reviewed is what runs
- run: terraform apply -auto-approve tfplan
Applying the saved plan file matters more than it looks. If you plan again at apply time, what runs is not what anyone reviewed. Drift, a teammate's portal click or a new provider version can all change it in between, and then your approval was for a document that no longer exists.
The pipeline has walked away. Kubernetes is alone on stage now, and this is where our second hero earns top billing. "Orchestration" usually just means "some Kubernetes stuff happens". It is more specific than that: four jobs, running all the time, that nobody has to ask for.
You said catalog-api needs 150 millicores of CPU and 192 MB of memory. The scheduler finds a node with space, follows your rules ("spread these across three availability zones", "keep batch jobs off this pool"), and places the pod there. If no node has space, the cluster autoscaler asks Azure for another VM, and about three minutes later there is one. Note that Terraform set the range (3 to 30 nodes); Kubernetes picks the number, minute by minute.
orders-api needs to call catalog-api. It does not know an IP address. It calls http://catalog-api.shop.svc.cluster.local, a name that points, inside the cluster, to whichever pods are healthy right now. Pods die and come back with new IP addresses every day. The name never changes.
Before: a config file with 10.2.14.7 in it, and a ticket to update it whenever the server is rebuilt.
After: a name. The infrastructure keeps the promise behind it.
The Horizontal Pod Autoscaler watches CPU, memory, or your own signal like "messages waiting in the Service Bus queue", and adds pods. If the pods do not fit, the cluster autoscaler adds nodes. Two loops, one inside the other, both closing gaps.
This is the one you will care about most, because it runs during every deploy. Kubernetes replaces old pods with new ones gradually, and will not send traffic to a new pod until that pod says it is ready. Two numbers control the whole thing; play with them and see what happens to your capacity in the middle of a deploy.
Set maxUnavailable to 0 and the rollout never drops below full capacity; it costs a few extra pods' worth of compute for a couple of minutes. Set it to 4 and the deploy is faster, but you run at half capacity in the middle of it, which is fine at 3 a.m. and a disaster during a sale. This is the trade-off, and you decide it for each service: the same risk grouping that decided who needs a human approval decides these two numbers too.
spec:
replicas: 8
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2 # may temporarily run 10
maxUnavailable: 0 # never dip below 8 ready
minReadySeconds: 10 # "ready" must hold for 10s before we trust it
template:
spec:
terminationGracePeriodSeconds: 45 # let in-flight requests finish
topologySpreadConstraints: # don't put all 8 in one zone
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: catalog-api }
CPU tells you about load only after it has arrived. For orders-api, the queue length warns you before it arrives. KEDA scales on the queue length directly, and can even scale down to zero when the queue is empty:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: orders-worker }
spec:
scaleTargetRef: { name: orders-worker }
minReplicaCount: 1
maxReplicaCount: 40
triggers:
- type: azure-servicebus
metadata:
queueName: orders-to-fulfil
messageCount: "20" # one pod per 20 queued messages
authenticationRef: { name: azure-workload-identity }
A PodDisruptionBudget stops planned disruptions, like node upgrades and autoscaler clean-up, from taking down more pods than you can afford. Without it, an AKS node image upgrade can drain three nodes and leave you with just one pod for a while:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: catalog-pdb }
spec:
minAvailable: "70%"
selector:
matchLabels: { app: catalog-api }
Kubernetes sends SIGTERM and waits. If your app exits immediately, you lose whatever requests were in progress. The .NET Generic Host handles this correctly if you let it:
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(30)); // < terminationGracePeriod
Every architecture is a theory until something goes wrong. Here is the part that justifies all the effort: on the night an Azure host fails and takes a node with it, the difference between the old world and this one is whether your phone rings.
A node dies. No warning, no draining. It just stops responding.
Forty seconds pass, four missed heartbeats, and the control plane marks the node NotReady. Those pods are removed from every service's routing list, so within a minute traffic stops going to pods that no longer exist.
Then nothing happens for five more minutes. That is the default wait for an unreachable node (the node.kubernetes.io/unreachable taint), and the wait is on purpose: Kubernetes would rather sit out a short network problem than move your whole workload because a network switch had a hiccup. When the wait ends, the pods are removed for real. The deployment controller does its maths: seven pods, should be eight. The scheduler finds space on the healthy nodes. New pods start. Readiness probes pass. Capacity is back to full.
Meanwhile, the autoscaler has already asked Azure for a replacement VM, using the node pool definition Terraform wrote months ago and has not thought about since.
Traffic protected in under a minute, capacity back in about five, people involved: zero. If five minutes of running short is too long for you, lower tolerationSeconds in the pod spec, and accept that an unstable network will now make pods move around more. The monitor still glows, but nobody is watching it, because there is nothing to watch.
Try it yourself. Kill a node, or turn up the traffic until the cluster has to grow.
Self-healing has a hard limit, and it is fair to say so. Kubernetes will restart a crashed pod forever. It cannot tell you the pod crashes because of a null reference on line 84. It will happily run eight copies of a broken build. It will not notice that your responses became twice as slow, only that the process is still alive. And Terraform will rebuild a badly designed network exactly as badly, every time you ask.
That is why the last piece of the picture is seeing. Container Insights and Managed Prometheus collect metrics; Managed Grafana draws them; Application Insights follows a single order across all four services and shows you which step got slow. Without that, you have an orchestra playing perfectly in a room with no windows.
Alert on problems your customers feel, not on their causes. High CPU is not an incident. A 12% error rate is.
| Signal | Threshold | Why |
|---|---|---|
| CrashLoopBackOff | any pod, 5 min | A restart loop is a failed deploy that nobody noticed. |
| HTTP 5xx rate | > 2% over 5 min | The only number a customer experiences. |
| Response time of the slowest 5% | > 2× the usual value over the last 7 days | Catches "slow" before it becomes "down". |
| Node pool at max | at max_count > 10 min | You have hit your limit; the autoscaler has no more room. |
That last one is where the two heroes talk to each other: the alert fires because Kubernetes has used up the space Terraform gave it. The fix is a pull request that changes max_count, reviewed, planned and applied, not a click in the portal.
Terraform builds the theatre: it writes down what the world should look like and makes Azure match, carefully, with a person reading the difference. Kubernetes conducts the show: it reads what should be running and spends the rest of its life closing the gap, placing pods, resolving names, adding capacity and replacing the dead. Git is the music sheet both of them read from, and the pipeline is how a new page gets added.
That is it. Everything else is detail, and details can be learnt. The idea is what matters.
Both heroes have the same superpower, and neither can do the other's job. Terraform: declared vs. Azure, minutes, when asked. Kubernetes: declared vs. running, seconds, forever. GitOps: declared vs. cluster, applied to your delivery process itself. Learn the loop once and the whole ecosystem stops being a pile of tools and becomes one idea, repeated at three speeds.
| # | Do this | Not this |
|---|---|---|
| 1 | Terraform first — one cluster, one node pool, from a file, from day zero | Clicking a cluster together "to try it" and never rebuilding it |
| 2 | Remote state in Azure Storage from the very first init | Local state "just for now" |
| 3 | Workload identity and OIDC federation from day one | Retrofitting security after the secrets are everywhere |
| 4 | One service, deployed end to end by a pipeline, before adding a second | Designing a multi-region mesh before anything runs |
| 5 | Readiness probes and resource requests on every service | Defaults, then wondering why deploys drop traffic |
| 6 | Risk-tier your services and gate them differently | One approval policy for everything — or none at all |
| 7 | Observability wired up before your first real incident | Adding dashboards while production burns |
Do you need any of this? If you run three services and deploy weekly, Azure Container Apps or App Service will serve you better and cost far less in human effort. Terraform is still worth every minute, because the first hero's argument has nothing to do with Kubernetes. AKS is worth its complexity when you have many services, many teams, traffic that changes a lot, and a platform team that owns the cluster. Choosing it too early is a common and costly mistake; choosing it too late is a painful one. Both are worth thinking about honestly.
Every diagram on this page is a live simulation, not a screenshot. The timings are examples; the mechanics are real. Code samples are for azurerm 4.x, Kubernetes 1.34+ and .NET 9. AKS supports three generally available minor versions at a time, and several provider setting names changed between azurerm 3.x and 4.x, so check the docs for your pinned versions before copying anything into production.
Discussion