CloudNet@ Team Study/EKS Workshop 4th Cohort

What is AWS LoadBalancer Controller?

"Everything about infra" 2026. 3. 25. 03:38
포스팅은 CloudNet@팀 서종호(Gasida)님이 진행하시는
AWS EKS Workshop Study 내용을 참고하여 작성합니다.

 

안녕하세요!

오늘은 EKS를 외부로 노출하기 위해

LoadBalancer Controller를 알아보는 시간을 갖도록 하겠습니다.

 

 

 

01. EKS NLB (Network LoadBalancer) 모드

NLB → EKS Pod까지 트래픽이 어떻게 흐르는지는 타겟 유형(인스턴스 vs IP)externalTrafficPolicy

설정에 따라 완전히 달라집니다. 이걸 이해하고 넘어가야 Client IP 보존 여부와 SNAT 동작을 예측할 수 있습니다.

 

1-1. 인스턴스 타겟 모드 (NodePort)

  • NLB가 노드의 NodePort로 트래픽을 보내는 방식입니다.
  • LoadBalancer Controller 없이도 동작하지만, externalTrafficPolicy 설정에 따라 동작이 갈립니다.

 

📌 externalTrafficPolicy: Cluster (default)

Cluster 모드의 핵심 문제는 SNAT 입니다.

NLB는 3개 노드에 고르게 트래픽을 뿌리는데, Node 2나 Node 3처럼 해당 파드가 없는 노드로 트래픽이 들어오면
iptables가 다른 노드의 파드로 다시 라우팅합니다. 이 과정에서 노드 IP로 SNAT이 발생해 '원래 Client IP'를 잃어버립니다.

DNAT이 두 번 발생하는 흐름을 정리하면 아래와 같습니다.
NLB가 자신의 IP → 노드 NodePort로 DNAT, 노드 iptables가 NodePort → 파드 IP로 DNAT
Node 2처럼 파드가 없는 노드에서 다른 노드로 건너갈 때는 SNAT까지 추가됩니다.

뭔가 와닿지 않아서 단계별로 도식화를 해보았습니다.

Node 2(파드 없음)가 SNAT을 할 수 밖에 없는 이유가 따로 있습니다.

만약 SNAT없이 src: 50.1.1.1을 그대로 Node 1(파드 있음)으로 보낸다고 가정해볼까요?

Node 1의 파드가 응답을 보낼 때 dst: 50.1.1.1이 되고, 이 응답 패킷은 Node 2를 전혀 거치지 않고 Client로 직접 날아갑니다.
그러면 Client 입장에서는 자기가 NLB에 요청을 보냈는데 엉뚱한 IP(Node 1)에서 응답이 오는 셈이 되어 TCP 연결이 깨집니다.

그래서 Node 2가 Node 1으로 패킷을 넘길 때 src를 Node2_IP 로 바꿔야(SNAT), 응답이 다시 Node 2로 돌아오고
→ Node 2가 NLB로 → NLB가 Client로 정상적으로 돌려줄 수 있게 됩니다.

즉, SNAT은 버그가 아니라 비대칭 라우팅 문제를 막기 위한 필연적인 동작이라고 보시면 됩니다.

이 SNAT 때문에 파드가 보는 src IP가 Node2 IP가 되어버려서 원래 Client IP인 50.1.1.1은 사라지게 됩니다.

 

📌 externalTrafficPolicy: Local (권장)

Local 모드에서는 두 가지 중요한 변화가 생깁니다.

첫째, iptables룰이 해당 노드에 파드가 없으면 다른 노드로 건너가지 않고 자기 노드의 파드로만 연결합니다.
덕분에 노드 간 SNAT이 사라지고 Client IP가 그대로 파드까지 전달됩니다.

둘째, NLB 헬스 체크가 파드가 없는 노드를 자동으로 실패 처리해서 그쪽으로는 트래픽 자체를 보내지 않습니다.
부하분산 최적화가 자동으로 되는 셈이죠.

 


서비스/파드 배포 테스트

📌 사전 준비 확인

Client IP를 응답으로 돌려주는 단순한 echo 서버를 사용합니다. (어느 파드가 요청을 받았는지 출력됨)

# echo-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: echo
  template:
    metadata:
      labels:
        app: echo
    spec:
      containers:
      - name: echo
        image: ealen/echo-server:latest
        ports:
        - containerPort: 80
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchLabels:
                app: echo
            topologyKey: kubernetes.io/hostname

