Skip to content

Service Types: Deep Dive

This document explains how each Kubernetes Service type routes traffic, why kube-proxy modes matter for performance, and when to choose between Service types, Ingress, and Gateway API. It connects the demo manifests to the networking data plane that makes services work.


A Service provides a stable network endpoint for a set of pods. Pods come and go. Their IP addresses change on every restart. A Service gives you a DNS name and (usually) a virtual IP that stays constant regardless of which pods are backing it.

The demo creates five services, one of each type, all targeting the same backend:

apiVersion: apps/v1
kind: Deployment
metadata:
name: echo-server
namespace: service-types-demo
spec:
replicas: 2
selector:
matchLabels:
app: echo-server
template:
metadata:
labels:
app: echo-server
spec:
containers:
- name: echo
image: registry.k8s.io/echoserver:1.10
ports:
- containerPort: 8080

Two pods, each listening on port 8080. Every Service type routes traffic to these pods differently.


apiVersion: v1
kind: Service
metadata:
name: echo-clusterip
namespace: service-types-demo
spec:
type: ClusterIP
selector:
app: echo-server
ports:
- port: 80
targetPort: 8080

ClusterIP assigns a virtual IP from the cluster’s service CIDR (e.g., 10.96.0.0/12). This IP is only routable within the cluster. External clients cannot reach it.

Traffic flow:

Pod A --> ClusterIP (10.96.X.X:80) --> kube-proxy rules --> Pod B (10.244.Y.Y:8080)

The port: 80 is what clients connect to. The targetPort: 8080 is the port on the backend pod. kube-proxy translates between them.

ClusterIP is the right choice for internal service-to-service communication. A frontend pod calls http://echo-clusterip:80 and kube-proxy load-balances the request to one of the backend pods.


NodePort: External Access Without a Load Balancer

Section titled “NodePort: External Access Without a Load Balancer”
apiVersion: v1
kind: Service
metadata:
name: echo-nodeport
namespace: service-types-demo
spec:
type: NodePort
selector:
app: echo-server
ports:
- port: 80
targetPort: 8080
nodePort: 30080

NodePort opens a port on every node in the cluster. Any traffic to <NodeIP>:30080 is forwarded to the Service’s backend pods.

Traffic flow:

Client --> Node:30080 --> kube-proxy rules --> Pod (10.244.Y.Y:8080)

NodePort ranges default to 30000-32767. You can specify a port within this range or let Kubernetes assign one.

A NodePort Service also gets a ClusterIP. It is a superset of ClusterIP. Internal pods can still use the ClusterIP. External clients use the NodePort.

NodePort works for development, testing, and on-premise environments without cloud load balancers. In production cloud environments, prefer LoadBalancer or Ingress.


LoadBalancer: Cloud-Native External Access

Section titled “LoadBalancer: Cloud-Native External Access”
apiVersion: v1
kind: Service
metadata:
name: echo-loadbalancer
namespace: service-types-demo
spec:
type: LoadBalancer
selector:
app: echo-server
ports:
- port: 80
targetPort: 8080

LoadBalancer provisions an external load balancer through the cloud provider’s controller (AWS ELB, GCP Network LB, Azure LB). The load balancer gets a public IP and forwards traffic to the NodePorts on each node.

Traffic flow:

Client --> Cloud LB (public IP:80) --> Node:NodePort --> kube-proxy --> Pod

A LoadBalancer Service includes both a ClusterIP and a NodePort. It is a superset of both.

On minikube, LoadBalancer services stay Pending unless you run minikube tunnel, which simulates a cloud load balancer by assigning a local IP.

Each LoadBalancer Service provisions a separate cloud load balancer. At $15-20/month per LB (typical cloud pricing), 20 services cost $300-400/month. This is why Ingress and Gateway API exist: they share a single load balancer across multiple services.


apiVersion: v1
kind: Service
metadata:
name: echo-headless
namespace: service-types-demo
spec:
clusterIP: None
selector:
app: echo-server
ports:
- port: 8080
targetPort: 8080

Setting clusterIP: None creates a headless Service. No virtual IP is assigned. No kube-proxy rules are created. DNS resolves directly to pod IPs.

Traffic flow:

Pod A --> DNS lookup (echo-headless) --> Pod IP directly --> Pod B

A normal ClusterIP Service returns a single A record pointing to the virtual IP:

echo-clusterip.service-types-demo.svc.cluster.local --> 10.96.42.1

A headless Service returns multiple A records, one per pod:

echo-headless.service-types-demo.svc.cluster.local --> 10.244.0.5
--> 10.244.0.6

The client receives all pod IPs and can implement its own load balancing or connect to a specific pod.

Headless services also create SRV records that include port information:

_http._tcp.echo-headless.service-types-demo.svc.cluster.local
--> 0 0 8080 10-244-0-5.echo-headless.service-types-demo.svc.cluster.local
--> 0 0 8080 10-244-0-6.echo-headless.service-types-demo.svc.cluster.local

SRV records are used by service discovery libraries that need to know both the address and the port.

Headless services are required for StatefulSets (stable per-pod DNS). They are also useful when the client needs to know all backend pod IPs, like a database client that maintains connections to specific replicas.


apiVersion: v1
kind: Service
metadata:
name: external-api
namespace: service-types-demo
spec:
type: ExternalName
externalName: httpbin.org

An ExternalName Service creates a CNAME record in cluster DNS. No proxy, no ClusterIP, no endpoints. It is purely a DNS alias.

external-api.service-types-demo.svc.cluster.local --> CNAME --> httpbin.org
  • No port remapping. The client must use the target service’s port.
  • No health checking. The DNS record exists even if the target is down.
  • HTTPS clients must handle the hostname mismatch. The TLS certificate for httpbin.org will not match external-api.
  • Some client libraries do not follow CNAME records correctly, especially with HTTP/2.

ExternalName services let you reference external services by an internal DNS name. If you later move the service into the cluster, you change the Service type from ExternalName to ClusterIP without updating any client code.


kube-proxy implements the Service abstraction by programming networking rules on every node. It runs as a DaemonSet and watches the API server for Service and EndpointSlice changes.

kube-proxy creates iptables rules in the nat table. For each Service, there is a chain that DNAT’s (Destination NAT) the virtual IP to a pod IP. Load balancing is done probabilistically using iptables --probability matches.

Advantages:

  • Mature and well-tested.
  • No user-space proxying; packets stay in kernel space.

Disadvantages:

  • O(n) rule evaluation. With thousands of services, iptables performance degrades.
  • Adding or removing a service requires updating the entire rule set.
  • Load balancing is random, not round-robin or least-connections.

IPVS (IP Virtual Server) uses kernel-level load balancing hash tables. It supports O(1) connection dispatch regardless of the number of services.

Advantages:

  • Better performance at scale (10,000+ services).
  • Multiple load balancing algorithms: round-robin, least-connections, source-hash.
  • Incremental rule updates (add/remove individual services).

Disadvantages:

  • Requires the ip_vs kernel module.
  • Some edge cases with iptables interaction.

nftables is the successor to iptables. The nftables kube-proxy mode translates Service rules to nftables syntax. It offers better performance than iptables and uses verdicts and maps instead of long chains.

This is the newest mode and is considered the future direction. It provides the same semantics as iptables mode but with better rule management.

ScenarioRecommended Mode
Small clusters (< 1000 services)iptables (default)
Large clusters (1000+ services)IPVS
New installations (1.29+)nftables
Edge cases with iptablesIPVS

An Endpoints object lists all pod IPs for a Service in a single object. For a Service with 1000 pods, the Endpoints object contains 1000 addresses. Every time a pod changes, the entire object is updated and sent to every watching node.

EndpointSlices split endpoints into smaller groups (default 100 per slice). A Service with 1000 pods has 10 EndpointSlice objects. When one pod changes, only the affected slice is updated.

Benefits:

  • Smaller watch payloads. Only changed slices are transmitted.
  • Better for large services. Endpoints objects have a practical limit around 1000 addresses.
  • Support for dual-stack (IPv4 and IPv6) and topology hints.

kube-proxy watches EndpointSlices by default.


By default, kube-proxy distributes requests randomly (iptables mode) or round-robin (IPVS mode). Session affinity routes all requests from the same client to the same pod.

spec:
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800

ClientIP affinity uses the client’s source IP to determine which pod receives the request. All requests from the same IP go to the same pod for timeoutSeconds (default 10800, or 3 hours).

Limitations:

  • Only works with ClientIP. There is no cookie-based affinity at the Service level. Use Ingress for that.
  • Does not work well with NAT or shared source IPs, where many clients appear as one IP.

The externalTrafficPolicy field controls how NodePort and LoadBalancer Services handle traffic from outside the cluster.

Traffic can be routed to pods on any node. If a request arrives at Node A but the pod is on Node B, kube-proxy forwards it across nodes.

spec:
externalTrafficPolicy: Cluster

Advantage: Even load distribution. Disadvantage: Extra network hop. The source IP is lost (SNAT).

Traffic is only routed to pods on the node that received it. If no pod runs on that node, the request is dropped (or the load balancer health check removes that node).

spec:
externalTrafficPolicy: Local

Advantage: Preserves client source IP. No extra hop. Disadvantage: Uneven load distribution (nodes with more pods get more traffic).

Use Local when:

  • You need the client’s real IP address (logging, rate limiting, geo-routing).
  • You want to minimize network latency (no cross-node forwarding).
  • Your load balancer supports health checks (it removes nodes without pods).

Similar to externalTrafficPolicy but for traffic originating inside the cluster.

spec:
internalTrafficPolicy: Local

With Local, in-cluster traffic is routed only to pods on the same node as the caller. This reduces cross-node traffic but requires pods to be present on every node (or accept failures when no local pod exists).

This is useful for node-local services like log aggregators or caches.


A single Service can expose multiple ports:

spec:
ports:
- name: http
port: 80
targetPort: 8080
- name: grpc
port: 9090
targetPort: 9090

When multiple ports are defined, each port must have a name. The name is used in EndpointSlices and by Ingress rules.


Ingress is a Kubernetes resource for HTTP routing. It sits in front of ClusterIP Services and routes by host and path. An Ingress Controller (nginx, HAProxy, Traefik, Envoy) implements the spec. It typically runs as a Deployment with a LoadBalancer Service, sharing that single load balancer across all Ingress rules.

Advantages: widely supported, single load balancer for multiple services, HTTP-level features like TLS termination and path-based routing.

Limitations: HTTP/HTTPS only, limited extensibility (annotations are vendor-specific), no built-in traffic splitting.

Gateway API is the successor to Ingress. It separates infrastructure concerns (Gateway) from application routing (HTTPRoute). It supports HTTP, HTTPS, TCP, UDP, gRPC, and TLS. It has built-in traffic splitting for canary deployments.

ScenarioRecommendation
Simple HTTP routingIngress
TCP/UDP routingGateway API
Multi-team environmentsGateway API
Traffic splitting/canaryGateway API
Maximum compatibilityIngress
New projects (Kubernetes 1.27+)Gateway API

Service Topology and Topology-Aware Routing

Section titled “Service Topology and Topology-Aware Routing”

Topology-aware routing (successor to the deprecated Service Topology feature) uses topology hints in EndpointSlices to prefer routing traffic to pods in the same zone.

When enabled, kube-proxy prefers endpoints in the same availability zone as the calling pod. This reduces cross-zone traffic and associated costs.

metadata:
annotations:
service.kubernetes.io/topology-mode: Auto

In Auto mode, the EndpointSlice controller adds topology hints based on zone distribution. kube-proxy uses these hints to prefer local endpoints.

This works best when pods are evenly distributed across zones. With skewed distributions, some zones may not have enough capacity, causing overload.


TypePath
ClusterIPPod -> CoreDNS -> ClusterIP -> kube-proxy DNAT -> Pod IP
NodePortClient -> Node:30080 -> kube-proxy DNAT -> Pod IP
LoadBalancerClient -> Cloud LB -> Node:NodePort -> kube-proxy DNAT -> Pod IP
HeadlessPod -> CoreDNS (returns pod IPs) -> direct connection to Pod IP
ExternalNamePod -> CoreDNS (CNAME) -> external DNS -> external service

For NodePort and LoadBalancer with externalTrafficPolicy: Cluster, kube-proxy may forward across nodes to reach the target pod. With externalTrafficPolicy: Local, traffic stays on the receiving node.


The demo deploys all five Service types against the same backend. This lets you compare their behavior directly:

  1. ClusterIP: Only reachable from inside the cluster. Use port-forward for local access.
  2. NodePort: Reachable at $(minikube ip):30080 without port-forward.
  3. LoadBalancer: Requires minikube tunnel to simulate a cloud LB.
  4. Headless: DNS returns pod IPs instead of a virtual IP.
  5. ExternalName: DNS alias to httpbin.org. No local pods involved.

The backend is a 2-replica echoserver that returns request details (headers, source IP, hostname). This makes it easy to see which pod handled the request and whether the client IP was preserved.


On bare-metal or minikube, there is no cloud controller to provision a load balancer. The Service stays Pending. Use MetalLB for bare-metal LoadBalancer support, or minikube tunnel for local development.

Two Services cannot use the same NodePort. If you hardcode nodePort: 30080, no other Service can use port 30080. Let Kubernetes assign ports automatically to avoid conflicts.

Connecting to an ExternalName Service over HTTPS will fail certificate validation. The certificate is for httpbin.org, but the client connected to external-api. Either accept the mismatch or use a proxy that rewrites the Host header.

A headless Service without a selector creates no EndpointSlices. You must create Endpoints manually. This is useful for pointing at external hosts by IP.