In the previous lesson we dismantled the ClusterIP fiction: an address that exists on no interface and lives only as iptables rules programmed by kube-proxy. But that discovery leaves untouched the problem we have been dragging along since the end of module 3: a ClusterIP is only reachable from inside the cluster. A customer who wants to buy a ticket on www.rutasnorte.example has no way of reaching web-store. This lesson walks through the four Service types —ClusterIP, NodePort, LoadBalancer and ExternalName— plus two variants that are not types but behave as if they were: headless services and selectorless services. By the end you will know exactly which one to use in each situation, why LoadBalancer is not the answer for publishing the platform, and how to give the external payment gateway a name inside the cluster.

Contents

  1. Overview: six ways to declare a Service
  2. How each type builds on the previous one
  3. NodePort in detail
  4. LoadBalancer and its cost
  5. externalTrafficPolicy: Cluster versus Local
  6. ExternalName: the payment gateway
  7. Selectorless services: endpoints by hand
  8. Headless services
  9. sessionAffinity, named ports and multiple ports
  10. What Rutas Norte uses and why

  1. Overview: six ways to declare a Service

Before going into detail, here is the table worth keeping in front of you throughout the lesson.

Form Key spec Reach Who implements it When to use it
ClusterIP type: ClusterIP (default) Inside the cluster only kube-proxy Communication between components. 90% of cases
NodePort type: NodePort Any node's IP, port 30000-32767 kube-proxy Testing, lab, or as the foundation of an external balancer
LoadBalancer type: LoadBalancer Public/external IP A cloud provider controller Exposing a TCP/UDP service to the outside in the cloud
ExternalName type: ExternalName + externalName DNS name redirection CoreDNS (CNAME record) Giving an internal name to an outside service
Headless clusterIP: None DNS only, no virtual IP CoreDNS (one A per pod) StatefulSets, clients that balance on their own
Selectorless No selector, with a manual EndpointSlice Same as its type You, by hand External service with fixed IPs, migrations

The last two rows are not values of type: they are modifications that combine with it. A Service can be ClusterIP and headless, or ClusterIP and selectorless.

  1. How each type builds on the previous one

The first three types are not alternatives: they are cumulative layers. A NodePort still has its ClusterIP and still works from inside. A LoadBalancer still has its NodePort.

flowchart TB
    subgraph LB["type: LoadBalancer"]
      subgraph NP["type: NodePort"]
        subgraph CIP["type: ClusterIP"]
          A["internal virtual IP<br/>10.96.x.x:port<br/>kube-proxy rules"]
        end
        B["Plus: port 30000-32767<br/>open on ALL nodes"]
      end
      C["Plus: external IP provisioned<br/>by the provider, pointing<br/>at the nodes NodePorts"]
    end

Check it against the output of kubectl:

kubectl get svc -n rutas-norte-dev
NAME           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
bookings-api   ClusterIP      10.96.201.14    <none>        3000/TCP       9d
web-store      NodePort       10.96.88.7      <none>        80:31080/TCP   4m
demo-lb        LoadBalancer   10.96.44.190    10.0.0.51     80:32410/TCP   2m

Look at the PORT(S) column: 80:31080/TCP means "port 80 of the Service, also published on port 31080 of every node". And all three have a CLUSTER-IP, including the LoadBalancer. That accumulation is why diagnosis always goes from the inside out: if the ClusterIP does not work, the NodePort will not work either.

  1. NodePort in detail

A NodePort opens the same port on every node in the cluster, regardless of where the pods are. If you call that port on a node that hosts no replica, kube-proxy forwards the traffic to the node that does have one.

# k8s/environments/dev/web-store-service-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
  name: web-store
  namespace: rutas-norte-dev
  labels:
    app: web-store
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  type: NodePort
  selector:            # remember: only app and environment in selectors
    app: web-store
    environment: dev
  ports:
    - name: http
      port: 80         # ClusterIP port
      targetPort: 8080 # nginx container port
      nodePort: 31080  # OPTIONAL: if omitted, the cluster assigns it

