Table of contents
- Why You Can’t Trust Pod IPs
- The Structure a Service Creates
- Endpoints: What Really Happens Behind a Service
- Four Types of Services
- DNS: Why Service Names Work as Addresses
- Calling a Service from a Pod in Practice
- Headless Service: When You Need Pod IPs Directly
- Network Policies: Controlling Pod-to-Pod Communication
- Summary Table
Why You Can’t Trust Pod IPs
In Part 3, we established that pods are disposable. When they die, their IP disappears; when they come back, they get a different IP. This fact explains why the Service abstraction is necessary.
Imagine if the frontend called backend pods directly by IP. The moment a backend pod restarts, all connections break. Even if you scale out to 3 replicas, the frontend only knows one IP, so load balancing doesn’t work. No matter how well Kubernetes manages pods, if the calling side uses pod IPs directly, all these benefits collapse.
A Service is an abstraction that creates a fixed access point in front of multiple pods. Even though pod IPs change constantly, a Service’s IP (ClusterIP) remains stable. So the caller doesn’t need to worry about pod count, pod location, or pod health — just knowing the Service name is enough.
The Structure a Service Creates
To understand Services, let’s start with the simplest example: placing one Service in front of 3 backend pods.
# backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: myapp:1.0
ports:
- containerPort: 8080
---
# backend-service.yaml
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 8080
type: ClusterIP
The key is the selector. This Service declares that it will back itself with pods carrying the app: backend label. Kubernetes automatically discovers and connects pods matching this label.
flowchart LR
FE[Frontend Pod] -->|http://backend:80| SVC[Service: backend<br/>ClusterIP: 10.96.0.42]
SVC --> P1[Pod 1<br/>10.244.1.5:8080]
SVC --> P2[Pod 2<br/>10.244.1.6:8080]
SVC --> P3[Pod 3<br/>10.244.2.3:8080]
The frontend sends requests to http://backend:80. The Service’s ClusterIP is a virtual IP accessible only within the cluster, and through iptables rules managed by kube-proxy, traffic is forwarded to one of the actual pods. Even when pods die and new ones come up, the Service remains unchanged.
Endpoints: What Really Happens Behind a Service
When you create a Service, an Endpoints resource with the same name is automatically created. Endpoints is the real-time list of “which pod IPs does this Service currently point to.”
kubectl get endpoints backend
# NAME ENDPOINTS AGE
# backend 10.244.1.5:8080,10.244.1.6:8080,10.244.2.3:8080 5m
This list isn’t static. The Endpoints controller continuously updates it by watching pod label changes, readiness status, and deletion events. Pods that haven’t passed the readiness probe are excluded from Endpoints. This is where the importance of readiness from Part 3 becomes apparent. When readiness is false, the pod is automatically removed from behind the Service, ensuring that during rolling updates, traffic only goes to ready pods.
As clusters grow, the Endpoints object can become too large, so it gets split into a more efficient format called EndpointSlice. Functionally, it does the same thing.
Four Types of Services
Services come in several types, each solving a different problem. Let’s compare how each type creates its traffic path in one diagram:
flowchart TB
subgraph CIP["ClusterIP (Internal Only)"]
direction TB
CIP_IN["Cluster-Internal Pod"]
CIP_POD["Backend Pods"]
CIP_IN -->|ClusterIP| CIP_POD
end
subgraph NP["NodePort"]
direction TB
NP_EXT["External User"]
NP_NODE["Node (kube-proxy)"]
NP_POD["Backend Pods"]
NP_EXT -->|"NodeIP:30080"| NP_NODE
NP_NODE --> NP_POD
end
subgraph LB["LoadBalancer"]
direction TB
LB_EXT["External User"]
LB_CLOUD["Cloud LB (ELB/GCLB)"]
LB_NODE["Node"]
LB_POD["Backend Pods"]
LB_EXT -->|"Public IP:80"| LB_CLOUD
LB_CLOUD --> LB_NODE
LB_NODE --> LB_POD
end
subgraph EN["ExternalName"]
direction TB
EN_IN["Internal Pod"]
EN_DNS["CoreDNS (CNAME)"]
EN_EXT["db.prod.example.com"]
EN_IN -->|"db.mynamespace.svc"| EN_DNS
EN_DNS --> EN_EXT
end
ClusterIP: Internal Only
The default. Assigns a virtual IP accessible only within the cluster. Used for internal services like backends, databases, and internal APIs. Safe since it’s not directly exposed to the outside.
spec:
type: ClusterIP # Also the default if omitted
NodePort: Expose via Node Port
Opens the same port (default range 30000-32767) on all nodes and forwards traffic arriving at that port to the Service.
spec:
type: NodePort
ports:
- port: 80
targetPort: 8080
nodePort: 30080
Traffic reaches this Service regardless of which node IP it enters through. Convenient for labs and local development, but using NodePort alone in production is rare. Port numbers are limited, and clients need to know node IPs.
LoadBalancer: Automatic Cloud LB Provisioning
The type used on clouds like AWS, GCP, and Azure. When you create a Service of this type, the Cloud Controller Manager automatically creates a load balancer (ELB, GCLB, etc.) for that cloud and attaches it in front.
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
Running kubectl get svc shows the cloud LB’s public IP in the EXTERNAL-IP column. This is the most common way to expose services externally. However, on on-premises environments it doesn’t work out of the box and requires additional setup like MetalLB.
ExternalName: Alias for External Addresses
Used when you want to call an external address from within the cluster “as if it were an internal service.” For example, when you want to give an internal name to an external database’s DNS.
spec:
type: ExternalName
externalName: db.prod.example.com
This Service gets CNAMEd to db.prod.example.com even when called as db.mynamespace.svc.cluster.local. Useful for abstracting external addresses per environment without hardcoding them in application code.
DNS: Why Service Names Work as Addresses
Kubernetes clusters run CoreDNS by default. This DNS is what makes calls like curl http://backend work from within a pod.
The DNS naming convention for Services is:
<service>.<namespace>.svc.cluster.local
Within the same namespace, just backend is enough. For Services in a different namespace, you need to append the namespace like backend.other-ns. If you exec into a pod and look at /etc/resolv.conf, you’ll see search domains that include your namespace:
kubectl exec -it some-pod -- cat /etc/resolv.conf
# nameserver 10.96.0.10
# search mynamespace.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5
Thanks to this setup, when namespaces differ, you can point to different services per environment without changing code. In the dev namespace, backend resolves to the dev backend; in the prod namespace, backend resolves to the prod backend.
Calling a Service from a Pod in Practice
Let’s summarize the flow when a frontend pod calls a backend Service:
sequenceDiagram
participant APP as Frontend App
participant DNS as CoreDNS
participant IPT as iptables (kube-proxy)
participant POD as Backend Pod
APP->>DNS: Resolve backend
DNS-->>APP: 10.96.0.42 (ClusterIP)
APP->>IPT: Request to 10.96.0.42:80
IPT->>POD: DNAT to 10.244.1.5:8080
POD-->>APP: Response
Throughout this entire process, the frontend knows nothing about how many backend pods there are, where they are, or when they died. That’s the abstraction that Services promise.
Headless Service: When You Need Pod IPs Directly
Very occasionally, you need the actual list of pod IPs. For example, when you need to access individual members of a StatefulSet’s DB cluster directly. This is when you use a Headless Service.
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
clusterIP: None # This makes it headless
selector:
app: mysql
ports:
- port: 3306
clusterIP: None is the key. This Service doesn’t get a ClusterIP assigned. Instead, DNS queries return multiple pod IPs directly. When used with a StatefulSet, you can reach individual pods with names like mysql-0.mysql, mysql-1.mysql. Primarily used for distributed systems that need cluster member configuration.
Network Policies: Controlling Pod-to-Pod Communication
By default, all pods in a Kubernetes cluster can communicate with each other. But in practice, you often want restrictions like “frontend pods can only call backend pods and can’t reach the DB.”
NetworkPolicy declares this:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-allow-frontend
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
This policy declares “app: backend pods only accept TCP 8080 traffic from app: frontend pods.” Requests from other pods are all denied.
Note that NetworkPolicy only works when the CNI (Container Network Interface — a standard spec for attaching network interfaces to containers) plugin supports it. CNIs like Calico and Cilium support it, while simpler ones like Flannel do not. The cluster’s CNI choice has a real impact here.
Summary Table
A table summarizing Service types and their use cases helps solidify the picture:
| Type | Scope | Primary Use |
|---|---|---|
| ClusterIP | Cluster internal | Communication between internal services |
| NodePort | Node IP:port | Simple external exposure (dev/labs) |
| LoadBalancer | Cloud LB | External traffic in production |
| ExternalName | DNS CNAME | Alias for external services |
| Headless | Direct to pods via DNS | Individual access to StatefulSet members |
Among these, LoadBalancer is the path for exposing services externally, but in practice it’s common to add one more layer called Ingress in front. Why that’s the case and how to configure it is covered in the next part.
In the next part, we’ll look at Ingress, which receives external traffic and routes it to multiple services, as well as the Gateway API that succeeds it. We’ll cover TLS termination and host-based routing all in one go.




Loading comments...