DevFox Labs
HomeLearningToolsAboutContact
beginner30 minby DevFox

Services and Reaching Your Pods

Pod IPs change constantly, so nothing addresses them directly. ClusterIP, NodePort and LoadBalancer, cluster DNS, and how a Service finds its pods.

  • Kubernetes

On this page

  • Why pod addresses are unusable
  • A Service, in nine lines
  • Endpoints: the live list
  • DNS, and how a name becomes an address
  • What actually balances the traffic
  • The three types
  • ClusterIP — inside the cluster only
  • NodePort — a port on every node
  • LoadBalancer — an external address
  • The mistake: selector drift
  • Reaching a Service from your machine
  • Headless Services
  • Cheat sheet
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    Your Deployment keeps three pods running, which is the good news. The bad news is that nothing can reliably talk to them. Each pod has its own IP address, those addresses change every time a pod is replaced, and there are three of them anyway — so which one would you connect to?

    The Service is the answer to both halves. By the end of this lesson you will have one stable name in front of a changing set of pods, you will know the three types and when each is right, and you will know why nslookup inside a pod is the fastest way to settle any argument about cluster networking.

    Why pod addresses are unusable

    Every pod gets a real, routable IP inside the cluster:

    bash
    kubectl get pods -l app=web -o wide
    text
    NAME                   READY  STATUS   IP           NODE
    web-6c9f7d4b58-mn4pq   1/1    Running  10.244.1.7   learning-worker
    web-6c9f7d4b58-vz8lt   1/1    Running  10.244.2.4   learning-worker2
    web-6c9f7d4b58-jd5rw   1/1    Running  10.244.1.8   learning-worker

    You can connect to 10.244.1.7 from another pod, and it works. It also stops working the moment that pod is replaced — which happens on every deploy, every node drain, every crash. The replacement has a new address, and nothing told you.

    So the problem is not "how do I reach a pod". It is "how do I reach the application, given that its pods are a moving target". That is a job for something with a stable identity and a live view of which pods are currently healthy.

    A Service, in nine lines

    yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: web
    spec:
      selector:
        app: web
      ports:
        - port: 80
          targetPort: 80
    bash
    kubectl apply -f service.yaml
    kubectl get service web
    text
    NAME   TYPE        CLUSTER-IP     PORT(S)   AGE
    web    ClusterIP   10.96.142.31   80/TCP    6s

    That CLUSTER-IP is stable for the life of the Service. So is the name web. Neither changes when pods do.

    Two fields do the work.

    selector is a label query — the same mechanism a Deployment uses to find its pods. Any pod carrying app: web is a candidate. Note that the Service does not reference the Deployment at all; it references labels. A Service can front pods from two Deployments, or from none, and it neither knows nor cares.

    ports maps the Service's port to the pods'. port is what clients connect to on the Service. targetPort is the port on the pod. They are often the same and do not have to be:

    yaml
    ports:
      - port: 80 # clients use this
        targetPort: 8000 # the container listens on this

    Name the target port instead of numbering it

    If your pod template declares ports: [{name: http, containerPort: 8000}], the Service can say targetPort: http. Then changing the container's port is one edit in one place, and the Service follows.

    Endpoints: the live list

    The Service is the stable front. Behind it is a list that changes constantly, and it is a real object you can look at:

    bash
    kubectl get endpointslices -l kubernetes.io/service-name=web
    kubectl describe service web
    text
    Name:              web
    Selector:          app=web
    Type:              ClusterIP
    IP:                10.96.142.31
    Port:              80/TCP
    TargetPort:        80/TCP
    Endpoints:         10.244.1.7:80,10.244.2.4:80,10.244.1.8:80

    Those endpoints are maintained by a controller watching pods that match the selector. Scale the Deployment and the list grows. Delete a pod and it is removed before the pod dies, then the replacement is added when it becomes ready.

    This is the single most useful diagnostic in cluster networking:

    bash
    kubectl describe service web | grep Endpoints

    An empty Endpoints list means nothing will ever answer, and it has only two causes.

    1. 1

      Does any pod carry the selector's labels?

      kubectl get pods -l app=web. Nothing back means the selector and the pod labels disagree — and Kubernetes will never tell you, because a Service may legitimately exist before its pods.

    2. 2

      Are the matching pods ready?

      A pod joins the endpoint list when it passes its readiness probe and leaves the moment it fails one. READY 0/1 means it exists and receives nothing.

    3. 3

      Both fine? Then the Service is not your problem

      Endpoints listed means traffic is being delivered. Look at the application, the port numbers, or the client.

    Between them these account for most "my Service does not work" reports

    Readiness decides membership

    A pod joins the endpoint list when it passes its readiness probe, and leaves the moment it fails one. That is the whole mechanism behind zero-downtime rollouts: a new pod receives no traffic until it says it is ready. The probes lesson makes this concrete.

    DNS, and how a name becomes an address

    You have seen the cluster IP, and you will almost never type one. Every Service gets a DNS name, served by CoreDNS — the component you saw running in kube-system.

    From any pod in the same namespace, the Service's name is enough:

    bash
    kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- sh
    # inside:
    wget -qO- http://web
    nslookup web
    text
    Name:      web.default.svc.cluster.local
    Address 1: 10.96.142.31 web.default.svc.cluster.local

    The full form shows the structure:

    text
    web.default.svc.cluster.local
    ^^^ ^^^^^^^ ^^^ ^^^^^^^^^^^^^
     |     |     |        └── cluster domain
     |     |     └── it is a Service
     |     └── namespace
     └── Service name

    Which gives you three ways to refer to the same thing, and a rule for picking:

    text
    web                             # same namespace — use this
    web.default                     # a different namespace
    web.default.svc.cluster.local   # fully qualified, in config files

    Two details that matter in practice. The name resolves whether or not any pod is behind it, so a successful nslookup proves the Service exists and nothing more — check Endpoints for the rest. And DNS gives you the Service's address, not a pod's; the load balancing happens after that, in the kernel.

    What actually balances the traffic

    The cluster IP is a virtual address. No network interface has it, and nothing listens on it.

    When a pod sends a packet to 10.96.142.31:80, rules programmed on that node by kube-proxy rewrite the destination to one of the current endpoints, chosen roughly at random, and the packet goes straight to that pod. No proxy process sits in the path; it is kernel-level rewriting, which is why it costs almost nothing.

    Two consequences worth knowing:

    Balancing is per connection, not per request. A client that opens one HTTP connection and keeps it alive — which is most modern clients — sends every request to the same pod. That surprises people who scale up and see load stay uneven. Fixing it means either short-lived connections or a real layer-7 proxy.

    There is no health checking in the data path. Endpoints are managed by readiness, so a pod that fails between health checks receives traffic until the next one. Your clients should retry.

    The three types

    Same object, one extra field, three different scopes.

    ClusterIP

    Internal only. The default.

    Your database, your cache, one service calling another. This is the right answer for almost everything.

    NodePort

    A high port on every node.

    Useful on a local cluster or for a quick test. Awkward in production: the port range is 30000–32767 and clients would have to know node addresses.

    LoadBalancer

    A real external address.

    One cloud load balancer per Service, each with its own address and its own monthly bill. On a local cluster nothing provides one, so EXTERNAL-IP sits at <pending> forever.

    Each one builds on the one before it — a NodePort is also a ClusterIP, and a LoadBalancer is also a NodePort

    ClusterIP — inside the cluster only

    The default, and the right answer for anything internal:

    yaml
    spec:
      type: ClusterIP # can be omitted

    Reachable only from inside the cluster. Your database Service, your cache, one microservice calling another — all ClusterIP.

    NodePort — a port on every node

    yaml
    spec:
      type: NodePort
      ports:
        - port: 80
          targetPort: 80
          nodePort: 30080 # optional; 30000-32767
    text
    NAME   TYPE       CLUSTER-IP     PORT(S)        AGE
    web    NodePort   10.96.142.31   80:30080/TCP   4s

    Kubernetes opens port 30080 on every node, and traffic to any node's address on that port reaches the Service. Useful for a local cluster or a quick test; awkward in production, because the port range is high-numbered and clients would need to know node addresses.

    LoadBalancer — an external address

    yaml
    spec:
      type: LoadBalancer

    Asks the infrastructure for a real external load balancer and puts its address in front of the Service. On a cloud provider you get a public IP within a minute. On a local cluster nothing provides one, so the EXTERNAL-IP sits at <pending> forever, which is not a bug.

    One load balancer per Service adds up

    Every LoadBalancer Service provisions its own cloud load balancer, each with its own address and its own monthly cost. Ten services means ten of them. The usual answer is one LoadBalancer in front of an Ingress controller, with every application behind it as ClusterIP — which is the next lesson.

    The mistake: selector drift

    Bad — the Service selects a label no pod carries:

    yaml
    # deployment.yaml
    template:
      metadata:
        labels:
          app: web
          tier: frontend
    ---
    # service.yaml
    spec:
      selector:
        app: web-frontend

    Good — the selector matches labels the pods actually have:

    yaml
    spec:
      selector:
        app: web

    Both objects apply cleanly. Kubernetes does not validate that a Service's selector matches anything, because a Service may legitimately be created before its pods. So you get a healthy-looking Service, a resolving DNS name, and every connection refused — with no error anywhere.

    A Service selector may also be narrower than the pods' labels, which is fine and useful: selecting app: web catches pods labelled app: web, tier: frontend. It must never mention a label the pods do not have.

    Reaching a Service from your machine

    Your laptop is not in the cluster network, so a ClusterIP is not reachable from it. Two ways in during development:

    bash
    kubectl port-forward service/web 8080:80

    A tunnel from your machine to one pod behind the Service, for as long as the command runs. Note it picks a pod — it is not load balanced.

    bash
    kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- sh

    A throwaway pod inside the cluster, from which every Service name resolves. This is the honest way to test cluster networking, because it tests it from where your application actually sits.

    Headless Services

    One variant worth recognising. Setting clusterIP: None produces a headless Service: no virtual IP, and DNS returns the pod addresses directly, one record per pod.

    yaml
    spec:
      clusterIP: None
      selector:
        app: postgres

    That is what you want when a client needs to know about individual pods rather than being balanced across them — a database replica set, or anything where the caller does its own discovery. It is the addressing model StatefulSets use.

    Cheat sheet

    yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: web
    spec:
      type: ClusterIP # ClusterIP | NodePort | LoadBalancer
      selector:
        app: web # must match labels the pods HAVE
      ports:
        - name: http
          port: 80 # what clients connect to
          targetPort: 8000 # the container's port (or its name)
    bash
    # Everyday
    kubectl apply -f service.yaml
    kubectl get services
    kubectl describe service web            # ...and read Endpoints
    
    # The one diagnostic that matters
    kubectl describe service web | grep Endpoints
    #   empty  -> no pod matches the selector, or none is ready
    #   listed -> the Service is fine; look elsewhere
    
    kubectl get pods -l app=web             # do any pods match?
    kubectl get endpointslices -l kubernetes.io/service-name=web
    
    # Testing from inside the cluster (the honest way)
    kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- sh
    #   wget -qO- http://web
    #   nslookup web
    
    # Reaching it from your own machine
    kubectl port-forward service/web 8080:80    # one pod, not balanced
    
    # DNS names
    #   web                            same namespace
    #   web.default                    another namespace
    #   web.default.svc.cluster.local  fully qualified
    
    # The three types
    #   ClusterIP     internal only — the default, and usually right
    #   NodePort      a high port on every node — local clusters, tests
    #   LoadBalancer  a real external address — one per Service, costly
    
    # Headless, for per-pod addressing
    #   clusterIP: None  -> DNS returns pod IPs, no balancing

    Where to go next

    Your application has a stable internal address. What it does not have is a way for anyone outside the cluster to reach it on a real hostname over HTTPS — NodePort is a high-numbered port and LoadBalancer is one address per Service.

    Ingress is the answer, and it is the next lesson: one entry point, routing by hostname and path to as many Services as you like. Before that, run the busybox pod and try nslookup on a Service, then delete every pod behind it and try again. Watching the name resolve while nothing answers is what makes the endpoints check stick.