• English icon
  • German icon
Explainer

What happens after kubectl apply on Azure Kubernetes Service

· Series: Containers on Azure

azureakskuberneteskubectlreadiness-probedevops

I deployed an app to Azure Kubernetes Service with one command, kubectl apply. One second later it said: created. The first real answer from my app came thirty-four seconds after that.

This explainer follows that one deploy second by second: what "created" really promises, why a pod that says Running gets no traffic yet, where AKS puts the public IP, and why a ready pod still isn't reachable right away. Everything below comes from a real run on my own AKS cluster.

Key points

  • Created means stored. kubectl apply returns as soon as the API server has checked your manifest and stored it as the desired state. Controllers, the scheduler and the kubelet do the actual work afterwards.
  • Running is not ready. A pod that says Running has a started container. The Service only sends traffic to pods whose readiness probe has succeeded (READY 1/1). In my run: Running at about 4 seconds, ready at 25.
  • The public IP lives in a resource group you didn't create. A Service of type LoadBalancer makes AKS add a public IP to the cluster's Azure load balancer, both in the node resource group MC_<resource group>_<cluster>_<region>, which AKS owns and deletes with the cluster.
  • Ready is not reachable. The Azure load balancer checks each node on the Service's node port, every five seconds by default, and only forwards traffic once that check passes. In my run, the first answer from outside came nine seconds after the pod was ready.

What created means

kubectl sends your YAML to the cluster's API server, the front desk of Kubernetes. The API server checks the objects, stores them as the desired state and answers right away. That answer is the word "created":

> kubectl apply -f k8s/
deployment.apps/aks-api created
service/aks-api-service created

About one second, and nothing was running yet. Then controllers take over: small loops that compare what you asked for with what exists and fix the difference. The Deployment's controller creates a ReplicaSet, whose only job is to keep the right number of pods running. A pod is the wrapper around your container; the ReplicaSet creates it. The scheduler picks a node for the pod (one of the cluster's virtual machines), and the kubelet, the agent on that node, pulls the image from your registry and starts the container. Each name builds on the one before:

> kubectl get deploy,rs,pods -l app=aks-api
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/aks-api 1/1 1 1 35s
NAME DESIRED CURRENT READY AGE
replicaset.apps/aks-api-74ffd6567d 1 1 1 36s
NAME READY STATUS RESTARTS AGE
pod/aks-api-74ffd6567d-6lht7 1/1 Running 0 36s

Created is the ticket on the rail, not the plate on the table.

Running and ready

Right after the apply I started a small watcher that prints only what changes. At about four seconds the pod said Running 0/1: the container had started. But the Service didn't send it any traffic yet, because ready is a separate check.

The Learn module's deployment manifest has a readiness probe:

readinessProbe:
httpGet:
path: /readyz
port: http # the container port named http = 8000
initialDelaySeconds: 15
periodSeconds: 10

The kubelet calls the app on /readyz. Two numbers decide when: how long the probe waits before its first call (initialDelaySeconds) and how often it calls after that (periodSeconds). Until a call succeeds, the pod isn't ready, and the Service's list of endpoints (the pods it may send traffic to) stays empty. In my run it had zero ready endpoints until 25 seconds, then one.

Running means the process started. Ready means the probe said yes. Only ready pods get traffic.

The public IP

The Service in the manifest has the type LoadBalancer:

spec:
type: LoadBalancer
selector:
app: aks-api
version: v1
ports:
- port: 80
targetPort: http

For that type, AKS asks Azure for a public IP on the cluster's load balancer and points it at the Service. The external IP said <pending> for about sixteen seconds:

> kubectl get svc aks-api-service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
aks-api-service LoadBalancer 10.0.88.69 203.0.113.10 80:31652/TCP 36s

I created one resource group for this cluster. The IP isn't in it. It sits in the cluster's node resource group, which AKS created next to mine:

> az aks show -g rg-demo -n aks-demo --query nodeResourceGroup -o tsv
MC_rg-demo_aks-demo_westeurope
> az resource list -g MC_rg-demo_aks-demo_westeurope --query "[].type" -o tsv
Microsoft.Network/publicIPAddresses
Microsoft.Network/loadBalancers
Microsoft.ManagedIdentity/userAssignedIdentities
Microsoft.Network/networkSecurityGroups
Microsoft.Network/virtualNetworks
Microsoft.Compute/virtualMachineScaleSets
Microsoft.Network/publicIPAddresses

