[CloudNeta] EKS 워크샵 스터디 (2) - EKS Network Part 2 - CNI 설정과 Service(L4)

이번 게시글에서는 EKS 워크샵 스터디 제 2주차 내용을 작성합니다.

이 글은 3부로 나누어집니다.

  1. 2주차 - EKS Networking Part 1 - VPC CNI와 파드 네트워킹
  2. 2주차 - EKS Networking Part 2 - CNI 설정과 Service(L4) (현재 보고계신 글)
  3. 2주차 - EKS Networking Part 3 - Ingress, ExternalDNS, Gateway API
이번 글에서 배울 것

  • AWS VPC CNI 설정 변경 (Prefix Delegation 등)
  • 노드에 파드 생성 갯수 제한
  • Service & Amazon EKS 네트워킹 지원
  • AWS LoadBalancer Controller & Service (L4)

이전 글에 이어서, VPC CNI 설정을 변경하고 파드 생성갯수 제한이 어떻게 달라지는지 살펴본 뒤, Service와 L4 로드밸런싱을 실습합니다.

VPC CNI 설정 바꾸기

설정을 바꾸기 전에, 현재 노드들의 네트워크 인터페이스 상태부터 확인해봅시다.

현재 노드 상태 확인

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i sudo ip -c addr; echo; done

각 노드의 인터페이스를 정리하면:

노드 ens5 (Primary ENI) ens6 (Secondary ENI) veth (파드)
노드 1 (192.168.4.44) 192.168.4.44/22 192.168.7.40/22 1개 (eniacae5529058)
노드 2 (192.168.1.58) 192.168.1.58/22 192.168.1.8/22 2개 (eniaa9a601f849, enid8dd5b67408)
노드 3 (192.168.9.33) 192.168.9.33/22 192.168.8.200/22 2개 (enidc82c4d90e6, eni499eaac00bf)
노드 1에는 veth가 1개 뿐

노드 2, 3에는 veth가 2개(파드 + CoreDNS)인데, 노드 1에는 1개만 있습니다. 노드 1에는 netshoot 파드 1개만 배치되어 있고 CoreDNS가 없기 때문입니다. 파드 수에 따라 veth pair 수가 달라지는 것을 직접 확인할 수 있습니다.

라우팅 테이블도 같이 보면, 파드가 배치된 만큼 veth 경로가 추가된 것을 알 수 있습니다:

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i sudo ip -c route; echo; done

# 노드 1 - 파드 1개 → veth 경로 1개
192.168.6.254 dev eniacae5529058 scope link

# 노드 2 - 파드 2개 → veth 경로 2개
192.168.1.201 dev enid8dd5b67408 scope link
192.168.2.141 dev eniaa9a601f849 scope link

# 노드 3 - 파드 2개 → veth 경로 2개
192.168.9.201 dev eni499eaac00bf scope link
192.168.10.233 dev enidc82c4d90e6 scope link

IPAMD로 IP 할당 상태 확인

VPC CNI의 IPAMD(IP Address Management Daemon)는 localhost:61679 에 디버깅용 API를 노출합니다. 이를 통해 ENI별 IP 풀 상태를 확인할 수 있습니다.

더 자세한 트러블슈팅 방법은 IPAMD debugging commands 문서를 참고하세요.

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i curl -s http://localhost:61679/v1/enis | jq; echo; done

노드 1의 결과를 예로 보면:

{
  "TotalIPs": 10,
  "AssignedIPs": 1,
  "ENIs": {
    "eni-098b...": {
      // Primary ENI (DeviceNumber: 0)
      "IsPrimary": true,
      "AvailableIPv4Cidrs": {
        "192.168.6.254/32": {
          // ← 파드에 할당됨 (netshoot-pod)
          "IPAddresses": {
            "192.168.6.254": { "k8sPodName": "netshoot-pod-..." }
          }
        },
        "192.168.4.200/32": {}, // 나머지 4개는 웜 풀에 대기 중
        "192.168.4.208/32": {},
        "192.168.7.32/32": {},
        "192.168.7.57/32": {}
      }
    },
    "eni-070f...": {
      // Secondary ENI (DeviceNumber: 1) - 전부 미할당
      "IsPrimary": false,
      "AvailableIPv4Cidrs": { "192.168.4.207/32": {}, "...": {} } // 5개 IP, WARM_ENI_TARGET=1로 미리 확보
    }
  }
}

정리하면:

노드 ENI 수 전체 IP 할당된 IP 웜 풀 IP
노드 1 2 (Primary + Secondary) 10 1 (netshoot) 9
노드 2 2 (Primary + Secondary) 10 2 (netshoot + CoreDNS) 8
노드 3 2 (Primary + Secondary) 10 2 (netshoot + CoreDNS) 8

WARM_ENI_TARGET=1 설정에 의해, 파드가 Primary ENI의 보조 IP만 사용하더라도 Secondary ENI가 미리 하나 더 붙어있는 것을 확인할 수 있습니다. 이 "여유분" ENI 덕분에 새 파드가 생성될 때 ENI 할당을 기다리지 않고 바로 IP를 받을 수 있습니다.

CNI 환경변수 확인

앞서 살펴보았던 설정값을 다시 살펴봅시다.

$ k get ds aws-node -n kube-system -o json | jq '.spec.template.spec.containers[0].env'
...
  {
    "name": "WARM_ENI_TARGET",
    "value": "1"
  },
...

파일 수정

eks.tf 파일을 아래와 같이 수정합니다:

# add-on
addons = {
  coredns = {
    most_recent = true
  }
  kube-proxy = {
    most_recent = true
  }
  vpc-cni = {
    most_recent = true
    before_compute = true
    configuration_values = jsonencode({
      env = {
        #WARM_ENI_TARGET = "1" # 현재 ENI 외에 여유 ENI 1개를 항상 확보
        WARM_IP_TARGET  = "5" # 현재 사용 중인 IP 외에 여유 IP 5개를 항상 유지, 설정 시 WARM_ENI_TARGET 무시됨
        MINIMUM_IP_TARGET   = "10" # 노드 시작 시 최소 확보해야 할 IP 총량 10개
        #ENABLE_PREFIX_DELEGATION = "true"
        #WARM_PREFIX_TARGET = "1" # PREFIX_DELEGATION 사용 시, 1개의 여유 대역(/28) 유지
      }
    })
  }
}

수정 후 테라폼을 재배포하면, EKS 콘솔에서 VPC CNI 애드온이 업데이트 중인 것을 확인할 수 있습니다.

2-001-pt2-aws-vpc-cni-updating

배포가 완료되면 아래와 같이 상태가 Active로 전환됩니다.

2-002-pt2-aws-vpc-cni-change-done

변경된 설정 확인

배포가 완료되었으니, aws-node 데몬셋이 정상적으로 재시작되었는지 확인합니다.

$ kubectl get pod -n kube-system -l k8s-app=aws-node
NAME             READY   STATUS    RESTARTS   AGE
aws-node-bpslz   2/2     Running   0          4m16s
aws-node-cspdf   2/2     Running   0          4m8s
aws-node-zzndx   2/2     Running   0          4m12s

3개 노드 모두 2/2 Running이고 AGE가 짧은 것으로 보아, 설정 변경에 따라 파드가 재생성된 것을 알 수 있습니다.

eksctl로 애드온 상태를 확인하면, CONFIGURATION VALUES에 우리가 설정한 값이 반영된 것을 볼 수 있습니다.

$ eksctl get addon --cluster myeks
NAME            VERSION                 STATUS  ISSUES  CONFIGURATION VALUES
coredns         v1.13.2-eksbuild.3      ACTIVE  0
kube-proxy      v1.34.5-eksbuild.2      ACTIVE  0
vpc-cni         v1.21.1-eksbuild.5      ACTIVE  0       {"env":{"MINIMUM_IP_TARGET":"10","WARM_IP_TARGET":"5"}}

실제 aws-node 데몬셋의 환경변수에서도 확인해봅시다.