Cluster 모드 Service도 같이 배포 해줍시다.

# svc-instance-cluster.yaml
apiVersion: v1
kind: Service
metadata:
  name: echo-instance-cluster
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
  type: LoadBalancer
  externalTrafficPolicy: Cluster
  selector:
    app: echo
  ports:
  - port: 80
    targetPort: 80

배포는 아래와 같이 완료되었습니다.

 

📌 Cluster 모드 테스팅

현재 파드와 노드의 IP를 확인해본다면 위 사진과 같습니다.

현재 192.168.2.161의 IP를 가진 노드에는 Pods 할당이 안된 것을 확인할 수 있습니다.
그렇다면, 이제는 할당이 안된 Pod로 트래픽을 보낸다면 어떻게 동작하는지 확인해 보겠습니다.

# ① 내 PC → 파드 없는 Node 도착
125.190.25.41.51514 > 192.168.2.161.32443   (In)

# ② SNAT 발생! src가 Node IP로 바뀌면서 파드로 전달
192.168.2.161.6726  > 192.168.9.148.80      (Out)
                      ↑ 파드 IP (hl2vf)
# ③ 파드 → Node 응답
192.168.9.148.80    > 192.168.2.161.6726    (In)

# ④ Node → 내 PC로 돌려줌
192.168.2.161.32443 > 125.190.25.41.51514   (Out)

여기서 참고로 192.168.9.148은 192.168.10.143의 IP가진 노드의 할당된 파드 입니다.

즉, 파드가 할당되지 않은 노드에 트래픽이 발생하였을 때 파드가 할당된 노드로 요청을 보내서 SNAT이 발생함.

 

📌 Local 모드 테스팅

기존 cluster 모드를 테스팅 하였던 service는 삭제 후, 아래 yaml 파일을 배포 후 확인.

cat << 'EOF' > svc-instance-local.yaml
apiVersion: v1
kind: Service
metadata:
  name: echo-instance-local
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  selector:
    app: echo
  ports:
  - port: 80
    targetPort: 80
EOF

배포는 아래와 같이 완료되었습니다.

그러면 파드가 없는 노드에 접속하여 똑같이 tcpdump를 띄워놓고 테스팅 진행하겠습니다.

로컬모드에서 파드가 없는 노드쪽으로 NLB가 트래픽을 아에 보내지 않는 것을 확인하였습니다!

심지어 source IP가 제 로컬 대역인 것도 확인이 가능합니다.

 


1-2. IP 타겟 모드

NLB가 노드를 거치지 않고 파드 IP로 직접 트래픽을 보냅니다.

반드시 AWS LoadBalancer Controller가 필요하고, 적절한 IAM 정책이 있어야 합니다.

 

📌 Proxy Protocol v2 비활성화 (기본)

IP 타겟 모드는 노드의 iptables를 완전히 우회해서 NLB → 파드로 직접 연결됩니다.

NLB가 파드로 패킷을 전달할 때 NLB 자신의 IP로 SNAT을 수행 하기 때문에,
파드 입장에서 보이는 소스 IP는 Client가 아니라 NLB IP입니다.

인스턴스 타겟 모드에서 externalTrafficPolicy: Local로 Client IP를 보존했던 것과 달리,
IP 타겟 모드에서는 기본 상태로는 Client IP 확인이 불가능합니다.

 

📌 Proxy Protocol v2 활성화 (기본)

PPv2를 활성화하면 NLB가 TCP 페이로드 앞에 Proxy Protocol v2 바이너리 헤더를 붙여서 파드로 보냅니다.

이 헤더 안에 원본 Client IP와 포트가 그대로 담겨 있습니다. NLB 레벨의 SNAT은 여전히 발생하지만,
애플리케이션이 이 헤더를 읽을 수 있으면 "아, 진짜 클라이언트는 1.2.3.4구나"를 알 수 있게 됩니다.

단, PPv2는 TCP 페이로드를 수정하는 방식이기 때문에, 파드 안에서 동작하는 애플리케이션이 PPv2를 이해하도록
명시적으로 설정해야 합니다.
설정 없이 PPv2를 켜면 애플리케이션이 헤더를 쓰레기 데이터로 인식해서 연결이 깨질 수 있습니다.

