Skip to main content
Kubernetes · lesson 4 of 10
Kubernetes intermediate Lesson 4 of 10

Services and Networking

Put a real backend behind ClusterIP, NodePort and LoadBalancer Services, then trace DNS across namespaces, EndpointSlices and headless Services.

By SkillByExample · Updated

Why Services exist

Pods are ephemeral: when one is replaced, it comes back with a new IP. A Service puts a stable virtual IP and DNS name in front of a set of Pods, chosen by a label selector, and load-balances across whichever of those Pods are Ready.

The outputs below come from a single-node minikube cluster started with minikube start -p rw-k8s --driver=docker. The profile name becomes the node name, so NODE shows rw-k8s rather than minikube. Pod hashes, IPs and ages will differ on yours.

A backend to route to

A Service with nothing behind it has nothing to forward to. This Deployment runs two copies of hashicorp/http-echo, a tiny HTTP server, on port 8080. Each copy replies with its own Pod name, so you can see which Pod answered.

# api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: echo
          image: hashicorp/http-echo:1.0
          args: ["-listen=:8080", "-text=hello from $(POD_NAME)"]
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          ports:
            - containerPort: 8080
$ kubectl apply -f api.yaml
deployment.apps/api created
$ kubectl rollout status deployment/api
Waiting for deployment "api" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "api" rollout to finish: 1 of 2 updated replicas are available...
deployment "api" successfully rolled out
$ kubectl get pods -l app=api -o wide
NAME                   READY   STATUS    RESTARTS   AGE   IP           NODE     NOMINATED NODE   READINESS GATES
api-785f67cc54-7mkfv   1/1     Running   0          3s    10.244.0.4   rw-k8s   <none>           <none>
api-785f67cc54-mbwl4   1/1     Running   0          3s    10.244.0.3   rw-k8s   <none>           <none>

The $(POD_NAME) in args is expanded by Kubernetes from the env entry, which the downward API fills with each Pod’s own name. The two Pod IPs, 10.244.0.3 and 10.244.0.4, are what every Service below forwards to, and they would change if these Pods were replaced.

ClusterIP (the default)

A ClusterIP Service is reachable only from inside the cluster. It is the usual way for one workload to call another.

# service-clusterip.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-service
spec:
  selector:
    app: api          # routes to pods with this label
  ports:
    - protocol: TCP
      port: 80        # port on the Service
      targetPort: 8080  # port on the Pod
  type: ClusterIP
$ kubectl apply -f service-clusterip.yaml
service/api-service created
$ kubectl get service api-service
NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
api-service   ClusterIP   10.102.76.117   <none>        80/TCP    0s
$ kubectl describe service api-service
Name:                     api-service
Namespace:                default
Labels:                   <none>
Annotations:              <none>
Selector:                 app=api
Type:                     ClusterIP
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.102.76.117
IPs:                      10.102.76.117
Port:                     <unset>  80/TCP
TargetPort:               8080/TCP
Endpoints:                10.244.0.3:8080,10.244.0.4:8080
Session Affinity:         None
Internal Traffic Policy:  Cluster
Events:                   <none>

The Service got a virtual IP, 10.102.76.117, that no Pod owns. Endpoints is the part that matters: the two Pod IPs from the previous step, each paired with targetPort 8080. Clients connect to port 80 on the ClusterIP, and the node forwards each connection to one of those addresses.

To call it, you need a Pod inside the cluster. A long-running curl Pod is handy for the rest of this lesson:

kubectl run client --image=curlimages/curl:8.10.1 -- sleep 3600
kubectl wait --for=condition=Ready pod/client
for i in 1 2 3 4; do kubectl exec client -- curl -sS http://api-service; done
kubectl exec client -- curl -sS http://api-service.default.svc.cluster.local
$ kubectl run client --image=curlimages/curl:8.10.1 -- sleep 3600
pod/client created
$ kubectl wait --for=condition=Ready pod/client
pod/client condition met
$ for i in 1 2 3 4; do kubectl exec client -- curl -sS http://api-service; done
hello from api-785f67cc54-mbwl4
hello from api-785f67cc54-mbwl4
hello from api-785f67cc54-7mkfv
hello from api-785f67cc54-mbwl4
$ kubectl exec client -- curl -sS http://api-service.default.svc.cluster.local
hello from api-785f67cc54-7mkfv