$ kubectl describe ds aws-node -n kube-system | grep -E "WARM_IP_TARGET|MINIMUM_IP_TARGET"
      MINIMUM_IP_TARGET:                      10
      WARM_IP_TARGET:                         5

전체 환경변수 목록을 보면, 변경한 값 외에도 VPC CNI가 사용하는 주요 설정들을 한눈에 확인할 수 있습니다.

$ kubectl get ds aws-node -n kube-system -o json | jq '.spec.template.spec.containers[0].env'

주요 환경변수를 정리하면:

환경변수 설명
WARM_IP_TARGET 5 여유 IP 5개를 항상 유지 (설정 시 WARM_ENI_TARGET 무시)
MINIMUM_IP_TARGET 10 노드 시작 시 최소 확보할 IP 총량
WARM_ENI_TARGET 1 여유 ENI 1개 확보 (위 설정에 의해 무시됨)
ENABLE_PREFIX_DELEGATION false Prefix Delegation 비활성화
AWS_VPC_ENI_MTU 9001 Jumbo Frame 지원 (VPC 내부)
AWS_VPC_K8S_CNI_EXTERNALSNAT false VPC CNI 자체 SNAT 사용
AWS_VPC_K8S_CNI_VETHPREFIX eni veth pair 이름 접두사
WARM_IP_TARGETWARM_ENI_TARGET의 관계

WARM_IP_TARGET이 설정되면 WARM_ENI_TARGET은 무시됩니다. 기존에는 ENI 단위로 여유분을 확보했지만, 이제는 IP 단위로 세밀하게 관리합니다. MINIMUM_IP_TARGET=10과 함께 사용하면, 노드 시작 시 최소 10개 IP를 확보하되 이후에는 항상 5개의 여유 IP를 유지하게 됩니다.

CNI 로그로 동작 확인

VPC CNI는 /var/log/aws-routed-eni/ 디렉터리에 로그를 남깁니다.

$ ssh ec2-user@$NODE1 tree /var/log/aws-routed-eni
/var/log/aws-routed-eni
├── ebpf-sdk.log
├── ipamd.log IP 할당/해제 관리 로그
├── network-policy-agent.log
├── egress-v6-plugin.log
└── plugin.log CNI 플러그인 호출 로그 (파드 생성/삭제)

plugin.log: 파드 생성 시 CNI가 하는 일

파드가 생성될 때 CNI 플러그인이 어떤 과정을 거치는지 확인할 수 있습니다.

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i sudo cat /var/log/aws-routed-eni/plugin.log | jq '.msg'; echo; done

노드 1의 로그를 순서대로 읽어보면:

1. "Received CNI add request: ContainerID(e864...) ... K8S_POD_NAME=netshoot-pod-64fbf7fb5-bzvd8"
   → kubelet이 CNI에 네트워크 연결 요청

2. "Received add network response from ipamd ... IPv4Addr:\"192.168.6.254\" RouteTableId:254"
   → IPAMD가 웜 풀에서 IP 할당, Primary ENI(RouteTableId=254=main)에서 나옴

3. "SetupPodNetwork: hostVethName=eniacae5529058, contVethName=eth0, ipAddr=192.168.6.254/32"
   → veth pair 생성 (호스트 측: eniacae5529058, 파드 측: eth0)

4. "Successfully setup container route, containerAddr=192.168.6.254/32, rtTable=main"
   → /32 호스트 라우트 추가 (ip route에서 봤던 그 경로)

5. "Network Policy agent for EnforceNpToPod returned Success : true"
   → 네트워크 정책 적용 완료
Secondary ENI에 배치된 파드는 라우팅이 다르다

노드 3의 netshoot 파드는 Secondary ENI(DeviceNumber=1)에서 IP를 받았기 때문에 RouteTableId:2가 할당되고, fromContainer rule, rtTable=2 라우트가 추가로 설정됩니다. Part 1에서 봤던 ip rulefrom 192.168.7.40 lookup 2 규칙과 같은 원리입니다. 즉 VPC의 소스/대상 확인을 통과하기 위해 해당 ENI로만 패킷이 나가도록 보장합니다.

ipamd.log: IP 풀 관리와 ENI 유지 로직

IPAMD는 주기적으로 IP 풀 상태를 점검하고, 불필요한 ENI를 해제할지 결정합니다.

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i "sudo tail -20 /var/log/aws-routed-eni/ipamd.log" | jq '.msg'; echo; done

각 노드의 로그를 보면 ENI를 삭제하지 않는 이유가 명확히 나옵니다:

# 노드 1 (파드 1개)
"IP stats for Network Card 0 - total IPs: 10, assigned IPs: 1, cooldown IPs: 0"
"ENI eni-098b... cannot be deleted because it is primary"
"ENI eni-070f... cannot be deleted because it is required for WARM_IP_TARGET: 5"

# 노드 2 (파드 2개)
"IP stats for Network Card 0 - total IPs: 10, assigned IPs: 2, cooldown IPs: 0"
"ENI eni-0075... cannot be deleted because it is primary"
"ENI eni-0cbe... cannot be deleted because it is required for WARM_IP_TARGET: 5"

# 노드 3 (파드 2개)
"IP stats for Network Card 0 - total IPs: 10, assigned IPs: 2, cooldown IPs: 0"
"ENI eni-05d1... cannot be deleted because it is primary"
"ENI eni-0270... cannot be deleted because it has pods assigned"

ENI가 삭제되지 않는 이유를 정리하면:

노드 Primary ENI Secondary ENI Secondary를 유지하는 이유
노드 1 삭제 불가 (primary) 삭제 불가 WARM_IP_TARGET: 5 충족에 필요
노드 2 삭제 불가 (primary) 삭제 불가 WARM_IP_TARGET: 5 충족에 필요
노드 3 삭제 불가 (primary) 삭제 불가 파드가 할당되어 있음 (netshoot)

노드 1, 2의 Secondary ENI는 파드가 할당되어 있지 않지만, WARM_IP_TARGET=5를 만족시키려면 Primary ENI의 여유 IP만으로는 부족하기 때문에 유지됩니다. 노드 3은 netshoot 파드가 Secondary ENI의 IP(192.168.9.201)를 직접 사용하고 있어서 삭제가 불가능합니다.

노드에 파드 생성 갯수 제한하기

이어서 파드 생성갯수를 제한하는 구성에 대해 살펴보겠습니다. 여기서는 앞서 살펴본 두가지에 대해 실습하고자 합니다. 첫째는 Secondary IPv4 addresses, 둘째는 Prefix Delegation입니다. 배포가 잘 되었는지 확인하기 위해 helm을 이용하여 kube-ops-view 를 준비하고 릴리즈 해보겠습니다.

kube-ops-view 준비하기

간단한 배포관련 도구입니다. 멀티 쿠버네티스 클러스터에도 대응하며, 여기서는 간단하기에 채택했습니다.

# kube-ops-view
helm repo add geek-cookbook https://geek-cookbook.github.io/charts/
helm install kube-ops-view geek-cookbook/kube-ops-view --version 1.2.2 --set service.main.type=NodePort,service.main.ports.http.nodePort=30000 --set env.TZ="Asia/Seoul" --namespace kube-system

# 확인
kubectl get deploy,pod,svc,ep -n kube-system -l app.kubernetes.io/instance=kube-ops-view

# kube-ops-view 접속
open "http://$NODE1:30000/#scale=1.5"
open "http://$NODE1:30000/#scale=1.3"

배포가 되면 이런 화면을 만나보실 수 있습니다.

2-003-pt2-deploy-done

Secondary IPv4 addresses 살펴보기

VPC CNI는 파드마다 ENI의 보조 IP를 하나씩 할당하므로, 노드에 생성할 수 있는 파드 수는 인스턴스 타입이 지원하는 ENI 수와 ENI당 IP 수에 의해 물리적으로 제한됩니다. aws-nodekube-proxy는 호스트 네트워크를 사용하므로 이 제한에 포함되지 않아, 최대 파드 수에 2를 더합니다.

최대파드

각 ENI의 Primary IP는 ENI 자체 주소로 사용되므로 -1을 합니다. +2는 호스트 네트워크를 사용하는 aws-node, kube-proxy 파드입니다.

