Pods are ephemeral — their IPs change on every restart. A Service provides a stable endpoint (a fixed IP and DNS name) that load-balances across a set of Pods. It's the primary mechanism for service discovery and internal/external traffic routing in Kubernetes.

1. ClusterIP — Internal Service Discovery

The default Service type. Creates a virtual IP (ClusterIP) accessible only within the cluster.

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP                # default — can omit
  selector:
    app: web                     # finds Pods with this label
  ports:
    - name: http
      port: 80                   # Service port (what clients connect to)
      targetPort: 8080           # Pod port (where traffic is forwarded)
      protocol: TCP

How It Works

Client Pod curl web:80 Service: web ClusterIP: 10.96.0.50 port: 80 → targetPort: 8080 Pod A :8080 Pod B :8080 Pod C :8080 DNAT: 10.96.0.50:80 → random Pod IP:8080

port vs targetPort vs containerPort

FieldDefined OnMeaning
portServiceThe port clients use to reach the Service
targetPortServiceThe port on the Pod to forward to (can be a name)
containerPortPod specInformational only — doesn't affect routing
# targetPort can reference a named port:
spec:
  ports:
    - port: 80
      targetPort: http-server    # ← references the Pod's named port
---
# In the Pod:
ports:
  - name: http-server
    containerPort: 8080
# Useful when different Pod versions use different port numbers
ClusterIP is virtual. There's no process listening on 10.96.0.50. kube-proxy (or eBPF in Cilium) programs kernel rules that intercept packets destined for this IP and DNAT them to a real Pod IP. The ClusterIP exists only in the network rules — it's not pingable, not routable outside the cluster.

2. NodePort — Expose on Every Node

Opens a static port (30000-32767) on every node's IP. External clients connect via <NodeIP>:<NodePort>.

apiVersion: v1
kind: Service
metadata:
  name: web-nodeport
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80              # ClusterIP port (still created)
      targetPort: 8080      # Pod port
      nodePort: 30080       # Static port on all nodes (optional — auto-assigned if omitted)

Traffic Flow

Client → 192.168.1.10:30080 (any node IP)
       → DNAT to ClusterIP 10.96.0.50:80
       → DNAT to Pod 10.244.2.8:8080

Note: NodePort also creates a ClusterIP — it's a superset.

NodePort is rarely used directly in production. It requires clients to know node IPs (which change with autoscaling) and uses non-standard ports (30000-32767). It's mainly used as the foundation for LoadBalancer type or for quick testing. In production, use LoadBalancer or Ingress.

3. LoadBalancer — Cloud Integration

Provisions an external load balancer (cloud-provider-specific) that routes traffic to the Service's NodePorts.

apiVersion: v1
kind: Service
metadata:
  name: web-lb
  annotations:
    # Cloud-specific annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: nlb     # AWS NLB
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 443
      targetPort: 8443

What Gets Created

LoadBalancer (external IP: 52.1.2.3)
  └─ NodePort (30080 on all nodes)          ← LB sends traffic here
      └─ ClusterIP (10.96.0.50:443)
          └─ Pods (10.244.x.x:8443)

# Client → 52.1.2.3:443 → Node:30080 → Pod:8443
CloudDefault LB TypeAnnotation to Control
AWSClassic LB (L4)aws-load-balancer-type: nlb for NLB
GCPNetwork LB (L4)Use Ingress for HTTP LB (L7)
AzureAzure LB (L4)azure-load-balancer-internal: true for internal
Bare metal❌ Stays PendingUse MetalLB to get LoadBalancer on bare metal
LoadBalancer = NodePort + cloud LB. The cloud-controller-manager provisions the external LB and points it at the NodePort. This is why LoadBalancer type stays Pending forever on bare-metal clusters — there's no cloud API to provision an LB. MetalLB fills this gap for on-prem.

4. Headless Services (clusterIP: None)

No virtual IP. DNS returns the individual Pod IPs directly. Used for StatefulSets and client-side load balancing.

