Skip to content

Implementing Kubernetes Gateway API using AWS Load Balancer Controller - part I: L4 (NLBGatewayAPI)

10 minute read
Content level: Expert
0

The open-source AWS Load Balancer Controller supports Gateway API in GA (General Available) since v3.0.0.

This two-part article series provides a comprehensive example of installing AWS Load Balancer Controller (LBC) with Gateway API support, using Pod Identity and two sample implementations using L4 (NLBGatewayAPI) and L7 ((ALBGatewayAPI) routing and sample AWS CLI and kubectl outputs for reference.

Background

The open-source AWS Load Balancer Controller supports Gateway API in GA (General Available) since v3.0.0.

This fact and customer demand for implementing a Gateway API using AWS Load Balancer Controller (LBC), led to the creation of this article.

Prerequisites

To follow this post you need a running EKS cluster with EKS managed add-on eks-pod-identity-agent with a recent version and uses the LBC Helm chart app version v2.17.1.

Create the AWS service account using Pod Identity

The following steps show the necessary steps to create all IAM and Pod Identity related resources for the LBC service account (SA) aws-load-balancer-controller. If you want to use a different SA, please change the following examples accordingly.

# Download the IAM policy document
$ curl -sSLO https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.17.1/docs/install/iam_policy.json

# create the IAM policy
$ aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam_policy.json
{
    "Policy": {
        "PolicyName": "AWSLoadBalancerControllerIAMPolicy",
        "PolicyId": "ANPA<redacted>",
        "Arn": "arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy",
        "Path": "/",
        "DefaultVersionId": "v1",
        "AttachmentCount": 0,
        "PermissionsBoundaryUsageCount": 0,
        "IsAttachable": true,
        "CreateDate": "2026-03-12T10:54:21+00:00",
        "UpdateDate": "2026-03-12T10:54:21+00:00"
    }
}

# Create the trust policy for the IAM role
$ cat aws-lb-ctlr-trust-policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "pods.eks.amazonaws.com"
      },
      "Action": [
        "sts:AssumeRole",
        "sts:TagSession"
      ]
    }
  ]
}

# Create an IAM role called AWSLbCtlrRole
$ aws iam create-role --role-name AWSLbCtlrRole --assume-role-policy-document file://aws-lb-ctlr-trust-policy
{
    "Role": {
        "Path": "/",
        "RoleName": "AWSLbCtlrRole",
        "RoleId": "AROA<redacted>",
        "Arn": "arn:aws:iam::<redacted>:role/AWSLbCtlrRole",
        "CreateDate": "2026-03-12T10:59:46+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "pods.eks.amazonaws.com"
                    },
                    "Action": [
                        "sts:AssumeRole",
                        "sts:TagSession"
                    ]
                }
            ]
        }
    }
}

# Attach the previously downloaded IAM policy to the role
$ aws iam attach-role-policy --role-name AWSLbCtlrRole --policy-arn arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy

$ aws iam list-attached-role-policies  --role-name  AWSLbCtlrRole
{
    "AttachedPolicies": [
        {
            "PolicyName": "AWSLoadBalancerControllerIAMPolicy",
            "PolicyArn": "arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy"
        }
    ]
}

# create a Pod Identity association for the SA
$ aws eks create-pod-identity-association --cluster-name <cluster name>-demo --namespace kube-system --service-account aws-load-balancer-controller --role-arn arn:aws:iam::<redacted>:role/AWSLbCtlrRole
{
    "association": {
        "clusterName": "karpenter-demo",
        "namespace": "kube-system",
        "serviceAccount": "aws-load-balancer-controller",
        "roleArn": "arn:aws:iam::<redacted>:role/AWSLbCtlrRole",
        "associationArn": "arn:aws:eks:eu-west-1:618668373247:podidentityassociation/<cluster name>/a-<redacted>",
        "associationId": "a-<redacted>",
        "tags": {},
        "createdAt": "2026-03-12T12:03:25.017000+01:00",
        "modifiedAt": "2026-03-12T12:03:25.017000+01:00",
        "disableSessionTags": false
    }
}

AWS Load Balancer Controller installation

Gateway API support for LBC is not enabled by default and has to be enabled by turning on the respective featureGates.

The following Helm values.yaml file is used for customisation of the installation :

clusterName: <cluster name>
serviceAccount:
  create: true
  name: aws-load-balancer-controller
# controllerConfig specifies controller configuration
controllerConfig:
  # featureGates set of key: value pairs that describe AWS load balance controller features
  featureGates:
    NLBGatewayAPI: true
    ALBGatewayAPI: true