예를 들어 이번 실습에 사용하는 t3.medium의 경우:

항목
최대 ENI 수 3
ENI당 IPv4 주소 수 6
최대 파드 수

혹은 API 호출을 통해 살펴볼 수도 있습니다.

$ aws ec2 describe-instance-types --filters Name=instance-type,Values=t3.\* \
 --query "InstanceTypes[].{Type: InstanceType, MaxENI: NetworkInfo.MaximumNetworkInterfaces, IPv4addr: NetworkInfo.Ipv4AddressesPerInterface}" \
 --output table
--------------------------------------
|        DescribeInstanceTypes       |
+----------+----------+--------------+
| IPv4addr | MaxENI   |    Type      |
+----------+----------+--------------+
|  15      |  4       |  t3.2xlarge  |
|  15      |  4       |  t3.xlarge   |
|  12      |  3       |  t3.large    |
|  6       |  3       |  t3.medium   |
|  2       |  2       |  t3.nano     |
|  2       |  2       |  t3.micro    |
|  4       |  3       |  t3.small    |
+----------+----------+--------------+
인스턴스 타입별 최대 파드 수 목록

AWS에서 인스턴스 타입별 ENI/IP 제한을 정리한 공식 목록을 제공합니다: eni-max-pods.txt

maxPods 우선순위와 관리형/자체관리형 노드 차이에 대해서는 maxPods 결정 방법을 참고하세요.

최대 파드 생성 확인해보기

$ kubectl get pod -o=custom-columns=NAME:.metadata.name,IP:.status.podIP,NODE:.spec.nodeName
NAME                               IP              NODE
nginx-deployment-54fc99c8d-rwwrw   192.168.9.139   ip-192-168-9-33...
nginx-deployment-54fc99c8d-s5wfj   192.168.6.215   ip-192-168-4-44...
nginx-deployment-54fc99c8d-vmjh7   192.168.2.87    ip-192-168-1-58...

이어서 스케일아웃을 단계적으로 테스트해봅시다.

kubectl scale deployment nginx-deployment --replicas=8    # 문제없음
kubectl scale deployment nginx-deployment --replicas=15   # 문제없음
kubectl scale deployment nginx-deployment --replicas=30   # 문제없음
kubectl scale deployment nginx-deployment --replicas=50   # ← 여기서 Pending 발생!

replicas를 50으로 올리자 일부 파드가 Pending 상태에 빠집니다.

$ kubectl get pods | grep Pending
nginx-deployment-54fc99c8d-29q7d   0/1     Pending   0          99s
nginx-deployment-54fc99c8d-4p9bx   0/1     Pending   0          99s
nginx-deployment-54fc99c8d-8qbqx   0/1     Pending   0          99s
... (8개 파드 Pending)

이벤트를 보면 원인이 명확합니다:

Warning  FailedScheduling  0/3 nodes are available: 3 Too many pods.

3 Too many pods - 3개 노드 모두 파드 수 상한에 도달했습니다.

IPAMD로 IP 소진 확인

IPAMD API로 각 노드의 IP 풀 상태를 확인하면:

$ for i in $NODE1 $NODE2 $NODE3; do echo ">> node $i <<"; ssh ec2-user@$i curl -s http://localhost:61679/v1/enis | jq '.["0"] | {TotalIPs, AssignedIPs, ENIs: (.ENIs | to_entries | map({key: .key, IsPrimary: .value.IsPrimary, DeviceNumber: .value.DeviceNumber}))}'; echo; done
노드 ENI 수 TotalIPs AssignedIPs 여유 IP
노드 1 3 (Primary + Secondary + Tertiary) 15 15 0
노드 2 3 15 15 0
노드 3 3 15 15 0

3개 노드 모두 ENI 3개를 전부 사용하고 15개 IP가 전량 할당된 상태입니다. 이전에는 ENI 2개(10 IP)만 사용했는데, 파드 수가 늘어나면서 3번째 ENI(DeviceNumber=2, RouteTableID=3)가 자동으로 추가되었습니다.

t3.medium의 한계인 ENI 3개 × 보조 IP 5개 = 15개를 완전히 소진했으므로, 더 이상 파드에 줄 IP가 없습니다. 클러스터 전체로는 15 × 3 = 45개이고, 여기에 nginx + netshoot + CoreDNS + kube-ops-view가 전부 차지하고 있어 replicas=50은 불가능합니다.

공식으로 검증

t3.medium 최대 파드 수 =
3노드 합계 = 개이지만, 시스템 파드(CoreDNS 2개, netshoot 3개, kube-ops-view 1개 등)가 이미 점유하고 있으므로 nginx에 할당 가능한 슬롯은 약 42개입니다.

Prefix Delegation 살펴보기

Prefix Delegation은 ENI에 개별 IP 대신 /28 대역(16개 IP)을 통째로 할당하는 방식입니다. 이를 통해 동일한 ENI 수로도 훨씬 많은 파드를 수용할 수 있습니다.

사전 조건

Prefix Delegation을 사용하려면 다음 조건을 충족해야 합니다(참고 링크):

현재 사용 중인 인스턴스가 Nitro 기반인지 확인해봅시다:

$ aws ec2 describe-instance-types --instance-types t3.medium --query "InstanceTypes[].Hypervisor"
[
    "nitro"
]

t3.medium은 Nitro 기반이므로 Prefix Delegation을 사용할 수 있습니다.

이를 위해 eks.tf 파일도 수정합시다. ENABLE_PREFIX_DELEGATION = true를 확인하시면 됩니다.

# add-on
addons = {
  coredns = {
    most_recent = true
  }
  kube-proxy = {
    most_recent = true
  }
  vpc-cni = {
    most_recent = true
    before_compute = true
    configuration_values = jsonencode({
      env = {
        #WARM_ENI_TARGET = "1" # 현재 ENI 외에 여유 ENI 1개를 항상 확보
        #WARM_IP_TARGET  = "5" # 현재 사용 중인 IP 외에 여유 IP 5개를 항상 유지, 설정 시 WARM_ENI_TARGET 무시됨
        #MINIMUM_IP_TARGET   = "10" # 노드 시작 시 최소 확보해야 할 IP 총량 10개
        ENABLE_PREFIX_DELEGATION = "true"
        #WARM_PREFIX_TARGET = "1" # PREFIX_DELEGATION 사용 시, 1개의 여유 대역(/28) 유지
      }
    })
  }
}
파드 업데이트 필요

기존 파드들도 위 설정 적용을 위해 재기동이 필요합니다!

kubectl rollout restart -n kube-system deployment coredns
kubectl rollout restart -n kube-system deployment kube-ops-view

최대 파드 생성 확인해보기

마찬가지로 재구성 후 다시 확인해봅시다.

$ k get pod -owide
NAME                               READY   STATUS    RESTARTS   AGE   IP              NODE                                              NOMINATED NODE   READINESS GATES
nginx-deployment-54fc99c8d-685vs   1/1     Running   0          4s    192.168.11.81   ip-192-168-9-33.ap-northeast-2.compute.internal   <none>           <none>
nginx-deployment-54fc99c8d-nsqld   1/1     Running   0          4s    192.168.5.129   ip-192-168-4-44.ap-northeast-2.compute.internal   <none>           <none>
nginx-deployment-54fc99c8d-xbmx4   1/1     Running   0          4s    192.168.3.225   ip-192-168-1-58.ap-northeast-2.compute.internal   <none>           <none>

$ k get pod -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME                               IP
nginx-deployment-54fc99c8d-685vs   192.168.11.81
nginx-deployment-54fc99c8d-nsqld   192.168.5.129
nginx-deployment-54fc99c8d-xbmx4   192.168.3.225

이어서 스케일아웃을 단계적으로 테스트해봅시다.

kubectl scale deployment nginx-deployment --replicas=30   # 문제없음
kubectl scale deployment nginx-deployment --replicas=50   #

IP 갯수는 여전히 넉넉함에도 maxPods 갯수 제한으로 배포가 되지 못하는 모습입니다.

