Kubernetes Services on EKS: ClusterIP, NodePort & LoadBalancer

Pod IP addresses change every time a pod is created, rescheduled, or replaced. A Kubernetes Service solves this by giving a stable virtual IP address and DNS name that always routes traffic to whichever pods are currently healthy, regardless of how many times those pods have been restarted or moved across nodes.

By the end of this page you will understand what each Service type does, how selectors and DNS work under the hood, when to use ClusterIP vs LoadBalancer vs Ingress, and how to debug a Service that is not routing traffic correctly.

Simple explanation

Analogy: the reception desk

Think of a Kubernetes Service as a reception desk for a busy department. Staff come and go, people move to different offices, and some days there are three people on the team while other days there are ten. Callers always dial the same reception number. The receptionist knows who is available and routes the call. Callers never need to track individual direct lines.

In Kubernetes terms:

  • The reception number is the Service’s stable virtual IP address (the ClusterIP).
  • The receptionist is kube-proxy, which maintains routing rules on every node.
  • The staff are your pods. They come and go as Kubernetes Deployments scale up, roll out new versions, or recover from crashes.
  • The directory is the Endpoints object: a live list of pod IPs that currently match the Service selector.

If you have ever wondered why your Kubernetes pods can call each other by name instead of IP address, Services and CoreDNS are how that works.

Why Kubernetes Services exist

When Kubernetes schedules a pod, it assigns an IP address from the node’s CIDR block. On EKS with the VPC CNI plugin (covered in the EKS networking model), that IP comes directly from your VPC subnet. The problem: that IP is ephemeral.

Every time a pod is:

  • Restarted after a crash
  • Rescheduled onto a different node after a node failure
  • Replaced by a rolling update from a Deployment

…it gets a brand new IP address. Any caller that hard-coded the old IP now gets connection refused.

A Service abstracts over this by giving you a stable virtual IP (called the ClusterIP) that never changes for the life of the Service. You point your code at the Service, and Kubernetes handles routing to whatever pods are currently alive.

The key insight

You never need to know or track pod IPs. Write your service URLs using the Service name (or its full DNS name) and let Kubernetes keep the routing table up to date as pods come and go.

How Kubernetes Services work

Selectors and endpoints#

When you create a Service with a selector, the Kubernetes control plane continuously watches for pods whose labels match that selector. It records their IPs in an Endpoints object (or the newer EndpointSlices for large clusters) with the same name as the Service.

kubectl get endpoints web-service
# NAME          ENDPOINTS                              AGE
# web-service   10.0.1.5:80,10.0.1.6:80,10.0.1.7:80   5m

When a pod is added, removed, or becomes unready, the endpoints list updates within seconds. The Service IP itself never changes.

Virtual IPs and kube-proxy#

The Service’s virtual IP is not assigned to any real network interface. It exists only in the iptables (or IPVS) rules that kube-proxy maintains on every node. When a packet arrives at the Service IP, kube-proxy’s rules rewrite the destination IP to one of the healthy pod IPs and forward it. This is why ClusterIP Services work without any AWS infrastructure at all.

Tip

kube-proxy distributes traffic round-robin across all healthy endpoints by default. Pods that fail their readiness probe are automatically removed from the endpoints list, so the Service only ever routes to pods that are ready to serve traffic.

Service discovery with DNS#

Every Service automatically gets a DNS record created by CoreDNS, which runs in the kube-system namespace. The full DNS name format is:

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

From within the same namespace, the short name works:

curl http://cache-service:6379

From a different namespace, use the full name:

curl http://cache-service.production.svc.cluster.local:6379

You never need to hard-code IP addresses. A Service can be created before or after the pods it selects. DNS starts resolving as soon as the Service and at least one matching pod both exist.

Service types at a glance

TypeReachabilityAWS resource createdBest forAvoid when
ClusterIPInside the cluster onlyNoneInternal service-to-service callsYou need external access
NodePortVia node IP + port (30000-32767)NoneDev/test, direct node accessProduction external traffic
LoadBalancerExternal (internet or VPC)NLB by defaultExposing TCP/UDP services externallyYou have many HTTP services (use Ingress instead)
ExternalNameDNS alias to an external hostnameNoneAbstracting external services like RDSYou need TLS termination or port remapping

ClusterIP

