4. Services and DNS
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Full code: code/kubernetes/l04, with its commands in l04/run.sh. As in lessons 2 and 3, the commands are shown as run in Bash.
Every pod has its own IP address, and every pod is temporary. In lesson 3, each update replaced the three C# pods with new ones, new names and new addresses. A client can’t keep a list of them. A Service is the stable part: a name, a virtual IP address that doesn’t change for the Service’s lifetime, and a label selector that decides, at every moment, which pods receive the traffic.
The lesson reuses lesson 3’s two Deployments, and adds a pod to send requests from inside the cluster:
# Lesson 4: a pod to send requests from inside the cluster. curl's image would run curl and exit: sleep instead.apiVersion: v1kind: Podmetadata: name: clientspec: terminationGracePeriodSeconds: 0 containers: - name: curl image: curlimages/curl:8.22.0 command: ["sleep", "infinity"]> kubectl create namespace l04namespace/l04 created> kubectl apply -n l04 -f l03/csharp-api.yaml -f l03/java-reactor-api.yaml -f l04/client.yamldeployment.apps/csharp-api createddeployment.apps/java-reactor-api createdpod/client createdClusterIP
Section titled “ClusterIP”l04/services.yaml puts a Service in front of each Deployment:
# Lesson 4: a stable name and virtual IP in front of each Deployment of lesson 3.apiVersion: v1kind: Servicemetadata: name: csharp-apispec: # ClusterIP is the default: reachable from inside the cluster only type: ClusterIP selector: app: csharp-api ports: - name: http port: 80 targetPort: http---apiVersion: v1kind: Servicemetadata: name: java-reactor-apispec: selector: app: java-reactor-api ports: - name: http port: 80 targetPort: httpport is what clients call; targetPort: http is the pods’ port named http in lesson 3, 8080. Naming the port in the pod template lets the application change its port without touching the Service.
> kubectl apply -n l04 -f l04/services.yamlservice/csharp-api createdservice/java-reactor-api created> kubectl get -n l04 servicesNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEcsharp-api ClusterIP 10.96.34.184 <none> 80/TCP 0sjava-reactor-api ClusterIP 10.96.210.58 <none> 80/TCP 0s> kubectl exec -n l04 client -- curl -s http://csharp-api/{"app":"csharp-api","runtime":"<runtime>","os":"<os>","machine":"csharp-api-55b7fbb748-dn79k"}> kubectl exec -n l04 client -- curl -s http://java-reactor-api/{"app":"java-reactor-api","runtime":"<runtime>","os":"<os>","machine":"java-reactor-api-64bd9f4b94-xkbjc"}kubectl exec runs curl inside the client pod, as docker exec would. The machine field is the pod’s host name, which is its name: the Service chose one of the pods.
The script replaces the runtime and OS, which depend on the image build, with placeholders. On the author’s machine, the C# API reported ".NET 10.0.12" and "Ubuntu 24.04.5 LTS", the distribution of its base image, and the Java API "Java 25.0.4+7-LTS" and "Linux 6.18.40.1-microsoft-standard-WSL2": Java’s os.name and os.version describe the kernel, which is Docker Desktop’s WSL 2 kernel, shared by every container.
A ClusterIP is reachable only from inside the cluster. No process listens on it: on each node, kube-proxy programs the kernel to rewrite packets sent to the Service’s address and port towards one of the pods. On kind, kube-proxy uses iptables; its log says "Using iptables Proxier". A new Service takes a moment to be programmed, which is why the script waits for the first curl to answer.
The name csharp-api was resolved by the cluster’s DNS, CoreDNS, which creates a record for every Service. Its full name includes the namespace:
> kubectl exec -n l04 client -- curl -s http://csharp-api.l04.svc.cluster.local/{"app":"csharp-api","runtime":"<runtime>","os":"<os>","machine":"csharp-api-55b7fbb748-9rj4s"}> kubectl exec -n l04 client -- cat /etc/resolv.confsearch l04.svc.cluster.local svc.cluster.local cluster.localnameserver 10.96.0.10options ndots:5> kubectl exec -n l04 client -- nslookup csharp-api.l04.svc.cluster.local.Server: 10.96.0.10Address: 10.96.0.10:53
Name: csharp-api.l04.svc.cluster.localAddress: 10.96.34.184The kubelet writes each pod’s /etc/resolv.conf:
nameserver 10.96.0.10is the ClusterIP of thekube-dnsService inkube-system, in front of the CoreDNS pods of lesson 1.searchlets a short name work:csharp-apiis tried ascsharp-api.l04.svc.cluster.localfirst. From another namespace,csharp-api.l04is enough.ndots:5means that a name with fewer than five dots goes through the search list before being tried as is.api.github.comhas two, so a pod first asks forapi.github.com.l04.svc.cluster.localand two other names that don’t exist. A trailing dot, as in thenslookupabove, marks a name as complete and skips the list.
The DNS for Services and Pods page lists the records; Debugging DNS resolution is the page to open when names don’t resolve.
EndpointSlices: which pods, and are they ready?
Section titled “EndpointSlices: which pods, and are they ready?”The Service’s selector is evaluated by the EndpointSlice controller, which writes the matching pods’ addresses into EndpointSlices. kube-proxy reads those, not the pods.
> kubectl get -n l04 endpointslicesNAME ADDRESSTYPE PORTS ENDPOINTS AGEcsharp-api-<slice> IPv4 8080 10.244.0.241,10.244.0.239,10.244.0.240 3sjava-reactor-api-<slice> IPv4 8080 10.244.0.243,10.244.0.244 3s> kubectl get -n l04 endpoints csharp-apiWarning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSliceNAME ENDPOINTS AGEcsharp-api 10.244.0.239:8080,10.244.0.240:8080,10.244.0.241:8080 3sEach slice name ends with five random characters, replaced here by <slice>. The older Endpoints object still exists, and kubectl warns: it held every address of a Service in one object, which didn’t scale to thousands of pods, and has been deprecated since 1.33. Tutorials that still use kubectl get endpoints work, for now.
Readiness, from lesson 2, matters here. l04/unready.yaml adds a fourth pod with the label app: csharp-api and a readiness probe that always fails:
> kubectl apply -n l04 -f l04/unready.yamlpod/csharp-api-unready created> kubectl get -n l04 endpointslices -l kubernetes.io/service-name=csharp-api -o jsonpath='{range .items[*].endpoints[*]}{.targetRef.name} ready={.conditions.ready}{"\n"}{end}' | sortcsharp-api-55b7fbb748-9rj4s ready=truecsharp-api-55b7fbb748-dn79k ready=truecsharp-api-55b7fbb748-zw7ph ready=truecsharp-api-unready ready=falseThe selector matches it, so the slice lists it, marked ready=false. Thirty requests show that kube-proxy skips it:
> kubectl exec -n l04 client -- sh -c 'for i in $(seq 30); do curl -s http://csharp-api/; echo; done' | …3 pods answered 30 requests; csharp-api-unready answered 0(The part after | counts the machine values; the full command is in run.sh.) This is also a trap: a Service selects any pod with its labels, including one from another Deployment, or one started by hand for debugging. Readiness protected the clients this time.
NodePort
Section titled “NodePort”A NodePort Service also opens a port, from 30000 to 32767, on every node. l04/nodeport.yaml asks for 30080, which kind/cluster.yaml maps to 127.0.0.1:30080 on your machine with extraPortMappings:
spec: type: NodePort selector: app: csharp-api ports: - name: http port: 80 targetPort: http nodePort: 30080> kubectl apply -n l04 -f l04/nodeport.yamlservice/csharp-api-nodeport created> kubectl get -n l04 service csharp-api-nodeportNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEcsharp-api-nodeport NodePort 10.96.80.124 <none> 80:30080/TCP 1s> curl -s http://localhost:30080/{"app":"csharp-api","runtime":"<runtime>","os":"<os>","machine":"csharp-api-55b7fbb748-dn79k"}The last curl runs on your machine, not in the cluster: on Windows, curl.exe in PowerShell. A NodePort Service is also a ClusterIP Service. It is the simplest way in, but the clients have to know node addresses and an unusual port; kind’s port mapping must be declared when the cluster is created.
LoadBalancer
Section titled “LoadBalancer”A LoadBalancer Service asks the infrastructure for an external load balancer: on AKS, EKS or GKE, the cloud provider’s controller creates one and writes its address into the Service. kind has no cloud, so the course runs cloud-provider-kind v0.11.1, which plays that role with one Envoy container per load balancer. check.sh starts it as a container on kind’s Docker network, with access to Docker’s socket to create those containers:
docker run -d --name learn-k8s-cloud-provider --network kind -v /var/run/docker.sock:/var/run/docker.sock \ registry.k8s.io/cloud-provider-kind/cloud-controller-manager:v0.11.1@sha256:40e18b9cd9c798cce40d39ff5c099a4f16a36c268d8e1dda469d73955a35e403In Git Bash, set MSYS_NO_PATHCONV=1 first, as check.sh does, or Git Bash rewrites /var/run/docker.sock into a Windows path; /var/run/docker.sock is the socket’s path inside Docker Desktop’s VM, not on Windows. Access to the Docker socket means control of Docker: run only an image you trust, pinned by digest as here.
# Lesson 4: a LoadBalancer Service. In a cloud, the provider creates a load balancer; on kind, cloud-provider-kind does.apiVersion: v1kind: Servicemetadata: name: java-reactor-api-lbspec: type: LoadBalancer selector: app: java-reactor-api ports: - name: http port: 80 targetPort: http> kubectl apply -n l04 -f l04/loadbalancer.yamlservice/java-reactor-api-lb created> kubectl get -n l04 service java-reactor-api-lbNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEjava-reactor-api-lb LoadBalancer 10.96.155.58 172.23.0.4 80:<node-port>/TCP 1s> kubectl exec -n l04 client -- curl -s http://172.23.0.4/{"app":"java-reactor-api","runtime":"<runtime>","os":"<os>","machine":"java-reactor-api-64bd9f4b94-g8wnk"}> docker ps --filter name=kindccm --format '{{.Names}} {{.Ports}}'kindccm-86739d9686d6 0.0.0.0:80->80/tcp, 0.0.0.0:52142->10000/tcp, [::]:52142->10000/tcpEXTERNAL-IP is the Envoy container’s address on the kind network. A LoadBalancer Service is also a NodePort Service, on a port chosen at random, and so a ClusterIP Service: each type adds a way in.
On Linux, that address is reachable from the host. On Windows and macOS, Docker runs in a VM and it isn’t: from Windows, curl to 172.23.0.4 timed out. cloud-provider-kind’s README describes the workaround it applies there, publishing the load balancer’s port on the host. On the author’s machine, the first load balancer got port 80 itself, as docker ps shows, and curl http://localhost/ from Windows answered; the Gateway below got a random host port instead. Before relying on either, check docker ps, and note that port 80 on your machine may already be taken.
One entry point: the Gateway API
Section titled “One entry point: the Gateway API”With two APIs, two load balancers mean two addresses, and in a cloud, two bills. An HTTP router can send both host names to the right Service from one address. Kubernetes has two APIs for that. The newer one, the Gateway API, splits the job in three objects:
| Object | Who writes it | What it says |
|---|---|---|
| GatewayClass | the platform, with its controller | which implementation, like a StorageClass |
| Gateway | the cluster operator | an entry point: addresses, ports, protocols, TLS certificates |
| HTTPRoute | the application team | which requests go to which Service |
The Gateway API is not built into Kubernetes: its objects are custom resources (lesson 13) installed from its releases. cloud-provider-kind installed version 1.5.0 of the standard channel, and created a GatewayClass and an IngressClass:
> kubectl get gatewayclass,ingressclassNAME CONTROLLER ACCEPTED AGEgatewayclass.gateway.networking.k8s.io/cloud-provider-kind kind.sigs.k8s.io/gateway-controller True 32m
NAME CONTROLLER PARAMETERS AGEingressclass.networking.k8s.io/cloud-provider-kind (default) kind.sigs.k8s.io/ingress-controller <none> 32ml04/gateway.yaml declares one Gateway and one route with a rule per host:
# Lesson 4: one entry point for both APIs with the Gateway API, routed by host name.apiVersion: gateway.networking.k8s.io/v1kind: Gatewaymetadata: name: webspec: # The class provided by cloud-provider-kind; a cloud or a gateway controller provides its own gatewayClassName: cloud-provider-kind listeners: - name: http protocol: HTTP port: 80---apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: name: apisspec: parentRefs: - name: web rules: - matches: - headers: - name: Host value: csharp.example.test backendRefs: - name: csharp-api port: 80 - matches: - headers: - name: Host value: java.example.test backendRefs: - name: java-reactor-api port: 80> kubectl apply -n l04 -f l04/gateway.yamlgateway.gateway.networking.k8s.io/web createdhttproute.gateway.networking.k8s.io/apis created> kubectl wait -n l04 --for=condition=Programmed gateway/web --timeout=120sgateway.gateway.networking.k8s.io/web condition met> kubectl get -n l04 gateway webNAME CLASS ADDRESS PROGRAMMED AGEweb cloud-provider-kind 172.23.0.5 True 1s> kubectl get -n l04 httproute apis -o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'Accepted=True AcceptedResolvedRefs=True ResolvedRefsThe statuses are the first thing to read when a route doesn’t work: Programmed says the Gateway’s controller has configured the proxy, Accepted that the Gateway took the route, and ResolvedRefs that the Services it names exist. Then one address serves both APIs:
> kubectl exec -n l04 client -- curl -s -H "Host: csharp.example.test" http://172.23.0.5/{"app":"csharp-api","runtime":"<runtime>","os":"<os>","machine":"csharp-api-55b7fbb748-zw7ph"}> kubectl exec -n l04 client -- curl -s -H "Host: java.example.test" http://172.23.0.5/{"app":"java-reactor-api","runtime":"<runtime>","os":"<os>","machine":"java-reactor-api-64bd9f4b94-g8wnk"}> kubectl exec -n l04 client -- curl -s -o /dev/null -w '%{http_code} %header{server}\n' -H 'Host: other.example.test' http://172.23.0.5/404 envoy-H "Host: …" stands in for DNS records that would point both names at the Gateway. A host that no rule matches gets a 404 from Envoy itself, without reaching any pod.
Ingress
Section titled “Ingress”Ingress is the older API for the same job, one object with host and path rules. It is stable and still supported, but frozen: new features go to the Gateway API. Its most common controller, ingress-nginx, was retired in March 2026, and you will still meet it in existing clusters. l04/ingress.yaml:
# Lesson 4: the older API for the same job as the Gateway. Without ingressClassName, the default IngressClass applies:# on kind, the one cloud-provider-kind creates.apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: apisspec: rules: - host: csharp.example.test http: paths: - path: / pathType: Prefix backend: service: name: csharp-api port: name: http - host: java.example.test http: paths: - path: / pathType: Prefix backend: service: name: java-reactor-api port: name: http> kubectl apply -n l04 -f l04/ingress.yamlingress.networking.k8s.io/apis created> kubectl get -n l04 ingress apis -o custom-columns=NAME:.metadata.name,CLASS:.spec.ingressClassName,HOSTS:.spec.rules[*].host,ADDRESS:.status.loadBalancer.ingress[0].ipNAME CLASS HOSTS ADDRESSapis cloud-provider-kind csharp.example.test,java.example.test 172.23.0.6> kubectl get -n l04 gateways,httproutesNAME CLASS ADDRESS PROGRAMMED AGEgateway.gateway.networking.k8s.io/kind-ingress-gateway cloud-provider-kind 172.23.0.6 True 2sgateway.gateway.networking.k8s.io/web cloud-provider-kind 172.23.0.5 True 4s
NAME HOSTNAMES AGEhttproute.gateway.networking.k8s.io/apis 4shttproute.gateway.networking.k8s.io/apis-57edfef398 ["java.example.test"] 2shttproute.gateway.networking.k8s.io/apis-f45dc871d2 ["csharp.example.test"] 2s> kubectl exec -n l04 client -- curl -s -H 'Host: java.example.test' http://172.23.0.6/{"app":"java-reactor-api","runtime":"<runtime>","os":"<os>","machine":"java-reactor-api-64bd9f4b94-xkbjc"}The class was filled in by the default IngressClass. cloud-provider-kind doesn’t implement Ingress separately: it translates the Ingress into a Gateway of its own, kind-ingress-gateway, and one HTTPRoute per host. Those routes use the route’s hostnames field, the usual way to route by host, where apis matched the Host header in its rules. The translation is a fair summary of the relationship between the two APIs, and the ingress2gateway tool does the same for migrations.
Which one?
Section titled “Which one?”| Need | Use |
|---|---|
| Pods calling each other inside the cluster | ClusterIP, by DNS name |
| A quick look from your machine | kubectl port-forward, or a NodePort on a local cluster |
| One TCP or UDP service exposed outside | LoadBalancer |
| HTTP routing by host or path, TLS, several services behind one address | Gateway API (Ingress in existing clusters) |
> kubectl delete namespace l04 --wait=truenamespace "l04" deletedKey takeaways
Section titled “Key takeaways”- A Service gives pods a stable name and virtual IP, and chooses them by label at every moment; its
targetPortcan name the pods’ port. - CoreDNS resolves
name,name.namespaceandname.namespace.svc.cluster.local;ndots:5makes external names go through the search list first. - EndpointSlices list the pods behind a Service with their readiness, and kube-proxy sends traffic only to ready ones; the Endpoints API is deprecated.
- NodePort adds a port on every node, and LoadBalancer adds an external address from the infrastructure; on kind, cloud-provider-kind provides it, and on Windows and macOS the address is reached through a port published on the host.
- The Gateway API splits routing into GatewayClass, Gateway and HTTPRoute, and reports each step in their statuses; Ingress does the same job in one frozen API.
Exercises
Section titled “Exercises”1. A Service with clusterIP: None is headless. Create one for the C# API (l04/headless.yaml) while csharp-api-unready is running, and resolve its name. What do you get, and what is missing?
Solution
> kubectl apply -n l04 -f l04/headless.yamlservice/csharp-api-headless created> kubectl get -n l04 service csharp-api-headlessNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEcsharp-api-headless ClusterIP None <none> 80/TCP 1s> kubectl exec -n l04 client -- nslookup -type=a csharp-api-headless.l04.svc.cluster.local.Server: 10.96.0.10Address: 10.96.0.10:53
Name: csharp-api-headless.l04.svc.cluster.localAddress: 10.244.0.241Name: csharp-api-headless.l04.svc.cluster.localAddress: 10.244.0.239Name: csharp-api-headless.l04.svc.cluster.localAddress: 10.244.0.240
> kubectl get -n l04 pods -l app=csharp-api -o custom-columns='NAME:.metadata.name,IP:.status.podIP,READY:.status.conditions[?(@.type=="Ready")].status' --sort-by=.metadata.nameNAME IP READYcsharp-api-55b7fbb748-9rj4s 10.244.0.239 Truecsharp-api-55b7fbb748-dn79k 10.244.0.241 Truecsharp-api-55b7fbb748-zw7ph 10.244.0.240 Truecsharp-api-unready 10.244.0.245 FalseNo virtual IP: the name resolves to the addresses of the ready pods themselves, and the client picks one. The unready pod’s 10.244.0.245 is missing. Headless Services serve clients that want to see every instance, such as a database driver or a gRPC client balancing on its own, and give StatefulSet pods a stable DNS name each (lesson 6).
2. l04/wrong-selector.yaml has a typo in its selector: app: csharp_api. How does it fail, and how would you find the cause?
Solution
> kubectl apply -n l04 -f l04/wrong-selector.yamlservice/csharp-api-typo created> kubectl exec -n l04 client -- curl -sS -m 5 http://csharp-api-typo/curl: (7) Failed to connect to csharp-api-typo:80 after 0 ms: Could not connect to servercommand terminated with exit code 7> kubectl get -n l04 endpointslices -l kubernetes.io/service-name=csharp-api-typoNAME ADDRESSTYPE PORTS ENDPOINTS AGEcsharp-api-typo-<slice> IPv4 <unset> <unset> 1sThe Service is created without complaint, its name resolves, and the connection is refused at once: kube-proxy rejects traffic to a Service without endpoints. An EndpointSlice with no endpoints is the sign. Compare the selector with the pods’ labels, kubectl get pods --show-labels. The same symptom appears when every matching pod is unready.
3. Every cluster has a Service named kubernetes in the default namespace. What is behind it? Call it from the client pod, in l04.
Solution
> kubectl get -n default service kubernetesNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEkubernetes ClusterIP 10.96.0.1 <none> 443/TCP 9h> kubectl exec -n l04 client -- curl -sk https://kubernetes.default/version{ "major": "1", "minor": "37", "emulationMajor": "1", "emulationMinor": "37", "minCompatibilityMajor": "1", "minCompatibilityMinor": "36", "gitVersion": "v1.37.0", "gitCommit": "f54c212e3a2f75d674b717a9b29052b20b60aefc", "gitTreeState": "clean", "buildDate": "2026-08-26T10:44:25Z", "goVersion": "go1.26.6", "compiler": "gc", "platform": "linux/amd64"}The API server. kubernetes.default is a name in another namespace, completed by the search list. /version is readable without credentials; -k skips the certificate check, which a real client does with the cluster’s CA certificate, mounted by default in every pod with its service account token. This is how controllers and operators running in pods reach the API (lesson 10 gives them permissions).
Sources
Section titled “Sources”- Kubernetes documentation: Service, Virtual IPs and service proxies, DNS for Services and Pods, Debugging DNS resolution, EndpointSlices, Ingress, Gateway API
- Kubernetes blog: Endpoints deprecation, Ingress NGINX retirement
- Gateway API, ingress2gateway
- kind: LoadBalancer, Ingress, extra port mappings; cloud-provider-kind README at v0.11.1