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:
| Field | Description |
|---|---|
name | Identifier for the port; required when there are multiple ports |
port | The port the Service exposes to clients |
targetPort | The port on the Pod to forward to (can be a number or a named port from the container spec) |
protocol | TCP (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.
- Cluster (default): kube-proxy may forward the packet to a Pod on a different node. This provides even load distribution but performs SNAT, masking the original client IP.
- Local: Traffic is delivered only to Pods running on the node that received it. If no local Pod exists, the packet is dropped. This preserves the client's source IP but can cause uneven distribution.
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.