Both Pods answered, but not in strict turns: mbwl4 served three of the four calls. kube-proxy in its default iptables mode picks a backend at random for each new connection, so the split evens out over many requests, not over four. The short name api-service and the full name api-service.default.svc.cluster.local reach the same Service.

NodePort

A NodePort Service also opens a fixed port, from 30000-32767, on every node. Anything that can reach a node’s IP can reach the Service.

# service-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-nodeport
spec:
  selector:
    app: api
  ports:
    - protocol: TCP
      port: 80          # internal cluster port
      targetPort: 8080  # pod port
      nodePort: 30080   # port exposed on every node (30000-32767)
  type: NodePort
kubectl apply -f service-nodeport.yaml
kubectl get service api-nodeport
kubectl get nodes -o wide
kubectl exec client -- curl -sS http://192.168.49.2:30080
kubectl exec client -- curl -sS --retry 5 --retry-connrefused http://192.168.49.2:30080
$ kubectl apply -f service-nodeport.yaml
service/api-nodeport created
$ kubectl get service api-nodeport
NAME           TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
api-nodeport   NodePort   10.111.71.240   <none>        80:30080/TCP   0s
$ kubectl get nodes -o wide
NAME     STATUS   ROLES           AGE    VERSION   INTERNAL-IP    EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION                       CONTAINER-RUNTIME
rw-k8s   Ready    control-plane   3m3s   v1.35.1   192.168.49.2   <none>        Debian GNU/Linux 12 (bookworm)   5.15.153.1-microsoft-standard-WSL2   docker://29.2.1
$ kubectl exec client -- curl -sS http://192.168.49.2:30080
curl: (7) Failed to connect to 192.168.49.2 port 30080 after 0 ms: Could not connect to server
command terminated with exit code 7
$ kubectl exec client -- curl -sS --retry 5 --retry-connrefused http://192.168.49.2:30080
curl: (7) Failed to connect to 192.168.49.2 port 30080 after 0 ms: Could not connect to server
hello from api-785f67cc54-mbwl4

80:30080/TCP means port 80 on the ClusterIP and port 30080 on the node, so a NodePort Service is a ClusterIP Service with an extra door on each node. The first call was refused because the commands ran back-to-back in a script (typed by hand, the first call usually succeeds): apply returns as soon as the API server stores the Service. kube-proxy then programs the node’s port separately, a moment later. --retry-connrefused retries until the port is open, and here it answered on the second attempt. Use your own node’s INTERNAL-IP in the URL.

On Docker Desktop for Windows or macOS, 192.168.49.2 is not reachable from your own machine, only from inside the cluster. minikube service opens a tunnel to the node port for you:

$ minikube -p rw-k8s service api-nodeport --url
http://127.0.0.1:51584
! Because you are using a Docker driver on windows, the terminal needs to be open to run it.
$ curl -sS http://127.0.0.1:51584
hello from api-785f67cc54-mbwl4

The first command keeps running and holds the tunnel open. The second is from another terminal. The local port, 51584 here, is chosen at random each time.

LoadBalancer

On a cloud cluster, a LoadBalancer Service asks the provider for an external load balancer with its own public IP. On minikube, nothing provides one until you start minikube tunnel.

# service-lb.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-lb
spec:
  selector:
    app: api
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: LoadBalancer
$ kubectl apply -f service-lb.yaml
service/api-lb created
$ kubectl get service api-lb
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
api-lb   LoadBalancer   10.96.226.205   <pending>     80:31551/TCP   0s

EXTERNAL-IP stays <pending> until something assigns an address. It also got a ClusterIP and a random node port, 31551, so a LoadBalancer Service is a NodePort Service with an external address in front. Start the tunnel in a second terminal and leave it running:

$ minikube -p rw-k8s tunnel
* Tunnel successfully started

* NOTE: Please do not close this terminal as this process must stay alive for the tunnel to be accessible ...

! Access to ports below 1024 may fail on Windows with OpenSSH clients older than v8.1. For more information, see: https://minikube.sigs.k8s.io/docs/handbook/accessing/#access-to-ports-1024-on-windows-requires-root-permission
* Starting tunnel for service api-lb.
$ kubectl get service api-lb
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
api-lb   LoadBalancer   10.96.226.205   127.0.0.1     80:31551/TCP   17s
$ curl -sS http://127.0.0.1
hello from api-785f67cc54-mbwl4