ClusterIP is the default Service type. It assigns a virtual IP that is reachable only from within the cluster. No AWS infrastructure is provisioned and there is no cost beyond the cluster itself.

When to use it: Any time one service needs to call another inside the same cluster. A backend API calling Redis, a frontend calling an auth service, a worker reading from a queue. The vast majority of Services in a production EKS cluster are ClusterIP.

When not to use it: When anything outside the cluster (external users, other VPCs, on-premises systems) needs to reach the Service. ClusterIP is invisible from outside the cluster by design.

apiVersion: v1
kind: Service
metadata:
  name: cache-service
  namespace: production
spec:
  selector:
    app: redis-cache
  ports:
  - port: 6379
    targetPort: 6379
  type: ClusterIP

Other pods reach this at cache-service.production.svc.cluster.local:6379. No further configuration needed.

Default to ClusterIP

If you are not sure which type to use, start with ClusterIP. It is the safest default: no public exposure, no AWS costs, and you can always change the type later.

NodePort

NodePort builds on ClusterIP by also opening a port in the range 30000-32767 on every node in the cluster. External traffic reaching <any-node-IP>:<nodePort> gets forwarded to the Service.

Why it exists: NodePort was the original way to expose a Service externally before cloud load balancer integration existed. It works on any Kubernetes cluster without cloud infrastructure.

Not recommended for EKS production traffic

NodePort exposes traffic on node IPs directly. Callers need to know a node’s IP and the high port number. If that node goes away, callers must switch to another node IP. Node IPs are not stable DNS endpoints, and you must open security groups for the entire NodePort range on your worker nodes. On EKS, the AWS Load Balancer Controller and Ingress give you a much cleaner and more resilient solution.

When NodePort is still useful:

  • Local development with minikube or kind.
  • Integration tests in CI that need to call a Service from outside the cluster.
  • As the target type for a legacy AWS Classic Load Balancer (rare in modern EKS setups).
apiVersion: v1
kind: Service
metadata:
  name: web-service
spec:
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 31080  # optional; omit to let Kubernetes pick a port
  type: NodePort

LoadBalancer on EKS

When you create a type: LoadBalancer Service on EKS, the AWS cloud controller manager provisions an AWS Network Load Balancer (NLB) and registers the worker node IPs (or pod IPs in IP mode) as targets. The NLB gets a stable DNS name you can CNAME your domain to.

NLBs operate at Layer 4 (TCP/UDP). They preserve client source IPs and handle high-throughput workloads well. They are the right choice when you need to expose raw TCP or UDP traffic, not HTTP.

apiVersion: v1
kind: Service
metadata:
  name: web-service
  namespace: production
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "external"
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
spec:
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 8080
  - port: 443
    targetPort: 8443
  type: LoadBalancer

Use scheme: "internet-facing" to make the NLB publicly accessible. Use scheme: "internal" for a load balancer accessible only within your VPC, which is useful for internal microservices reachable from other AWS services or accounts.

Cost warning: one NLB per Service

Each type: LoadBalancer Service provisions a separate NLB. AWS charges an hourly rate plus per-LCU fees for each one. If you have ten LoadBalancer Services, you have ten NLBs running at all times. For multiple HTTP/HTTPS services, an Ingress with an ALB is significantly cheaper: one ALB can route to many services based on hostname or path rules.

ExternalName

ExternalName creates a CNAME DNS record in CoreDNS pointing to an external hostname. It does not proxy traffic; it only redirects DNS lookups.

When it helps: Your code inside the cluster calls rds-primary.production.svc.cluster.local. In development, that resolves to a local test database. In production, you configure ExternalName to point at your actual RDS endpoint. The application code never changes; only the Service definition does.

apiVersion: v1
kind: Service
metadata:
  name: rds-primary
  namespace: production
spec:
  type: ExternalName
  externalName: mydb.abc123.us-east-1.rds.amazonaws.com

Pods calling rds-primary in the same namespace get the CNAME response and connect directly to the RDS endpoint.

ExternalName does not proxy traffic

No kube-proxy routing rules are created. TLS certificates issued to the external hostname are not valid for the in-cluster DNS name. Port remapping is not supported. ExternalName only redirects DNS; it is not a general-purpose proxy. If you need real proxying, use a standard Service with manually managed endpoints, or handle it at the application layer.

For managing secrets and credentials needed to connect to those external services, use Kubernetes Secrets instead of baking credentials into the Service definition.

