DevOps ·

Kubernetes Networking Explained: Services, Ingress, Network Policies, and DNS Resolution

Kubernetes networking has a flat model and a surprisingly rich set of abstractions built on top of it. This guide covers the pod network model, all four Service types, Ingress controllers, Network Policies, CoreDNS, and the debugging techniques you reach for when traffic stops flowing.

Kubernetes Networking Explained: Services, Ingress, Network Policies, and DNS Resolution

Kubernetes networking trips people up because the abstractions compound. You add a Service on top of a pod network, an Ingress on top of a Service, and a NetworkPolicy on top of all of it. When something breaks, you have four layers to interrogate and most guides only explain one of them at a time.

This article covers all of it: the pod network model, every Service type, Ingress and IngressClass, NetworkPolicy for segmentation, CoreDNS for service discovery, and the debugging workflow you use when packets stop arriving.

The Pod Network Model

Every pod in a Kubernetes cluster gets its own IP address. Not every pod on a node shares a node IP through NAT. Every pod has a routable IP. This is the foundational assumption everything else builds on.

The consequence is that pods can communicate directly with each other across nodes without any NAT translation. A pod on node A with IP 10.244.1.5 can open a TCP connection to a pod on node B with IP 10.244.2.12 and the source address seen by the destination is the real pod IP, not the node IP.

Kubernetes does not implement this itself. It delegates to a Container Network Interface (CNI) plugin. Flannel uses VXLAN overlays. Calico uses BGP to distribute routes. Cilium uses eBPF to intercept and redirect packets in the kernel. The choice of CNI affects performance, NetworkPolicy support, and observability, but the flat IP model is the guarantee every CNI must satisfy.

Pods are ephemeral. Their IPs change on restart. You never hardcode a pod IP in application code. This is exactly why Services exist.

Service Types

A Service gives you a stable virtual IP (the ClusterIP) that load balances across a set of pods. The pods in scope are selected by label.

ClusterIP

ClusterIP is the default. It creates a virtual IP reachable only from inside the cluster. kube-proxy programs iptables (or IPVS) rules on every node so that traffic to the ClusterIP gets DNAT’d to one of the backing pod IPs.

apiVersion: v1
kind: Service
metadata:
  name: payments-api
  namespace: billing
spec:
  selector:
    app: payments-api
  ports:
    - name: http
      port: 80
      targetPort: 3000
  type: ClusterIP

The selector app: payments-api matches any pod with that label. Add a pod with the label and it immediately receives traffic. Remove the label or delete the pod and it stops receiving traffic within seconds, as the Endpoints controller reconciles.

ClusterIP is the right type for service-to-service communication inside the cluster. It is not reachable from outside the cluster.

NodePort

NodePort opens a port (30000-32767 by default) on every node in the cluster and forwards traffic from that port to the Service’s backing pods.

apiVersion: v1
kind: Service
metadata:
  name: payments-api
  namespace: billing
spec:
  selector:
    app: payments-api
  ports:
    - name: http
      port: 80
      targetPort: 3000
      nodePort: 31080
  type: NodePort

Traffic arriving at <any-node-ip>:31080 gets forwarded to the payments-api pods regardless of which node the traffic lands on. The forwarding is handled by kube-proxy on each node.

NodePort is rarely the right long-term solution. You expose node IPs to external traffic, which is a security surface. You also depend on knowing node IPs, which change when nodes are replaced. It is useful for local development with tools like minikube or kind, and sometimes for debugging production issues when you need to bypass an Ingress.

LoadBalancer

LoadBalancer extends NodePort by additionally provisioning an external load balancer through the cloud provider. On AWS this creates an ELB or NLB. On GCP it creates a Cloud Load Balancer. The load balancer gets a public IP and forwards traffic to the NodePort on the cluster nodes.

apiVersion: v1
kind: Service
metadata:
  name: payments-api
  namespace: billing
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: nlb
    service.beta.kubernetes.io/aws-load-balancer-internal: "false"
spec:
  selector:
    app: payments-api
  ports:
    - name: https
      port: 443
      targetPort: 3000
  type: LoadBalancer