# 여전히 파드를 올리지 못합니다!
$ k events
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-qpnkt    Successfully assigned default/nginx-deployment-54fc99c8d-qpnkt to ip-192-168-4-44.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-f8xld    Successfully assigned default/nginx-deployment-54fc99c8d-f8xld to ip-192-168-9-33.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-vkglb    Successfully assigned default/nginx-deployment-54fc99c8d-vkglb to ip-192-168-1-58.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-7mr28    Successfully assigned default/nginx-deployment-54fc99c8d-7mr28 to ip-192-168-4-44.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-lbrsm    Successfully assigned default/nginx-deployment-54fc99c8d-lbrsm to ip-192-168-9-33.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-jbbtj    Successfully assigned default/nginx-deployment-54fc99c8d-jbbtj to ip-192-168-1-58.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-65652    Successfully assigned default/nginx-deployment-54fc99c8d-65652 to ip-192-168-1-58.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-vmxxp    Successfully assigned default/nginx-deployment-54fc99c8d-vmxxp to ip-192-168-4-44.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-tnp8z    Successfully assigned default/nginx-deployment-54fc99c8d-tnp8z to ip-192-168-9-33.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-nkmj4    Successfully assigned default/nginx-deployment-54fc99c8d-nkmj4 to ip-192-168-1-58.ap-northeast-2.compute.internal
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-lg9k2    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-rff9z    Successfully assigned default/nginx-deployment-54fc99c8d-rff9z to ip-192-168-9-33.ap-northeast-2.compute.internal
3m11s                    Normal    Scheduled                Pod/nginx-deployment-54fc99c8d-bh4x9    Successfully assigned default/nginx-deployment-54fc99c8d-bh4x9 to ip-192-168-4-44.ap-northeast-2.compute.internal
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-9w677    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-cx2nv    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-l98h8    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-l4m8x    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-lk2mc    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-flh88    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Warning   FailedScheduling         Pod/nginx-deployment-54fc99c8d-k4pc6    0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
3m11s                    Normal    Created                  Pod/nginx-deployment-54fc99c8d-nkmj4    Created container: nginx
3m11s                    Normal    Created                  Pod/nginx-deployment-54fc99c8d-bh4x9    Created container: nginx
3m11s                    Normal    Created                  Pod/nginx-deployment-54fc99c8d-jbbtj    Created container: nginx
3m11s                    Normal    Started                  Pod/nginx-deployment-54fc99c8d-rff9z    Started container nginx
3m11s                    Normal    Started                  Pod/nginx-deployment-54fc99c8d-vmxxp    Started container nginx
3m11s                    Normal    Created                  Pod/nginx-deployment-54fc99c8d-vmxxp    Created container: nginx
3m11s                    Normal    Pulled                   Pod/nginx-deployment-54fc99c8d-vmxxp    Container image "nginx:alpine" already present on machine

# ipamd 도 여전히 문제가 발생합니다.
$
{
  "level": "debug",
  "ts": "2026-03-25T14:23:27.705Z",
  "caller": "ipamd/ipamd.go:1479",
  "msg": "ENI eni-05d1e98a33e1a7761 cannot be deleted because it is primary"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:23:32.706Z",
  "caller": "ipamd/ipamd.go:765",
  "msg": "IP stats for Network Card 0 - total IPs: 32, assigned IPs: 15, cooldown IPs: 0"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:23:32.706Z",
  "caller": "ipamd/ipamd.go:1479",
  "msg": "ENI eni-0270c1686bee6d972 cannot be deleted because it has pods assigned"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:23:32.706Z",
  "caller": "ipamd/ipamd.go:1479",
  "msg": "ENI eni-05d1e98a33e1a7761 cannot be deleted because it is primary"
}

강제로 kubelet을 통해 maxPods를 임시수정해봅시다.

# 기본 정보 확인
cat /etc/kubernetes/kubelet/config.json | grep maxPods
cat /etc/kubernetes/kubelet/config.json.d/40-nodeadm.conf | grep maxPods

# sed 로 변경 : 기존 17 -> 변경 40
sudo sed -i 's/"maxPods": 17/"maxPods": 50/g' /etc/kubernetes/kubelet/config.json
sudo sed -i 's/"maxPods": 17/"maxPods": 50/g' /etc/kubernetes/kubelet/config.json.d/40-nodeadm.conf

# 적용
sudo systemctl restart kubelet

이를 진행하고, 추가로 증가해봅니다. 110개부터는 안되는군요.

$ k events
32s                     Normal    Scheduled                 Pod/nginx-deployment-54fc99c8d-h5zv9                   Successfully assigned default/nginx-deployment-54fc99c8d-h5zv9 to ip-192-168-1-58.ap-northeast-2.compute.internal
32s                     Normal    Scheduled                 Pod/nginx-deployment-54fc99c8d-748tg                   Successfully assigned default/nginx-deployment-54fc99c8d-748tg to ip-192-168-9-33.ap-northeast-2.compute.internal
32s                     Normal    Scheduled                 Pod/nginx-deployment-54fc99c8d-hz6qc                   Successfully assigned default/nginx-deployment-54fc99c8d-hz6qc to ip-192-168-4-44.ap-northeast-2.compute.internal
32s                     Normal    Scheduled                 Pod/nginx-deployment-54fc99c8d-5f6qv                   Successfully assigned default/nginx-deployment-54fc99c8d-5f6qv to ip-192-168-1-58.ap-northeast-2.compute.internal
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-5f6qv                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "8fd9c2d8a86537da6eb83056dfff61a55c7076b9e3b0c32bc7a36b9ea112626b": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-r2jq8                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "1d6325a03385173bbcdbbafb18eb0c37ecb5d0ba11587f9efc740fa6ebb57ff4": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Normal    Started                   Pod/nginx-deployment-54fc99c8d-c224v                   Started container nginx
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-gfv8w                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "1fc6a78c8a8059274af6d736c56315dc9e5fd95fb5ef27d68a44097cc7b54657": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-89jkv                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "5ad63c83929aee252f131f7f82f345b724ef6384ba5a6729ee7b20b982d379aa": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-748tg                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "92e32a4abb534053e57ff73d46afb4d66322fe284bb07c82dacd9dbada60694c": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Normal    Created                   Pod/nginx-deployment-54fc99c8d-c224v                   Created container: nginx
32s                     Normal    Pulled                    Pod/nginx-deployment-54fc99c8d-c224v                   Container image "nginx:alpine" already present on machine
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-n7f72                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "85f72d9f38d9d936c429881b2e3bdc1990a527de0c48f3b556564c089d126a56": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Normal    Created                   Pod/nginx-deployment-54fc99c8d-dxntd                   Created container: nginx
32s                     Normal    Pulled                    Pod/nginx-deployment-54fc99c8d-9grtq                   Container image "nginx:alpine" already present on machine
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-5df7h                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "16caed006dfaf73b4ddd30cffe34656340dd2965957119b8d4a8dfcef1991bb6": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-scq6x                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "1a8c64e876e16adcfd1f950e726e483f27096b175e8073cb1d64e9121c6507dd": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Normal    Started                   Pod/nginx-deployment-54fc99c8d-dxntd                   Started container nginx
32s                     Normal    Created                   Pod/nginx-deployment-54fc99c8d-9grtq                   Created container: nginx
32s                     Normal    Pulled                    Pod/nginx-deployment-54fc99c8d-dxntd                   Container image "nginx:alpine" already present on machine
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-frnj2                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "c7fabcea376623217f6379e5e8256764b9ad4669a4255d699a58101697feaec7": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-glsqk                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "d0700ece10ae886439eba8190266552b965a013910cd6d1231cafabbc4582f1f": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
32s                     Normal    Started                   Pod/nginx-deployment-54fc99c8d-9grtq                   Started container nginx
32s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-fnwjr                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "b8764b780679cfa7bfb8085c5a9c63fff449716c24f88d85d9468397c243f826": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
22s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-5df7h                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "4f7daf22fdcb220c22eac2bfa059b36068fa313853bb5d4b116efeac1576398b": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
21s                     Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-fnwjr                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "60b3dc790002becd3a590a89e7189ed4f4cbe25ee56678a156d5468ac56b2701": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
... 생략
4s                      Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-mn4vq                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "04f203ab3e7918ffd0ca46e80d5d530d73a5e9961ad7c2a0cf6708a177386ad3": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
4s                      Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-h5zv9                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "b722fac3259ba917699afb41d9a079db0a0172b30c0631d922c648fd1e75edf6": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container
3s                      Warning   FailedCreatePodSandBox    Pod/nginx-deployment-54fc99c8d-5f6qv                   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "c796de616b9587f18718b8e6620f2a8f7e94fe5a82dfe62c04e149d053e21b42": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: failed to assign an IP address to container