Points to be clear about:

  • The default range is 30000-32767 (about 2,700 ports). It can be changed with --service-node-port-range on the apiserver, but that is a cluster decision, not an application team one.
  • If you omit nodePort, Kubernetes picks a free one. That is the recommended approach: pinning it introduces a potential clash between teams and an Invalid value: provided port is already allocated when somebody gets there first.
  • The port is opened on every node, even if only one has pods. With externalTrafficPolicy: Local this changes (section 5).
  • Try it in minikube:
minikube -p rutas-norte service web-store -n rutas-norte-dev --url
http://192.168.49.2:31080

Why it is not used in production

Problem Consequence
Ugly, arbitrary ports Nobody is going to type http://rutasnorte.example:31080 into a browser
No domain name and no TLS HTTPS would have to be solved outside
No high availability You point at one node's IP; if that node goes down, the service stops answering even though the pod is alive
Requires opening the firewall 2,700 potentially reachable ports on every node
Does not group services One port per Service, with no host-based or path-based routing

The legitimate use of NodePort is as a foundation: cloud balancers and many Ingress controllers rest on exactly that. For development and quick demos, it is perfect.

  1. LoadBalancer and its cost

type: LoadBalancer tells the cluster: "ask the infrastructure provider for an external balancer pointing at this service". Kubernetes does not materialise it: that is done by a controller that is part of the cloud's cloud-controller-manager, or by an add-on such as MetalLB in on-premises installations.

apiVersion: v1
kind: Service
metadata:
  name: web-store
  namespace: rutas-norte-pro
spec:
  type: LoadBalancer
  selector:
    app: web-store
    environment: pro
  ports:
    - name: http
      port: 80
      targetPort: 8080

In a cluster with no cloud provider, the result is this:

NAME        TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
web-store   LoadBalancer   10.96.44.190   <pending>     80:32410/TCP   5m

That eternal <pending> is one of the most repeated questions from newcomers. It is not a fault: it is that nobody has picked up the request. The object is created correctly, but there is no controller capable of provisioning an external IP. In minikube it is solved with:

# In another terminal; it asks for an administrator password and must be left open
minikube -p rutas-norte tunnel
NAME        TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)
web-store   LoadBalancer   10.96.44.190   10.96.44.190  80:32410/TCP

minikube tunnel creates routes on your machine so that the assigned IPs become reachable. It is a local emulation, not a real balancer.

The economic argument that leads to Ingress

Every Service of type LoadBalancer triggers the provisioning of a separate balancer in the cloud, with its own public IP and its own invoice. For Rutas Norte:

Service to publish Its own balancer? Approximate monthly cost
web-store Yes 1 balancer
bookings-api Yes 1 balancer
Internal admin dashboard Yes 1 balancer
Grafana metrics (07-04) Yes 1 balancer

Four balancers, four public IPs, four TLS certificates to manage separately and no way to route by path or by header. Multiplied by three environments, twelve. The alternative is a single LoadBalancer in front of an Ingress controller, which distributes internally by domain and by path. That is where 04-04 comes from.

On top of that, LoadBalancer operates at L4: it does not understand HTTP, it cannot route by Host, it cannot rewrite paths. For non-HTTP services (an MQTT broker, a postgres exposed to another network) it is still the right answer.

  1. externalTrafficPolicy: Cluster versus Local

It applies to NodePort and LoadBalancer, and it decides what a node does when external traffic for a Service arrives.

flowchart TB
    subgraph CL["externalTrafficPolicy: Cluster (default)"]
      direction LR
      C1["Client 203.0.113.9"] --> N1["Node A<br/>(no pods)"]
      N1 -->|"SNAT: source becomes node A IP"| N2["Node B<br/>web-store pod"]
      N2 --> R1["The pod sees source = 192.168.49.2"]
    end
    subgraph LO["externalTrafficPolicy: Local"]
      direction LR
      C2["Client 203.0.113.9"] --> M1["Node A<br/>(no pods)"]
      M1 -->|"DROPS"| X1["No answer"]
      C2 --> M2["Node B<br/>web-store pod"]
      M2 --> R2["The pod sees source = 203.0.113.9"]
    end