apiVersion: v1
kind: Service
metadata:
  name: db-headless
spec:
  clusterIP: None           # ← Headless
  selector:
    app: postgres
  ports:
    - port: 5432
# DNS query returns ALL Pod IPs:
nslookup db-headless.default.svc.cluster.local
# 10.244.1.5
# 10.244.2.8
# 10.244.3.2

# With StatefulSet: individual Pod DNS records too:
# postgres-0.db-headless.default.svc.cluster.local → 10.244.1.5
Normal ServiceHeadless Service
DNS returns 1 IP (ClusterIP)DNS returns all Pod IPs
Load balancing by kube-proxyLoad balancing by client (or none)
Single stable VIPNo VIP — direct Pod access
Use: generic servicesUse: StatefulSets, peer discovery, gRPC
gRPC clients benefit from headless Services. gRPC uses HTTP/2 with long-lived connections — kube-proxy's L4 load balancing only picks a backend once per connection. With headless, the gRPC client gets all Pod IPs via DNS and can do its own round-robin across connections.

5. ExternalName — DNS Alias

Maps a Service name to an external DNS name. No proxying, no ClusterIP — just a CNAME record.

apiVersion: v1
kind: Service
metadata:
  name: external-db
spec:
  type: ExternalName
  externalName: prod-db.abc123.us-east-1.rds.amazonaws.com
# Inside the cluster:
nslookup external-db.default.svc.cluster.local
# → CNAME: prod-db.abc123.us-east-1.rds.amazonaws.com

# Code just connects to "external-db:5432" — unaware it's external

Use case: Abstract external dependencies behind a K8s Service name. If you later migrate the database into the cluster, just change the Service from ExternalName to ClusterIP — no code changes needed.

6. Advanced Service Features

Session Affinity

spec:
  sessionAffinity: ClientIP        # Same client → same Pod
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800        # 3 hours (default)
# Uses source IP to pin connections. Useful for stateful apps
# that store session in memory (not recommended — use external session store)

externalTrafficPolicy

PolicyBehaviorTrade-off
Cluster (default)Traffic can hop to any node to reach a PodEven distribution, but source IP is lost (SNAT)
LocalTraffic only goes to Pods on the receiving nodePreserves source IP, but uneven distribution (nodes without Pods get no traffic)
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local     # Preserve client source IP
# The LB health-checks which nodes have Pods — only sends traffic there
# Pods on other nodes are unreachable via this path
externalTrafficPolicy: Local Load Balancer Node 1 (has Pod) ✅ Receives traffic Node 2 (no Pod) ❌ LB skips (health check fails) Node 3 (has Pod) ✅ Receives traffic
Use Local when you need the client's real IP (for rate limiting, geo-routing, access logs). Use Cluster when even distribution matters more than source IP preservation. With Local, ensure enough Pods are spread across nodes to avoid overloading specific nodes.

Topology Aware Hints (K8s 1.23+)

metadata:
  annotations:
    service.kubernetes.io/topology-mode: Auto
# kube-proxy routes traffic to Pods in the SAME zone preferentially
# Reduces cross-zone data transfer costs (significant on cloud)
# Falls back to other zones if local zone has insufficient capacity
In AWS/GCP, cross-AZ traffic costs money (~$0.01/GB). Topology-aware routing keeps traffic local to the AZ when possible, saving significant cost for high-traffic services. But it can cause uneven load if Pods aren't evenly distributed across zones — combine with Pod Topology Spread Constraints.

Multi-Port Services

spec:
  ports:
    - name: http             # name required when multiple ports
      port: 80
      targetPort: 8080
    - name: https
      port: 443
      targetPort: 8443
    - name: metrics
      port: 9090
      targetPort: 9090

7. EndpointSlices — How Services Find Pods

When a Service selector matches Pods, Kubernetes creates EndpointSlice objects listing the backing Pod IPs. kube-proxy watches these to build its routing rules.