로그 상으로도 IP할당이 불가하다 나오고요.

{
  "level": "debug",
  "ts": "2026-03-25T14:33:36.700Z",
  "caller": "rpc/rpc_grpc.pb.go:135",
  "msg": "DelNetworkRequest: K8S_POD_NAME:\"nginx-deployment-54fc99c8d-gfv8w\"  K8S_POD_NAMESPACE:\"default\"  K8S_POD_INFRA_CONTAINER_ID:\"220bba4334af3f7e21c99abb972c10a4edf630627b6331a7f5947b429983e08d\"  Reason:\"PodDeleted\"  ContainerID:\"220bba4334af3f7e21c99abb972c10a4edf630627b6331a7f5947b429983e08d\"  IfName:\"eth0\"  NetworkName:\"aws-cni\"  K8S_POD_UID:\"bbe2d52d-9c6f-478d-b451-ec439f14cec6\""
}
{
  "level": "debug",
  "ts": "2026-03-25T14:33:36.700Z",
  "caller": "ipamd/rpc_handler.go:353",
  "msg": "UnassignPodIPAddress: IP address pool stats: total 33, assigned 32, sandbox aws-cni/220bba4334af3f7e21c99abb972c10a4edf630627b6331a7f5947b429983e08d/eth0"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:33:36.700Z",
  "caller": "ipamd/rpc_handler.go:353",
  "msg": "UnassignPodIPAddress: Failed to find IPAM entry under full key, trying CRI-migrated version"
}
{
  "level": "warn",
  "ts": "2026-03-25T14:33:36.700Z",
  "caller": "ipamd/rpc_handler.go:353",
  "msg": "UnassignPodIPAddress: Failed to find sandbox _migrated-from-cri/220bba4334af3f7e21c99abb972c10a4edf630627b6331a7f5947b429983e08d/unknown"
}
{
  "level": "info",
  "ts": "2026-03-25T14:33:36.700Z",
  "caller": "rpc/rpc_grpc.pb.go:135",
  "msg": "Send DelNetworkReply: IPAddress: [], err: 1 error occurred:\n\t* datastore: unknown pod\n\n"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:33:38.140Z",
  "caller": "ipamd/ipamd.go:765",
  "msg": "IP stats for Network Card 0 - total IPs: 32, assigned IPs: 32, cooldown IPs: 0"
}
{
  "level": "debug",
  "ts": "2026-03-25T14:33:38.141Z",
  "caller": "ipamd/ipamd.go:1479",
  "msg": "ENI eni-0270c1686bee6d972 cannot be deleted because it has pods assigned"
}
>> node REDACTED <<
{
  "0": {
    "TotalIPs": 33,
    "AssignedIPs": 32,
    ...

그럼 이 상황에서 두번째 관리형 노드를 배포해보고 최대파드 생성을 체크해봅시다.

secondary node group 배포이후 110개 배포해보기

이어서 두번째 노드그룹을 배포하고, maxPods 확인 및 배포해서 테스트해봅시다.

$ eksctl get nodegroup --cluster myeks
CLUSTER NODEGROUP               STATUS  CREATED                 MIN SIZE        MAX SIZE        DESIRED CAPACITY        INSTANCE TYPE   IMAGE ID                ASG NAME               TYPE
myeks   myeks-1nd-node-group    ACTIVE  2026-03-25T08:05:45Z    2               5               3                       t3.medium       AL2023_x86_64_STANDARD  eks-myeks-1nd-node-group-c0ce9203-93c2-9fb0-cda6-475a093fd7c1   managed
myeks   myeks-2nd-node-group    ACTIVE  2026-03-25T14:37:02Z    1               1               1                       c5.large        AL2023_x86_64_STANDARD  eks-myeks-2nd-node-group-38ce92b6-b297-6e0d-3573-9f35605debe4   managed

$ aws ec2 describe-instances \
  --query 'Reservations[].Instances[].{ID:InstanceId,Type:InstanceType,State:State.Name,PublicIPAdd:PublicIpAddress,PrivateIP:PrivateIpAddress,Name:Tags[?Key==`Name`]|[0].Value}' \
  --output table
-------------------------------------------------------------------------------------------------------------
|                                             DescribeInstances                                             |
+---------------------+-----------------------+-----------------+-----------------+-----------+-------------+
|         ID          |         Name          |    PrivateIP    |   PublicIPAdd   |   State   |    Type     |
+---------------------+-----------------------+-----------------+-----------------+-----------+-------------+
|  i-008dc157c0269fea5|  myeks-1nd-node-group |  192.168.4.44   |  3.34.195.178   |  running  |  t3.medium  |
|  i-088bdd51c689cf201|  myeks-1nd-node-group |  192.168.1.58   |  43.203.200.165 |  running  |  t3.medium  |
|  i-087d438a6c75b18f3|  myeks-1nd-node-group |  192.168.9.33   |  3.39.239.143   |  running  |  t3.medium  |
|  i-03e62d2db7d97bfe5|  myeks-2nd-node-group |  192.168.11.194 |  54.180.121.152 |  running  |  c5.large   |
+---------------------+-----------------------+-----------------+-----------------+-----------+-------------+
[aws-sts] 캐시된 토큰 사용 (13분 남음)

$ kubectl get node -l tier=secondary
NAME                                                STATUS   ROLES    AGE   VERSION
ip-192-168-11-194.ap-northeast-2.compute.internal   Ready    <none>   74s   v1.34.4-eks-f69f56f
[aws-sts] 캐시된 토큰 사용 (13분 남음)

maxNodes를 확인 후, 배포해봅시다.

ssh $NODE4

[ec2-user@ip-192-168-11-194 ~]$ cat /etc/kubernetes/kubelet/config.json | jq
{
...
  "maxPods": 29,
...
}
[ec2-user@ip-192-168-11-194 ~]$ cat /etc/kubernetes/kubelet/config.json.d/40-nodeadm.conf
{
    "apiVersion": "kubelet.config.k8s.io/v1beta1",
    "clusterDNS": [
        "10.100.0.10"
    ],
    "kind": "KubeletConfiguration",
    "maxPods": 110

90개까진 못버팁니다. 이 경우 maxPods를 고치고 구동해봅시다.

cat /etc/kubernetes/kubelet/config.json | grep maxPods
cat /etc/kubernetes/kubelet/config.json.d/40-nodeadm.conf | grep maxPods

# sed 로 변경 : 기존 29 -> 변경 110
sudo sed -i 's/"maxPods": 29/"maxPods": 110/g' /etc/kubernetes/kubelet/config.json
sudo sed -i 's/"maxPods": 29/"maxPods": 110/g' /etc/kubernetes/kubelet/config.json.d/40-nodeadm.conf

# 적용
sudo systemctl restart kubelet

110개는 안되는군요. 시스템 파드가 차지하고 있기 때문에 안되는 것으로 보입니다.

to ip-192-168-11-194.ap-northeast-2.compute.internal
66s                      Warning   FailedScheduling          Pod/nginx-deployment-2-64c6845c57-xqxcc                  0/4 nodes are available: 1 Too many pods, 3 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/4 nodes are available: 1 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.
66s                      Normal    Scheduled                 Pod/nginx-deployment-2-64c6845c57-7h8xl                  Successfully assigned default/nginx-deployment-2-64c6845c57-7h8xl to ip-192-168-11-194.ap-northeast-2.compute.internal
66s                      Normal    Scheduled                 Pod/nginx-deployment-2-64c6845c57-ghkbc                  Successfully assigned default/nginx-deployment-2-64c6845c57-ghkbc to ip-192-168-11-194.ap-northeast-2.compute.internal
66s                      Normal    Scheduled                 Pod/nginx-deployment-2-64c6845c57-29dn4                  Successfully assigned default/nginx-deployment-2-64c6845c57-29dn4 to ip-192-168-11-194.ap-northeast-2.compute.internal
66s                      Warning   FailedScheduling          Pod/nginx-deployment-2-64c6845c57-2wc88                  0/4 nodes are available: 1 Too many pods, 3 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/4 nodes are available: 1 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.

그리고 돌고있는 파드의 값을 바꾸면 안되겠지요. 영속적으로 저장되는 게 아니니까요. 영속적으로 이 상태를 보존하기 위한 시도를 해보았으나 이를 실패한 기록을 공유합니다.

절대 주의사항

scale 을 0으로 내리시고 다음 작업을 이어가세요!

kubectl scale deployment nginx-deployment --replicas=0

그렇지 않으면 110개 한개치에서 evict를 자동으로 기다리다가 파드가 무한정 더 늘어납니다.
그럴 땐 당황하지 말고 위 터미널로 해결하세요.

Service 와 Amazon EKS의 네트워킹 지원

왜 Service가 필요한가

쿠버네티스의 서비스를 설명하기에 앞서, 통상의 서버 배포를 생각해봅시다.

전통적인 서버는 Pet(반려동물)입니다. 이름을 붙이고(web-server-01), 고정 IP를 할당하고, 아프면 치료합니다. 운영자는 이 서버의 IP를 외우고, 설정 파일에 하드코딩하고, 장애가 나면 그 서버를 직접 고칩니다. 고정 IP 하나가 있어야 인터넷과 통신이 되었고, 그 IP가 곧 서버의 정체성이었죠.

쿠버네티스의 파드는 Cattle(가축)입니다. 번호를 매기고(nginx-deployment-54fc99c8d-gfv8w), 아프면 도태시키고 새로 만듭니다. 파드가 재시작되면 IP가 바뀌고, 스케일아웃하면 파드가 늘어나고, 스케일인하면 사라집니다. 개별 파드의 IP에 의존하는 것은 애초에 불가능한 구조입니다.

그러면 클라이언트는 어떤 IP로 요청을 보내야 할까요? 파드 3개가 떠 있는데, 어떤 파드로 가야 하죠? 파드가 죽고 새로 뜨면 IP가 바뀌는데, 그때마다 클라이언트 설정을 바꿔야 할까요?

이 문제를 해결하는 것이 Service입니다.

Service가 해결하는 것

Service는 휘발적인 파드 집합 앞에 **안정적인 가상 IP(ClusterIP)**와 DNS 이름을 제공합니다.

                          ┌─────────────────────┐
                          │  Service             │
                          │  ClusterIP:          │
 ┌──────────┐   요청      │  10.100.23.45        │
 │ 클라이언트 │ ─────────▶ │                      │
 │  파드      │            │  selector:           │
 └──────────┘            │    app: my-app       │
                          └──────┬──────────────┘

                    iptables DNAT (kube-proxy)

                 ┌───────────────┼───────────────┐
                 │               │               │
                 ▼               ▼               ▼
          ┌────────────┐ ┌────────────┐ ┌────────────┐
          │  파드 1      │ │  파드 2      │ │  파드 3      │
          │ 192.168.1.201│ │ 192.168.6.254│ │ 192.168.9.201│
          │  (Node 2)   │ │  (Node 1)   │ │  (Node 3)   │
          └────────────┘ └────────────┘ └────────────┘

즉, Service는 서비스 디스커버리(어디에 있는지 찾기)와 로드밸런싱(여러 파드에 분산)을 동시에 제공하는 추상화 계층입니다.

어떻게 구현되나? (Part 1에서 이미 봤습니다)

사실, Part 1에서 이미 Service의 구현체를 들여다본 적이 있습니다. Part 1의 iptables NAT 규칙 살펴보기에서 확인했던 체인들이 바로 그것입니다.

Service를 구현하는 컴포넌트는 세 가지이며, 각각 독립적인 역할을 맡습니다:

컴포넌트 역할 계층
VPC CNI 파드에 VPC 실제 IP를 할당하고, 파드 간 네이티브 라우팅을 제공. 모든 것의 기반 L3
kube-proxy iptables 규칙으로 ClusterIP → 실제 파드 IP로 DNAT. Service의 트래픽 라우팅을 담당 L3/L4
CoreDNS 서비스명.namespace.svc.cluster.local → ClusterIP로 DNS 해석. 서비스 디스커버리 담당 L7(DNS)

이 셋의 관계를 정리하면:

1. CoreDNS: "my-service.default.svc.cluster.local 의 IP는 10.100.23.45야"
     ↓ (DNS 응답)
2. 클라이언트 파드가 10.100.23.45 로 요청
     ↓ (패킷 전송)
3. kube-proxy: iptables DNAT로 10.100.23.45 → 192.168.1.201 (실제 파드 IP)로 변환
     ↓ (DNAT 완료)
4. VPC CNI: 192.168.1.201은 VPC의 실제 IP → 오버레이 없이 VPC 라우팅으로 직접 도달

Part 1에서 봤던 KUBE-SVC-*KUBE-SEP-* 체인이 3번 단계, --probability로 균등 분배하던 규칙이 바로 Service의 로드밸런싱 구현체입니다. 그리고 4번 단계에서 VPC CNI가 빛을 발합니다. DNAT 후의 목적지가 VPC 실제 IP이므로, 별도의 터널링이나 오버레이 없이 VPC 라우팅만으로 패킷이 도달합니다.

VPC CNI는 Service를 구현하지 않습니다

오해하기 쉬운 부분입니다. VPC CNI는 파드에 IP를 부여하고 파드 간 L3 연결을 제공하는 기반 인프라입니다. Service의 ClusterIP 라우팅(iptables DNAT)은 kube-proxy가, DNS 기반 서비스 디스커버리는 CoreDNS가 담당합니다. 이 셋은 모두 독립적인 EKS 애드온이며, 각자의 영역에서 협력하여 Service라는 추상화를 완성합니다.

kube-proxy와 프록시 모드

위에서 kube-proxy가 iptables 규칙으로 Service를 구현한다고 했는데, 좀 더 정확히 말하면 이것은 여러 프록시 모드 중 하나입니다. kube-proxy는 각 노드에서 DaemonSet으로 동작하며(Docs), Service와 Endpoints 오브젝트를 watch하다가 변경이 생기면 노드의 패킷 포워딩 규칙을 갱신합니다.

핵심은 이것입니다: kube-proxy는 직접 트래픽을 중계하지 않습니다. 커널의 패킷 필터링 계층에 규칙을 심어두고, 실제 패킷 처리는 커널이 합니다. kube-proxy가 죽어도 이미 설정된 규칙은 살아있으므로 기존 Service 통신은 계속 동작합니다(물론 규칙 갱신은 안 되지만요).

kube-proxy는 선택사항입니다

공식 문서에 따르면, kube-proxy 대신 동등한 기능을 제공하는 네트워크 플러그인(예: Cilium의 eBPF 기반 kube-proxy replacement)을 사용한다면 kube-proxy를 아예 배포하지 않아도 됩니다.

프록시 모드는 역사적으로 발전해왔으며, 현재 네 가지가 존재합니다:

1. User space 프록시 모드 (deprecated)

초기 구현체로, 현재는 사용되지 않습니다. kube-proxy 프로세스가 직접 프록시 역할을 수행했습니다.

클라이언트 → iptables(REDIRECT) → kube-proxy 프로세스(user space) → 백엔드 파드

모든 Service 트래픽이 user space의 kube-proxy 프로세스를 거쳐야 했기 때문에, kernel space ↔ user space 전환 비용이 매 패킷마다 발생했습니다. kube-proxy 프로세스가 죽으면 Service 통신 자체가 끊기는 SPOF 문제도 있었죠.

2. iptables 프록시 모드 (netfilter)

현재 EKS의 기본 모드이며, Part 1에서 확인한 모드입니다. kube-proxy가 직접 proxy 역할을 하는 대신, netfilter에 규칙만 설정하고 실제 패킷 처리는 전부 커널이 합니다.

클라이언트 → netfilter(DNAT) → 백엔드 파드

          kube-proxy는 여기에
          규칙만 써넣는 역할

ClusterIP 접근 시 iptables 체인이 실제로 어떤 순서로 적용되는지(PREROUTING → KUBE-SERVICES → KUBE-SVC → KUBE-SEP → POSTROUTING)는 ClusterIP Service의 iptables 체인 흐름에서 상세히 정리했습니다.

Part 1에서 확인한 kube-proxy 설정을 다시 보면:

$ k describe cm -n kube-system kube-proxy-config
iptables:
  masqueradeAll: false
  masqueradeBit: 14
  minSyncPeriod: 0s
  syncPeriod: 30s     # ← 30초마다 규칙 동기화
mode: "iptables"       # ← 현재 사용 중인 모드
iptables 모드의 한계와 deprecation

iptables 규칙은 선형 탐색(linear search) 구조입니다. Service와 Endpoint가 수천 개로 늘어나면 규칙 수가 폭발적으로 증가하고, 매 패킷마다 체인을 순회하는 비용이 무시할 수 없게 됩니다. 또한 규칙 갱신 시 전체 테이블을 교체하므로 동기화 시간도 길어집니다.

이 한계를 극복하기 위해 IPVS, nftables 모드가 등장했으며, Kubernetes 1.35+부터는 iptables 모드에 deprecation warning이 표시됩니다. (Blog, KEP-5495)

3. IPVS 프록시 모드

Linux 커널의 L4 로드밸런서인 IPVS(IP Virtual Server)를 활용하는 모드입니다. iptables 모드와 마찬가지로 netfilter hook을 기반으로 하지만, 내부 데이터 구조로 해시 테이블을 사용하여 O(1)에 가까운 룩업 성능을 제공합니다.

비교 항목 iptables 모드 IPVS 모드
데이터 구조 체인(선형 탐색) 해시 테이블(O(1) 룩업)
동작 위치 kernel space kernel space
규칙 갱신 전체 테이블 교체 개별 규칙 증분 업데이트
LB 알고리즘 random(probability) rr, wrr, lc, wlc, sh, sed, nq 등 다양
대규모 클러스터 성능 저하 발생 안정적

IPVS 모드는 Service 수가 1,000개를 넘어가는 대규모 클러스터에서 iptables 대비 확실한 성능 이점이 있습니다. 다만 IPVS만으로 모든 것을 처리할 수는 없어서, SNAT이나 masquerade 같은 일부 기능에는 여전히 iptables를 병행합니다.

EKS에서의 IPVS 모드

EKS에서도 kube-proxy의 mode를 "ipvs"로 변경하여 사용할 수 있습니다. 자세한 내용은 EKS Best Practices - IPVS 문서를 참고하세요.

4. nftables 프록시 모드

iptables의 후속 API인 nftables를 사용하는 모드입니다. Linux 커널 5.13 이상이 필요합니다.

nftables는 iptables와 같은 netfilter 서브시스템 위에서 동작하지만, 더 나은 성능과 확장성을 위해 설계되었습니다. Endpoint 변경 시 규칙 갱신이 iptables 모드보다 빠르고 효율적이며, 커널 내 패킷 처리 성능도 개선됩니다(Service가 수만 개 수준일 때 체감).

iptables 모드 deprecation 예정

Kubernetes 1.35+부터 iptables 모드에 deprecation warning이 표시되며, nftables 모드로의 전환이 권장되고 있습니다. (Blog, KEP-5495)

5. eBPF 기반 (kube-proxy replacement)

kube-proxy 자체를 대체하는 접근입니다. Cilium 같은 CNI 플러그인이 eBPF 프로그램을 커널에 직접 로드하여, netfilter/iptables를 우회(bypass) 하고 패킷을 처리합니다.

[iptables/netfilter 기반]
패킷 → netfilter hooks → iptables/nftables/IPVS 규칙 → 라우팅 → 전달

[eBPF 기반]
패킷 → eBPF 프로그램 (TC/XDP) → 바로 전달

netfilter 스택 자체를 건너뛰므로 오버헤드가 가장 낮습니다. XDP(eXpress Data Path)와 결합하면 NIC 드라이버 레벨에서 패킷을 처리할 수도 있습니다. 다만 이 방식은 특정 CNI(Cilium 등)에 종속되며, EKS 기본 구성인 VPC CNI + kube-proxy 조합과는 별도의 선택지입니다.

정리

모드 데이터 경로 핵심 특징 비고
userspace kube-proxy 프로세스 매 패킷 kernel↔user 전환 deprecated
iptables netfilter (iptables API) 커널에서 처리, 선형 탐색 EKS 기본값, 1.35+ deprecation 예정
IPVS netfilter (IPVS + iptables) 해시 테이블, 다양한 LB 알고리즘 대규모 클러스터에 유리
nftables netfilter (nftables API) iptables 후속, 더 빠른 규칙 갱신 커널 5.13+, Kubernetes 1.31+ stable
eBPF eBPF (TC/XDP) netfilter 우회, 최저 오버헤드 Cilium 등 별도 CNI 필요

Service 타입

쿠버네티스 Service에는 네 가지 타입이 있으며, 위에서 아래로 갈수록 외부 노출 범위가 넓어집니다:

타입 접근 범위 동작
ClusterIP 클러스터 내부 가상 IP(ClusterIP)를 할당. 기본값
NodePort 클러스터 외부 (노드 IP) ClusterIP + 모든 노드의 특정 포트(30000-32767)를 열어 외부 노출
LoadBalancer 클러스터 외부 (LB) NodePort + 클라우드 로드밸런서(ELB/NLB)를 자동 프로비저닝
ExternalName DNS 레벨 ClusterIP 없이, CNAME 레코드로 외부 서비스를 클러스터 내부 이름에 매핑

EKS 환경에서는 LoadBalancer 타입이 AWS의 로드밸런서와 연동됩니다. 그런데 이 "연동"을 누가, 어떻게 하느냐에 따라 방식이 나뉩니다.

Amazon EKS의 외부 노출 방안

쿠버네티스에서 Service를 외부에 노출하려면 결국 클러스터 밖의 로드밸런서나 프록시가 필요합니다. AWS 환경에서는 이를 구현하는 방식이 역사적으로 발전해왔으며, 크게 네 가지로 나눌 수 있습니다.

이 섹션은 아래 AWS 공식 블로그를 참고하여 작성했습니다.

1. In-tree Cloud Controller Manager

쿠버네티스에 내장된(in-tree) AWS 클라우드 프로바이더가 Service type: LoadBalancer를 감지하면, **Cloud Controller Manager(CCM)**를 통해 CLB(Classic Load Balancer) 또는 NLB(Network Load Balancer)를 자동으로 프로비저닝합니다.

Service (type: LoadBalancer)
  → Cloud Controller Manager (in-tree)
    → CLB(기본) / NLB(어노테이션 지정 시) 프로비저닝
      → NodePort를 통해 파드에 도달 (instance mode만 지원)

이 방식은 **instance mode(NodePort 경유)**만 지원합니다. 로드밸런서가 각 노드의 NodePort로 트래픽을 보내고, 노드의 iptables가 다시 파드로 DNAT합니다. 즉 트래픽이 LB → 노드 → (iptables DNAT) → 파드두 번 홉합니다.

별도의 컨트롤러 설치 없이 기본 제공되므로 설정이 간단하다는 장점이 있습니다. 어노테이션 수준의 세밀한 제어가 필요하지 않은 단순한 L4 노출이라면 여전히 유효한 선택지입니다.

in-tree vs out-of-tree

in-tree 클라우드 프로바이더는 쿠버네티스 코어에 AWS 종속 코드가 포함되어 있어, 쿠버네티스 릴리스 주기에 맞춰야만 AWS 기능을 업데이트할 수 있었습니다. 이를 분리(out-of-tree)하여 AWS 자체 릴리스 주기로 독립시킨 것이 AWS Load Balancer Controller입니다.

2. AWS Load Balancer Controller (LBC)

AWS에서 공식으로 제공하는 out-of-tree 컨트롤러입니다. 클러스터에 별도로 설치해야 하지만, in-tree CCM 대비 훨씬 풍부한 기능을 제공합니다:

K8s 리소스 프로비저닝 대상 계층
Service (type: LoadBalancer) NLB (Network Load Balancer) L4
Ingress ALB (Application Load Balancer) L7

LBC의 핵심 차별점은 두 가지 트래픽 모드를 지원한다는 것입니다:

Instance mode vs IP mode

[Instance mode]  — in-tree CCM과 동일한 경로, LBC도 지원
NLB → Target Group (노드 IP:NodePort)
  → 노드의 iptables DNAT → 파드

[IP mode]  — LBC만 가능, VPC CNI 필수
NLB → Target Group (파드 IP:Port 직접 등록)
  → 파드에 바로 도달
비교 항목 Instance mode IP mode
Target Group 대상 노드 IP + NodePort 파드 IP + 컨테이너 Port
트래픽 홉 2홉 (노드 → iptables → 파드) 1홉 (NLB → 파드)
클라이언트 IP 보존 externalTrafficPolicy 설정 필요 기본 보존
CNI 요구사항 제한 없음 VPC CNI 필수 (파드 IP가 VPC 실제 IP여야 함)
사용 시점 오버레이 CNI 사용 시, 호환성 우선 VPC CNI 환경에서 성능/가시성 우선

IP mode가 가능한 이유는 Part 1에서 살펴본 VPC CNI 덕분입니다. 파드 IP가 VPC의 실제 IP이므로, NLB의 Target Group에 파드 IP를 직접 등록할 수 있습니다. 오버레이 기반 CNI에서는 파드 IP가 VPC 라우팅 테이블에 존재하지 않으므로 이 방식이 불가능합니다.

Pod Readiness Gate

LBC는 Pod Readiness Gate를 지원합니다. 파드가 기동되어도 NLB Target Group에 등록되고 헬스체크를 통과하기 전까지는 파드를 Ready 상태로 표시하지 않습니다. 이를 통해 배포 중 트래픽 유실을 방지할 수 있습니다.

3. Ingress (L7 리버스 프록시)

Service가 L4(TCP/UDP) 수준의 로드밸런싱이라면, Ingress는 L7(HTTP/HTTPS) 수준의 라우팅을 제공합니다. 호스트명이나 경로 기반으로 여러 Service에 트래픽을 분기할 수 있습니다.

# 하나의 ALB로 여러 서비스를 라우팅
apiVersion: networking.k8s.io/v1
kind: Ingress
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /users
            backend:
              service: { name: user-svc, port: { number: 80 } }
          - path: /orders
            backend:
              service: { name: order-svc, port: { number: 80 } }

EKS에서 AWS LBC가 Ingress 리소스를 감지하면 ALB를 프로비저닝합니다. ALB도 NLB와 마찬가지로 instance mode와 IP mode를 모두 지원합니다. 서비스 10개를 외부에 노출해야 할 때 NLB를 10개 만드는 대신, ALB 하나로 L7 라우팅하는 것이 비용과 운영 면에서 일반적입니다.

4. Gateway API

Ingress의 후속 표준으로, 더 풍부한 라우팅 기능과 역할 분리를 제공합니다.

Ingress:  하나의 리소스에 인프라 설정 + 라우팅 규칙이 혼재

Gateway API:
  GatewayClass  ← 인프라팀: "어떤 종류의 게이트웨이를 쓸 것인가" (ALB, NLB, ...)
  Gateway       ← 인프라팀: "리스너 포트, TLS 설정"
  HTTPRoute     ← 개발팀: "이 호스트/경로는 이 서비스로"

EKS에서는 AWS LBC가 Gateway API도 지원하며, 이 내용은 Part 3에서 다룹니다.

정리

방식 프로비저닝 계층 트래픽 모드 트래픽 경로
In-tree CCM CLB/NLB L4 instance만 LB → NodePort → 파드
AWS LBC (Service) NLB L4 instance / IP LB → 파드 IP 직접(IP)
AWS LBC (Ingress) ALB L7 instance / IP LB → 파드 IP 직접(IP)
AWS LBC (Gateway API) ALB/NLB L4/L7 instance / IP LB → 파드 IP 직접(IP)

어떤 방식을 선택할지는 요구사항에 따라 갈립니다:

이 중 AWS LBC + NLB IP 모드(Service L4)를 이어서 실습합니다.

AWS LoadBalancer Controller (LBC) 와 Service (L4)

앞서 EKS의 외부 노출 방안을 개관했으니, 이제 실제로 AWS LBC가 NLB를 어떻게 프로비저닝하고 트래픽을 전달하는지 구체적으로 살펴봅시다.

LBC 설치는 1주차에서 완료

IRSA 설정, Helm을 통한 LBC 설치, 서브넷 태깅 등 LBC 배포 과정은 1주차 - EKS 설치 및 기본 설정에서 이미 다뤘습니다. 이번 글에서는 LBC가 설치된 상태에서 NLB의 동작 방식과 트래픽 흐름에 집중합니다.

이 섹션의 다이어그램은 AWS 공식 블로그 Deploying AWS Load Balancer Controller on Amazon EKS에서 가져왔습니다.

NLB의 두 가지 트래픽 모드

AWS LBC가 Service (type: LoadBalancer)를 처리할 때 NLB를 프로비저닝하며, 트래픽을 파드까지 전달하는 경로는 instance modeIP mode 두 가지입니다.

Instance mode (인스턴스 유형)

NLB의 Target Group에 노드(EC2 인스턴스)의 NodePort를 등록합니다. in-tree CCM과 동일한 트래픽 경로입니다.

NLB Instance mode 다이어그램

외부 클라이언트 → NLB → 노드(NodePort) → iptables DNAT → 파드

이 모드에서 클라이언트 IP 보존 여부는 externalTrafficPolicy 설정에 따라 달라집니다:

externalTrafficPolicy 로드밸런싱 클라이언트 IP 동작
Cluster (기본값) 2번 분산 (NLB → iptables) 손실 (SNAT됨) 노드에 도착한 트래픽을 iptables가 다른 노드의 파드로도 전달 가능. 고른 분산이지만 추가 홉 + SNAT 발생
Local 1번 분산 (NLB만) 보존 노드에 도착한 트래픽을 해당 노드의 로컬 파드에만 전달. 추가 홉 없이 클라이언트 IP 유지

Local 모드에서의 핵심 동작:

Cluster vs Local 트레이드오프

Cluster는 분산이 고르지만 클라이언트 IP를 잃고, Local은 클라이언트 IP를 보존하지만 파드가 특정 노드에 몰려있으면 부하가 불균형해질 수 있습니다. 실무에서는 클라이언트 IP 보존이 필요한 경우(로깅, 접근 제어)에 Local을 사용합니다.

IP mode (IP 유형)

NLB의 Target Group에 파드 IP를 직접 등록합니다. AWS LBC 설치가 필수이며, in-tree CCM으로는 사용할 수 없습니다.

외부 클라이언트 → NLB → 파드 IP (VPC 네이티브, 1홉)

두 모드 비교

비교 항목 Instance mode IP mode
Target Group 대상 노드 IP + NodePort 파드 IP + 컨테이너 Port
트래픽 홉 2홉 (노드 → iptables → 파드) 1홉 (NLB → 파드)
클라이언트 IP 보존 externalTrafficPolicy: Local 필요 기본 보존
필수 요구사항 없음 (in-tree CCM으로도 가능) AWS LBC + VPC CNI
레이턴시 NodePort 경유로 상대적 높음 직접 통신으로 낮음
사용 시점 오버레이 CNI 사용 시, 호환성 우선 VPC CNI 환경에서 성능·가시성 우선