ex: nginx라면 'proxy_protocol on' , HAProxy라면 'mode tcp accept-proxy' 등.

 


 

AWS LBC with IRSA install

먼저 LBC(파드)가 AWS Service를 이용하는 방법은 아래와 같습니다.

  • 방안1. IRSA - 이번에 사용 할 방법.
  • 방안2. Pod Identity
  • 방안3. EC2 Instance Profile - 비권장
EC2 instance Profile 사용을 비권장 하는 이유:
LBC 파드가 워커 노드의 EC2 인스턴스 프로파일을 그대로 가져다 쓰는 방식입니다.

첫째, EC2 인스턴스 프로파일은 노드 자체가 필요한 권한을 가지고 있는데, LBC까지 그 권한을 통째로 상속받습니다.
둘째, 해당 노드의 모든 파드가 같은 권한을 공유합니다. LBC 파드만 AWS API를 써야하는데 같은 노드에 뜬 다른 파드들도 동일한
권한을 사용할 수 있게 됩니다.
  • 방안4. Static Credentials - 절대 금지
AWS Access Key ID + Secret Access Key를 환경변수나 Config Map / Secret에 직접 박아넣는 방식입니다.

첫째, 크리덴셜이 노출되면 답이 없습니다. (한번 유출되면 AWS 계정 전체가 위험해짐)
둘째, 만료 관리가 안된다. IRSA나 Pod Identity는 임시 토큰을 자동으로 갱신하지만, 해당 방법은 수동으로 로테이션 해야함..

 

📌 사전 확인

# OIDC Provider
aws iam list-open-id-connect-providers

aws eks describe-cluster --name myeks \
  --query "cluster.identity.oidc.issuer" \
  --output text
https://oidc.eks.ap-northeast-2.amazonaws.com/id/1BB80004FADD0C9E59C6641F386155BD

# public subnet 찾기
aws ec2 describe-subnets --filters "Name=tag:kubernetes.io/role/elb,Values=1" --output table

# private subnet 찾기
aws ec2 describe-subnets --filters "Name=tag:kubernetes.io/role/internal-elb,Values=1" --output table

 

IAM Policy 생성

# IAM Policy json 파일 다운로드 : Download an IAM policy for the AWS Load Balancer Controller that allows it to make calls to AWS APIs on your behalf.
curl -o aws_lb_controller_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/refs/heads/main/docs/install/iam_policy.json
cat aws_lb_controller_policy.json | jq

# AWSLoadBalancerControllerIAMPolicy 생성 : Create an IAM policy using the policy downloaded in the previous step.
aws iam create-policy \
    --policy-name AWSLoadBalancerControllerIAMPolicy \
    --policy-document file://aws_lb_controller_policy.json

# 확인
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
aws iam get-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy | jq

 

IRSA 생성

# IRSA 생성 : cloudforamtion 를 통해 IAM Role 생성
CLUSTER_NAME=myeks
eksctl get iamserviceaccount --cluster $CLUSTER_NAME
kubectl get serviceaccounts -n kube-system aws-load-balancer-controller

eksctl create iamserviceaccount \
  --cluster=$CLUSTER_NAME \
  --namespace=kube-system \
  --name=aws-load-balancer-controller \
  --attach-policy-arn=arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy \
  --override-existing-serviceaccounts \
  --approve

# 확인
eksctl get iamserviceaccount --cluster $CLUSTER_NAME
NAMESPACE       NAME                            ROLE ARN
kube-system     aws-load-balancer-controller    arn:aws:iam::911283464785:role/eksctl-myeks-addon-iamserviceaccount-kube-sys-Role1-n7mN9x019mGE

# k8s 에 SA 확인
# Inspecting the newly created Kubernetes Service Account, we can see the role we want it to assume in our pod.
kubectl get serviceaccounts -n kube-system aws-load-balancer-controller -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::911283464785:role/eksctl-myeks-addon-iamserviceaccount-kube-sys-Role1-cNSJN97wewS7
  ...

ServiceAccount 생성된 것을 확인할 수 있습니다.

 

📌 AWS LBC 설치

# Helm Chart Repository 추가
helm repo add eks https://aws.github.io/eks-charts
helm repo update