With the Docker driver on Windows or macOS, the tunnel gives the Service 127.0.0.1 and forwards its port 80 from your machine. On EKS, GKE or AKS, the same manifest gets a real cloud load balancer address there instead, usually within a minute or two.

Service types summary

TypeReachable fromUse case
ClusterIPInside the cluster onlyWorkload-to-workload calls
NodePortEvery node’s IP on a fixed port, plus the ClusterIPDev/testing, your own external load balancer
LoadBalancerAn external IP from the cloud, plus node port and ClusterIPProduction internet-facing apps
ExternalNameInside the cluster, as a DNS alias (CNAME) to an outside nameGiving an external API a cluster name

DNS across namespaces

Every Service is in DNS as <service>.<namespace>.svc.cluster.local. The short name works only from the same namespace. Here the same curl image is run from a second namespace:

kubectl create namespace production
kubectl run client -n production --image=curlimages/curl:8.10.1 -- sleep 3600
kubectl wait -n production --for=condition=Ready pod/client
kubectl exec -n production client -- curl -sS http://api-service
kubectl exec -n production client -- curl -sS http://api-service.default
kubectl exec -n production client -- cat /etc/resolv.conf
$ kubectl create namespace production
namespace/production created
$ kubectl run client -n production --image=curlimages/curl:8.10.1 -- sleep 3600
pod/client created
$ kubectl wait -n production --for=condition=Ready pod/client
pod/client condition met
$ kubectl exec -n production client -- curl -sS http://api-service
curl: (6) Could not resolve host: api-service
command terminated with exit code 6
$ kubectl exec -n production client -- curl -sS http://api-service.default
hello from api-785f67cc54-7mkfv
$ kubectl exec -n production client -- cat /etc/resolv.conf
nameserver 10.96.0.10
search production.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

resolv.conf explains both results. A short name is tried against each search domain in turn. From production, api-service becomes api-service.production.svc.cluster.local, which doesn’t exist, and the other search domains miss too, so curl fails with exit 6, “could not resolve”. api-service.default becomes api-service.default.svc.cluster.local on the second search domain, which does exist. 10.96.0.10 is the cluster’s CoreDNS Service.

Endpoints and EndpointSlices

A Service’s backend list is kept in EndpointSlice objects, which the control plane rewrites whenever a matching Pod becomes Ready or goes away. The older Endpoints API still works but prints a deprecation warning (see Practice task 1).

$ kubectl get endpointslices -l kubernetes.io/service-name=api-service
NAME                ADDRESSTYPE   PORTS   ENDPOINTS               AGE
api-service-v952m   IPv4          8080    10.244.0.3,10.244.0.4   106s
$ kubectl scale deployment api --replicas=3
deployment.apps/api scaled
$ kubectl rollout status deployment/api
Waiting for deployment "api" rollout to finish: 2 of 3 updated replicas are available...
deployment "api" successfully rolled out
$ kubectl get endpointslices -l kubernetes.io/service-name=api-service
NAME                ADDRESSTYPE   PORTS   ENDPOINTS                          AGE
api-service-v952m   IPv4          8080    10.244.0.3,10.244.0.4,10.244.0.7   108s

The Service was not touched, but its slice picked up the third Pod, 10.244.0.7, as soon as it was Ready. The slice’s age, 108s, shows that it was updated in place, not recreated.

When a Service has no endpoints, the selector is almost always the cause. This one differs from api-service only in case:

# service-typo.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-typo
spec:
  selector:
    app: API          # labels are case-sensitive
  ports:
    - port: 80
      targetPort: 8080
$ kubectl apply -f service-typo.yaml
service/api-typo created
$ kubectl get endpointslices -l kubernetes.io/service-name=api-typo
NAME             ADDRESSTYPE   PORTS     ENDPOINTS   AGE
api-typo-bmsmn   IPv4          <unset>   <unset>     0s
$ kubectl exec client -- curl -sS -m 3 http://api-typo
curl: (7) Failed to connect to api-typo port 80 after 1 ms: Could not connect to server
command terminated with exit code 7
$ kubectl describe service api-typo | grep Selector
Selector:                 app=API
$ kubectl get pods --show-labels
NAME                   READY   STATUS    RESTARTS   AGE    LABELS
api-785f67cc54-7mkfv   1/1     Running   0          2m3s   app=api,pod-template-hash=785f67cc54
api-785f67cc54-9pk9r   1/1     Running   0          10s    app=api,pod-template-hash=785f67cc54
api-785f67cc54-mbwl4   1/1     Running   0          2m3s   app=api,pod-template-hash=785f67cc54
client                 1/1     Running   0          113s   run=client

