Expose a Pod as a Service
Learn how to expose a pod as a service in Kubernetes so that other pods and external clients can reach it. This lesson covers the problem it solves, the core mental model, step-by-step walkthrough, troubleshooting tips, and what to study next.
Focus: expose a pod as a service
You've done the hard work of building container images, configuring Pods with health checks, and understanding how they live and die in Kubernetes. Yet a Pod has no stable address—its IP changes on restart, and unless you're debugging inside the cluster, you can't reach it at all. That's where expose a pod as a service comes in. Services are the permanent front door that decouples clients from individual Pods, giving you a stable IP, DNS name, and built-in load balancing.
The problem this lesson solves
Without a Service, a Pod is an island. Clients must know its ephemeral cluster IP (which vanishes on restart) and no load distribution occurs when you scale replicas. Consider a web app Pod running on 10.244.1.5:8080:
- The Node fails → Pod is recreated on
10.244.2.7:8080. - Any client hard‑coding
10.244.1.5is now broken. - Even if you scale to three replicas, each has a different IP — how does a front‑end or ingress reach all of them?
Services solve this exact pain. They provide a stable endpoint (ClusterIP) and a selector that automatically tracks healthy Pods. As Pods come and go, the Service keeps its IP and routes traffic only to ready Pods.
Core concept / mental model
Think of a Kubernetes Service as a virtual IP with a label‑based switchboard. It never runs as a container — it's an API object that programs routing rules into every node's iptables or IPVS. The mental model has two parts:
- Selector – a set of labels the Service watches. Any Pod possessing those labels becomes a backend.
- Endpoints – the actual list of Pod IP + port pairs. The Service controller automatically populates an
Endpointsobject (orEndpointSlicein newer clusters) behind the scenes.
When a client sends traffic to the Service's ClusterIP, kube‑proxy intercepts it and forwards to one of the live Pods. The client never knows about individual Pod IPs — and doesn't need to.
Pro tip: Services use round‑robin by default when multiple Pods match the selector, giving you basic load balancing without any extra config.
How it works step by step
1. Label the Pod(s) you want to expose
Every Pod you want behind the Service must carry the labels your Service selector will match. For example, a web Pod might have app: web and tier: frontend.
2. Create the Service manifest
The Service YAML defines:
apiVersion: v1(Services are core resources)kind: Servicemetadata.name– a DNS name for cluster‑internal usespec.selector– the labels to match against Podsspec.ports– one or more port mappings (port,targetPort, optionalnodePort)spec.type–ClusterIP(default),NodePort,LoadBalancer, orExternalName
3. The controller does the rest
Once you apply the YAML:
- The Service controller creates an
Endpointsobject with all Pod IPs that matchselectorand are ready. - kube‑proxy on every node updates routing rules so traffic to
ClusterIP:portis load‑balanced. - If Pods are added or removed, the
Endpointslist updates automatically — usually within seconds.
Hands-on walkthrough
We'll create a simple nginx Pod and a Service to expose it. You need a running cluster (minikube or kind works fine).
Step A: Launch a Pod with labels
# pod-nginx.yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: myapp
tier: frontend
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
kubectl apply -f pod-nginx.yaml
Step B: Create the Service
# service-nginx.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-svc
spec:
selector:
app: myapp
tier: frontend
ports:
- protocol: TCP
port: 80 # Service port
targetPort: 80 # Pod container port
type: ClusterIP
kubectl apply -f service-nginx.yaml
Step C: Verify everything works
# Check the Service and its ClusterIP
kubectl get svc nginx-svc
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# nginx-svc ClusterIP 10.96.123.45 <none> 80/TCP 10s
# Inspect the automatically created Endpoints
kubectl get endpoints nginx-svc
# NAME ENDPOINTS AGE
# nginx-svc 10.244.1.5:80 10s
# Access the Pod via the Service from a temporary Pod
kubectl run test --image=curlimages/curl --rm -it --restart=Never -- curl http://nginx-svc:80
# Returns nginx welcome page
Expected output: the
curlinside the test Pod reaches the nginx welcome page via the service namenginx-svc— no hard‑coded IP needed.
Scaling the backend
Add a second Pod with the same labels and watch the Endpoints list expand:
# pod-nginx-2.yaml (same labels, different name)
apiVersion: v1
kind: Pod
metadata:
name: nginx-2
labels:
app: myapp
tier: frontend
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
kubectl apply -f pod-nginx-2.yaml
kubectl get endpoints nginx-svc
# NAME ENDPOINTS AGE
# nginx-svc 10.244.1.5:80,10.244.2.8:80 20s
The Service now load‑balances between two nginx Pods.
Compare options / when to choose what
Services offer four type fields. Here's when to use each:
| Type | Cluster‑internal only? | External access | Use case |
|---|---|---|---|
| ClusterIP (default) | Yes | No (without proxy/ingress) | Microservices talking to each other inside the cluster |
| NodePort | Yes | Yes via <NodeIP>:<NodePort> (port 30000–32767) |
Dev/test, direct node access, or when you don't have a LoadBalancer |
| LoadBalancer | Yes | Yes via external IP from cloud provider | Production services behind a cloud load balancer (AWS, GCP, Azure) |
| ExternalName | No | N/A (returns CNAME) | Pointing to an external database or API outside the cluster |
When to choose ClusterIP: This is your default for internal traffic. Use it for backend services, APIs consumed by other Pods, or any workload that doesn't need direct external exposure. Combine with an Ingress if external access is needed later.
Troubleshooting & edge cases
❌ Service created but Endpoints list is empty
Most common cause: selector labels don't match. Check labels on your Pod:
kubectl get pods --show-labels
Then verify the Service selector:
kubectl describe svc nginx-svc | grep -i selector
If they mismatch, edit the Service selector or relabel the Pod.
❌ Curl from inside the cluster times out
- Ensure the Pod's
targetPortmatches the container'scontainerPort. - Verify the Pod is
RunningandReadinessProbepasses (if set). Services only forward to ready Pods. - Test with a temporary Pod in the same namespace:
kubectl run tmp --image=busybox -it --rm -- wget -qO- http://nginx-svc.
❌ NodePort not reachable from outside
- Confirm the NodePort is in range 30000–32767.
- Check firewall / security groups on the node (cloud VMs often block the high ports).
- Use the correct node IP, not the pod or service IP:
curl <NodeIP>:<NodePort>.
Edge case: Service with no selector
You can create a Service without selector and manually manage an Endpoints object. This is useful for routing traffic to an external resource (e.g., a database running on bare metal).
What you learned & what's next
You now know how to expose a pod as a service: the core concept of a stable virtual IP with label‑based backend selection, how ClusterIP differs from NodePort and LoadBalancer, and how to verify it works with a simple test. You've mastered the key abstraction that every real‑world Kubernetes deployment relies on.
Next step: Now that your app has a stable internal address, the natural progression is to route external traffic with an Ingress controller — a smarter, HTTP‑aware gateway that sits in front of your Service and handles TLS termination, path‑based routing, and virtual hosts.
Practice recap
Create a Deployment with 3 replicas (label them app: myapp). Expose the deployment with kubectl expose deployment myapp --type=ClusterIP --port=80. Then deploy a temporary Pod and use curl to hit the service multiple times — you should see the response come from different replica Pods each time.
Common mistakes
- Forgetting to add labels to the Pod — the Service selector finds nothing, and
Endpointsstays empty. Always verify withkubectl get pods --show-labels. - Assuming
ClusterIPmakes the service accessible from outside the cluster —ClusterIPis internal only. UseNodePortorLoadBalancerfor external traffic. - Setting the wrong
targetPort— the Service forwards totargetPorton the Pod; if it doesn't matchcontainerPort, traffic drops.
Variations
- Use
kubectl exposecommand as a shortcut:kubectl expose pod nginx --type=ClusterIP --port=80 --target-port=80. - Headless Services (
.spec.clusterIP: None) — useful for StatefulSets DNS lookup without proxying. - ExternalName Services for pointing to legacy or off‑cluster resources without a native endpoint.
Real-world use cases
- Internal microservices: a user‑service Pod exposes API on port 8080; an order‑service Pod reaches it via
user-svc:8080(ClusterIP). - Database read replicas: a set of PostgreSQL read replicas each with
role: replicalabel; a ClusterIP Service balances queries across all replicas. - Dev/test with NodePort: expose a debug Pod on a fixed high port so developers can curl the app from their laptops without port‑forwarding.
Key takeaways
- A Service provides a stable virtual IP and DNS name, decoupling clients from individual Pod IPs.
- The Service selector uses Pod labels to dynamically build the backend list (Endpoints).
- ClusterIP (default) works for internal traffic only; NodePort and LoadBalancer add external access.
- Services automatically load‑balance across all ready Pods matching the selector (round‑robin).
- Always verify Endpoints after creating a Service — empty Endpoints means selector mismatch.
- Next lesson: Ingress — an HTTP‑aware gateway for external routing, TLS, and virtual hosts.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.