Kubernetes Service vs Ingress

This distinction trips up a lot of people.

  • A Service gives stable network access to a set of pods. It operates at Layer 4 (TCP/UDP). It knows nothing about HTTP paths, hostnames, or TLS certificates.
  • An Ingress handles HTTP and HTTPS routing at Layer 7. It reads the Host header and URL path and forwards requests to different Services based on rules.

On AWS, an Ingress resource backed by the AWS Load Balancer Controller provisions an Application Load Balancer (ALB). The ALB handles external traffic, applies TLS termination, evaluates routing rules, and sends traffic to the correct ClusterIP Service inside the cluster.

SituationUse
Internal pod-to-pod communicationClusterIP Service
Single TCP/UDP service exposed externallyLoadBalancer Service (NLB)
Multiple HTTP/HTTPS services on one load balancerIngress + ClusterIP Services
gRPC or raw TCP on a custom portLoadBalancer Service (NLB)
Abstracting an external hostnameExternalName Service
ALB comes from Ingress, not from LoadBalancer type

The ALB is not the default output of a type: LoadBalancer Service on EKS. A LoadBalancer Service creates an NLB. The ALB comes from an Ingress resource. This is a common point of confusion: the Service and the Ingress resource are separate Kubernetes objects that work together, they are not the same thing.

When to use each option

Use ClusterIP when:

  • The service only needs to be reached by other services inside the cluster.
  • Examples: Redis, internal APIs, auth services, message queue consumers.

Use NodePort when:

  • You are running locally or in CI and cannot provision a load balancer.
  • You need quick external access for a demo or test and do not care about stability.

Use LoadBalancer when:

  • You need to expose a TCP or UDP service directly to the internet or to another VPC.
  • You have a single externally-facing service and want the simplest setup.
  • You are using horizontal pod autoscaling and want traffic distributed to all pods automatically without HTTP-specific routing.

