Kubernetes Services vs Ingress: ClusterIP, NodePort, LoadBalancer
9 min readBytePatterns
How Kubernetes Services give changing Pods one stable name, what ClusterIP, NodePort and LoadBalancer do, and how Ingress routes HTTP by host and path to them.
Pods are disposable. A rollout replaces them, a node failure reschedules them, and every new Pod gets a new IP address. Yet the web tier still has to reach the API tier, and users still have to reach the web tier. Kubernetes answers the first problem with a Service and the second with a Service of the right type or an Ingress. Everything below comes from the Kubernetes documentation pages listed at the end, as of September 2026.
The problem it solves
Two separate questions hide in "how does traffic reach my Pods?":
- Inside the cluster: web Pods need to call "the API", not a list of IPs that changes on every deploy.
- From outside: browsers need one address, and for HTTP, many hostnames and paths usually share it.
A Service solves the first and, with the right type, part of the second. Ingress solves the HTTP part of the second, on top of Services.
The intuition
A Service is a stable front for a changing set of Pods. It selects Pods by label, say app: api, and gets a virtual IP, the cluster IP, and a DNS name. The Service's controller continuously scans for Pods matching the selector and keeps the Service's EndpointSlices up to date. On each node, kube-proxy usually maintains the network rules that send traffic for the cluster IP to one of those endpoints.
Readiness decides who is in rotation. A Pod that fails its readiness probe is removed from the endpoints and gets no traffic until it passes again; nothing is restarted.
From a Pod in the same namespace, the short name api resolves. The full form is my-svc.my-namespace.svc.cluster-domain.example, and from another namespace you add the namespace, as in api.other-ns.
The type decides who can reach it:
ClusterIP, the default, is reachable only from inside the cluster.NodePortalso opens the same port on every node, from 30000–32767 by default.LoadBalancerasks the cloud provider for an external load balancer in front of the Service.ExternalNamemaps the Service name to an outside DNS name.- A headless Service, with
clusterIP: None, returns the Pod IPs themselves instead of one virtual IP.
An Ingress is an HTTP router in front of Services. Its rules match a host and a path and name a backend Service; it can also terminate TLS once for all of them. Three facts define it:
- It needs an Ingress controller. An Ingress resource on its own has no effect.
- It carries HTTP and HTTPS only. Other protocols need
NodePortorLoadBalancer. - The API is frozen. It is stable and not being removed, but the project recommends Gateway API for new work.
Watch it run
The animation starts with Pods whose IPs change, then puts a Service in front of them. A web Pod calls http://api, and requests spread over the ready api Pods. When api-2 fails its readiness probe it drops out of the endpoints, and traffic goes to api-1 alone. Then the view moves outside: ClusterIP has no way in, a LoadBalancer adds one, and an Ingress behind it routes shop.example.com/api to the api Service. The last frame is the classic surprise: no controller, no routing.
Services & Ingress
Step 1 of 12
Pods come and go, and their IPs change with them. No client should chase addresses.
The same interactive animation as the lesson — step through it with the controls.
The code
A Service manifest, illustrative. targetPort is the container's port; if it is omitted, it defaults to the value of port:
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 80
targetPort: 8000
An Ingress that sends /api to the api Service and everything else to web:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
spec:
rules:
- host: shop.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
Next, a toy model, not kube-proxy or a real controller, of the two rules that decide where a request goes. A Service's endpoints are the Pods that match its selector and are ready:
pods = [
{"name": "api-1", "ip": "10.1.0.5", "labels": {"app": "api"}, "ready": True},
{"name": "api-2", "ip": "10.1.3.2", "labels": {"app": "api"}, "ready": False},
{"name": "web-1", "ip": "10.1.2.9", "labels": {"app": "web"}, "ready": True},
]
def endpoints(selector, pods):
"""Toy model: the Pods a Service would send traffic to."""
return [p["ip"] for p in pods
if all(p["labels"].get(k) == v for k, v in selector.items())
and p["ready"]] # not ready: out of rotation
print(endpoints({"app": "api"}, pods)) # ['10.1.0.5']
pods[1]["ready"] = True # readiness probe passes again
print(endpoints({"app": "api"}, pods)) # ['10.1.0.5', '10.1.3.2']
Ingress path matching as documented: Prefix compares whole path elements split on /, so /api matches /api/orders but not /apiary; when several rules match, the longest path wins, and Exact beats Prefix; a request that matches nothing goes to the default backend:
def prefix_match(rule, path):
"""Prefix pathType: element by element, split on '/'."""
r = [e for e in rule.split("/") if e]
p = [e for e in path.split("/") if e]
return p[:len(r)] == r
def route(rules, host, path, default=None):
"""Toy model of Ingress matching: longest path wins, Exact beats Prefix."""
best = None
for rule_host, rule_path, path_type, backend in rules:
if rule_host not in (None, host):
continue
if path_type == "Exact":
hit = path == rule_path
else:
hit = prefix_match(rule_path, path)
if hit:
rank = (len(rule_path.rstrip("/")), path_type == "Exact")
if best is None or rank > best[0]:
best = (rank, backend)
return best[1] if best else default
rules = [
("shop.example.com", "/", "Prefix", "web"),
("shop.example.com", "/api", "Prefix", "api"),
("shop.example.com", "/api/health", "Exact", "health"),
]
for path in ["/api/orders", "/apiary", "/api/health", "/api/health/deep", "/"]:
print(path, "->", route(rules, "shop.example.com", path))
print(route(rules, "other.example.com", "/api", default="default-backend"))
# /api/orders -> api
# /apiary -> web
# /api/health -> health
# /api/health/deep -> api
# / -> web
# default-backend
The matcher checked on 5,000 random paths and rule sets against a second, independently written definition, "the path equals the rule or continues with a slash", and a brute-force router that lists every match and takes the best:
import random
def prefix_reference(rule, path):
# same definition, written as "path equals rule, or continues with /"
rule = "/" + "/".join(e for e in rule.split("/") if e)
path = "/" + "/".join(e for e in path.split("/") if e)
return rule == "/" or path == rule or path.startswith(rule + "/")
def brute_route(rules, host, path, default=None):
matches = [(len(rp.rstrip("/")), pt == "Exact", b) for h, rp, pt, b in rules
if h in (None, host)
and (path == rp if pt == "Exact" else prefix_reference(rp, path))]
return max(matches, key=lambda m: (m[0], m[1]))[2] if matches else default
random.seed(5)
parts = ["a", "ab", "b"]
rand_path = lambda: "/" + "/".join(random.choice(parts) for _ in range(random.randint(0, 3)))
ok = True
for _ in range(5000):
rule, path = rand_path(), rand_path()
ok &= prefix_match(rule, path) == prefix_reference(rule, path)
rs, seen = [], set()
for i in range(random.randint(0, 5)):
rp, pt = rand_path(), random.choice(["Prefix", "Exact"])
if (rp.rstrip("/"), pt) not in seen: # no duplicate rules
seen.add((rp.rstrip("/"), pt))
rs.append((random.choice([None, "h1", "h2"]), rp, pt, f"svc{i}"))
ok &= route(rs, "h1", path, "default") == brute_route(rs, "h1", path, "default")
print(ok) # True
The complexity
The costs are operational:
- One
LoadBalancerper Service means one cloud load balancer per exposed Service. An Ingress lets many HTTP Services share one entry point, with TLS handled in one place. NodePortexposes every node on that port, and clients must know the node addresses and the allocated port number.- An Ingress adds a component you run, the controller, and its behaviour for
ImplementationSpecificpaths and extra features depends on which controller it is.
Where it goes wrong
- Creating an Ingress with no controller. Nothing routes, and nothing errors either. Check that a controller is running first.
- A selector that matches nothing. Labels on the Service and on the Pod template must agree; an empty endpoint list is the symptom.
- Expecting readiness to restart Pods. It only removes them from endpoints; restarting is the liveness probe's job.
- Sending TCP or UDP through Ingress. It is HTTP and HTTPS only.
- Assuming
/apimatches/apiary.Prefixworks on whole path elements.
How to say it in an interview
"A Service gives a label-selected set of Pods one stable virtual IP and DNS name, and its endpoints follow the Pods that match and are ready. ClusterIP is internal, NodePort opens a port on every node, and LoadBalancer asks the cloud for an external one. For HTTP, I'd put one Ingress, or Gateway API for new work, behind a single load balancer, routing by host and path to ClusterIP Services and terminating TLS once. It only works if an Ingress controller is running, and it only carries HTTP and HTTPS."
Readiness and liveness are covered in probes and self-healing, and a full design that puts these pieces together is in design a deployment on Kubernetes.
Sources
- Service — Kubernetes Documentation
- Ingress — Kubernetes Documentation
- DNS for Services and Pods — Kubernetes Documentation
- Gateway API — Kubernetes Documentation
- Liveness, readiness and startup probes — Kubernetes Documentation