The Service exists and its name resolves, so curl got as far as connecting and was refused (exit 7, not 6). The slice is empty, and the two diagnostic commands show why: the selector wants app=API and every Pod has app=api. Delete it before moving on:

$ kubectl delete -f service-typo.yaml
service "api-typo" deleted from default namespace

Headless Services (StatefulSets)

Setting clusterIP: None makes a Service headless: it gets no virtual IP, and its DNS name returns the Pod IPs themselves. Paired with a StatefulSet, each Pod also gets its own DNS name. The StatefulSet below runs sleep as a stand-in for a real database. Only the DNS records matter here.

# db-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: db-headless
spec:
  clusterIP: None       # headless: no virtual IP
  selector:
    app: db
  ports:
    - port: 5432
# db.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
spec:
  serviceName: db-headless   # gives each Pod a DNS name under this Service
  replicas: 2
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: db
          image: busybox:1.36
          command: ["sleep", "3600"]   # stands in for a real database
kubectl apply -f db-headless.yaml -f db.yaml
kubectl rollout status statefulset/db
kubectl get service db-headless
kubectl get pods -l app=db -o wide
kubectl exec client -- nslookup db-headless.default.svc.cluster.local
kubectl exec client -- nslookup db-0.db-headless.default.svc.cluster.local
$ kubectl apply -f db-headless.yaml -f db.yaml
service/db-headless created
statefulset.apps/db created
$ kubectl rollout status statefulset/db
Waiting for 2 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
$ kubectl get service db-headless
NAME          TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
db-headless   ClusterIP   None         <none>        5432/TCP   2s
$ kubectl get pods -l app=db -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP           NODE     NOMINATED NODE   READINESS GATES
db-0   1/1     Running   0          3s    10.244.0.8   rw-k8s   <none>           <none>
db-1   1/1     Running   0          2s    10.244.0.9   rw-k8s   <none>           <none>
$ kubectl exec client -- nslookup db-headless.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	db-headless.default.svc.cluster.local
Address: 10.244.0.8
Name:	db-headless.default.svc.cluster.local
Address: 10.244.0.9

$ kubectl exec client -- nslookup db-0.db-headless.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	db-0.db-headless.default.svc.cluster.local
Address: 10.244.0.8

CLUSTER-IP None is what makes it headless. The Service name returns both Pod IPs, and db-0.db-headless returns only db-0’s. That per-Pod name is what lets a database replica find “the primary” or “replica 1” by a name that survives restarts. The StatefulSet section of the controllers lesson covers the rest.

Learning outcomes

You can:

  • Write ClusterIP, NodePort and LoadBalancer Service manifests and reach each from the right place
  • Explain how a selector becomes EndpointSlice entries, and why port and targetPort differ
  • Use DNS for service discovery within and across namespaces
  • Debug a Service with kubectl describe, kubectl get endpointslices and --show-labels

Practice

These tasks continue from the cluster state the lesson leaves: api (scaled to 3), api-service, api-nodeport, api-lb, the db StatefulSet, and the client Pod. The outputs come from the same rw-k8s profile.

  1. Change api.yaml to run nginx:1.25, which listens on port 80, not 8080. Apply it and check the Service’s endpoints and the Pods.
Solution
# api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: web
          image: nginx:1.25
          ports:
            - containerPort: 80
$ kubectl apply -f api.yaml
deployment.apps/api configured
$ kubectl rollout status deployment/api
Waiting for deployment "api" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "api" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "api" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "api" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "api" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "api" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "api" rollout to finish: 1 old replicas are pending termination...
deployment "api" successfully rolled out
$ kubectl get endpoints api-service
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME          ENDPOINTS                           AGE
api-service   10.244.0.10:8080,10.244.0.11:8080   2m30s
$ kubectl get pods -l app=api -o wide
NAME                   READY   STATUS        RESTARTS   AGE     IP            NODE     NOMINATED NODE   READINESS GATES
api-5f9cf776d6-2nx2h   1/1     Running       0          1s      10.244.0.11   rw-k8s   <none>           <none>
api-5f9cf776d6-tr5hv   1/1     Running       0          3s      10.244.0.10   rw-k8s   <none>           <none>
api-785f67cc54-mbwl4   1/1     Terminating   0          2m36s   10.244.0.3    rw-k8s   <none>           <none>