The cloud provider provisions the load balancer asynchronously and populates status.loadBalancer.ingress with the external IP or hostname once it is ready.

The cost: one LoadBalancer Service typically maps to one cloud load balancer. At $15-20 per month per NLB on AWS, this adds up if you have many services that need external exposure. For HTTP traffic, Ingress is almost always the better answer because one load balancer can route to many services.

ExternalName

ExternalName maps a Service name to a DNS name outside the cluster. No proxying happens. The cluster DNS returns a CNAME pointing to the external name.

apiVersion: v1
kind: Service
metadata:
  name: legacy-payments
  namespace: billing
spec:
  type: ExternalName
  externalName: payments.legacy.internal.example.com

Pods in the cluster that resolve legacy-payments.billing.svc.cluster.local get back the CNAME for payments.legacy.internal.example.com. The actual connection goes directly to that address. This is useful during migrations when you want to route traffic to an external system using the same Service name your application already uses.

Ingress Controllers and IngressClass

An Ingress resource is a set of routing rules for HTTP and HTTPS traffic. It specifies which hostnames and paths should route to which Services. The Ingress resource itself is inert: you need an Ingress controller to read those rules and configure an actual reverse proxy.

Common controllers: nginx-ingress (the NGINX one from the community), ingress-nginx (the one maintained by the Kubernetes project itself, confusingly similar name), the AWS Load Balancer Controller, the GKE Ingress controller, Traefik, and Contour. They differ significantly in feature set and operational model.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payments-ingress
  namespace: billing
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - payments.example.com
      secretName: payments-tls
  rules:
    - host: payments.example.com
      http:
        paths:
          - path: /api/v1
            pathType: Prefix
            backend:
              service:
                name: payments-api
                port:
                  number: 80
          - path: /webhooks
            pathType: Prefix
            backend:
              service:
                name: webhook-processor
                port:
                  number: 80

The ingressClassName field selects which controller handles this Ingress. This replaced the older kubernetes.io/ingress.class annotation in Kubernetes 1.18+. If you have multiple Ingress controllers in the cluster (nginx for internal traffic, the AWS LBC for public traffic), ingressClassName determines which one picks up each Ingress resource.

The tls block references a Secret of type kubernetes.io/tls. You can provision these with cert-manager, which integrates with Let’s Encrypt and cloud certificate managers to handle rotation automatically.

Path matching behavior varies by controller. With nginx-ingress, Prefix matching is exact prefix on the path component. Exact requires an exact match. ImplementationSpecific defers to the controller’s own rules. The path type matters when you have overlapping prefixes.

Network Policies

By default, every pod can talk to every other pod in the cluster across any namespace. Network Policies change that. A NetworkPolicy selects a set of pods and defines what ingress (inbound) and egress (outbound) traffic is allowed.

NetworkPolicy is enforced by the CNI plugin, not by kube-proxy or the API server. If your CNI does not support NetworkPolicy (Flannel without Canal, for instance), the resources will be accepted by the API server but silently ignored. Calico, Cilium, and Weave all support it.

A policy that allows the payments-api to receive traffic only from the API gateway and the billing worker, on port 3000:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payments-api-ingress
  namespace: billing
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: api-gateway
        - podSelector:
            matchLabels:
              app: billing-worker
      ports:
        - protocol: TCP
          port: 3000

Once any NetworkPolicy selects a pod, all traffic not explicitly allowed is denied. If you add a policy that only allows ingress, egress from that pod is still unrestricted unless you also add an egress policy with policyTypes: [Egress].

A common pattern is a default-deny policy for a namespace, then explicit allow policies per service:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: billing
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

The empty podSelector matches all pods in the namespace. This denies all ingress and egress for every pod. Then you add explicit allow policies layered on top.

Cross-namespace traffic requires a namespaceSelector. Allow the monitoring namespace to scrape metrics from pods in the billing namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-prometheus-scrape
  namespace: billing
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
          podSelector:
            matchLabels:
              app: prometheus
      ports:
        - protocol: TCP
          port: 9090