Two public IPs (one for my Service, one for the cluster's outgoing traffic), the load balancer, the scale set behind my node, a network, a security group and an identity. AKS deletes this group with the cluster; don't change things there by hand.

The way in: clients call port 80 on the public IP. The load balancer hands the call to the node, on a node port the Service opened there (31652 in 80:31652/TCP). The Service passes it on to the pod's port named http, 8000.

Ready and reachable

Between sixteen seconds (the IP existed) and twenty-five (the pod was ready), a call from outside got nothing at all: no error, just a timeout. And even after the pod was ready, it took another nine seconds:

> python watch.py up aks-api --secs 300
...
15.8s svc aks-api-service EXTERNAL-IP 203.0.113.10
18.1s GET http://203.0.113.10/ timed out
25.0s svc aks-api-service ready endpoints 1
34.4s GET http://203.0.113.10/ 200 aks-api-74ffd6567d-6lht7

The reason sits in front of the cluster. The Azure load balancer checks every node on the Service's node port, every five seconds by default. As long as no ready pod sits behind that port, the check fails and the load balancer sends nothing to the node. After the pod is ready, the load balancer still has to see the check pass.

So there are three moments: created (1 s), ready (25 s) and reachable (34 s). Only the last one answers your users.

The fifteen seconds in my own manifest

The manifest I deployed came straight from the module. Its readiness probe waits fifteen seconds, and the kubelet only calls on a ten-second beat, so the first call landed at twenty-five. My little app doesn't need that long. I deployed the same app again with initialDelaySeconds: 1:

> python watch.py up aks-api-fast --secs 300
...
11.0s aks-api-fast-7f69b58c5-4jwsz Running 1/1
14.9s svc aks-api-fast-service EXTERNAL-IP 203.0.113.11
18.1s GET http://203.0.113.11/ 200 aks-api-fast-7f69b58c5-4jwsz

Ready at eleven seconds instead of twenty-five, the first answer at eighteen instead of thirty-four. Set that delay to what your app really needs, not to a number you copied. If an app really needs long to start, a startup probe is the cleaner tool than a long initial delay.

What happened when I ran it

Tested on October 26, 2026 in my own Azure subscription (AKS 1.36 in westeurope, Azure CLI 2.88.0, kubectl 1.36); everything was deleted afterwards. The cluster had one Standard_D2s_v5 node, the free tier and the registry attached with --attach-acr, as in the module's exercise. The app is a small Python API shaped like the exercise's (port 8000, /healthz, /readyz); the exercise's app also calls a model in Microsoft Foundry, which this run left out.

  • My first try to create the cluster in Germany West Central failed: (AKSCapacityHeavyUsage) Creating a new cluster is unavailable at this time in region germanywestcentral. West Europe worked; the cluster took 3 min 54 s.
  • kubectl events told the same story from the cluster's side: Scheduled and Pulling right after the apply, the image pulled and the container started about four seconds later, and EnsuredLoadBalancer about sixteen seconds after EnsuringLoadBalancer.
  • The load balancer had one rule for the Service, named after it: <hash>-TCP-80.
  • The watcher's clock starts about half a second after the apply returned, so the numbers above are rounded in the video.

What the video leaves out

  • Requests and limits, the other Service types (ClusterIP, NodePort) and the pod states that never start (ImagePullBackOff, CrashLoopBackOff): those get their own videos in this series.
  • The liveness probe: the Container Apps video on health probes explains liveness vs readiness.
  • externalTrafficPolicy: Local and the shared health probe mode of the AKS load balancer (preview): both change how the load balancer checks nodes.

Further reading

Video transcript

I deployed an app to Azure Kubernetes Service with one command, kubectl apply. One second later, it said: created. The first real answer from my app came thirty-four seconds after that. So what was the cluster doing all that time?

In this video, you'll see what happens between created and the first answer on AKS, with real timings from my cluster. And at the end: the fifteen seconds that were in my own manifest.

What did created actually promise? Most people read created as: my app is running. It promises much less. kubectl sends your YAML to the cluster's API server, the front desk of Kubernetes. The API server checks it, stores it as the desired state, and answers right away. That answer is the word created. I applied the folder with the two files from the Microsoft Learn module, a Deployment and a Service, with kubectl apply. Deployment created, service created, in about one second. Nothing was running yet. So who actually starts your container? Controllers: small loops that compare what you asked for with what exists, and fix the difference. A pod is the wrapper around your container. The Deployment's controller creates a ReplicaSet, whose only job is to keep the right number of pods running. The ReplicaSet creates the pod. The scheduler picks a node for that pod, one of the cluster's virtual machines. And the kubelet, the agent on that node, pulls the image from your registry and starts the container. I showed how images get into a registry in the registry video. To see that chain, I listed the deployment, its replica set and its pod. One of each, and every name builds on the one before. Created is the ticket on the rail, not the plate on the table.

When does a running pod get traffic? Right after the apply, I started a small watcher that prints only what changes, with the seconds since it started. At four seconds, the pod said Running. The container had started. So the pod is running. Will the Service send it traffic? Not yet. Look at the ready column: zero of one. A Service only sends traffic to pods that are ready, and ready is a separate check. The manifest has a readiness probe. The kubelet calls the app on a path, /readyz, on the container's port. The manifest gives that port a name, http. Two numbers decide when: how long the probe waits before its first call, and how often it calls after that. Until a call succeeds, the pod is not ready. Keep those two numbers in mind. The same watcher also counted the Service's ready endpoints, the pods it may send traffic to. Zero, until twenty-five seconds. Then one. I showed what readiness probes can and can't catch in the health probe video. Running means the process started. Ready means the probe said yes. Only ready pods get traffic. This series runs one Azure service at a time, for real. Subscribe to follow the series.

Where does the public IP come from? The Service in my manifest has the type LoadBalancer. For that type, AKS asks Azure for a public IP on the cluster's load balancer, and points it at the Service. Then I asked for the service itself, the way the module shows it. Type LoadBalancer, and an external IP. In my run, the external IP said pending for about sixteen seconds. Then Azure had built it. I created one resource group for this cluster. So where did Azure put that public IP? Not in mine. I asked AKS for the cluster's node resource group, the place where it keeps the cluster's Azure resources. MC_, then my resource group, the cluster's name and the region. Then I listed every resource in that node resource group, by type, to see what Azure had built for my cluster. Two public IPs: one for my Service, one for traffic going out. A load balancer. The scale set behind my node. A network, a security group and an identity. AKS created this second group with the cluster, and it deletes it with the cluster. Look, but don't change things there by hand. The way in tells the rest. Clients call port eighty on the public IP. The load balancer hands the call to the node, on a port the Service opened there, a node port. And the Service passes it on to the pod, on port eight thousand. Your Service asks, Azure builds the door, and AKS owns it.

What happens to a call before the pod is ready? In my run, the public IP existed from sixteen seconds. The pod was ready at twenty-five. In between, my watcher kept calling the IP. What does a call from outside get? Nothing at all. Not even an error. Here's the watcher again, from the moment the IP existed. Timed out at eighteen seconds. Ready at twenty-five. The first answer at thirty-four. The reason sits in front of your cluster. The Azure load balancer checks every node on the Service's node port, every five seconds by default. As long as no ready pod sits behind that port, the check fails, and the load balancer sends nothing to the node. After the pod is ready, the load balancer still has to see that check pass. In my run, that took another nine seconds. So there are three moments: created, ready and reachable. Only the last one answers your users.

To sum it up: created means the API server stored your manifest. Running means the container started. Ready means the readiness probe said yes, and only ready pods get traffic. The public IP comes from Azure, in the node resource group that AKS owns. And reachable means the load balancer has seen your pod too.

And the fifteen seconds in my own manifest? The manifest I deployed came straight from the module. Its readiness probe waits fifteen seconds, and it only calls on a ten-second beat. In my run, its first call landed at twenty-five. My little app doesn't need that long. So I deployed the same app again, with the first call after one second, and ran the watcher again. Ready at eleven seconds instead of twenty-five. The first answer at eighteen instead of thirty-four. Set that delay to what your app really needs, not to a number you copied.

The video on screen now shows what a readiness probe can catch, and what it can't. If this was useful, subscribe for more practical .NET and Azure.

Need this in your own Azure environment?

I build, migrate and run .NET platforms on Azure, with Azure DevOps and Terraform. If your team wants this set up properly, see what I offer.

See services