~/ nmadiraju.com Amazon EKS · Why Ingress exists
arrow_back Back to nmadiraju
Amazon EKS · Kubernetes networking

Why Ingress exists: follow one packet from a Pod to an ALB on Amazon EKS

Service, NodePort, LoadBalancer and Ingress are easy to define and easy to forget, because definitions skip the reason each one exists. This page starts from the failure that forces each object, then follows a single packet through one EKS cluster and shows where its destination address gets rewritten along the way.

bolt TL;DR

Pod IPs on EKS are real VPC addresses, but every replacement pod gets a new one. A Service gives the pods one stable name and a virtual IP, which kube-proxy rewrites to a ready pod. That virtual IP stops at the cluster edge, so a NodePort opens a port on every node and type: LoadBalancer puts an NLB in front. One NLB per app is costly and can't route by path, so an Ingress holds HTTP rules for many Services, and on EKS the AWS Load Balancer Controller turns those rules into one ALB. The call for HTTP on EKS: ClusterIP Services behind one ALB Ingress with target-type: ip, and a per-Service NLB only for non-HTTP traffic.

One EKS cluster, built up step by step, with a packet tracing each request The geometry never changes. Each step adds the objects it introduces: Service and kube-proxy, then a NodePort, an NLB per Service, an Ingress, and the AWS Load Balancer Controller that builds one ALB. A packet travels each step's path, and its destination label changes where kube-proxy or a load balancer rewrites it. Client laptop · internet VPC · 10.0.0.0/16 PUBLIC SUBNETS PRIVATE SUBNETS · pods get VPC IPs node-1 · EC2 · 10.0.1.10 node-2 · EC2 · 10.0.2.10 kube-proxy iptables DNAT rules kube-proxy iptables DNAT rules web pod 10.0.1.22 calls shop shop-7d9f-a 10.0.1.15 app=shop · :8080 api-5c8b-a 10.0.1.31 app=api · :8080 CoreDNS kube-system answers *.svc names shop-7d9f-b 10.0.2.31 app=shop · :8080 api-5c8b-b 10.0.2.44 app=api · :8080 LB Controller kube-system watches Ingress NLB · shop TCP :80 1 per Service NLB · api TCP :80 another DNS name ALB HTTP :80 / :443 / → shop /api → api :30080 :30080 KUBERNETES API · EKS Deployments shop · replicas: 2 api · replicas: 2 Service shop ClusterIP 172.20.14.7 selector app=shop eps 10.0.1.47, 10.0.2.31 Service api ClusterIP 172.20.9.3 selector app=api Ingress shop class: alb / → shop:80 /api → api:80 ADDRESS: (empty) IngressClass alb ingress.k8s.aws/alb

request packet DNS lookup control-plane call address rewritten here pod or agent AWS resource object in the API (config, not a process)

Press Play to watch all ten steps, or step through with Back and Next. Red step tabs are where something breaks; green ones add the fix. Voice reads each step aloud with your browser's built-in speech.

1Inside the cluster: why a Service exists

On EKS the VPC CNI gives every pod a secondary IP from its node's subnet. Pod IPs are ordinary VPC addresses, so the web pod can call a shop pod at 10.0.1.15:8080 on another node with no overlay and no NAT. That works until the shop pod is replaced.

Pods are replaced, not repaired. Each of these creates a new pod with a new IP:

A container restart inside the same pod keeps its IP; a new pod never does. Anything that stored 10.0.1.15 now times out, and with two replicas the caller would also need to pick one and skip any that aren't ready.

A Service fixes both problems. Its selector, app: shop, finds the pods, and the EndpointSlice controller keeps a list of their IPs with a ready flag on each. The Service gets a fixed virtual IP, 172.20.14.7, and a DNS name that CoreDNS answers: shop.default.svc.cluster.local, or just shop from the same namespace.

No machine owns the ClusterIP. kube-proxy on every node turns the Service into iptables rules that say "a packet to 172.20.14.7:80 gets its destination rewritten to one ready endpoint on :8080". The rewrite (DNAT) happens on the node that sends the packet, which then crosses the VPC straight to the chosen pod IP. That's also why you can't ping a ClusterIP in iptables mode, the EKS default: only the Service's TCP or UDP ports are translated.

