Lesson 08 Services & Networking
Theme:

Theory: Services & Networking

How Kubernetes Services expose Pods to the network, and how to template them effectively in Helm charts.

Why Services Exist

Pods are ephemeral. Every time a Pod restarts it may receive a different IP address. If a frontend application hard-codes a backend Pod IP, it will break the moment that Pod is replaced. Kubernetes Services solve this by providing a stable virtual IP (the ClusterIP) and a DNS name that remain constant regardless of which Pods are running behind them.

A Service uses a label selector to discover its target Pods. Any Pod whose labels match the selector is included in the Service's endpoint list. The kube-proxy component on each node programs iptables or IPVS rules to load-balance traffic across those endpoints.

The Four Service Types

ClusterIP

The default type. Kubernetes assigns a virtual IP from the cluster's service CIDR range. This IP is only reachable from within the cluster. ClusterIP is the right choice for any internal service-to-service communication — databases, caches, internal APIs, and message brokers.

apiVersion: v1
kind: Service
metadata:
  name: backend-api
spec:
  type: ClusterIP
  selector:
    app: backend
    tier: api
  ports:
    - name: http
      port: 80
      targetPort: 8080
      protocol: TCP

Here, port is the port the Service listens on (what clients connect to), and targetPort is the port the container actually serves on. They can differ, which is useful when you want clients to connect on a standard port like 80 while the application listens on 8080.

NodePort

Extends ClusterIP by opening a static port (default range 30000–32767) on every node in the cluster. External clients can reach the Service at <any-node-ip>:<nodePort>. Kubernetes still creates a ClusterIP behind the scenes; the NodePort simply adds another entry point.

NodePort is commonly used in on-premises environments without a cloud load balancer, or during local development with tools like Minikube. In production, it is usually combined with an external load balancer or reverse proxy that forwards traffic to the NodePort.

spec:
  type: NodePort
  selector:
    app: frontend
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 31000    # optional; auto-assigned if omitted

LoadBalancer

Extends NodePort by requesting an external load balancer from the cloud provider (AWS ELB, GCP Network LB, Azure LB). The cloud controller provisions a public or internal LB and configures it to forward traffic to the NodePort on each node. This is the simplest way to expose a service to the internet on managed Kubernetes.

Each LoadBalancer Service typically incurs its own cost. For clusters with many externally-facing services, an Ingress controller behind a single LoadBalancer is more economical.

ExternalName

Does not create any proxy or virtual IP. Instead, it returns a CNAME record pointing to the specified external hostname. This lets in-cluster applications reference external services (such as an RDS database or a third-party API) using a Kubernetes Service name.

apiVersion: v1
kind: Service
metadata:
  name: external-db
spec:
  type: ExternalName
  externalName: prod-db.c9abcdef.us-east-1.rds.amazonaws.com

No selector or ports are needed. When a Pod resolves external-db.default.svc.cluster.local, CoreDNS returns the CNAME. The main benefit is that you can swap the backing service by changing the ExternalName without modifying application code.

Port Mapping and Named Ports

Each entry in a Service's ports array has four key fields:

FieldDescription
nameIdentifier for the port; required when there are multiple ports
portThe port the Service exposes to clients
targetPortThe port on the Pod to forward to (can be a number or a named port from the container spec)
protocolTCP (default), UDP, or SCTP

Using a named targetPort (matching a containerPort name in the Pod spec) decouples the Service from the container's actual port number. If the container changes its listen port, you only update the Deployment — the Service template stays the same.

Headless Services

Setting clusterIP: None creates a headless Service. Instead of returning a single virtual IP, a DNS lookup on the Service name returns the individual Pod IPs as A records. This is essential for StatefulSets where each Pod needs a predictable, stable DNS name.

apiVersion: v1
kind: Service
metadata:
  name: cassandra-headless
spec:
  clusterIP: None
  selector:
    app: cassandra
  ports:
    - name: cql
      port: 9042
      targetPort: cql

With a StatefulSet named cassandra and this headless Service, each Pod gets a DNS entry like cassandra-0.cassandra-headless.default.svc.cluster.local. Clients can address specific replicas, which is required for databases that need peer-to-peer communication or leader election.