# Helm Chart - AWS Load Balancer Controller 설치
# https://artifacthub.io/packages/helm/aws/aws-load-balancer-controller
# https://github.com/aws/eks-charts/blob/master/stable/aws-load-balancer-controller/values.yaml
helm install aws-load-balancer-controller eks/aws-load-balancer-controller -n kube-system --version 3.1.0 \
  --set clusterName=$CLUSTER_NAME \
  --set serviceAccount.name=aws-load-balancer-controller \
  --set serviceAccount.create=false

# 확인
helm list -n kube-system
NAME                            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                                     APP VERSION
aws-load-balancer-controller    kube-system     1               2026-03-19 14:06:42.3664 +0900 KST      deployed        aws-load-balancer-controller-3.1.0        v3.1.0     

# 파드 상태 실패 확인
kubectl get pod -n kube-system -l app.kubernetes.io/name=aws-load-balancer-controller
NAME                                            READY   STATUS             RESTARTS      AGE
aws-load-balancer-controller-7875649799-vz9jj   0/1     Error              2 (26s ago)   43s
aws-load-balancer-controller-7875649799-wn799   0/1     CrashLoopBackOff   2 (5s ago)    43s

# 로그 확인 : vpc id 정보 획득 실패!
kubectl logs -n kube-system deployment/aws-load-balancer-controller
Found 2 pods, using pod/aws-load-balancer-controller-7875649799-vz9jj
{"level":"info","ts":"2026-03-19T06:06:48Z","msg":"version","GitVersion":"v3.1.0","GitCommit":"250024dbcc7a428cfd401c949e04de23c167d46e","BuildDate":"2026-02-24T18:21:40+0000"}
{"level":"error","ts":"2026-03-19T06:06:53Z","logger":"setup","msg":"unable to initialize AWS cloud","error":"failed to get VPC ID: failed to fetch VPC ID from instance metadata: error in fetching vpc id through ec2 metadata: get mac metadata: operation error ec2imds: GetMetadata, canceled, context deadline exceeded"}

helm repo에서 LBC를 설치하려고 했는데, 아래와 같은 에러가 발생했다. 왜일까?

AWS LBC가 Region과 VPC ID 정보 획득이 필요한데, 획득을 하지 못해서 실패하였다.

왜 VPC ID 정보 획득에 실패했을까?
LBC 파드가 BPC ID를 가져오는 경로는 아래와 같다.
LBC 파드 → 노드 → IMDS(169.254.169.254) → VPC ID 반환

여기서 IMDS란? EC2 인스턴스 자신의 메타데이터를 제공하는 링크로컬 주소입니다.
그렇다면, 문제는 네트워크 홉(Hop)인데요, 일반 EC2에서 curl을 날리면 홉이 1입니다.
근데 파드에서 날리면 홉이 2이기 때문에 홉 카운트를 2로 변경해야한다.

eks를 구성하는 테라폼 파일에서 metadata_options을 위와 같이 추가한 후 apply !

이렇게 HOP 카운트가 2로 늘어난 것을 확인할 수 있다.

LBC도 잘 설치되어 파드 형태로 올라온 것을 확인할 수 있습니다.

LBC의 클러스터롤, 롤을 확인하는 명령을 입력하여 어떤 권한을 가지고 있는지도 확인 !

 


서비스/파드 배포 테스트 with NLB

# 디플로이먼트 & 서비스 생성
cat << EOF > echo-service-nlb.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: deploy-echo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: deploy-websrv
  template:
    metadata:
      labels:
        app: deploy-websrv
    spec:
      terminationGracePeriodSeconds: 0
      containers:
      - name: aews-websrv
        image: k8s.gcr.io/echoserver:1.10  # open https://registry.k8s.io/v2/echoserver/tags/list
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: svc-nlb-ip-type
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "8080"
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
spec:
  allocateLoadBalancerNodePorts: false  # K8s 1.24+ 무의미한 NodePort 할당 차단
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP
  type: LoadBalancer
  selector:
    app: deploy-websrv
EOF

배포까지 아래와 같이 완료된 것을 확인할 수 있습니다.

echo-instance-local이란 서비스는 이전에 테스팅 했다가 못지운 녀석입니다.

LBC가 노드에 연결하지 않고, 바로 파드 IP로 타겟을 잡은 걸 확인할 수 있습니다.

 

빠른 실습을 위해서 NLB의 '등록 취소 지연(드레이닝 간격)을 아래와 같이 마지막 줄에 옵션 추가 !