content_copyCopy
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop
spec:
  replicas: 2
  selector:
    matchLabels: { app: shop }
  template:
    metadata:
      labels: { app: shop }
    spec:
      containers:
      - name: shop
        image: shop:1.4
        ports:
        - containerPort: 8080
apiVersion: v1
kind: Service
metadata:
  name: shop
spec:
  selector: { app: shop }   # finds the pods
  ports:
  - port: 80                # the ClusterIP port
    targetPort: 8080        # the pod port
# type: ClusterIP is the default
$ kubectl get pods -l app=shop -o wide
NAME          READY  IP          NODE
shop-7d9f-a   1/1    10.0.1.47   node-1    # was 10.0.1.15 before the rollout
shop-7d9f-b   1/1    10.0.2.31   node-2

$ kubectl get svc shop
NAME  TYPE       CLUSTER-IP    PORT(S)
shop  ClusterIP  172.20.14.7   80/TCP

$ kubectl get endpointslices -l kubernetes.io/service-name=shop
NAME         ADDRESSTYPE  PORTS  ENDPOINTS
shop-x7k2p   IPv4         8080   10.0.1.47,10.0.2.31

Readiness is what makes this safe during deploys. A pod whose readiness probe fails stays in the EndpointSlice but is marked not ready, kube-proxy stops sending to it, and traffic shifts to the other replica.

warning

Always define a readiness probe. A pod without one counts as ready as soon as its containers start, so a rollout sends traffic to an app that's still booting.

2From outside: NodePort, then LoadBalancer

The ClusterIP range (172.20.0.0/16 on this cluster, 10.100.0.0/16 on others) isn't in any VPC route table. It exists only as rules on the nodes, so a client on the internet, or in another VPC, has no route to it. For debugging, kubectl port-forward svc/shop 8080:80 tunnels one connection through the API server, but that isn't a way to serve users.

NodePort is a building block, not a front door. Changing the Service to type: NodePort makes kube-proxy open the same port, from 30000–32767, on every node, including nodes that run no shop pod, and traffic to any node on :30080 is rewritten to a shop pod anywhere in the cluster. Clients would need node IPs that change as nodes come and go, a high port, and a security group that allows it, and EKS nodes usually sit in private subnets anyway.

Setting externalTrafficPolicy: Local changes two things: only nodes that run a shop pod answer, and the client's source IP survives.

type: LoadBalancer keeps the NodePort and asks a controller to put a cloud load balancer in front of it. Which load balancer you get depends on what's watching:

Prefer ip mode in production: it removes a hop and works for every compute type. The player uses instance mode only so you can see the NodePort doing its job.

content_copyCopy
spec:
  type: NodePort
  selector: { app: shop }
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080     # 30000-32767; omit it to get one assigned
apiVersion: v1
kind: Service
metadata:
  name: shop
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: external
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: instance   # prefer ip in production
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
spec:
  type: LoadBalancer
  selector: { app: shop }
  ports:
  - port: 80
    targetPort: 8080

3Many apps, one entry: Ingress and its controller

A LoadBalancer Service is fine for one app. When api needs to be public too, it gets a second NLB with its own DNS name. At twenty microservices that's twenty load balancers at roughly $16 a month each in us-east-1 before traffic, twenty DNS names and twenty places to configure TLS. An NLB also works at layer 4: it sees IPs and ports, never the URL path or the Host header, so it can't send /api to one Service and / to another.

Keep a per-Service NLB when the traffic isn't HTTP, such as raw TCP, databases or MQTT, or when you need static IPs at layer 4. For HTTP, use an Ingress.

An Ingress describes layer-7 routing for many Services: hosts, paths and TLS. Each backend still points at a Service, which the controller uses to find the pods, so the Services stay. They just go back to type: ClusterIP.

warning

An Ingress on its own does nothing. kubectl apply succeeds, the object is stored, and its ADDRESS stays empty. Kubernetes defines the Ingress API but ships no implementation, because the right one depends on where you run. ingressClassName names the controller that owns it.