# Set the controller log level - info(default), debug (default "info")
# logLevel: debug
# the following section is needed if AL2023 and Karpenter are used, which by default block IMDS access for pods
# The AWS region for the kubernetes cluster.
region: <region>
# The VPC ID for the Kubernetes cluster.
vpcId: <vpc-id>

Installation is done using the Helm chart as follows:

$ helm install aws-load-balancer-controller eks/aws-load-balancer-controller  -n kube-system --set clusterName=karpenter-demo --set serviceAccount.create=true --set serviceAccount.name=aws-load-balancer-controller
NAME: aws-load-balancer-controller
LAST DEPLOYED: Thu Mar 12 12:10:18 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!

$ helm list -n kube-system                             
NAME                        	NAMESPACE  	REVISION	UPDATED                             	STATUS  	CHART                             APP VERSION
aws-load-balancer-controller	kube-system	1       	2026-03-12 12:10:18.718696 +0100 CET	deployed	aws-load-balancer-controller-1.17   v2.17.1    

# check that the deployment is running
$ kubectl get deploy -n kube-system aws-load-balancer-controller 
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
aws-load-balancer-controller   2/2     2            2           40s 

$ kubectl get po -n kube-system -l=app.kubernetes.io/name=aws-load-balancer-controller
NAME                                            READY   STATUS    RESTARTS   AGE
aws-load-balancer-controller-856755cd78-mm54g   1/1     Running   0          114s
aws-load-balancer-controller-856755cd78-tp74g   1/1     Running   0          114s

Quickstart using AWS Load Balancer Controller as a Kubernetes Gateway AP

According to the LBC documentation v3.1.0 the Gateway API implementation supports the following Gateway API routes:

  • L4 (NLBGatewayAPI): UDPRoute, TCPRoute, TLSRoute >=v2.13.3
  • L7 (ALBGatewayAPI): HTTPRoute, GRPCRoute >= 2.14.0 The LBC is built for Gateway API version v1.3.0.

For the core CustomeResourceDefinitions (CRD) and the basic idea behind Gateway API read upstream doc Introduction.

Gateway API v1 CRD consists of the following CRD:

$ kubectl api-resources | grep -w gateway.networking.k8s.io/v1
backendtlspolicies                  btlspolicy             gateway.networking.k8s.io/v1           true         BackendTLSPolicy
gatewayclasses                      gc                     gateway.networking.k8s.io/v1           false        GatewayClass
gateways                            gtw                    gateway.networking.k8s.io/v1           true         Gateway
grpcroutes                                                 gateway.networking.k8s.io/v1           true         GRPCRoute
httproutes                                                 gateway.networking.k8s.io/v1           true         HTTPRoute

Common configurations

LBC utilises upstream Gateway API CRD to implement the AWS specific configurations of the provisioned Elastic Load Balancers (ELB), which are reached v1beta1 API status.

Note: LBC Gateway API implementation does not support an annotation based approach!

$ kubectl api-resources | grep gateway.k8s.aws/v1beta1
listenerruleconfigurations                                 gateway.k8s.aws/v1beta1                true         ListenerRuleConfiguration
loadbalancerconfigurations                                 gateway.k8s.aws/v1beta1                true         LoadBalancerConfiguration
targetgroupconfigurations                                  gateway.k8s.aws/v1beta1                true         TargetGroupConfiguration

I use a slightly modified version of Kubernetes backend deployment and service as described in re:Post article Implementing Kubernetes Gateway API in EKS Auto Mode using Envoy Gateway with the following YAML: quickstart.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: backend
---
apiVersion: v1
kind: Service
metadata:
  name: backend
  labels:
    app: backend
    service: backend
spec:
  ports:
    - name: http
      port: 3000
      targetPort: 3000
  selector:
    app: backend
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend
      version: v1
  template:
    metadata:
      labels:
        app: backend
        version: v1
    spec:
      serviceAccountName: backend
      containers:
        - image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
          imagePullPolicy: IfNotPresent
          name: backend
          ports:
            - containerPort: 3000
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
---

Create a custom LoadBalancerConfiguration to modify the ELB scheme from default internal to internet-facing !!!

lbconfig.yaml

apiVersion: gateway.k8s.aws/v1beta1
kind: LoadBalancerConfiguration
metadata:
  name: internet-facing-config
spec:
  scheme: internet-facing

and apply it to the cluster:

$ kubectl apply -f lbconfig.yaml
loadbalancerconfiguration.gateway.k8s.aws/internet-facing-config created

Create a custom TargetGroupConfiguration to modify the targetType for (K8s) service backend from default instance to ip !!! tgconfig.yaml

