Introduction
You can now deploy and troubleshoot applications, but clients still need a dependable way to reach them. A Pod IP is temporary: a Deployment can replace a Pod at any time, giving the replacement a different IP address.
Kubernetes solves this with a Service. A Service selects a changing group of Pods and gives clients one stable network identity. In this lab, you will follow the complete connection path from labels to EndpointSlices, cluster DNS, and two Service types: ClusterIP for in-cluster access and NodePort for access through a node.
This lab deliberately stops at Services. Ingress adds HTTP routing and a separate controller; it is easier to understand after Service selection and reachability are concrete.
Deploy the Service Backends
In this step, you will create the replicated application that later Services will expose. Here, a backend means a Pod able to receive traffic for a Service.
Environment startup: This lab starts a complete Kubernetes cluster for you. Configuring its control plane, node, and networking components usually takes 2–3 minutes. Please wait patiently for the environment to finish loading before running the commands below.
Move to the workspace. cd changes the shell's current directory; it normally prints nothing when successful:
cd /home/labex/project/service-lab
Create a two-replica Deployment. The app: course-nginx Pod label is especially important because Services will use it as their selection rule.
The shell syntax cat <<'EOF' > filename is a here-document. Everything until the closing EOF is written to the file, and > creates or replaces that file. Quoting the first EOF prevents accidental shell expansion inside the YAML.
cat <<'EOF' > course-nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: course-nginx
spec:
replicas: 2
selector:
matchLabels:
app: course-nginx
template:
metadata:
labels:
app: course-nginx
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
EOF
Apply it and wait for both Pods. -f tells apply which file to read; rollout status waits for the Deployment controller; --timeout=60s limits that wait. In the final command, -l filters by label and -o wide adds Pod IP and node columns:
kubectl apply -f course-nginx-deployment.yaml
kubectl rollout status deployment/course-nginx --timeout=60s
kubectl get pods -l app=course-nginx -o wide
The named http port documents TCP port 80 on each container. The two rows should be Running and show different Pod IPs. Those IPs are real but not durable client addresses; the next steps add a stable Service identity in front of them.
Connect Labels to Service Selection
In this step, you will inspect the metadata that connects a Service to Pods. A Service does not select a Deployment by name; it independently finds Pods whose labels match its selector.
Show the Pod labels. -l app=course-nginx is a label selector, while --show-labels adds the complete label set as a final column:
kubectl get pods -l app=course-nginx --show-labels
Each Pod has app=course-nginx plus a generated pod-template-hash. Your Service should select only the stable application label.
Compare names, labels, and IPs in a compact view. -o custom-columns creates a table from selected object fields. Each heading before : is followed by the field path used for that column; the backslash continues one command on the next display line:
kubectl get pods -l app=course-nginx \
-o custom-columns='NAME:.metadata.name,LABEL:.metadata.labels.app,IP:.status.podIP,READY:.status.containerStatuses[0].ready'
The generated names and IPs distinguish individual Pods; the shared label describes their role. This is the level of indirection that lets a Service keep working when one Pod is replaced.
Confirm the Deployment's selector and Pod-template label match. -o jsonpath='...' extracts only the requested fields. Text outside braces becomes a label, field paths inside braces return values, and {"\n"} inserts a newline:
kubectl get deployment course-nginx \
-o jsonpath='Selector: {.spec.selector.matchLabels.app}{"\n"}Pod label: {.spec.template.metadata.labels.app}{"\n"}'
Both values should be course-nginx. A mismatched selector would leave the controller or Service disconnected from the intended Pods.
Create a ClusterIP Service
In this step, you will create the default Service type, ClusterIP. It provides a virtual IP and DNS name reachable by workloads inside the cluster.
Create the manifest using the same here-document pattern introduced in this lab:
cat <<'EOF' > course-nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
name: course-nginx
spec:
type: ClusterIP
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
EOF
Read the port mapping carefully:
port: 80is the port clients use on the Service.targetPort: httprefers to the named container port on each selected Pod.- The selector chooses the backend Pods; it is not a network address.
Validate, apply, and inspect the Service. --dry-run=client parses locally without creating anything; removing it performs the real apply; get service then reads the live object:
kubectl apply --dry-run=client -f course-nginx-service.yaml
kubectl apply -f course-nginx-service.yaml
kubectl get service course-nginx
The CLUSTER-IP value is assigned by Kubernetes. It remains stable for the lifetime of this Service even when its backend Pods change.
Trace the Service to EndpointSlices
In this step, you will follow the selector from the Service to the actual backend addresses. Kubernetes records these addresses in EndpointSlice objects.
Describe the Service. describe expands one named object into configuration, status, and related endpoint information, making it useful after the shorter get table:
kubectl describe service course-nginx
Look for Selector: app=course-nginx and an Endpoints line containing two Pod IPs on port 80.
List the EndpointSlice selected by the Service-name label. This -l selector uses the automatically added label kubernetes.io/service-name=course-nginx:
kubectl get endpointslices -l kubernetes.io/service-name=course-nginx
Inspect its addresses and readiness. This JSONPath uses range to repeat the enclosed template for every endpoint. It prints the first address, literal text ready=, the readiness value, and a newline:
kubectl get endpointslices -l kubernetes.io/service-name=course-nginx \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'
You should see two addresses with ready=true. The chain is now concrete:
Service selector -> matching Pod labels -> EndpointSlice addresses -> ready Pods
If a Service exists but has no endpoints, first compare its selector with the Pods' labels and readiness.
Reach the Service by Cluster DNS
In this step, you will act as an in-cluster client. Kubernetes DNS lets a Pod in the same namespace use the Service name course-nginx instead of remembering its virtual IP.
Start a temporary BusyBox Pod, request the NGINX page, and remove the client automatically when it exits. Read the options from top to bottom:
--imagechooses the container image and--image-pull-policy=IfNotPresentreuses the cached copy.--restart=Nevercreates a standalone Pod rather than a controller-managed workload.--rmremoves the Pod after its command exits, and-ikeeps command input/output attached.- The
--separator ends kubectl options; everything after it is the command inside the container. wget -qO-requests the URL quietly and writes the response body to the terminal.
kubectl run service-client \
--image=busybox:1.36 \
--image-pull-policy=IfNotPresent \
--restart=Never \
--rm -i \
-- wget -qO- http://course-nginx
The response contains the NGINX welcome page. Traffic traveled through the Service rather than directly to a chosen Pod IP.
Run a quieter success check. >/dev/null discards the HTML body, and && prints the message only if the request command succeeds:
kubectl run service-client-check \
--image=busybox:1.36 \
--image-pull-policy=IfNotPresent \
--restart=Never \
--rm -i \
-- wget -qO- http://course-nginx >/dev/null && echo "ClusterIP Service responded"
The success message proves both DNS resolution and HTTP reachability. The client Pod is temporary; the Service and its two backend Pods remain.
Add a NodePort Service
In this step, you will create a second Service for the same Pods using type NodePort. A NodePort opens a port from the default range 30000–32767 on each node and forwards it to the Service backends.
Kubernetes can read several objects from one multi-document YAML file. Each object keeps its own apiVersion, kind, metadata, and spec; a line containing --- separates one YAML document from the next.
Create a reusable file containing the existing ClusterIP Service and the new NodePort Service. Repeating the ClusterIP definition is safe: applying the same desired state leaves it unchanged.
cat <<'EOF' > course-nginx-services.yaml
apiVersion: v1
kind: Service
metadata:
name: course-nginx
spec:
type: ClusterIP
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: course-nginx-nodeport
spec:
type: NodePort
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080
protocol: TCP
EOF
Validate both YAML documents together before changing the cluster. The output should mention both service/course-nginx and service/course-nginx-nodeport, followed by (dry run):
kubectl apply --dry-run=client -f course-nginx-services.yaml
Apply and inspect them. The first command reads both documents from one file; the existing ClusterIP Service should be unchanged, while the NodePort Service is created. The second command reads the new live Service:
kubectl apply -f course-nginx-services.yaml
kubectl get service course-nginx-nodeport
The PORT(S) column shows 80:30080/TCP: port 80 is the Service port and 30080 is the node-facing port. Both Services select the same Pods and therefore can have the same backend addresses.
Compare the Two Access Boundaries
In this step, you will test the NodePort and summarize when each Service type is appropriate.
Get the Minikube node IP. $(...) is command substitution: the shell runs minikube ip and stores its output in the variable NODE_IP. -p labex-v135 selects the prepared profile, and echo lets you inspect the stored value:
NODE_IP=$(minikube ip -p labex-v135)
echo "$NODE_IP"
Request the application through the node-facing port. Kubernetes may need a few seconds to program the new node-level network rule after accepting the Service. The retry options make curl wait through that short convergence window instead of failing on the first refused connection:
curl -s --retry 5 --retry-connrefused --retry-delay 2 "http://${NODE_IP}:30080" | grep 'Welcome to nginx'
Here, -s hides the progress meter, --retry 5 permits up to five retries, --retry-connrefused treats an early refused connection as retryable, and --retry-delay 2 waits two seconds between attempts. Double quotes allow ${NODE_IP} to expand inside the URL. The pipe | passes the returned HTML to grep, which prints the matching welcome line as evidence.
The matching HTML title proves that the request reached a backend Pod. Compare the two paths you built:
in-cluster Pod -> course-nginx:80 -> ready backend Pod
VM/node client -> NODE_IP:30080 -> course-nginx-nodeport:80 -> ready backend Pod
Inspect both Services together:
kubectl get services course-nginx course-nginx-nodeport
Use ClusterIP for stable communication within the cluster; it is the default and the common foundation for other exposure mechanisms. NodePort adds a node-level entry point and is useful for learning, development, or integration with external load balancers. Both depend on correct selectors and ready EndpointSlices.
You will use these ideas independently in the next challenge by exposing more than one web workload.
Summary
You built a stable network identity in front of replaceable Pods. You connected Service selectors to Pod labels, traced the selected backends through EndpointSlices, reached a ClusterIP Service through cluster DNS, and added a NodePort entry point through the node.
The key mental model is that a Service is not the application and does not contain Pods. It continually represents a selected set of ready backends. When connectivity fails, follow the chain in order: Service ports, selector, Pod labels, EndpointSlices, backend readiness, and then the client boundary.