On EKS that controller is usually the AWS Load Balancer Controller, a Deployment in kube-system that reconciles in a loop:

  1. Watch Ingresses with ingressClassName: alb, plus their Services and EndpointSlices.
  2. Work out the ALB they need: listeners, one rule per host and path, and a target group per backend Service.
  3. Call the Elastic Load Balancing API to create or update it, using IAM permissions from IAM Roles for Service Accounts (IRSA) or EKS Pod Identity.
  4. Keep the target groups in step as pods come and go, and write the ALB's DNS name into the Ingress ADDRESS.

Three settings decide how that ALB behaves:

warning

Without group.name, each Ingress gets its own ALB. Ten teams with ten Ingresses bring back the one-load-balancer-per-app problem this section set out to fix.

content_copyCopy
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
  - host: shop.example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend: { service: { name: api, port: { number: 80 } } }
      - path: /
        pathType: Prefix
        backend: { service: { name: shop, port: { number: 80 } } }
$ kubectl get ingress shop
NAME  CLASS  HOSTS             ADDRESS
shop  alb    shop.example.com  k8s-default-shop-9f8e.us-east-1.elb.amazonaws.com

$ curl -H 'Host: shop.example.com' \
    http://k8s-default-shop-9f8e.us-east-1.elb.amazonaws.com/api/cart
# ALB rule /api*  -> target group api  -> 10.0.1.31, 10.0.2.44
# ALB rule /*     -> target group shop -> 10.0.1.47, 10.0.2.31

4Which object fixes which failure

FailureObjectWhat actually does the work
Pod IP changes when the pod is replaced; two replicas to choose fromService (ClusterIP)CoreDNS answers a stable name; kube-proxy on every node DNATs the ClusterIP to a ready endpoint
ClusterIP isn't routable from outside the clustertype: NodePortkube-proxy opens the same port, 30000–32767, on every node
Clients shouldn't need node IPs or high portstype: LoadBalancerA controller creates one NLB per Service in front of the nodes or pods
One load balancer per app; no routing by pathIngressNothing on its own; it stores HTTP routing rules
The Ingress has no ADDRESSIngress controllerThe AWS Load Balancer Controller builds one ALB and registers pod IPs as targets
TypeReachable fromAddressLayer
ClusterIPPods and nodes in the clusterVirtual IP and name.namespace.svc.cluster.localL4
NodePortAnything that can reach a node IPAny node IP on a port from 30000–32767L4
LoadBalancerThe internet or the VPC, by schemeOne NLB DNS name per ServiceL4
Ingress (not a Service type)The internet or the VPC, by schemeOne ALB DNS name for many ServicesL7: host, path, TLS

5Debugging a 502 or 503 from the ALB

Walk the same chain backwards, from the pod out to the Ingress:

  1. Pods: are they running, and does the readiness probe pass?
  2. EndpointSlice: does kubectl get endpointslices -l kubernetes.io/service-name=api list ready IPs? An empty list usually means the Service selector doesn't match the pod labels.
  3. Target group: are the targets healthy in the ELB console, and does the health check path return 200?
  4. Security groups: can the ALB's security group reach the pod port, 8080 here, not just 80?
  5. Ingress: kubectl describe ingress shop shows the controller's events, including permission and annotation errors.

6What comes after Ingress

On a new platform, start with the Gateway API. On an existing one, Ingress keeps working and there's no rush to migrate: the Ingress API is stable and frozen, and new features go into the Gateway API instead.

The reason to move is ownership. Gateway API splits what Ingress mixes together: a platform team owns the Gateway (the load balancer itself), and app teams attach their own HTTPRoutes to it. The AWS Load Balancer Controller supports the Gateway API too, so the chain on this page stays the same: Pod, Service, routing rules, and a controller that builds the load balancer.

Conclusion

Each object in the chain exists because the one before it fails at something specific, so remembering the failure brings back the object. For HTTP on EKS, run ClusterIP Services behind one ALB Ingress with target-type: ip and a shared group.name, keep per-Service NLBs for non-HTTP traffic, and when something breaks, walk the chain in reverse from the pod out.

References

Assumptions for the player and the examples: Amazon EKS with the VPC CNI, kube-proxy in iptables mode, and the AWS Load Balancer Controller v2 (an NLB in instance mode for the LoadBalancer Service, target-type: ip on the Ingress). IPs, the ClusterIP range and names are examples. Compare with your own cluster using kubectl get svc,endpointslices,ingress -A.