Applying the file also took replicas back from 3 to 2: the earlier kubectl scale was never written into api.yaml. The endpoints already list only the two new Pods, although the old mbwl4 Pod is still shutting down, because a Terminating Pod is dropped from the legacy Endpoints list first. EndpointSlices keep it listed with ready=false, terminating=true. Notice the port: 8080, from targetPort, although nginx listens on 80. The warning says the newer EndpointSlice API replaces Endpoints, but the command still works.

  1. Call api-service from the client Pod. It fails. Explain why from task 1’s output, fix service-clusterip.yaml, and call it again.
Solution

Change targetPort: 8080 to targetPort: 80 in service-clusterip.yaml, then:

kubectl exec client -- curl -sS -m 3 http://api-service
kubectl apply -f service-clusterip.yaml
kubectl get endpoints api-service
kubectl exec client -- curl -sS -m 3 http://api-service | grep '<title>'
$ kubectl exec client -- curl -sS -m 3 http://api-service
curl: (7) Failed to connect to api-service port 80 after 1 ms: Could not connect to server
command terminated with exit code 7
$ kubectl apply -f service-clusterip.yaml
service/api-service configured
$ kubectl get endpoints api-service
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME          ENDPOINTS                       AGE
api-service   10.244.0.10:80,10.244.0.11:80   2m38s
$ kubectl exec client -- curl -sS -m 3 http://api-service | grep '<title>'
<title>Welcome to nginx!</title>

The name resolved and the Service had endpoints, but it forwarded to port 8080, where nothing listens, so the connection was refused (curl exit 7). Non-empty endpoints only prove the selector matched. The port still has to be right.

  1. From a busybox:1.36 Pod, look up api-service by its short name, by its full name, and as if it lived in the production namespace.
Solution
kubectl run dns-test --image=busybox:1.36 -- sleep 3600
kubectl wait --for=condition=Ready pod/dns-test
kubectl get service api-service
kubectl exec dns-test -- nslookup api-service
kubectl exec dns-test -- nslookup api-service.default.svc.cluster.local
kubectl exec dns-test -- nslookup api-service.production.svc.cluster.local
$ kubectl run dns-test --image=busybox:1.36 -- sleep 3600
pod/dns-test created
$ kubectl wait --for=condition=Ready pod/dns-test
pod/dns-test condition met
$ kubectl get service api-service
NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
api-service   ClusterIP   10.102.76.117   <none>        80/TCP    2m44s
$ kubectl exec dns-test -- nslookup api-service
Server:		10.96.0.10
Address:	10.96.0.10:53

** server can't find api-service.cluster.local: NXDOMAIN

** server can't find api-service.cluster.local: NXDOMAIN


Name:	api-service.default.svc.cluster.local
Address: 10.102.76.117

** server can't find api-service.svc.cluster.local: NXDOMAIN

** server can't find api-service.svc.cluster.local: NXDOMAIN

command terminated with exit code 1
$ kubectl exec dns-test -- nslookup api-service.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	api-service.default.svc.cluster.local
Address: 10.102.76.117

$ kubectl exec dns-test -- nslookup api-service.production.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53

** server can't find api-service.production.svc.cluster.local: NXDOMAIN

** server can't find api-service.production.svc.cluster.local: NXDOMAIN

command terminated with exit code 1

Both working lookups return the ClusterIP, not a Pod IP. busybox’s nslookup tries every search suffix, printing an NXDOMAIN for each one that misses and exiting 1 even though default.svc.cluster.local matched. The full name avoids that noise. The production namespace exists, but there is no api-service in it, so that name doesn’t exist at all.

  1. The NodePort Service has the same problem as task 2. Show that it is broken, fix service-nodeport.yaml the same way, and call the NodePort from the curl Pod again.
Solution

Change targetPort: 8080 to targetPort: 80 in service-nodeport.yaml, then:

kubectl exec client -- curl -sS -m 3 http://192.168.49.2:30080
kubectl apply -f service-nodeport.yaml
kubectl get service api-nodeport
kubectl exec client -- curl -sS -m 3 http://192.168.49.2:30080 | grep '<title>'
$ kubectl exec client -- curl -sS -m 3 http://192.168.49.2:30080
curl: (7) Failed to connect to 192.168.49.2 port 30080 after 0 ms: Could not connect to server
command terminated with exit code 7
$ kubectl apply -f service-nodeport.yaml
service/api-nodeport configured
$ kubectl get service api-nodeport
NAME           TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
api-nodeport   NodePort   10.111.71.240   <none>        80:30080/TCP   116s
$ kubectl exec client -- curl -sS -m 3 http://192.168.49.2:30080 | grep '<title>'
<title>Welcome to nginx!</title>

Each Service carries its own targetPort, so fixing api-service did nothing for api-nodeport. The ClusterIP and node port did not change on apply, because only targetPort was updated. Use your own node’s INTERNAL-IP in the URL.

  1. Write api-headless, a headless version of api-service, and compare what DNS returns for it with task 3.
Solution
# api-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-headless
spec:
  clusterIP: None       # headless — no virtual IP
  selector:
    app: api
  ports:
    - port: 80
$ kubectl apply -f api-headless.yaml
service/api-headless created
$ kubectl get service api-headless
NAME           TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
api-headless   ClusterIP   None         <none>        80/TCP    0s
$ kubectl exec dns-test -- nslookup api-headless.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	api-headless.default.svc.cluster.local
Address: 10.244.0.10
Name:	api-headless.default.svc.cluster.local
Address: 10.244.0.11

$ kubectl get pods -l app=api -o wide
NAME                   READY   STATUS    RESTARTS   AGE   IP            NODE     NOMINATED NODE   READINESS GATES
api-5f9cf776d6-2nx2h   1/1     Running   0          30s   10.244.0.11   rw-k8s   <none>           <none>
api-5f9cf776d6-tr5hv   1/1     Running   0          32s   10.244.0.10   rw-k8s   <none>           <none>

With no ClusterIP, DNS returns one record per ready Pod, and the addresses match the Pod IPs. The client picks one and connects to it directly, with no kube-proxy balancing in between.

Clean up with:

kubectl delete -f api-headless.yaml -f db.yaml -f db-headless.yaml -f service-lb.yaml -f service-nodeport.yaml -f service-clusterip.yaml -f api.yaml
kubectl delete pod client dns-test
kubectl delete namespace production
$ kubectl delete -f api-headless.yaml -f db.yaml -f db-headless.yaml -f service-lb.yaml -f service-nodeport.yaml -f service-clusterip.yaml -f api.yaml
service "api-headless" deleted from default namespace
statefulset.apps "db" deleted from default namespace
service "db-headless" deleted from default namespace
service "api-lb" deleted from default namespace
service "api-nodeport" deleted from default namespace
service "api-service" deleted from default namespace
deployment.apps "api" deleted from default namespace
$ kubectl delete pod client dns-test
pod "client" deleted from default namespace
pod "dns-test" deleted from default namespace
$ kubectl delete namespace production
namespace "production" deleted

Deleting the namespace also deletes the client Pod inside it.

Frequently Asked Questions

Why not connect directly to Pod IPs?
Pod IPs are ephemeral. A Pod that is replaced or rescheduled comes back with a new IP. A Service gives clients one stable ClusterIP and DNS name, and Kubernetes keeps its list of backend Pod IPs up to date as Pods come and go.
How does DNS work inside a Kubernetes cluster?
The cluster runs CoreDNS, and every Service gets the name <service>.<namespace>.svc.cluster.local. A Pod's /etc/resolv.conf lists search domains starting with its own namespace, so the bare service name only resolves from the same namespace. From another namespace use <service>.<namespace>.
Why does my Kubernetes Service have no endpoints?
The Service's selector matches no Ready Pods. Labels are case-sensitive, so app=API does not match app=api. Compare kubectl describe service (the Selector line) with kubectl get pods --show-labels. If endpoints are listed but connections are refused, check that targetPort is the port the container actually listens on.
What is the difference between port and targetPort in a Service?
port is what clients connect to on the Service's ClusterIP. targetPort is the port on the Pod that traffic is forwarded to. They can differ, such as port 80 forwarding to a container listening on 8080, but targetPort must match the container's real listening port or every connection is refused.