apiVersion: gateway.k8s.aws/v1beta1
kind: TargetGroupConfiguration
metadata:
  name: custom-tg-config
spec:
  targetReference:
    name: backend
  defaultConfiguration:
    targetType: ip

and apply it to the cluster:

$ kubectl apply -f tgconfig.yaml 
targetgroupconfiguration.gateway.k8s.aws/custom-tg-config created

L4 (NLBGatewayAPI) routing.

LBC comes with two controllers. L4 routing uses the controller gateway.k8s.aws/nlb and provisions NLB.

First create the GatewayClass: gwc-nlb.yaml

apiVersion: gateway.networking.k8s.io/v1beta1
kind: GatewayClass
metadata:
  name: aws-nlb-gateway-class
spec:
  controllerName: gateway.k8s.aws/nlb

apply it to the cluster and check that config is ACCEPTED: true:

$ kubectl get gc aws-nlb-gateway-class
NAME                    CONTROLLER            ACCEPTED   AGE
aws-nlb-gateway-class   gateway.k8s.aws/nlb   True       8h

Now create the Gateway resource which references the LoadBalancerConfiguration internet-facing-config we created earlier: gw-public-nlb.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: aws-tcp-gateway
spec:
  gatewayClassName: aws-nlb-gateway-class
  infrastructure:
    parametersRef:
      kind: LoadBalancerConfiguration
      name: internet-facing-config
      group: gateway.k8s.aws
  listeners:
  - name: tcp-app
    protocol: TCP
    port: 8080
    allowedRoutes:
      namespaces:
        from: Same

apply it to the cluster and check the ADDRESS. A NLB will be provisioned, which takes some time until PROGRAMMED shows True:

$ kubectl apply -f gw-public-nlb.yaml 
gateway.gateway.networking.k8s.io/aws-tcp-gateway created

$ kubectl get gateway aws-tcp-gateway
NAME              CLASS                   ADDRESS                                                                        PROGRAMMED   AGE
aws-tcp-gateway   aws-nlb-gateway-class   k8s-default-awstcpga-<redacted1>-<redacted>.elb.eu-west-1.amazonaws.com   Unknown      11s

One can check the NLB using the following AWS elbv2 commands for reference:

# ELB is an internet-facing NLB (Type: network)
$ aws elbv2 describe-load-balancers --names k8s-default-awstcpga-<redacted1>
{
    "LoadBalancers": [
        {
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>",
            "DNSName": "k8s-default-awstcpga-<redacted1>-<redacted2>.elb.eu-west-1.amazonaws.com",
            "CanonicalHostedZoneId": "<redacted>",
            "CreatedTime": "2026-03-16T08:55:36.371000+00:00",
            "LoadBalancerName": "k8s-default-awstcpga-<redacted1>",
            "Scheme": "internet-facing",
            "VpcId": "vpc-<redacted>",
            "State": {
                "Code": "provisioning"
            },
            "Type": "network",
...

This NLB does not yet have a listener provisioned, even though it is referenced already as spec.listeners.name: tcp-app, because this still requires a TCPRoutes resource:

$ aws elbv2 describe-listeners --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>
{
    "Listeners": []
}

Note the API version of TCPRoutes (and UDPRoutes), which is still v1aplpha2` and subject to change !!!

$ kubectl api-resources | grep tcproute
tcproutes                                                  gateway.networking.k8s.io/v1alpha2     true         TCPRoute

$ kubectl api-resources | grep gateway.networking.k8s.io/v1alpha2
tcproutes                                                  gateway.networking.k8s.io/v1alpha2     true         TCPRoute
udproutes                                                  gateway.networking.k8s.io/v1alpha2     true         UDPRoute

The TCPRoutes resource YAML looks like: tcproute.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
  name: tcp-backend-aws
spec:
  parentRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: aws-tcp-gateway
    sectionName: tcp-app # Refers to the specific listener on the Gateway
  rules:
    - backendRefs:
        - name: backend
          port: 3000

$ kubectl apply -f tcproute.yaml
tcproute.gateway.networking.k8s.io/tcp-backend-aws created

$ kubectl get tcproutes.gateway.networking.k8s.io 
NAME              AGE
tcp-backend-aws   5s

Now check the NLB for related AWS resources listener, rules, target groups and target health:

# describe NLB listener, which listens on TCP port 8080 and has a FORWARD config with a target group
$ aws elbv2 describe-listeners --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>
{
    "Listeners": [
        {
            "ListenerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>",
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>",
            "Port": 8080,
            "Protocol": "TCP",
            "DefaultActions": [
                {
                    "Type": "forward",
                    "TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
                    "Order": 1,
                    "ForwardConfig": {
                        "TargetGroups": [
                            {
                                "TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
                                "Weight": 1
                            }
                        ],
                        "TargetGroupStickinessConfig": {
                            "Enabled": false
                        }
                    }
                }
            ]
        }
    ]
}

# describe NLB listener forward rule to target group
$ aws elbv2 describe-rules --listener-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>
{
    "Rules": [
        {
            "RuleArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener-rule/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>/<redacted4>",
            "Priority": "default",
            "Conditions": [],
            "Actions": [
                {
                    "Type": "forward",
                    "TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
                    "Order": 1,
                    "ForwardConfig": {
                        "TargetGroups": [
                            {
                                "TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
                                "Weight": 1
                            }
                        ],
                        "TargetGroupStickinessConfig": {
                            "Enabled": false
                        }
                    }
                }
            ],
            "IsDefault": true,
            "Transforms": []
        }
    ]
}

# describe NLB target group, which is of targetType: ip and is using targets from backend service on TCP port 3000
$ aws elbv2 describe-target-groups --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted>/<redacted>
{
    "TargetGroups": [
        {
            "TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
            "TargetGroupName": "k8s-default-tcpbacke-<redacted>",
            "Protocol": "TCP",
            "Port": 3000,
            "VpcId": "vpc-<redacted>",
            "HealthCheckProtocol": "TCP",
            "HealthCheckPort": "traffic-port",
            "HealthCheckEnabled": true,
            "HealthCheckIntervalSeconds": 15,
            "HealthCheckTimeoutSeconds": 5,
            "HealthyThresholdCount": 3,
            "UnhealthyThresholdCount": 3,
            "LoadBalancerArns": [
                "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted>/<redacted>"
            ],
            "TargetType": "ip",
            "IpAddressType": "ipv4"
        }
    ]
}

# targets are the backend deployment pod(s)
$ aws elbv2 describe-target-health  --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>
{
    "TargetHealthDescriptions": [
        {
            "Target": {
                "Id": "192.168.165.118",
                "Port": 3000,
                "AvailabilityZone": "eu-west-1c"
            },
            "HealthCheckPort": "3000",
            "TargetHealth": {
                "State": "healthy"
            },
            "AdministrativeOverride": {
                "State": "no_override",
                "Reason": "AdministrativeOverride.NoOverride",
                "Description": "No override is currently active on target"
            }
        }
    ]
}

# backend K8s pod
$ kubectl get pod backend-86c6c76f-vwbxt -o wide
NAME                     READY   STATUS    RESTARTS   AGE    IP                NODE                                           NOMINATED NODE   READINESS GATES
backend-86c6c76f-vwbxt   1/1     Running   0          4d8h   192.168.165.118   ip-<redacted>.eu-west-1.compute.internal   <none>           <none>

Finally create a Route53 alias record pointing to the NLB, check it and use curl to generate some traffic:

$ aws route53 list-resource-record-sets --hosted-zone-id <public hosted zoneID> --query 'ResourceRecordSets[?Name==`aws-k8s-gw-nlb.<redacted domain name>`]'
[
    {
        "Name": "aws-k8s-gw-nlb.<redacted domain name>",
        "Type": "A",
        "AliasTarget": {
            "HostedZoneId": "Z2IFOLAFXWLO4F",
            "DNSName": "k8s-default-awstcpga-<redacted1>-<redacted2>.elb.eu-west-1.amazonaws.com.",
            "EvaluateTargetHealth": true
        }
    }
]

$ dig +short aws-k8s-gw-nlb.<redacted domain name>
<public NLB IP>

The NLB GW was created with listeners on port 8080 !!!
$ curl -s -o /dev/null -w "%{http_code}\n" aws-k8s-gw-nlb.<redacted>:8080
200

$ curl aws-k8s-gw-nlb.<redacted domain name>:8080
{
 "path": "/",
 "host": "aws-k8s-gw-nlb.<redacted domain name>:8080",
 "method": "GET",
 "proto": "HTTP/1.1",
 "headers": {
  "Accept": [
   "*/*"
  ],
  "User-Agent": [
   "curl/8.7.1"
  ]
 },
 "namespace": "default",
 "ingress": "",
 "service": "",
 "pod": "backend-86c6c76f-vwbxt"
}

This completes the "L4 (NLBGatewayAPI)" part of this rePost series.

Part II will cover "L7 (ALBGatewayAPI)".

AWS
EXPERT
published 2 months ago494 views