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
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.
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 5mWhen 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.
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.localFrom within the same namespace, the short name works:
curl http://cache-service:6379From a different namespace, use the full name:
curl http://cache-service.production.svc.cluster.local:6379You 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
| Type | Reachability | AWS resource created | Best for | Avoid when |
|---|---|---|---|---|
| ClusterIP | Inside the cluster only | None | Internal service-to-service calls | You need external access |
| NodePort | Via node IP + port (30000-32767) | None | Dev/test, direct node access | Production external traffic |
| LoadBalancer | External (internet or VPC) | NLB by default | Exposing TCP/UDP services externally | You have many HTTP services (use Ingress instead) |
| ExternalName | DNS alias to an external hostname | None | Abstracting external services like RDS | You 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: ClusterIPOther pods reach this at cache-service.production.svc.cluster.local:6379. No further configuration needed.
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.
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
minikubeorkind. - 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: NodePortLoadBalancer 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: LoadBalancerUse 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.
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.comPods calling rds-primary in the same namespace get the CNAME response and connect directly to the RDS endpoint.
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.
| Situation | Use |
|---|---|
| Internal pod-to-pod communication | ClusterIP Service |
| Single TCP/UDP service exposed externally | LoadBalancer Service (NLB) |
| Multiple HTTP/HTTPS services on one load balancer | Ingress + ClusterIP Services |
| gRPC or raw TCP on a custom port | LoadBalancer Service (NLB) |
| Abstracting an external hostname | ExternalName Service |
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.comto service A,app.example.comto 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: ClusterIPReachable 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: LoadBalancerThe 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.comPods 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: ClusterIPIf labels do not match, the Service has zero endpoints and routes no traffic.
Common mistakes
- Selector labels not matching pod labels. If the Service selector says
app: web-apibut the Deployment pod template labels sayapp: 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. - 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.
- 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.
- Confusing
portandtargetPort. Theportfield is what the Service listens on. ThetargetPortis 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. - 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 endpointsFor 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.
Summary
- Services provide stable IP addresses and DNS names for sets of pods. Pod IPs change; Service IPs do not.
- Services find their pods using label selectors. If the selector does not match, endpoints are empty and no traffic routes.
- ClusterIP (internal only) is the default and the right choice for most Services. No AWS resources, no cost.
- NodePort exposes a Service on every node’s IP at a high port. Useful for dev and testing, not for production on EKS.
- LoadBalancer provisions an AWS NLB. Use it for TCP/UDP traffic or a single externally-exposed service.
- ExternalName creates a DNS alias to an external hostname. It does not proxy traffic.
- For multiple HTTP/HTTPS services, an Ingress with an ALB is cheaper and more flexible than one LoadBalancer Service per service.
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.