Note the indentation: namespaceSelector and podSelector under the same from list item means “pods matching this podSelector AND in namespaces matching this namespaceSelector”. Two separate list items would mean “pods matching this selector OR pods in namespaces matching that selector”. The difference is significant for security.

CoreDNS and Service Discovery

CoreDNS is the cluster DNS server. Every pod’s /etc/resolv.conf points to the CoreDNS ClusterIP (typically 10.96.0.10) with a search domain list like billing.svc.cluster.local svc.cluster.local cluster.local.

A Service named payments-api in the billing namespace is reachable at:

payments-api.billing.svc.cluster.local

Because of the search domains, pods in the same namespace can use just payments-api. Pods in other namespaces need at least payments-api.billing.

The DNS record types:

  • ClusterIP Services get an A record pointing to the virtual IP
  • Headless Services (ClusterIP: None) get A records pointing to individual pod IPs, one per ready pod
  • ExternalName Services get a CNAME record
  • Services with named ports get SRV records: _http._tcp.payments-api.billing.svc.cluster.local

Headless Services are useful when your application manages its own load balancing or needs to discover all pod IPs directly. StatefulSets use them to give each pod a stable DNS name: payments-worker-0.payments-worker.billing.svc.cluster.local.

Service Discovery in Application Code

In TypeScript, a service client that resolves via cluster DNS and respects Kubernetes-style health:

import { createConnection } from "net";

interface ServiceEndpoint {
  host: string;
  port: number;
}

function resolveService(name: string, namespace: string): ServiceEndpoint {
  // In-cluster: use short name for same-namespace, full name across namespaces
  const host = `${name}.${namespace}.svc.cluster.local`;
  return { host, port: 80 };
}

async function fetchWithRetry(
  endpoint: ServiceEndpoint,
  path: string,
  maxRetries = 3
): Promise<Response> {
  const url = `http://${endpoint.host}:${endpoint.port}${path}`;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        signal: AbortSignal.timeout(5000),
      });

      if (response.ok || response.status < 500) {
        return response;
      }

      // 5xx: retry
      if (attempt === maxRetries - 1) {
        throw new Error(`Service ${endpoint.host} returned ${response.status}`);
      }
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
      // Exponential backoff: 100ms, 200ms, 400ms
      await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
    }
  }

  throw new Error("unreachable");
}

// Usage
const paymentsEndpoint = resolveService("payments-api", "billing");
const result = await fetchWithRetry(paymentsEndpoint, "/api/v1/charge");

For services that use environment variable injection (the older Kubernetes mechanism before DNS was reliable), Kubernetes injects PAYMENTS_API_SERVICE_HOST and PAYMENTS_API_SERVICE_PORT automatically for any Service that exists when the pod starts. DNS is preferred: it handles Services created after the pod starts, and the variable names are not guaranteed to remain stable as naming conventions evolve.

Tradeoffs

DimensionClusterIPNodePortLoadBalancerIngress
External accessNoYes (node IP)Yes (dedicated IP)Yes (shared IP)
CostFreeFreeOne LB per ServiceOne LB for many Services
Protocol supportAnyAnyAnyHTTP/HTTPS only
TLS terminationApplicationApplicationLB or applicationController or application
Path-based routingNoNoNoYes
Operational complexityLowLowMediumMedium to high

Debugging Techniques

Verify Endpoints

The first thing to check when a Service does not route traffic is whether it has any Endpoints. A Service with no Endpoints means either no pods match the selector, or no matching pods pass their readiness probe.

kubectl get endpoints payments-api -n billing
# NAME           ENDPOINTS            AGE
# payments-api   10.244.1.5:3000     5m

kubectl describe service payments-api -n billing
# Check Selector and Endpoints sections

If Endpoints is empty, check pod labels match the Service selector exactly and that pods are Ready.

DNS Resolution

From inside the cluster, use a debug pod to test DNS:

kubectl run debug --image=nicolaka/netshoot --rm -it --restart=Never -- \
  nslookup payments-api.billing.svc.cluster.local