Use Ingress + ClusterIP Services when:

  • You have multiple HTTP or HTTPS services to expose.
  • You need path-based routing (/api/* to service A, /static/* to service B).
  • You need host-based routing (api.example.com to service A, app.example.com to service B).
  • You want TLS termination handled once at the load balancer layer.
  • Cost matters: one ALB is significantly cheaper than multiple NLBs.

Use ExternalName when:

  • You want to give an external DNS hostname a stable in-cluster alias.
  • You are migrating a workload and need to swap out an endpoint without touching application code.

Complete examples

ClusterIP — internal service#

apiVersion: v1
kind: Service
metadata:
  name: auth-service
  namespace: production
spec:
  selector:
    app: auth
  ports:
  - port: 8080
    targetPort: 8080
  type: ClusterIP

Reachable at auth-service.production.svc.cluster.local:8080 from anywhere in the cluster.

LoadBalancer — external NLB#

apiVersion: v1
kind: Service
metadata:
  name: api-gateway
  namespace: production
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "external"
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
spec:
  selector:
    app: api-gateway
  ports:
  - port: 443
    targetPort: 8443
  type: LoadBalancer

The NLB DNS name appears in kubectl get svc api-gateway under EXTERNAL-IP once provisioned.

ExternalName — abstract an RDS endpoint#

apiVersion: v1
kind: Service
metadata:
  name: primary-db
  namespace: production
spec:
  type: ExternalName
  externalName: mydb.cluster-abc123.us-east-1.rds.amazonaws.com

Pods call primary-db:5432 and DNS resolves it to the RDS hostname. The EKS networking model ensures those pods can reach the RDS endpoint directly over the VPC network.

Service and Deployment together#

Services find pods via label selectors. The selector in the Service must match the labels on the pod template in the Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web       # Service selector must match this
    spec:
      containers:
      - name: web-app
        image: nginx:1.27
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web-service
spec:
  selector:
    app: web           # Matches pods with label app: web
  ports:
  - port: 80
    targetPort: 80
  type: ClusterIP

If labels do not match, the Service has zero endpoints and routes no traffic.

Common mistakes

  1. Selector labels not matching pod labels. If the Service selector says app: web-api but the Deployment pod template labels say app: web_api (underscore vs hyphen), the Service has no endpoints and all traffic is dropped. Copy labels carefully; typos here produce silent failures that are hard to spot.
  2. Creating a LoadBalancer Service for every internal service. Internal services only need ClusterIP. Each LoadBalancer Service provisions a real NLB with ongoing AWS costs. A cluster with 20 internal services should have 20 ClusterIP Services, not 20 NLBs.
  3. Not using Ingress for multiple HTTP services. If you have five HTTP services, five LoadBalancer Services means five NLBs. One Ingress with an ALB can route to all five using path or host rules at a fraction of the cost.
  4. Confusing port and targetPort. The port field is what the Service listens on. The targetPort is the port on the container. They do not need to match. Mixing them up causes connection refused errors that look like Service misconfiguration but are actually a port number mismatch.
  5. Expecting ExternalName to proxy traffic. ExternalName only redirects DNS. It does not intercept connections, rewrite headers, handle TLS, or load balance. If you need real proxying, use a standard Service with endpoints pointing at your external IP, or handle it at the application layer.

Troubleshooting checklist

When a Service is not routing traffic, work through these commands in order:

# 1. Check the Service exists and has an IP
kubectl get svc web-service
# Look for: CLUSTER-IP is not <none>, TYPE matches what you expect,
# EXTERNAL-IP is set (for LoadBalancer) or shows <pending> if still provisioning
# 2. Check the endpoints — this is the most important command
kubectl get endpoints web-service
# If ENDPOINTS shows <none>, the selector is not matching any pods.
# Compare the selector in the Service spec against the pod labels.
# 3. Check EndpointSlices (used in newer clusters)
kubectl get endpointslices -l kubernetes.io/service-name=web-service
# 4. Check pod labels — are they what the Service selector expects?
kubectl get pods --show-labels -n production
# Compare the labels column against the selector in your Service spec
# 5. Describe the Service for events and configuration details
kubectl describe svc web-service
# Look at: Selector, Endpoints, and any Events at the bottom of the output
# 6. Check DNS resolution from inside a pod
kubectl exec -it <pod-name> -- nslookup web-service
kubectl exec -it <pod-name> -- nslookup web-service.production.svc.cluster.local
# If nslookup fails, CoreDNS may have an issue; check pods in kube-system
# 7. Check pod readiness — unready pods are removed from endpoints
kubectl get pods -n production
# Pods with STATUS Running but READY 0/1 are excluded from Service endpoints

For LoadBalancer Services stuck in <pending>, check that the IAM roles for your node group or service account have the permissions needed for the cloud controller manager to provision NLBs. Also check securing EKS clusters to ensure security group rules allow traffic on the expected ports.

Frequently asked questions

What is the difference between ClusterIP and LoadBalancer?

ClusterIP creates a virtual IP reachable only inside the cluster. No AWS resources are provisioned and there is no cost. LoadBalancer provisions an AWS Network Load Balancer (NLB) accessible from outside the cluster. Use ClusterIP for internal service-to-service calls, and LoadBalancer only when you need external access.

How does a Kubernetes Service find the right pods?

Services use label selectors. The selector field on the Service specifies key-value label pairs. Kubernetes watches for pods that have matching labels and adds their IPs to the Service Endpoints object. When pods are created, rescheduled, or deleted, the endpoints list updates automatically. The Service IP stays the same throughout.

Does a Kubernetes Service load balance traffic?

Yes. For ClusterIP and NodePort Services, kube-proxy maintains iptables or IPVS rules on every node that distribute traffic round-robin across all healthy pods in the endpoints list. For LoadBalancer Services, the AWS NLB distributes external traffic before it even reaches the cluster.

Do I still need Ingress if I already have a LoadBalancer Service?

A LoadBalancer Service provisions one NLB per Service. If you have multiple HTTP/HTTPS services, that means multiple NLBs, which gets expensive quickly. An Ingress resource backed by an Application Load Balancer (ALB) lets one load balancer route to many services using hostname or path rules. Use a LoadBalancer Service for TCP/UDP traffic or a single externally-exposed service. Use Ingress for HTTP routing.

When should I use ExternalName?

ExternalName is useful when your code inside the cluster needs to call an external service (like an RDS database or a third-party API) but you want to abstract the real hostname behind an in-cluster DNS name. It creates a CNAME in CoreDNS pointing to an external domain. ExternalName does not proxy traffic; it only redirects DNS. Avoid it for services that need TLS termination or custom port mappings.

Last verified: 4 April 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.