| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3 |
| Applies to | Any host with kubectl configured; Kubernetes 1.27+ cluster with Linux worker nodes |
| Cert prep | CKA · CKAD · CKS |
| Lab environment | Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm |
| Privilege | Normal user (no sudo required on the workstation) |
| Scope | ClusterIP, NodePort, and LoadBalancer Service types; headless ClusterIP Services; selectors; port fields; EndpointSlice inspection; readiness effects on backends; and end-to-end verification. Does not cover full Service troubleshooting runbooks, CoreDNS deep dives, Ingress or Gateway API rules, NetworkPolicy, kube-proxy internals, or manual legacy Endpoints creation. |
| Related guides | Expose services with Ingress |
A Deployment gives you Pods that come and go. A Service gives clients a stable discovery point in front of Pods that can change over time.
Before you expose a Service, read Kubernetes networking for how Pod networking, kube-proxy, and CNI plugins fit together.
This guide keeps one three-replica web Deployment in svc-lab and walks through:
- ClusterIP Service first, with EndpointSlice inspection
- Separate NodePort, LoadBalancer, and headless Service examples
Normal ClusterIP-based Services provide a virtual IP; headless Services expose backend addresses through DNS.
Why Kubernetes Needs Services
Pod IP addresses are not permanent. When a Pod is rescheduled, scaled, or replaced, its IP changes. Clients cannot hard-code Pod IPs and still survive normal cluster operations.
A Service solves that by providing:
- A stable DNS name and, for non-headless ClusterIP-based Services, a stable virtual IP
- Label-based Pod selection instead of per-Pod client configuration
- One place to define the client-facing port and backend target port
Kubernetes records the selected backend addresses in EndpointSlices. Traffic forwarding depends on two separate layers:
- Service proxy or dataplane (commonly kube-proxy, but a replacement may be used) watches Services and EndpointSlices and programs forwarding to eligible endpoints
- CNI plugin provides Pod networking; some products also replace kube-proxy, but those remain separate roles
For a normal selector-based ClusterIP Service, the traffic path is:
Client
↓
Service DNS name (optional resolution)
↓
ClusterIP and Service port
↓
Service proxy or dataplane rules
↓
Ready Pod IP and target portThe EndpointSlice is not a network hop. It supplies the backend addresses and readiness conditions that the Service dataplane uses to program this path.
Headless Services return backend addresses through DNS without using a virtual Service IP.
Create the Backend Deployment
I use a small nginx-based Deployment with three replicas, one named container port, and a readiness probe that gates traffic. Each Pod writes its hostname into the default page so curl command responses identify which backend answered. The Deployment selector uses app: web; Services in this lab select on service: web so you can change Service membership without disturbing ReplicaSet reconciliation.
Create the namespace and Deployment manifest:
apiVersion: v1
kind: Namespace
metadata:
name: svc-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: svc-lab
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
service: web
spec:
containers:
- name: app
image: nginx:1.27-alpine
command: ["/bin/sh", "-c"]
args:
- |
echo "server $(hostname)" > /usr/share/nginx/html/index.html
touch /tmp/healthy
exec nginx -g "daemon off;"
ports:
- name: http
containerPort: 80
readinessProbe:
exec:
command: ["/bin/sh", "-c", "test -f /tmp/healthy"]
initialDelaySeconds: 2
periodSeconds: 3Apply the manifest and wait for the rollout to finish:
kubectl apply -f web-deployment.yamlSample output:
namespace/svc-lab created
deployment.apps/web createdkubectl rollout status deployment/web -n svc-lab --timeout=120sSample output:
deployment "web" successfully rolled outkubectl rollout status watches the current Deployment revision until its rollout completes, which matches the explanation that all three replicas are ready for the following tests.
List the Pods with node and IP details:
kubectl get pods -n svc-lab -l service=web -o wideThe following outputs are representative. Pod and EndpointSlice names, IP addresses, endpoint order, Service IPs, generated node ports, nodes, and ages will differ between clusters.
Sample output:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web-<replicaset-hash>-<pod-suffix-1> 1/1 Running 0 <age> <pod-ip-1> worker01 <none> <none>
web-<replicaset-hash>-<pod-suffix-2> 1/1 Running 0 <age> <pod-ip-2> worker01 <none> <none>
web-<replicaset-hash>-<pod-suffix-3> 1/1 Running 0 <age> <pod-ip-3> worker01 <none> <none>Confirm the Pod-template labels the Service will select:
kubectl get pods -n svc-lab -l service=web --show-labelsSample output:
NAME READY STATUS RESTARTS AGE LABELS
web-<replicaset-hash>-<pod-suffix-1> 1/1 Running 0 <age> app=web,pod-template-hash=<replicaset-hash>,service=web
web-<replicaset-hash>-<pod-suffix-2> 1/1 Running 0 <age> app=web,pod-template-hash=<replicaset-hash>,service=web
web-<replicaset-hash>-<pod-suffix-3> 1/1 Running 0 <age> app=web,pod-template-hash=<replicaset-hash>,service=webAll three Pods carry app=web for the Deployment selector and service=web for Service selection. The pod-template-hash label is added automatically by the Deployment controller.
Configure Service Ports and ClusterIP
Create the ClusterIP Service
ClusterIP is the default Service type. It allocates a virtual IP that is normally reachable only inside the cluster.
Add this Service to the same manifest or apply it separately:
apiVersion: v1
kind: Service
metadata:
name: web
namespace: svc-lab
spec:
type: ClusterIP
selector:
service: web
ports:
- name: http
port: 80
targetPort: http
protocol: TCPApply the Service:
kubectl apply -f web-service.yamlInspect the allocated cluster IP and port:
kubectl get service -n svc-lab webSample output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web ClusterIP <cluster-ip> <none> 80/TCP <age>Understand port, targetPort, and containerPort
These fields show up together in Service and Pod YAML but play different roles:
| Field | Meaning |
|---|---|
containerPort |
Documents the port the container listens on; not required for numeric Service forwarding |
targetPort |
Port on the selected backend Pod |
port |
Port exposed by the Service |
nodePort |
Port exposed on each eligible node for a NodePort Service |
Keep these rules in mind when you read or write manifests:
targetPortdefaults to the value ofportwhen omitted.targetPortmay be numeric or reference a named Pod port (as in this lab,httpmaps to container port80).- A numeric
targetPort, such as80, sends traffic to that Pod port whether or notcontainerPort: 80is declared. - A named
targetPort, such ashttp, must match a named container port on each selected Pod. - When a Service exposes multiple ports, each port needs a unique name.
In the web Service above, clients connect to Service port 80, and Kubernetes forwards to the Pod port named http, which is container port 80.
Test Service DNS and ClusterIP
Test from a temporary client Pod inside the cluster. The namespace-qualified name web.svc-lab works without assuming that the cluster DNS domain is cluster.local. A Pod in the same namespace could also use the short name web.
Wait until the EndpointSlice lists all three backends, then press Ctrl+C:
kubectl get endpointslice -n svc-lab -l kubernetes.io/service-name=web --watchAll temporary client commands below attach to the Pod and remove it after the command exits. Depending on kubectl output handling, you may also see a final Pod deletion message after the displayed response.
Test a backend Pod IP and target port directly:
POD_IP=$(kubectl get pod -n svc-lab -l service=web -o jsonpath='{.items[0].status.podIP}')
kubectl run podip-client \
-n svc-lab \
--rm \
-i \
--restart=Never \
--image=curlimages/curl:8.5.0 \
--command \
-- curl \
-fsS "http://${POD_IP}:80"Sample output:
server web-<replicaset-hash>-<pod-suffix>This request bypasses Service DNS, the ClusterIP, and Service proxying. A failure here means the Pod is not serving on the expected target port, so fix the backend before troubleshooting the Service.
Test Service DNS resolution:
kubectl run curl-client -n svc-lab --rm -i --restart=Never --image=curlimages/curl:8.5.0 --command -- curl -fsS http://web.svc-labSample output:
server web-<replicaset-hash>-<pod-suffix>Test the ClusterIP and Service port directly:
CLUSTER_IP=$(kubectl get service web -n svc-lab -o jsonpath='{.spec.clusterIP}')
kubectl run clusterip-client \
-n svc-lab \
--rm \
-i \
--restart=Never \
--image=curlimages/curl:8.5.0 \
--command \
-- curl \
-fsS "http://${CLUSTER_IP}:80"Sample output:
server web-<replicaset-hash>-<pod-suffix>The DNS request verifies Service DNS resolution and routing. The ClusterIP request bypasses DNS and verifies the virtual IP and Service port directly. Kubernetes documents backend Pod-IP testing, DNS-name testing, and direct Service-IP testing as separate checks. Repeated curls may hit different replicas because the Service proxy load-balances across ready endpoints.
Understand EndpointSlices
How Service Selectors Choose Pods
The Service selector is compared with Pod labels. Matching Pod addresses are recorded in one or more EndpointSlice objects. The Service proxy watches Services and EndpointSlices, then forwards traffic to endpoints marked ready.
Inspect the Service selector and ports:
kubectl describe service -n svc-lab webOr, for compact output:
kubectl get service web -n svc-lab -o jsonpath='selector={.spec.selector}{"\n"}ports={.spec.ports}{"\n"}'List EndpointSlices owned by that Service:
kubectl get endpointslice -n svc-lab -l kubernetes.io/service-name=webSample output:
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-<endpointslice-suffix> IPv4 80 <pod-ip-1>,<pod-ip-2>,<pod-ip-3> <age>Read addresses, target ports, and ready conditions:
kubectl describe endpointslice -n svc-lab -l kubernetes.io/service-name=webSample output:
Name: web-<endpointslice-suffix>
Namespace: svc-lab
Labels: endpointslice.kubernetes.io/managed-by=endpointslice-controller.k8s.io
kubernetes.io/service-name=web
AddressType: IPv4
Ports:
Name Port Protocol
---- ---- --------
http 80 TCP
Endpoints:
- Addresses: <pod-ip-1>
Conditions:
Ready: true
TargetRef: Pod/web-<replicaset-hash>-<pod-suffix-1>
- Addresses: <pod-ip-2>
Conditions:
Ready: true
TargetRef: Pod/web-<replicaset-hash>-<pod-suffix-2>
- Addresses: <pod-ip-3>
Conditions:
Ready: true
TargetRef: Pod/web-<replicaset-hash>-<pod-suffix-3>Demonstrate Service membership without changing the Deployment selector. Pick one Pod and remove only the service label the Service matches on:
POD=$(kubectl get pod -n svc-lab -l service=web -o jsonpath='{.items[0].metadata.name}')
POD_IP=$(kubectl get pod -n svc-lab "$POD" -o jsonpath='{.status.podIP}')
kubectl label pod -n svc-lab "$POD" service=web-off --overwriteWatch until $POD_IP disappears, then press Ctrl+C:
kubectl get endpointslice -n svc-lab -l kubernetes.io/service-name=web --watchRestore the label and watch until the address returns:
kubectl label pod -n svc-lab "$POD" service=web --overwrite
kubectl get endpointslice -n svc-lab -l kubernetes.io/service-name=web --watchEndpointSlices contain Pods matching the Service selector. ReplicaSet ownership and reconciliation use the Deployment selector on app=web, so changing service affects Service membership without triggering replacement Pods.
EndpointSlices and Readiness
Readiness decides whether a Pod should receive traffic. A failing readiness probe marks the Pod NotReady without restarting the container. Liveness and startup probe details live in the Kubernetes health probes guide.
With all three lab Pods healthy, every address in the EndpointSlice shows Ready: true. Remove the readiness marker file on one Pod to simulate a probe failure:
POD=$(kubectl get pod -n svc-lab -l service=web -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n svc-lab "$POD" -- rm -f /tmp/healthyWait for the actual readiness transition:
kubectl wait -n svc-lab --for=condition=Ready=false "pod/$POD" --timeout=30sSample output:
pod/<generated-pod-name> condition metThe Pod stays Running, but it is not Ready. kubectl wait confirms the Pod condition changed; the EndpointSlice controller copies that state separately. EndpointSlices maintain their own ready, serving, and terminating conditions.
kubectl get endpointslice \
-n svc-lab \
-l kubernetes.io/service-name=web \
-o custom-columns='NAME:.metadata.name,PODS:.endpoints[*].targetRef.name,READY:.endpoints[*].conditions.ready' \
--watchWait until the entry corresponding to $POD changes to false, then press Ctrl+C and run kubectl describe endpointslice:
kubectl describe endpointslice -n svc-lab -l kubernetes.io/service-name=webSample output:
- Addresses: <pod-ip>
Conditions:
Ready: false
TargetRef: Pod/web-<replicaset-hash>-<pod-suffix>Normal Service routing stops selecting that backend while Ready is false. Restore the marker file and wait again:
kubectl exec -n svc-lab "$POD" -- touch /tmp/healthy
kubectl wait -n svc-lab --for=condition=Ready "pod/$POD" --timeout=30sWatch until that entry returns to true, then press Ctrl+C:
kubectl get endpointslice \
-n svc-lab \
-l kubernetes.io/service-name=web \
-o custom-columns='NAME:.metadata.name,PODS:.endpoints[*].targetRef.name,READY:.endpoints[*].conditions.ready' \
--watchThe EndpointSlice condition returns to Ready: true and the address is eligible for traffic again.
publishNotReadyAddresses: true on a Service is a special override used mainly for peer-discovery scenarios where DNS discovery and Service consumers must be able to reach Pods before those Pods become Ready. Leave it unset for normal HTTP Services.
EndpointSlices vs the Deprecated Endpoints API
EndpointSlices use the discovery.k8s.io/v1 API. A Service can have multiple EndpointSlices, which scale better and carry richer networking metadata than the older object.
On current clusters you may still see the core v1 Endpoints object. The Endpoints API was officially deprecated in Kubernetes 1.33.
kubectl get endpoints -n svc-lab webOn Kubernetes 1.33 and later, kubectl also displays a deprecation warning. Older clusters within this article's supported range may show the Endpoints table without that warning.
Sample output:
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME ENDPOINTS AGE
web <pod-ip-1>:80,<pod-ip-2>:80,<pod-ip-3>:80 <age>Use EndpointSlice commands for inspection and troubleshooting. Do not teach or rely on manually created legacy Endpoints objects for selector-based Services; the controller maintains EndpointSlices automatically.
Compare Service Types
NodePort
NodePort builds on ClusterIP and allocates one port from the cluster's configured NodePort range, which defaults to 30000–32767. The same port is configured on every node, although the node addresses that accept it can be restricted by the Service proxy's nodePortAddresses configuration. Clients can generally reach the Service at <NodeIP>:<nodePort>, though the exact reachable addresses depend on your network layout, firewalls, and whether the selected node address is included in the Service proxy's NodePort address configuration.
Create a NodePort Service (you can delete or keep the existing ClusterIP web Service; this example uses a separate name):
apiVersion: v1
kind: Service
metadata:
name: web-nodeport
namespace: svc-lab
spec:
type: NodePort
selector:
service: web
ports:
- name: http
port: 80
targetPort: httpApply it and read the allocated node port:
kubectl apply -f web-nodeport.yamlNODE_PORT=$(kubectl get service web-nodeport -n svc-lab -o jsonpath='{.spec.ports[0].nodePort}')
echo "$NODE_PORT"Sample output:
<node-port>kubectl get service -n svc-lab web-nodeportSample output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-nodeport NodePort <cluster-ip> <none> 80:<node-port>/TCP <age>List node addresses you can target from outside the cluster:
kubectl get nodes -o wideSample output:
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE
k8s-cp Ready control-plane <age> v1.36.3 <node-ip> <none> Rocky Linux 10.2 (Red Quartz)
worker01 Ready <none> <age> v1.36.3 <node-ip> <none> Rocky Linux 10.2 (Red Quartz)Test the NodePort data path from inside the cluster:
NODE_IP=$(kubectl get nodes \
-l '!node-role.kubernetes.io/control-plane' \
-o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
kubectl run nodeport-client \
-n svc-lab \
--rm \
-i \
--restart=Never \
--image=curlimages/curl:8.5.0 \
--command \
-- curl \
-fsS "http://${NODE_IP}:${NODE_PORT}"Sample output:
server web-<replicaset-hash>-<pod-suffix>This in-cluster test confirms that the node IP and NodePort route to the Service. To verify external access, run the same curl command from a workstation or client network that can route to the selected node IP. External reachability still depends on node routing and firewall rules.
NodePort is useful in labs and for simple exposure, but it is not the preferred production pattern for public HTTP routing. For stable hostnames, TLS, and path rules, use Ingress or Gateway API after internal Service routing works.
LoadBalancer
A LoadBalancer Service asks the platform for an external load-balancer front end. Provisioning is asynchronous; the address appears under .status.loadBalancer when the controller succeeds.
apiVersion: v1
kind: Service
metadata:
name: web-lb
namespace: svc-lab
spec:
type: LoadBalancer
selector:
service: web
ports:
- name: http
port: 80
targetPort: httpApply the manifest:
kubectl apply -f web-lb.yamlkubectl get service -n svc-lab web-lbSample output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-lb LoadBalancer <cluster-ip> <pending> 80:<node-port>/TCP <age>On a local kubeadm cluster without a cloud or metal load-balancer integration, EXTERNAL-IP commonly stays <pending>. That does not block in-cluster access through the ClusterIP. By default, Kubernetes allocates NodePorts for a LoadBalancer Service, which is why PORT(S) normally shows both the Service port and a generated node port. A load-balancer implementation that routes directly to Pods can use allocateLoadBalancerNodePorts: false. allocateLoadBalancerNodePorts defaults to true.
Avoid provider-specific annotations in portable tutorials; wire those only when you know your platform controller requires them.
Headless Service
A headless Service normally remains type: ClusterIP and sets clusterIP: None. Kubernetes does not define a separate headless Service type. DNS returns backend endpoint addresses directly instead of load-balancing through one Service IP.
apiVersion: v1
kind: Service
metadata:
name: web-headless
namespace: svc-lab
spec:
clusterIP: None
selector:
service: web
ports:
- name: http
port: 80
targetPort: httpApply the headless Service:
kubectl apply -f web-headless.yamlConfirm there is no cluster IP:
kubectl get service -n svc-lab web-headlessSample output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-headless ClusterIP None <none> 80/TCP <age>Inspect its EndpointSlice:
kubectl get endpointslice -n svc-lab -l kubernetes.io/service-name=web-headlessSample output:
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-headless-<endpointslice-suffix> IPv4 80 <pod-ip-1>,<pod-ip-2>,<pod-ip-3> <age>Resolve the Service DNS name from a debug Pod:
kubectl run dns-client -n svc-lab --rm -i --restart=Never --image=busybox:1.36.1 --command -- nslookup web-headless.svc-labSample output:
Server: <cluster-dns-ip>
Address: <cluster-dns-ip>:53
Name: web-headless.svc-lab
Address: <pod-ip-1>
Name: web-headless.svc-lab
Address: <pod-ip-2>
Name: web-headless.svc-lab
Address: <pod-ip-3>Headless Services are commonly paired with StatefulSets so each Pod keeps a stable network identity. See Kubernetes StatefulSets for ordered Pod names and per-replica DNS records.
Inspect a Service End to End
When Service traffic misbehaves, walk the path in order instead of jumping to external exposure:
- Check Service type, selector, and ports with
kubectl describe service. - Confirm matching Pod labels with
kubectl get pods --show-labels. - Confirm Pod readiness with
kubectl get pods(READY column). - Inspect EndpointSlices for addresses and
Readyconditions. - Test a Pod IP and
targetPortdirectly from a debug Pod. - Test the ClusterIP and Service
port. - Resolve the Service DNS name.
- Test NodePort or LoadBalancer only after internal routing works.
For a quick Service summary:
kubectl describe service -n svc-lab webSample output:
Name: web
Namespace: svc-lab
Selector: service=web
Type: ClusterIP
IP: <cluster-ip>
Port: http 80/TCP
TargetPort: http/TCP
Endpoints: <pod-ip-1>:80,<pod-ip-2>:80,<pod-ip-3>:80If internal checks pass but clients still fail, continue with the dedicated Service troubleshooting guide. For name-resolution problems, see Kubernetes DNS troubleshooting. For local workstation access without changing the Service type, kubectl port-forward is often enough during development.
Service Type Comparison
| Service form | Main address | Typical use |
|---|---|---|
| ClusterIP | Internal virtual IP | Communication inside the cluster |
| NodePort | Node IP and allocated port | Simple external or lab access |
| LoadBalancer | External load-balancer address | Provider-integrated external exposure |
| Headless ClusterIP | Backend addresses through DNS | Direct discovery and stateful peers |
What's Next
- Troubleshoot a Kubernetes Service That Is Not Working
- Services, CoreDNS and Name Resolution
- Kubernetes NetworkPolicy with Examples
References
- Kubernetes documentation — Service
- Kubernetes documentation — Debug Services — test backend Pod IPs, Service DNS, ClusterIP, selectors, and EndpointSlices separately
- Kubernetes documentation — EndpointSlices
- Kubernetes documentation — DNS for Services and Pods — short, namespace-qualified, and headless Service DNS
- Kubernetes documentation — Liveness, Readiness, and Startup Probes — readiness transitions and Service backends
- Kubernetes documentation — ReplicaSet — Deployment selector versus Service selector
- Kubernetes API reference — Service v1
- Kubernetes API reference — EndpointSlice discovery.k8s.io/v1
- Kubernetes API reference — Endpoints v1 — deprecated from Kubernetes 1.33
- kubectl run reference — temporary attached client Pods used in the verification commands
Summary
You built a three-replica web Deployment and fronted it with Kubernetes Services in svc-lab. A ClusterIP Service gave in-cluster clients one DNS name and virtual IP while EndpointSlices listed the Pod addresses and target ports behind that front door.
The important split is between Service port and Pod targetPort, and between the Deployment selector (app) and the Service selector (service). Readiness probes control which backends are eligible; a Pod can stay Running while Service traffic skips it. NodePort and LoadBalancer extend the same selector model outward, and headless Services trade the virtual IP for direct DNS answers when peers need individual Pod addresses.
On a bare lab cluster, LoadBalancer may stay pending even when ClusterIP routing works, so verify internal paths first. When something still fails after that checklist, use the Service troubleshooting guide before changing Ingress rules or cloud load-balancer settings.

