Both pods said Running, one of one ready. The Service had a public IP. And every call I made to that IP timed out: ten in a row, with no error in any status.
This explainer breaks a Kubernetes Service on Azure Kubernetes Service twice, on my own cluster, and shows what really connects a Service to its pods: the labels it selects, the port it calls, and the address you call it on.
Key points
- A Service follows labels, not names. It never looks at your Deployment. Its selector is a list of labels, and only pods that carry every one of them, with the same values, go on its endpoint list.
- One missing label is silent. With no matching pod, the endpoint list is empty: the pods are Running and ready, the Service has its IP, and no warning or event tells you anything.
- Nobody checks the target port.
portis what callers use,targetPortis where the pod listens. A wrong target port keeps the pods on the list, and calls get "connection refused", then "timed out" once the Azure load balancer's own check gives up on the node. - Inside the cluster, call the name; from outside, the load balancer. ClusterIP gives a name and an internal address, NodePort adds a port on every node (which has no public IP on AKS), LoadBalancer adds the public IP.
Labels, not names
A Service is one stable address in front of your pods, and it passes each call on to one of them. Most people picture it pointing at a Deployment. It doesn't: it has a selector, a short list of labels. Labels are name tags, a key and a value, that the pod template puts on every pod. Kubernetes compares each pod's labels with the selector; a pod that carries all of them goes on the Service's endpoint list (its address and port), and the Service only sends calls to the ready pods on that list.
For the run I took the Service from the Microsoft Learn module, which selects two labels, app and version, and put it in front of my own deployment web, whose pod template only sets app: web:
# Servicespec:type: LoadBalancerselector:app: webversion: v1ports:- port: 80targetPort: http---# Deployment: the pod template (the broken version)template:metadata:labels:app: web
Everything looked fine:
> kubectl get pods,svc -l app=webNAME READY STATUS RESTARTS AGEpod/web-6555d997fb-vpl2j 1/1 Running 0 53spod/web-6555d997fb-xhnn2 1/1 Running 0 53sNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEservice/web LoadBalancer 10.0.160.151 203.0.113.12 80:31713/TCP 53s
The Service's own description showed the problem, and nothing else did:
> kubectl describe svc web...Selector: app=web,version=v1Type: LoadBalancer...TargetPort: http/TCPNodePort: http 31713/TCPEndpoints:...
The Learn module shows Endpoints: <none> for this case; with kubectl 1.36 the line was simply empty. The pods' labels:
> kubectl get pods -l app=web --show-labelsNAME ... LABELSweb-6555d997fb-vpl2j ... app=web,pod-template-hash=6555d997fb,...web-6555d997fb-xhnn2 ... app=web,pod-template-hash=6555d997fb,...
No version. One missing label, and neither pod made the list. The fix is one line in the pod template, version: v1. A changed template means new pods, so the Deployment rolled out two new ones with both labels and removed the old ones:
> python watch.py up web --secs 18021.5s web-654b6b7b85-sjwzj Running 1/122.0s svc web ready endpoints 132.0s GET http://203.0.113.12/ 200 web-654b6b7b85-sjwzj
Two ports
A Service has two ports. port is what callers use (80). targetPort is where the pod listens. In my manifest the target port is a name, http, and the container gives its port that name:
# Deployment: the containerports:- name: httpcontainerPort: 8000
So the Service follows the container. Then I broke it on purpose: targetPort: 8080, a number you see in many examples, the module's too, on an app that listens on 8000. The labels still matched, so both pods stayed on the list. Four calls, three seconds apart:
> python watch.py get web --tries 4 --gap 32.4s GET http://203.0.113.12/ connection refused7.7s GET http://203.0.113.12/ connection refused12.8s GET http://203.0.113.12/ connection refused20.8s GET http://203.0.113.12/ timed out
Refused means the call reached a pod and nothing listened on 8080; Kubernetes never checks that port. The timeout comes from the Azure load balancer. Your pods run on nodes, the virtual machines of the cluster; for a Service with a public IP, every node opens a port of its own, the node port, and passes what arrives there on to the target port. The load balancer checks that node port on every node, every five seconds by default (with externalTrafficPolicy: Cluster, AKS's default probe mode). Its check lands on the same wrong port and fails, and after two misses the load balancer stops sending anything to the node.
The same check explains the first break: with an empty endpoint list the check failed too, so those calls only timed out. And after the label fix, the first answer came ten seconds after the pod was ready: the check had to pass again first. Back to targetPort: http, and the app answered again after about eight seconds.
Give the target port the container port's name, and the Service follows your container.
Which address
The Service type decides who can reach it, and the three types build on each other:
| Type | What it adds | Reachable from |
|---|---|---|
| ClusterIP (the default) | an internal address and a DNS name | inside the cluster |
| NodePort | a port on every node, 30000–32767 | the network the nodes are in |
| LoadBalancer | an Azure load balancer with a public IP in front of the node ports | the internet |
I added a ClusterIP and a NodePort Service for the same pods:
> kubectl get svcNAME TYPE EXTERNAL-IP PORT(S)web LoadBalancer 203.0.113.12 80:31713/TCPweb-internal ClusterIP <none> 80/TCPweb-np NodePort <none> 80:30412/TCP
Inside the cluster you call a Service by its name. From one of my own pods:
> kubectl exec deploy/web -- python get.py http://web-internal200 {"pod": "web-654b6b7b85-sjwzj", "version": "v1"}
The full name web-internal.default.svc.cluster.local answered too. No port in the address means 80, the Service's port. The module's example puts the target port in the address (…svc.cluster.local:8080); callers always use the Service's port.
The module also says: reach a NodePort from outside on the node's IP and the node port. My node had no public address:
> kubectl get nodes -o wideNAME INTERNAL-IP EXTERNAL-IP ...aks-nodepool1-24214868-vmss000000 10.224.0.4 <none> ...
AKS nodes get no public IP unless you turn it on (--enable-node-public-ip). A node port works inside the network, and it's the door the load balancer uses. Inside the cluster, call the name. From outside, call the load balancer. For public HTTP apps, Microsoft recommends an ingress controller or the Gateway API in front of ClusterIP Services instead of one public IP per Service.
The one line to read first
The endpoints line of kubectl describe service doesn't only tell you whether the list is empty; it shows the port. When my target port was wrong, I listed the same list directly, where Kubernetes keeps it, in endpoint slices:
> kubectl get endpointslices -l kubernetes.io/service-name=webNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-cdxkl IPv4 8080 10.244.0.154,10.244.0.165 110s
Two pod addresses, and port 8080 for an app on 8000. An empty list: check the labels. The wrong port: check the target port. One line, both mistakes.
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. One AKS cluster served four videos of this series; this page uses its Service steps. The cluster had one Standard_D2s_v5 node and the free tier; the app is a small Python API shaped like the module exercise's (port 8000, /healthz, /readyz), without the exercise's model in Microsoft Foundry.
- With the missing label, the watcher stopped after "10 failed GETs in a row while the pods are ready" at 52 seconds; the pods had been ready since 22.8 seconds.
kubectl describe svc webshowed only two Normal events (EnsuringLoadBalancer,EnsuredLoadBalancer): nothing about the empty selector match.- After the label fix the Deployment rolled out pods with the new template hash (
654b6b7b85); the first new pod was ready at 21.5 seconds and on the endpoint list at 22.0. - With
targetPort: 8080, the change from refused to timed out happened between about 13 and 16 seconds into the watcher, which started right after the apply.
What the video leaves out
- ExternalName Services, network policies and kube-proxy or DNS failures (Microsoft's troubleshooting guide covers them).
externalTrafficPolicy: Localand the shared health probe mode of the AKS load balancer (preview), which change how the load balancer checks nodes.- Debug pods (
kubectl run) and ephemeral debug containers; the run usedkubectl execinto the app's own pod.