Cluster (default) Local
Extra hop between nodes Yes, if the node has no pods No, never
Client source IP Lost (SNAT to the node) Preserved
Load distribution Uniform across all pods Proportional to the pods on that node
Balancer health check Every node answers Only those with pods (via healthCheckNodePort)
If a node has no pods It forwards It drops the packet

The imbalance of Local deserves an example. If web-store has 3 replicas and node A hosts 2 while node B hosts 1, a balancer splitting 50/50 between nodes will give each pod on node A 25% of the traffic and the one on node B 50%. It is corrected with pod anti-affinity (06-05).

For Rutas Norte the source IP matters: the access logs, the request rate limiting during bank-holiday peaks and the blocking of abusive addresses all depend on it. With Cluster, every request would appear to come from the nodes.

spec:
  type: LoadBalancer
  externalTrafficPolicy: Local   # preserves the real client IP

When traffic arrives through an HTTP Ingress there is an alternative: the controller adds the X-Forwarded-For header with the real IP. But that only works for HTTP and only if the application reads it. There is also internalTrafficPolicy, with the same idea applied to internal cluster traffic: Local forces a pod to connect only to replicas on its own node.

  1. ExternalName: the payment gateway

bookings-api has to charge for tickets against pagos.proveedorexterno.example, which lives outside the cluster. You could put that domain in a ConfigMap and be done with it, but ExternalName gives a very useful layer of indirection.

# k8s/environments/pro/payment-gateway-externalname.yaml
apiVersion: v1
kind: Service
metadata:
  name: payment-gateway
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
spec:
  type: ExternalName
  externalName: pagos.proveedorexterno.example

What happens underneath: CoreDNS creates a CNAME record. When bookings-api resolves payment-gateway.rutas-norte-pro.svc.cluster.local, the answer is an alias to pagos.proveedorexterno.example, which is resolved by external DNS (04-03).

kubectl run t --rm -it --restart=Never -n rutas-norte-pro \
  --image=nicolaka/netshoot -- nslookup payment-gateway
Server:    10.96.0.10
Address:   10.96.0.10#53

payment-gateway.rutas-norte-pro.svc.cluster.local
    canonical name = pagos.proveedorexterno.example.
Name:      pagos.proveedorexterno.example
Address:   198.51.100.44

Advantages for our case:

  • The application always points at payment-gateway, in all three environments. What changes is the externalName in each namespace: in rutas-norte-dev it would point at pagos-sandbox.proveedorexterno.example, in pro at the production one. Switching provider does not touch a single line of code.
  • There is no proxy: traffic goes straight from the pod to the external destination, without passing through kube-proxy.

And its three limitations, which cause surprises:

  1. It does not translate ports. The ports of an ExternalName is decorative. If the provider listens on 8443, your client must ask for 8443.
  2. It does not do TLS. The provider's certificate will be the one for pagos.proveedorexterno.example, not for payment-gateway, so SNI/hostname validation will use the real name. With HTTP and a rewritten Host, the check can fail at the other end.
  3. It depends on external DNS. If CoreDNS cannot forward to the node's resolver, this does not work.

An important note for 04-06: an ExternalName does not count as an exception in a network policy. To allow egress towards the provider you will have to authorise its IP range with ipBlock.

  1. Selectorless services: endpoints by hand

A normal Service finds its destinations through the selector. If you omit the selector, the endpoints controller creates nothing and you can write the EndpointSlice yourself. The result is a normal ClusterIP, with its DNS name and its balancing, pointing at addresses that are not pods.

A realistic Rutas Norte case: during the migration, the billing database is still on a virtual machine in the office, at 10.20.30.40:5432.

# k8s/environments/pro/external-billing.yaml
apiVersion: v1
kind: Service
metadata:
  name: legacy-billing
  namespace: rutas-norte-pro