# Inspect endpoints:
kubectl get endpointslices -l kubernetes.io/service-name=web
# NAME       ADDRESSTYPE   PORTS   ENDPOINTS                     AGE
# web-abc    IPv4          8080    10.244.1.5,10.244.2.8,...     5d

kubectl describe endpointslice web-abc
# Endpoints:
#   - Addresses: 10.244.1.5
#     Conditions: Ready=true
#     TargetRef: Pod/web-abc123
#     NodeName: worker-1
#     Zone: us-east-1a

Services Without Selectors

You can create a Service that points to external IPs by manually creating Endpoints:

# Service without selector:
apiVersion: v1
kind: Service
metadata:
  name: external-api
spec:
  ports:
    - port: 443
---
# Manual EndpointSlice (or Endpoints):
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: external-api-1
  labels:
    kubernetes.io/service-name: external-api
addressType: IPv4
ports:
  - port: 443
endpoints:
  - addresses: ["203.0.113.50", "203.0.113.51"]

Use case: Route to external services (databases, APIs) through a K8s Service name while controlling the endpoint IPs manually.

Summary: Service Types at a Glance

TypeAccessible FromGetsUse Case
ClusterIPInside cluster onlyVirtual IP + DNSInternal microservice communication
NodePortOutside via node IP:portClusterIP + static port on all nodesDev/test, quick external access
LoadBalancerInternet/VPC via external IPNodePort + cloud LBProduction external services
ExternalNameInside cluster (DNS only)CNAME recordAlias for external DNS names
Headless (clusterIP: None)Inside cluster (direct Pod IPs)DNS A records per PodStatefulSets, gRPC, peer discovery

📝 Quiz: Services in Depth

Q1: A Service has port: 80 and targetPort: 3000. A client inside the cluster connects to the Service on port 80. What port does the Pod receive traffic on?

Port 3000. The Service listens on port 80 (client-facing) and forwards to port 3000 (targetPort) on the Pod. kube-proxy creates a DNAT rule: destination 10.96.x.x:80 → Pod_IP:3000.

Q2: You create a type: LoadBalancer Service on a bare-metal cluster. What happens?

The Service is created and gets a ClusterIP and NodePort, but the EXTERNAL-IP stays <pending> forever. There's no cloud controller to provision an external LB. Solution: install MetalLB which provides a LoadBalancer implementation for bare-metal clusters.

Q3: Your app needs the real client IP for rate limiting. The Service is type: LoadBalancer. What setting do you need?

externalTrafficPolicy: Local. With the default Cluster policy, the node SNATs the traffic when forwarding to another node, losing the original source IP. With Local, traffic only goes to Pods on the receiving node — no SNAT, original IP preserved.

Q4: A gRPC service with 5 replicas uses a normal ClusterIP Service. Clients report all requests go to the same Pod. Why?

gRPC uses HTTP/2 with persistent connections. kube-proxy load-balances at L4 (connection level), not per-request. Once a connection is established to one Pod, all requests over that connection go to the same Pod. Fix: use a headless Service and client-side load balancing, or an L7 load balancer (Ingress/service mesh) that balances per-request.

Q5: What's the difference between a headless Service and an ExternalName Service?

Headless (clusterIP: None): Has a selector. DNS returns multiple A records (Pod IPs). Used for direct Pod access within the cluster.
ExternalName: No selector, no ClusterIP. DNS returns a CNAME pointing to an external hostname. Used to alias external services as cluster-internal names.

Q6: Why does Kubernetes use EndpointSlices instead of the older Endpoints resource?

Scalability. The old Endpoints resource was a single object listing ALL Pod IPs for a Service. For Services with thousands of endpoints, updating one Pod IP meant rewriting the entire object (expensive watch event). EndpointSlices split endpoints into smaller chunks (~100 per slice) — updates are smaller, watches are more efficient, and they support dual-stack (IPv4+IPv6) and topology hints.