Skip to main content

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.yaml and then kubectl 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)
Warning

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.

Tip

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: Local on 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: Local sends in-cluster traffic only to pods on the same node, which suits node-local agents.
  • trafficDistribution: PreferSameZone (or PreferSameNode, stable since 1.35) keeps traffic in the client's zone or node when possible, which cuts cross-zone costs. The older value PreferClose is deprecated in favor of PreferSameZone.
  • spec.externalIPs is 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 endpointslices with 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.
  • ClusterIP is internal, NodePort opens a port on every node, LoadBalancer asks 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

Next, in Part 6, you configure podinfo without rebuilding its image and give it a TLS certificate.

Updated:

Found an issue?