spec:
  ports:
    - name: postgres
      port: 5432
      targetPort: 5432
      protocol: TCP
  # NO selector: nobody fills in the endpoints automatically
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: legacy-billing-manual
  namespace: rutas-norte-pro
  labels:
    # MANDATORY: links the slice to the Service
    kubernetes.io/service-name: legacy-billing
addressType: IPv4
ports:
  - name: postgres      # must match the Service port name
    port: 5432
    protocol: TCP
endpoints:
  - addresses:
      - "10.20.30.40"
    conditions:
      ready: true

Critical details:

  • The kubernetes.io/service-name label is what joins the slice to the Service. Without it, the Service is left with no endpoints.
  • The ports[].name field of the EndpointSlice must match the ports[].name of the Service.
  • Nobody health-checks that address. If the machine goes down, the Service keeps sending traffic. Keeping the list up to date is your responsibility.
  • The addresses cannot be Service IPs (10.96.x.x) or loopback addresses.

Main advantage: the application connects to legacy-billing:5432 from day one. The day that database is migrated to a pod in the cluster, you add the selector, delete the manual slice and the application never notices. It is the indirection pattern that makes migrations bearable.

  1. Headless services

A Service with clusterIP: None is headless: it has no virtual IP, creates no kube-proxy rules and balances nothing. All it does is publish in DNS one A record per pod matching the selector.

apiVersion: v1
kind: Service
metadata:
  name: bookings-postgres-headless
  namespace: rutas-norte-pro
spec:
  clusterIP: None          # this is what makes it headless
  selector:
    app: bookings-postgres
    environment: pro
  ports:
    - name: postgres
      port: 5432
Normal Service Headless Service
CLUSTER-IP 10.96.x.x None
kube-proxy rules Yes No
DNS answer One A: the virtual IP One A per ready pod
Balancing Done by the kernel Done by the client
Typical use Everything else Clustered databases, StatefulSets
$ nslookup bookings-postgres-headless.rutas-norte-pro.svc.cluster.local
Name:  bookings-postgres-headless...  Address: 10.244.0.14
Name:  bookings-postgres-headless...  Address: 10.244.0.18
Name:  bookings-postgres-headless...  Address: 10.244.0.23

What is this for? For the cases where the client needs to tell the pods apart:

  • A PostgreSQL replica set with one primary and two secondaries: the client writes to the primary and reads from the secondaries. A ClusterIP distributing at random would break it.
  • Distributed databases (Cassandra, Elasticsearch, Kafka) where each node must know the others by name.
  • Clients with their own balancing (gRPC), which want the list of destinations so they can distribute per request rather than per connection.

The full detail of the DNS records it generates is in 04-03, and its real use —with stable identities such as bookings-postgres-0.bookings-postgres-headless— arrives with the StatefulSets of 06-01, where bookings-postgres will finally stop being a Deployment.

One nuance: a Service that is headless and selectorless generates no A records; instead CoreDNS returns the names from externalName if there is one, or the manual endpoints you define.

  1. sessionAffinity, named ports and multiple ports

sessionAffinity: ClientIP

By default kube-proxy distributes connections at random. With affinity, all connections from the same source IP go to the same pod for a while.

spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800   # 3 hours; the default is also 10800

It is a blunt hammer and this has to be said plainly:

  • It works by IP, not by user. A hundred clients behind a company's NAT are a single IP and all land on the same pod.
  • It breaks load distribution and complicates progressive rollouts.
  • With externalTrafficPolicy: Cluster, the source IP already arrives masqueraded, so affinity is applied to the node IP: useless.

For Rutas Norte we do not use it: bookings-api is stateless, and the buyer's session lives in a JWT and in redis-cache. That is the correct solution; session affinity is a patch for applications that keep state in memory.

Named ports and multiple ports

When a Service exposes more than one port, name is mandatory on each of them.

apiVersion: v1
kind: Service
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
spec:
  selector:
    app: bookings-api
    environment: pro
  ports:
    - name: http          # mandatory when there is more than one
      port: 3000
      targetPort: api     # points at a NAMED container port
      protocol: TCP
    - name: metrics       # Prometheus will consume it in 07-03
      port: 9090
      targetPort: metrics
      protocol: TCP