Pattern

Many Helm charts create two Services for StatefulSets — a headless Service for internal peer communication and a regular ClusterIP or LoadBalancer Service for client traffic.

externalTrafficPolicy

This field controls how traffic from outside the cluster is routed once it reaches a node. It applies only to NodePort and LoadBalancer Services.

Use Local when your application needs the real client IP (for geo-routing, rate limiting, or logging). Ensure that Pod scheduling spreads Pods across all nodes receiving traffic, or use a topology-aware load balancer.

sessionAffinity

By default, Kubernetes distributes requests round-robin across healthy endpoints. Setting sessionAffinity: ClientIP pins all requests from the same client IP to the same backend Pod for a configurable timeout (default 10800 seconds / 3 hours).

spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600

This is useful during migration of legacy applications that store session state in memory. The long-term best practice is to externalize session state to a shared store like Redis or a database, making Pods truly stateless.

Service Discovery

DNS (Preferred)

CoreDNS (or kube-dns in older clusters) creates DNS records for every Service. The fully qualified domain name follows this pattern:

<service-name>.<namespace>.svc.cluster.local

Within the same namespace, Pods can use just the service name (my-api). Across namespaces, use my-api.other-namespace. The full FQDN is rarely needed but is always valid.

For headless Services, DNS returns multiple A records (one per Pod). For regular Services, DNS returns the single ClusterIP.

Environment Variables

Kubelet injects environment variables for every active Service into new Pods. For a Service named my-api on port 80, the Pod receives MY_API_SERVICE_HOST and MY_API_SERVICE_PORT. This mechanism has a significant limitation: the Service must exist before the Pod starts. DNS-based discovery does not have this ordering constraint.

EndpointSlices

In Kubernetes 1.21+, the EndpointSlice API replaced the legacy Endpoints resource. Each EndpointSlice holds up to 100 endpoints. For Services with hundreds of backing Pods, this means smaller, more efficient API objects and faster update propagation.

EndpointSlices are managed automatically by the EndpointSlice controller. You rarely interact with them directly, but understanding them helps when debugging connectivity issues. Use kubectl get endpointslices -l kubernetes.io/service-name=my-service to inspect them.

Templating Services in Helm

A production-quality Helm chart should let the operator control the Service type, ports, and policies entirely through values.yaml. Here is a proven pattern:

# values.yaml
service:
  enabled: true
  type: ClusterIP
  port: 80
  targetPort: 8080
  nodePort: ""
  clusterIP: ""
  externalTrafficPolicy: ""
  sessionAffinity: ""
  annotations: {}

headless:
  enabled: false

The template uses conditionals to render only the fields relevant to the chosen Service type:

# templates/service.yaml
{{- if .Values.service.enabled }}
apiVersion: v1
kind: Service
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    {{- include "mychart.labels" . | nindent 4 }}
  {{- with .Values.service.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  type: {{ .Values.service.type }}
  {{- if and (eq .Values.service.type "ClusterIP") .Values.service.clusterIP }}
  clusterIP: {{ .Values.service.clusterIP }}
  {{- end }}
  {{- if .Values.service.externalTrafficPolicy }}
  externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }}
  {{- end }}
  {{- if .Values.service.sessionAffinity }}
  sessionAffinity: {{ .Values.service.sessionAffinity }}
  {{- end }}
  selector:
    {{- include "mychart.selectorLabels" . | nindent 4 }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}
      protocol: TCP
      name: http
      {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
      nodePort: {{ .Values.service.nodePort }}
      {{- end }}
{{- end }}

Validation tip

Always run helm template . --set service.type=NodePort --set service.nodePort=31000 and helm template . --set service.type=LoadBalancer to verify that conditionals produce valid YAML for every Service type your chart supports.

Summary

Kubernetes Services are the glue between ephemeral Pods and stable network access. Understanding the four Service types, DNS discovery, traffic policies, and headless Services gives you the foundation to build Helm templates that work across development, staging, and production. The key design principle is to parameterize everything through values.yaml so that switching from ClusterIP to LoadBalancer is a single-line change, not a template rewrite.

Further Reading