metadata:
  name: svc-nlb-ip-type
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "8080"
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    service.beta.kubernetes.io/aws-load-balancer-target-group-attributes: deregistration_delay.timeout_seconds=60 # 추가.
    
# 수정 완료 후 apply
kubectl apply -f echo-service-nlb.yaml

AWS NLB 정보는 아래와 같이 확인이 가능합니다.

aws elbv2 describe-load-balancers | jq

aws elbv2 describe-load-balancers --query 'LoadBalancers[*].State.Code' --output text
ALB_ARN=$(aws elbv2 describe-load-balancers --query 'LoadBalancers[?contains(LoadBalancerName, `k8s-default-svcnlbip`) == `true`].LoadBalancerArn' | jq -r '.[0]')

aws elbv2 describe-target-groups --load-balancer-arn $ALB_ARN | jq
TARGET_GROUP_ARN=$(aws elbv2 describe-target-groups --load-balancer-arn $ALB_ARN | jq -r '.TargetGroups[0].TargetGroupArn')

aws elbv2 describe-target-health --target-group-arn $TARGET_GROUP_ARN | jq

 

📌 분산 접속 확인 테스트 - PPv2 비활성화

현재 IP 타겟 모드에서 PPv2 비활성화 상태로 테스팅을 진행하였습니다.

파드가 보는 Client IP가 내 로컬 PC IP가 아니라 NLB IP로 보이는 것을 확인하였습니다.
즉, NLB가 파드로 직접 패킷을 전달할 때 NLB 자신의 IP로 SNAT을 한 것 확인.

 

📌 분산 접속 확인 테스트 - PPv2 활성화

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-ppv2-conf
data:
  nginx.conf: |
    events {}
    http {
      log_format ppv2 '\$proxy_protocol_addr - \$remote_addr [\$time_local] "\$request" \$status';
      access_log /dev/stdout ppv2;
      server {
        listen 80 proxy_protocol;
        real_ip_header proxy_protocol;
        set_real_ip_from 0.0.0.0/0;
        location / {
          return 200 "client_ip=\$proxy_protocol_addr\nnlb_ip=\$realip_remote_addr\n";
          add_header Content-Type text/plain;
        }
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gasida-web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gasida-web
  template:
    metadata:
      labels:
        app: gasida-web
    spec:
      terminationGracePeriodSeconds: 0
      containers:
      - name: gasida-web
        image: nginx:alpine
        ports:
        - containerPort: 80
        volumeMounts:
        - name: nginx-conf
          mountPath: /etc/nginx/nginx.conf
          subPath: nginx.conf
      volumes:
      - name: nginx-conf
        configMap:
          name: nginx-ppv2-conf
---
apiVersion: v1
kind: Service
metadata:
  name: svc-nlb-ip-type-pp
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"
spec:
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
  type: LoadBalancer
  loadBalancerClass: service.k8s.aws/nlb
  selector:
    app: gasida-web
EOF

아래와 같이 배포가 완료된 것을 확인할 수 있습니다.

1초마다 NLB로 curl을 반복적으로 날렸더니 결과는 아래와 같습니다.

NLB의 대상 그룹이 Pod IP를 가르키고 있더라도, NLB IP로 SNAT은 되고 있지만,

실질적인 Client IP도 알고 있음을 확인할 수 있습니다 (PPv2 헤더 안에 원본 Client IP 노출)

 

명확하게 확인을 하고 싶어서 터미널 3개를 띄우고 테스트를 해보았습니다.

위 터미널 curl 결과:
client_ip = 125.190.25.41 -> 내 로컬 PC이므로, PPv2 헤더에서 복원한 내 PC IP
nlb_ip = 192.168.9.236 -> NLB 내부 IP

왼쪽 하단 파드 로그:
125.190.25.41 - 125.190.25.41 -> nginx가 PPv2에서 파싱한 Client IP

오른쪽 파드 tcpdump:
192.168.2.17 > 192.168.4.82.80 : NLB IP → 파드로 직접 전달

NLB와 파드간 통신에서도 실제 PPv2 헤더에서 복원한 내 PC IP와 통신이 가능한 것을

확인해보았습니다.

 


 

긴 글 읽어주셔서 감사합니다.

 

다음 글은 Ingress 및 Gateway API 내용으로 찾아뵙도록 하겠습니다.