Skip to content
ioob.dev
Go back

Kubernetes Beginner Series 5 — Services and Networking

· 6 min read
Kubernetes Series (5/12)
  1. Kubernetes Beginner Series 1 — What Is Kubernetes
  2. Kubernetes Beginner Series 2 — Cluster Architecture
  3. Kubernetes Beginner Series 3 — Pod
  4. Kubernetes Beginner Series 4 — Controllers
  5. Kubernetes Beginner Series 5 — Services and Networking
  6. Kubernetes Beginner Series 6 — Ingress and Gateway API
  7. Kubernetes Beginner Series 7 — ConfigMap and Secret
  8. Kubernetes Beginner Series 8 — Storage: PV, PVC, StorageClass
  9. Kubernetes Beginner Series 9 — Resource Management and Autoscaling
  10. Kubernetes Beginner Series 10 — RBAC and Security: The Principle of Least Privilege
  11. Kubernetes Beginner Series 11 — Observability: Logs, Metrics, and Traces
  12. Kubernetes Beginner Series 12 — Helm and Package Management
Table of contents

Table of contents

Why You Can’t Trust Pod IPs

In Part 3, we established that pods are disposable. When they die, their IP disappears; when they come back, they get a different IP. This fact explains why the Service abstraction is necessary.

Imagine if the frontend called backend pods directly by IP. The moment a backend pod restarts, all connections break. Even if you scale out to 3 replicas, the frontend only knows one IP, so load balancing doesn’t work. No matter how well Kubernetes manages pods, if the calling side uses pod IPs directly, all these benefits collapse.

A Service is an abstraction that creates a fixed access point in front of multiple pods. Even though pod IPs change constantly, a Service’s IP (ClusterIP) remains stable. So the caller doesn’t need to worry about pod count, pod location, or pod health — just knowing the Service name is enough.

The Structure a Service Creates

To understand Services, let’s start with the simplest example: placing one Service in front of 3 backend pods.

# backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
spec:
  replicas: 3
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
      - name: backend
        image: myapp:1.0
        ports:
        - containerPort: 8080
---
# backend-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: backend
spec:
  selector:
    app: backend
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP

The key is the selector. This Service declares that it will back itself with pods carrying the app: backend label. Kubernetes automatically discovers and connects pods matching this label.

flowchart LR
    FE[Frontend Pod] -->|http://backend:80| SVC[Service: backend<br/>ClusterIP: 10.96.0.42]
    SVC --> P1[Pod 1<br/>10.244.1.5:8080]
    SVC --> P2[Pod 2<br/>10.244.1.6:8080]
    SVC --> P3[Pod 3<br/>10.244.2.3:8080]

The frontend sends requests to http://backend:80. The Service’s ClusterIP is a virtual IP accessible only within the cluster, and through iptables rules managed by kube-proxy, traffic is forwarded to one of the actual pods. Even when pods die and new ones come up, the Service remains unchanged.

Endpoints: What Really Happens Behind a Service

When you create a Service, an Endpoints resource with the same name is automatically created. Endpoints is the real-time list of “which pod IPs does this Service currently point to.”

kubectl get endpoints backend
# NAME      ENDPOINTS                                      AGE
# backend   10.244.1.5:8080,10.244.1.6:8080,10.244.2.3:8080   5m

This list isn’t static. The Endpoints controller continuously updates it by watching pod label changes, readiness status, and deletion events. Pods that haven’t passed the readiness probe are excluded from Endpoints. This is where the importance of readiness from Part 3 becomes apparent. When readiness is false, the pod is automatically removed from behind the Service, ensuring that during rolling updates, traffic only goes to ready pods.

As clusters grow, the Endpoints object can become too large, so it gets split into a more efficient format called EndpointSlice. Functionally, it does the same thing.

Four Types of Services

Services come in several types, each solving a different problem. Let’s compare how each type creates its traffic path in one diagram:

flowchart TB
    subgraph CIP["ClusterIP (Internal Only)"]
        direction TB
        CIP_IN["Cluster-Internal Pod"]
        CIP_POD["Backend Pods"]
        CIP_IN -->|ClusterIP| CIP_POD
    end
    subgraph NP["NodePort"]
        direction TB
        NP_EXT["External User"]
        NP_NODE["Node (kube-proxy)"]
        NP_POD["Backend Pods"]
        NP_EXT -->|"NodeIP:30080"| NP_NODE
        NP_NODE --> NP_POD
    end
    subgraph LB["LoadBalancer"]
        direction TB
        LB_EXT["External User"]
        LB_CLOUD["Cloud LB (ELB/GCLB)"]
        LB_NODE["Node"]
        LB_POD["Backend Pods"]
        LB_EXT -->|"Public IP:80"| LB_CLOUD
        LB_CLOUD --> LB_NODE
        LB_NODE --> LB_POD
    end
    subgraph EN["ExternalName"]
        direction TB
        EN_IN["Internal Pod"]
        EN_DNS["CoreDNS (CNAME)"]
        EN_EXT["db.prod.example.com"]
        EN_IN -->|"db.mynamespace.svc"| EN_DNS
        EN_DNS --> EN_EXT
    end