If the name resolves but the Service is unreachable, the problem is in routing or NetworkPolicy, not DNS. If the name does not resolve, CoreDNS may be down or misconfigured. Check CoreDNS pod status in the kube-system namespace and look at its logs.

Connectivity Testing

Test TCP connectivity directly from a pod inside the cluster:

kubectl exec -it <pod-name> -n billing -- \
  curl -v http://payments-api:80/healthz

# Or test raw TCP
kubectl exec -it <pod-name> -n billing -- \
  nc -zv payments-api 80

For diagnosing NetworkPolicy issues, packet capture is the most reliable tool. Cilium and Calico have their own policy tracing commands. For raw packet inspection, tcpdump from inside the target pod’s namespace (using a privileged debug container):

kubectl debug -it <pod-name> -n billing \
  --image=nicolaka/netshoot \
  --target=payments-api \
  -- tcpdump -i eth0 port 3000

Then from another terminal, send a request to the Service and watch whether packets arrive at the pod. If the Service has Endpoints but packets never arrive at the pod, NetworkPolicy is blocking them. If packets arrive but the pod is not responding, the problem is in the application.

Ingress Debugging

Ingress problems usually fall into three categories: the controller cannot find the backend Service, TLS certificate issues, or path matching that does not work as expected.

# Check Ingress status
kubectl describe ingress payments-ingress -n billing

# Check controller logs (nginx-ingress example)
kubectl logs -n ingress-nginx \
  deployment/ingress-nginx-controller \
  --tail=100 | grep payments

# Verify the TLS secret exists and has the right keys
kubectl get secret payments-tls -n billing -o jsonpath='{.data}' | \
  python3 -c "import sys,json,base64; d=json.load(sys.stdin); print(list(d.keys()))"

For path matching issues, test directly against the controller’s pod IP bypassing DNS to isolate whether the problem is in routing rules or upstream DNS:

kubectl get pods -n ingress-nginx -o wide
# Note the pod IP

kubectl run debug --image=nicolaka/netshoot --rm -it --restart=Never -- \
  curl -H "Host: payments.example.com" http://<controller-pod-ip>/api/v1/test

Production Considerations

Readiness gates over readiness probes for zero-downtime deployments. Standard readiness probes only check whether the application is alive. Pod Readiness Gates let you gate readiness on external conditions, like whether the pod’s Endpoints entry has propagated to all nodes. Without this, traffic can arrive at a pod before kube-proxy has updated its iptables rules.

NetworkPolicy in CI, not just production. NetworkPolicy violations fail silently at the application layer: the pod just sees a connection timeout. Testing NetworkPolicy rules in a CI environment (using kind with Calico) catches misconfigurations before they cause production incidents.

CoreDNS cache TTL tuning. The default cache TTL in CoreDNS is 30 seconds. For rapidly changing endpoints, this can cause stale resolution. For most workloads it is fine. If you see DNS-related flakiness during deployments, check whether your application is caching DNS results longer than the cluster TTL.

IPVS mode for kube-proxy at scale. The default iptables mode creates one rule per Service-to-pod mapping. At 10,000 Services this becomes a performance problem: iptables rule traversal is O(n). IPVS mode uses kernel hash tables and is O(1). Enable it in the kube-proxy ConfigMap under mode: ipvs.

Egress NetworkPolicy for DNS. If you apply a default-deny-egress policy, pods cannot resolve DNS. You must explicitly allow egress to port 53 on the CoreDNS ClusterIP. This is the most common mistake when first applying network segmentation.

# Allow DNS egress for all pods in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: billing
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

The Kubernetes networking model is simple at its core: every pod gets an IP, all pod IPs are routable without NAT. Every abstraction above that, Services, Ingress, NetworkPolicy, DNS, is layered deliberately on top of that guarantee. When traffic breaks, you trace it layer by layer from pod IP to Endpoints to Service to Ingress controller to DNS, and the problem is almost always a missing label, a missing policy exception, or a controller that was never configured to read the resource.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.