Kubernetes Services, DNS and Gateway API Explained
Give pods a stable address with Kubernetes Services and DNS, compare ClusterIP, NodePort and LoadBalancer, and route HTTP with Gateway API, not Ingress.
TLDR: A Service gives a changing set of pods one stable virtual IP and DNS name (podinfo.demo.svc.cluster.local) and spreads connections over the pods that are ready. ClusterIP Services are reachable inside the cluster, NodePort and LoadBalancer Services from outside. For HTTP from outside, use Gateway API: a Gateway owned by the platform team and HTTPRoutes owned by app teams. The Ingress API still works, but the most popular controller for it, ingress-nginx, was retired in March 2026.
In this part you put a Service in front of the podinfo Deployment, look at the EndpointSlices and DNS records behind it, expose it as a NodePort and a LoadBalancer, then route it through a Gateway and split traffic between a stable version and a canary.
Prerequisites
- The podinfo Deployment from Part 4, running 6.15.0 with three replicas.
- If you start here on a new cluster, run
kubectl apply -f 04-deployments/deployment.yamland thenkubectl set image deployment/podinfo podinfo=ghcr.io/stefanprodan/podinfo:6.15.0.
The Kubernetes Network Model
Kubernetes requires a flat network: every pod gets its own IP address, and every pod can reach every other pod on any node without NAT. The network plugin (kindnet on kind; Cilium, Calico or the cloud provider's plugin elsewhere) builds that network. By default nothing is blocked; NetworkPolicies (Part 10) restrict it.
Pod IPs change whenever pods are replaced, so clients should never use them. A Service solves that:
A ClusterIP Service
apiVersion: v1
kind: Service
metadata:
name: podinfo
labels:
app: podinfo
spec:
type: ClusterIP # the default: a stable virtual IP inside the cluster
selector:
app: podinfo # send traffic to ready pods with this label
ports:
- name: http
port: 80 # the port clients connect to
targetPort: http # the named container port (9898)
port is what clients connect to, and targetPort is where the pods listen. Here targetPort names the container port http from the Deployment, so the Service keeps working if the port number in the pods changes. (More on port and targetPort.)
The EndpointSlice lists the IPs of the pods that match the selector, with a ready condition for each. Services send new connections only to ready endpoints, so a pod that fails its readiness probe stays in the slice, marked not ready, and gets no new traffic, which you will see happen in Part 9. The older Endpoints object still exists, but it is deprecated since Kubernetes 1.33, and kubectl says so:
kubectl get endpoints podinfo
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME ENDPOINTS AGE
podinfo 10.244.1.4:9898,10.244.2.3:9898,10.244.2.4:9898 0s
Call the Service from a client pod inside the cluster. Each new connection can go to a different pod:
The spread is random per connection, not a strict round robin. Services balance connections, not requests: a client that keeps one long-lived connection (HTTP/2, gRPC, a database pool) stays on one pod.
Service DNS
CoreDNS gives every Service a name of the form <service>.<namespace>.svc.cluster.local. Inside the same namespace the short name podinfo works, and from another namespace podinfo.demo:
The search list in /etc/resolv.conf is what makes short names work, and ndots:5 means that any name with fewer than five dots is tried with each search suffix first. A lookup for an external name such as api.example.com therefore asks CoreDNS several questions before the real one. If DNS load matters, use fully qualified names with a trailing dot (api.example.com.) or lower ndots in the pod's dnsConfig. The last search domain comes from the node; here it is the GitHub-hosted runner's domain.
Kubernetes also injects PODINFO_SERVICE_HOST-style environment variables for Services that existed before a pod started. Use DNS instead; the variables do not update and leak details between apps.
Service Types
| Type | Reachable from | Typical use |
|---|---|---|
ClusterIP |
Inside the cluster | Everything internal; the default |
NodePort |
Every node's IP on a port 30000 to 32767 | Testing, or behind your own load balancer |
LoadBalancer |
A load balancer the cloud creates | Exposing one service to the internet or a private network |
ExternalName |
Inside the cluster, as a DNS alias | Giving an external host a name inside the cluster |
Headless (clusterIP: None) |
DNS only, one record per pod | StatefulSets, client-side load balancing (Part 7) |
A NodePort or LoadBalancer Service exposes every path the app serves. podinfo has debug endpoints such as /env, /panic and /cache that anyone who reaches it can call. On a cloud cluster a LoadBalancer is usually on the internet, so expose apps through a Gateway that routes only the paths clients need (below), and delete the test Services at the end of this part.
NodePort
apiVersion: v1
kind: Service
metadata:
name: podinfo-nodeport
spec:
type: NodePort
selector:
app: podinfo
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080 # must be in 30000-32767; kind-config.yaml maps it to localhost
kind's config from Part 2 maps port 30080 of the control plane container to your machine, so you can reach the NodePort on localhost:
80:30080/TCP means Service port 80 is also open on port 30080 of every node. curl retries because kube-proxy needs a moment to program the new port; -f turns HTTP errors into failures so they are retried too. kube-proxy on the control plane node forwards the connection to a podinfo pod on one of the workers.
LoadBalancer
On a cloud cluster, a LoadBalancer Service makes the cloud controller manager create a load balancer and write its address into the Service. On DigitalOcean Kubernetes that is a DigitalOcean Load Balancer, configured with service.beta.kubernetes.io/do-loadbalancer-* annotations and billed while it exists. On kind nothing creates one, so the address stays <pending> (why a LoadBalancer stays pending):
apiVersion: v1
kind: Service
metadata:
name: podinfo-lb
spec:
type: LoadBalancer
selector:
app: podinfo
ports:
- name: http
port: 80
targetPort: http
cloud-provider-kind fills that gap for local clusters. It runs as a Docker container next to the cluster, gives LoadBalancer Services an address on the kind Docker network, and also implements Gateway API. Start it (on Linux it may need sudo for the Docker socket):
On Linux you can reach the 172.18.0.x address directly, and from WSL 2 on Windows too. On macOS, Docker runs in a VM and that address is not reachable from your computer; cloud-provider-kind then maps each load balancer to a port on localhost (docker ps shows it on a container named kindccm-...). The simpler way on macOS is to run curl itself in a container on the kind network, as shown for the Gateway below.
Gateway API
A LoadBalancer per Service gets expensive and gives you no HTTP routing: no host names, no paths, no TLS in one place. That is what Gateway API is for. It is a set of Kubernetes APIs (CRDs maintained by the Kubernetes networking SIG) with three main resources, split by who owns them:
A Gateway controller turns these objects into a real proxy. Production clusters use Envoy Gateway, Cilium, Istio, NGINX Gateway Fabric, Traefik, kgateway or the cloud provider's own controller; cloud-provider-kind includes a small one and installs the Gateway API CRDs for you:
The Gateway
The platform team owns the entry point. This Gateway listens for HTTP on port 80 for any host under k8s-guide.example, and only namespaces with the label gateway-access: "true" may attach routes to it:
# Owned by the platform team: one shared entry point for the cluster
apiVersion: v1
kind: Namespace
metadata:
name: gateway-infra
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: gateway-infra
spec:
gatewayClassName: cloud-provider-kind
listeners:
- name: http
protocol: HTTP
port: 80
hostname: '*.k8s-guide.example'
allowedRoutes:
namespaces:
from: Selector # only namespaces with this label may attach routes
selector:
matchLabels:
gateway-access: 'true'
The HTTPRoute
The app team owns the route. It attaches to the Gateway and sends requests for podinfo.k8s-guide.example to the podinfo Service. It matches only / and /version, the two paths clients need; requests for podinfo's debug endpoints never reach the pods.
# Owned by the app team: how requests for podinfo reach its Service
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: podinfo
spec:
parentRefs:
- name: public
namespace: gateway-infra
hostnames:
- podinfo.k8s-guide.example
rules:
# Only the paths clients need. podinfo also has debug endpoints, such as
# /env, /configs and /panic, that must never be reachable from outside.
- matches:
- path:
type: Exact
value: /
- path:
type: Exact
value: /version
backendRefs:
- name: podinfo
port: 80
Accepted=True means the Gateway allowed the route to attach, and ResolvedRefs=True means its backend Service exists. When a route does not work, these two conditions are the first place to look: a namespace without the label gives Accepted=False with the reason NotAllowedByListeners.
There is no DNS record for podinfo.k8s-guide.example, so tell curl which address to use for it:
/version reaches podinfo, /env does not match the route, and a host without a route gets a 404 from the Gateway itself.
On macOS, the Gateway's 172.18.0.x address is inside Docker's VM. Run curl from a container attached to the kind Docker network instead; it reaches the same address, so you can use it for every curl ... $GW_IP command in this part and in Part 10:
alias kcurl='docker run --rm --network kind curlimages/curl:8.23.0'
kcurl -s --resolve podinfo.k8s-guide.example:80:$GW_IP http://podinfo.k8s-guide.example/version
This run used the same container approach once, from Linux, to check that it works (Docker pulls the curl image first):
docker run --rm --network kind curlimages/curl:8.23.0 -s --resolve podinfo.k8s-guide.example:80:$GW_IP http://podinfo.k8s-guide.example/version
Unable to find image 'curlimages/curl:8.23.0' locally
8.23.0: Pulling from curlimages/curl
3f72b3b77eb8: Pulling fs layer
3f72b3b77eb8: Verifying Checksum
3f72b3b77eb8: Download complete
3f72b3b77eb8: Pull complete
Digest: sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
Status: Downloaded newer image for curlimages/curl:8.23.0
{
"commit": "dd507173b7b75b2312a36cabe0de5f09c1ce69c8",
"version": "6.15.0"
}
In production you add an HTTPS listener with a certificate from a Secret (cert-manager can issue and renew it) and point a DNS record at the Gateway's address.
A Canary Release with Weights
An HTTPRoute can split traffic between backends by weight. Deploy a canary of podinfo with its own Service and a different message, then send 10 percent of requests to it:
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo-canary
labels:
app: podinfo-canary
spec:
replicas: 1
selector:
matchLabels:
app: podinfo-canary
template:
metadata:
labels:
app: podinfo-canary
spec:
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.15.0
env:
- name: PODINFO_UI_MESSAGE
value: canary
ports:
- name: http
containerPort: 9898
readinessProbe:
httpGet:
path: /readyz
port: http
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: podinfo-canary
spec:
selector:
app: podinfo-canary
ports:
- name: http
port: 80
targetPort: http
# The same route, now splitting traffic 90/10 between stable and canary
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: podinfo
spec:
parentRefs:
- name: public
namespace: gateway-infra
hostnames:
- podinfo.k8s-guide.example
rules:
# Only the paths clients need. podinfo also has debug endpoints, such as
# /env, /configs and /panic, that must never be reachable from outside.
- matches:
- path:
type: Exact
value: /
- path:
type: Exact
value: /version
backendRefs:
- name: podinfo
port: 80
weight: 90
- name: podinfo-canary
port: 80
weight: 10
The sleep gives the Gateway a few seconds to load the changed route. The split is per request and random, so 200 requests only approximate 180 and 20; this run got 181 and 19. Unlike splitting traffic by putting both versions behind one Service, where the share follows the number of pods, the weight does not depend on how many pods each version runs. If the canary looks healthy, raise its weight step by step; if not, set it to 0. Argo Rollouts and Flagger can drive these weights from metrics.
Ingress and the ingress-nginx Retirement
Before Gateway API, HTTP routing used the Ingress API. It is still part of Kubernetes and still served, but it is frozen: it gets no new features, and everything beyond host and path routing lives in controller-specific annotations that do not move between controllers.
# The older Ingress API: still served, but needs an Ingress controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: podinfo
spec:
ingressClassName: example # the IngressClass of the controller you install
rules:
- host: podinfo.k8s-guide.example
http:
paths:
# Only the public paths, as in the HTTPRoute
- path: /
pathType: Exact
backend:
service:
name: podinfo
port:
number: 80
- path: /version
pathType: Exact
backend:
service:
name: podinfo
port:
number: 80
kubectl apply -f 05-networking/ingress.yaml --dry-run=server
ingress.networking.k8s.io/podinfo created (server dry run)
An Ingress does nothing without an Ingress controller. The most widely used one, ingress-nginx, was retired by Kubernetes SIG Network and the Security Response Committee on 24 March 2026 after years without enough maintainers. Existing installations keep working, but there are no more releases, bug fixes or security patches (announcement). Check whether you run it:
kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx
If you do, plan a migration. ingress2gateway (1.0 since March 2026) converts Ingress resources, including over 30 common ingress-nginx annotations, into Gateway API resources and warns about what it cannot translate. If you must stay on Ingress for now, move to a maintained controller such as Traefik, HAProxy, Kong or your cloud provider's controller. Our post on the ingress-nginx end of life walks through a migration.
Traffic Policies Worth Knowing
externalTrafficPolicy: Localon a NodePort or LoadBalancer Service keeps the client's source IP and avoids a second hop, but nodes without a ready pod then drop the traffic, so the load balancer must health-check nodes.internalTrafficPolicy: Localsends in-cluster traffic only to pods on the same node, which suits node-local agents.trafficDistribution: PreferSameZone(orPreferSameNode, stable since 1.35) keeps traffic in the client's zone or node when possible, which cuts cross-zone costs. The older valuePreferCloseis deprecated in favor ofPreferSameZone.spec.externalIPsis deprecated since 1.36 and scheduled for removal in 1.43, because it lets anyone who can create a Service intercept traffic to any IP. Use a LoadBalancer or a Gateway instead.
Clean Up
Remove the test Services, the canary and the client, and put the route back to 100 percent stable. Keep the podinfo Service, the Gateway and the route; Part 10 uses them again:
Common Mistakes
- A selector that matches no pods. The Service exists, but its EndpointSlice is empty and connections fail. Compare
kubectl get endpointsliceswith the pod labels. - Calling pods by IP. Pod IPs change on every restart; use the Service name.
- One LoadBalancer per app. Each one costs money and an IP address. Share one Gateway and use routes.
- Exposing every path. Route only the paths clients need, and keep admin and debug endpoints internal.
- Staying on ingress-nginx. It no longer gets security fixes. Move to Gateway API or a maintained controller.
Summary
- A Service gives ready pods a stable IP and DNS name; EndpointSlices track which pods are behind it.
ClusterIPis internal,NodePortopens a port on every node,LoadBalancerasks the cloud for a load balancer.- Gateway API splits ownership: the platform team runs the Gateway, app teams attach HTTPRoutes with hosts, paths and weights.
- ingress-nginx was retired on 24 March 2026; ingress2gateway helps you move to Gateway API.
Keep Learning
- Kubernetes Service types: ClusterIP, NodePort and LoadBalancer
- Ingress vs LoadBalancer in Kubernetes
- Kubernetes services and networking interview questions
- Kubernetes networking simulator and DNS simulator
Next, in Part 6, you configure podinfo without rebuilding its image and give it a TLS certificate.
Tags
Found an issue?