With the container declaring:

    ports:
      - name: api
        containerPort: 3000
      - name: metrics
        containerPort: 9090

Advantages of using a named targetPort: if tomorrow the application changes port, you only touch the Deployment. And port names are what enable the DNS SRV records (04-03). Constraint: the name must be 15 characters or fewer and consist of lowercase letters, digits and hyphens.

  1. What Rutas Norte uses and why

Component Type chosen Reason
web-store ClusterIP It will be published through Ingress (04-04), not directly
bookings-api Multi-port ClusterIP (http + metrics) Same; metrics are never exposed outside
bookings-postgres ClusterIP (and headless in 06-01) It must never leave the cluster
redis-cache ClusterIP Internal use only
notifications-worker No Service It receives no inbound connections; it only consumes from the queue
payment-gateway ExternalName Indirection towards the external provider
legacy-billing Selectorless + manual EndpointSlice Database still outside the cluster
Ingress controller LoadBalancer (just one) One balancer for the whole platform

That last row is the practical conclusion of the lesson: the entire platform ends up with a single paid entry point, and everything else lives on ClusterIP.

One detail about notifications-worker worth internalising: not every Deployment needs a Service. A Service exists to receive connections. A process that only makes outbound connections needs none. (It will need one if it later exposes metrics for Prometheus.)

Common Mistakes and Tips

  • Setting type: LoadBalancer on a local cluster and expecting an IP. The <pending> is what you should expect with no provider. In minikube, minikube tunnel; on your own server, MetalLB.
  • Pinning nodePort by hand "so it is easy to remember". It creates clashes between teams and deployments that fail with provided port is already allocated. Let the cluster assign it.
  • Switching from ClusterIP to NodePort and believing you lose the ClusterIP. You do not: the types are cumulative. Internal traffic carries on unchanged.
  • Using ExternalName with different ports. It does not translate ports. If the provider listens on 8443, your client must ask for 8443 explicitly.
  • A selectorless Service with no EndpointSlice. It is created without a single error and never answers. Always check with kubectl get endpointslices -l kubernetes.io/service-name=<svc>.
  • Forgetting name on a multi-port Service. The error is clear (must specify name for multiple ports), but it arrives late: get into the habit of always naming ports, even with a single one.
  • Using sessionAffinity to fix an application with state in memory. You are papering over the problem. Move the state out to redis-cache.
  • Expecting a headless Service to balance. It does not. The client receives all the IPs and decides for itself. Many HTTP libraries just take the first one.
  • Tip: kubectl get svc -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,TYPE:.spec.type is an audit at a glance. Any unexpected LoadBalancer is money walking out of the door.
  • Tip: when debugging, always work from the inside out. ClusterIP → NodePort → LoadBalancer. A failure in the inner layer shows up in all the outer ones.

Exercises

Exercise 1: Publish web-store with NodePort and check the layers

In rutas-norte-dev, change the web-store Service to NodePort without pinning the port. Check that it still works from inside through its ClusterIP and from outside through the node's IP. Explain which column of kubectl get svc tells you both ports.

Exercise 2: The payment gateway with ExternalName

Create the payment-gateway Service in rutas-norte-dev pointing at a sandbox (pagos-sandbox.proveedorexterno.example) and in rutas-norte-pro at the real domain. Verify from an ephemeral pod that resolution returns a different CNAME in each namespace. Explain why this lets the bookings-api code be identical in both environments.

Exercise 3: Billing database outside the cluster

Create legacy-billing as a selectorless Service with a manual EndpointSlice pointing at 10.20.30.40:5432. Check that the Service has endpoints and that the name resolves. Then trigger the classic failure: remove the kubernetes.io/service-name label from the slice and observe what happens.

Solutions

Exercise 1

