ClusterIP Services

[!NOTE] This module explores the core principles of ClusterIP Services, deriving solutions from first principles and hardware constraints to build world-class, production-ready expertise.

1. The Problem: Ephemeral IPs

In the world of virtual machines, IP addresses were static. You could hardcode db-server: 192.168.1.50 into your application config.

In Kubernetes, Pods are mortal. They are created and destroyed dynamically by Deployments, DaemonSets, or scaling events. Each time a Pod is reborn, it gets a new IP address.

If a frontend Pod tries to talk to a backend Pod using its direct IP, the connection will break as soon as the backend Pod restarts. We need a stable abstraction.

2. The Solution: Service (ClusterIP)

A Service is an abstraction that defines a logical set of Pods and a policy by which to access them. The default type is ClusterIP.

  1. Stable Virtual IP: The Service is assigned a Virtual IP (VIP) from a special range (e.g., 10.96.0.0/12). This IP never changes as long as the Service exists.
  2. Stable DNS Name: Kubernetes automatically assigns a DNS name like my-service.my-ns.svc.cluster.local.
  3. Load Balancing: Traffic sent to the VIP is automatically load-balanced across the healthy backing Pods.

[!NOTE] The ClusterIP is a Virtual IP. It doesn’t exist on any network interface. It is a fake IP that is intercepted by iptables or IPVS rules on every node.


3. Interactive: Service Discovery Visualizer

Visualize how traffic is routed from a client to a dynamic set of backend Pods. Observe how the Service abstraction shields the client from Pod churn.

Frontend Pod

curl http://backend

Service (ClusterIP)

backend-svc 10.96.0.1
Selector: app=api

Backend Pods

Pod-A (10.1.0.5)
Pod-B (10.1.0.6)
Pod-C (10.1.0.7)

4. Under the Hood: kube-proxy

How does a “fake” IP address like 10.96.0.1 actually work? The magic happens via kube-proxy, a component running on every node.

1. The Watcher

kube-proxy watches the Kubernetes API Server for changes to Services and Endpoints (Pods).

2. The Rule Maker

When a new Service is created, kube-proxy writes rules to the node’s packet filtering system.

  • iptables mode (Legacy/Standard): Uses Linux iptables NAT tables. It’s O(N) complexity, meaning it gets slow with thousands of services.
  • IPVS mode (Performance): Uses Linux IP Virtual Server. It uses hash tables for O(1) lookups, supporting massive scale.

3. The Flow

When your Pod sends a packet to 10.96.0.1:

  1. The packet hits the node’s network interface.
  2. DNAT (Destination NAT) rules trigger.
  3. The destination IP is rewritten from 10.96.0.1 to 10.1.0.5 (one of the backing Pods).
  4. The packet is routed to the Pod.

[!TIP] Client-Side Balancing: Kubernetes Services perform client-side load balancing. The translation happens on the source node before the packet even leaves the machine.


5. Service Discovery: DNS

Kubernetes runs a DNS server (CoreDNS) as a Deployment. Every Service gets a DNS record.

Record Type Format Example
A Record <svc>.<ns>.svc.cluster.local backend.default.svc.cluster.local
SRV Record _<port>._<proto>.<svc>.<ns>... _http._tcp.backend.default...

When you run curl http://backend inside a Pod:

  1. The Pod’s /etc/resolv.conf points to the CoreDNS Service IP.
  2. CoreDNS resolves backend to the ClusterIP 10.96.0.1.
  3. The request is sent to 10.96.0.1.
  4. iptables captures it and redirects it to a real Pod IP.

6. Code Example: Service Manifest & Access

Service YAML
Java Client
Go Client
apiVersion: v1
kind: Service
metadata:
  name: payment-service
  namespace: finance
spec:
  type: ClusterIP  # Default
  selector:
    app: payment   # Matches Pod labels
  ports:
  - protocol: TCP
    port: 80     # Service exposes port 80
    targetPort: 8080 # Container listens on 8080
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PaymentClient {
  public static void main(String[] args) {
    // Accessing the service via its DNS name
    // Format: http://<service-name>.<namespace>
    String serviceUrl = "http://payment-service.finance";

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(serviceUrl + "/process"))
        .GET()
        .build();

    client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .thenAccept(System.out::println)
        .join();
  }
}
package main

import (
  "fmt"
  "io"
  "net/http"
)

func main() {
  // Accessing the service via its DNS name
  // Format: http://<service-name>.<namespace>
  serviceUrl := "http://payment-service.finance"

  resp, err := http.Get(serviceUrl + "/process")
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  body, _ := io.ReadAll(resp.Body)
  fmt.Println("Response:", string(body))
}