ClusterIP: Internal Only

The default. Assigns a virtual IP accessible only within the cluster. Used for internal services like backends, databases, and internal APIs. Safe since it’s not directly exposed to the outside.

spec:
  type: ClusterIP  # Also the default if omitted

NodePort: Expose via Node Port

Opens the same port (default range 30000-32767) on all nodes and forwards traffic arriving at that port to the Service.

spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080

Traffic reaches this Service regardless of which node IP it enters through. Convenient for labs and local development, but using NodePort alone in production is rare. Port numbers are limited, and clients need to know node IPs.

LoadBalancer: Automatic Cloud LB Provisioning

The type used on clouds like AWS, GCP, and Azure. When you create a Service of this type, the Cloud Controller Manager automatically creates a load balancer (ELB, GCLB, etc.) for that cloud and attaches it in front.

spec:
  type: LoadBalancer
  ports:
  - port: 80
    targetPort: 8080

Running kubectl get svc shows the cloud LB’s public IP in the EXTERNAL-IP column. This is the most common way to expose services externally. However, on on-premises environments it doesn’t work out of the box and requires additional setup like MetalLB.

ExternalName: Alias for External Addresses

Used when you want to call an external address from within the cluster “as if it were an internal service.” For example, when you want to give an internal name to an external database’s DNS.

spec:
  type: ExternalName
  externalName: db.prod.example.com

This Service gets CNAMEd to db.prod.example.com even when called as db.mynamespace.svc.cluster.local. Useful for abstracting external addresses per environment without hardcoding them in application code.

DNS: Why Service Names Work as Addresses

Kubernetes clusters run CoreDNS by default. This DNS is what makes calls like curl http://backend work from within a pod.

The DNS naming convention for Services is:

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

Within the same namespace, just backend is enough. For Services in a different namespace, you need to append the namespace like backend.other-ns. If you exec into a pod and look at /etc/resolv.conf, you’ll see search domains that include your namespace:

kubectl exec -it some-pod -- cat /etc/resolv.conf
# nameserver 10.96.0.10
# search mynamespace.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5

Thanks to this setup, when namespaces differ, you can point to different services per environment without changing code. In the dev namespace, backend resolves to the dev backend; in the prod namespace, backend resolves to the prod backend.

Calling a Service from a Pod in Practice

Let’s summarize the flow when a frontend pod calls a backend Service:

sequenceDiagram
    participant APP as Frontend App
    participant DNS as CoreDNS
    participant IPT as iptables (kube-proxy)
    participant POD as Backend Pod

    APP->>DNS: Resolve backend
    DNS-->>APP: 10.96.0.42 (ClusterIP)
    APP->>IPT: Request to 10.96.0.42:80
    IPT->>POD: DNAT to 10.244.1.5:8080
    POD-->>APP: Response

Throughout this entire process, the frontend knows nothing about how many backend pods there are, where they are, or when they died. That’s the abstraction that Services promise.

Headless Service: When You Need Pod IPs Directly

Very occasionally, you need the actual list of pod IPs. For example, when you need to access individual members of a StatefulSet’s DB cluster directly. This is when you use a Headless Service.

apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  clusterIP: None  # This makes it headless
  selector:
    app: mysql
  ports:
  - port: 3306

clusterIP: None is the key. This Service doesn’t get a ClusterIP assigned. Instead, DNS queries return multiple pod IPs directly. When used with a StatefulSet, you can reach individual pods with names like mysql-0.mysql, mysql-1.mysql. Primarily used for distributed systems that need cluster member configuration.

Network Policies: Controlling Pod-to-Pod Communication

By default, all pods in a Kubernetes cluster can communicate with each other. But in practice, you often want restrictions like “frontend pods can only call backend pods and can’t reach the DB.”

NetworkPolicy declares this:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-allow-frontend
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - protocol: TCP
      port: 8080

This policy declares “app: backend pods only accept TCP 8080 traffic from app: frontend pods.” Requests from other pods are all denied.

Note that NetworkPolicy only works when the CNI (Container Network Interface — a standard spec for attaching network interfaces to containers) plugin supports it. CNIs like Calico and Cilium support it, while simpler ones like Flannel do not. The cluster’s CNI choice has a real impact here.

Summary Table

A table summarizing Service types and their use cases helps solidify the picture:

TypeScopePrimary Use
ClusterIPCluster internalCommunication between internal services
NodePortNode IP:portSimple external exposure (dev/labs)
LoadBalancerCloud LBExternal traffic in production
ExternalNameDNS CNAMEAlias for external services
HeadlessDirect to pods via DNSIndividual access to StatefulSet members

Among these, LoadBalancer is the path for exposing services externally, but in practice it’s common to add one more layer called Ingress in front. Why that’s the case and how to configure it is covered in the next part.


In the next part, we’ll look at Ingress, which receives external traffic and routes it to multiple services, as well as the Gateway API that succeeds it. We’ll cover TLS termination and host-based routing all in one go.

-> Part 6: Ingress and Gateway API


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes Beginner Series 4 — Controllers
Next Post
Kubernetes Beginner Series 6 — Ingress and Gateway API