kubectl patch svc web-store -n rutas-norte-dev -p '{"spec":{"type":"NodePort"}}'
kubectl get svc web-store -n rutas-norte-dev
NAME        TYPE       CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE
web-store   NodePort   10.96.88.7    <none>        80:30417/TCP   9d
# From inside: the ClusterIP is still alive
kubectl run t --rm -it --restart=Never -n rutas-norte-dev \
  --image=nicolaka/netshoot -- curl -s -o /dev/null -w '%{http_code}\n' http://web-store

# From outside
curl -s -o /dev/null -w '%{http_code}\n' http://$(minikube -p rutas-norte ip):30417

The PORT(S) column, formatted as 80:30417/TCP, gives you both: on the left the ClusterIP port, on the right the NodePort. It proves that NodePort adds a layer on top of ClusterIP.

Exercise 2

for NS in dev pro; do
  DEST=$( [ $NS = pro ] && echo pagos.proveedorexterno.example \
                        || echo pagos-sandbox.proveedorexterno.example )
  kubectl create service externalname payment-gateway \
    --external-name=$DEST -n rutas-norte-$NS
done

kubectl run t --rm -it --restart=Never -n rutas-norte-dev \
  --image=nicolaka/netshoot -- nslookup payment-gateway
payment-gateway.rutas-norte-dev.svc.cluster.local
    canonical name = pagos-sandbox.proveedorexterno.example.

The bookings-api code always uses http://payment-gateway/charges. The namespace determines which real provider it translates to, with no different environment variables and no branches in the code. Switching provider means editing one manifest.

Exercise 3

kubectl apply -f k8s/environments/pro/external-billing.yaml

kubectl get endpointslices -n rutas-norte-pro \
  -l kubernetes.io/service-name=legacy-billing
NAME                     ADDRESSTYPE   PORTS   ENDPOINTS     AGE
legacy-billing-manual    IPv4          5432    10.20.30.40   8s
# Trigger the failure: remove the label joining slice and Service
kubectl label endpointslice legacy-billing-manual \
  -n rutas-norte-pro kubernetes.io/service-name-

kubectl get endpointslices -n rutas-norte-pro \
  -l kubernetes.io/service-name=legacy-billing
# No resources found

The Service still exists, its ClusterIP is still assigned, kubectl get svc shows nothing odd, and every connection hangs until it times out. It is the same clinical picture as a selector that does not match, seen in 02-05: the Service looks healthy and has no destinations. That is why the first check when "the Service does not answer" is always to look at its endpoints.

Conclusion

You now know the full catalogue. ClusterIP is the default type and the one used by nearly the whole platform: reachable from inside only. NodePort adds on top of it a port in the 30000-32767 range open on every node, useful for testing and as the foundation of other mechanisms, but unacceptable in production because of its arbitrary ports, its lack of TLS and its dependence on one specific node's IP. LoadBalancer adds on top of that an external IP provisioned by the provider —hence the eternal <pending> when there is none, and minikube tunnel to emulate one— and its cost of one balancer per service is exactly the argument that justifies Ingress. ExternalName routes no traffic: it makes CoreDNS return a CNAME, and with it we have given the payment gateway a stable internal name, different per environment, without touching the code.

You know that the first three types are cumulative layers, which forces you to always diagnose from the inside out. You understand externalTrafficPolicy: Cluster forwards between nodes but destroys the client's source IP, whereas Local preserves it at the cost of uneven load distribution. And you have a grip on the two variants that are not types: selectorless services, where you write the EndpointSlice yourself to point at an external database and where the kubernetes.io/service-name label is the link everybody forgets; and headless services, which give up the virtual IP in order to publish one A record per pod, indispensable when the client needs to tell replicas apart.

One thread has run through the whole lesson without being developed: DNS. ExternalName works because CoreDNS creates a CNAME. Headless works because CoreDNS creates one A per pod. bookings-api finds bookings-postgres because there is a name that resolves. The next lesson, 04-03, opens that box: what CoreDNS is, which short name forms work and when, why ndots: 5 can multiply queries to the payment gateway by five, and how to diagnose the three DNS failures you will see again and again throughout your career.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved