본문 바로가기

카테고리 없음

[AEWS 4기] EKS 스터디 9주차 (AI/ML 워크로드 EKS GPU Node 구성하기 )

header


title: "EKS에서 GPU Node Group 구성하고 nvidia.com/gpu 확인하기"
date: 2026-05-16
tags: [AWS, EKS, Kubernetes, Terraform, NVIDIA, GPU]


EKS Managed Node Group에 NVIDIA GPU 노드를 붙이고, Kubernetes에서 nvidia.com/gpu 리소스가 실제로 보이는지 확인한 과정을 정리했다.

들어가며

EKS에서 GPU 워크로드를 테스트하려면 단순히 GPU 인스턴스를 하나 띄우는 것으로 끝나지 않는다. 노드 AMI는 NVIDIA 드라이버가 포함된 EKS Optimized AMI를 써야 하고, Kubernetes가 GPU를 리소스로 인식하도록 NVIDIA Device Plugin도 설치해야 한다.

이번에는 Terraform으로 g5.xlarge 기반 GPU Node Group을 만들고, GPU 노드에는 taint를 걸어 GPU가 필요한 Pod만 배치되도록 구성했다. 마지막으로 kubectl describe node에서 nvidia.com/gpu: 1이 보이는 것까지 확인했다.

전체 구성

이번 구성의 핵심은 다음 네 가지다.

항목 설명
AMI Type AL2023_x86_64_NVIDIA NVIDIA 드라이버가 포함된 EKS Optimized AMI
Instance Type g5.xlarge NVIDIA GPU 1개가 포함된 인스턴스
Capacity Type ON_DEMAND 테스트 안정성을 위해 On-Demand 사용
Disk Size 100GiB CUDA/ML 이미지 pull을 고려해 넉넉하게 설정

GPU 노드는 비용이 꽤 나갈 수 있기 때문에 기본값은 비활성화하고, 필요할 때만 EnableGpuNodeGroup=true로 켜는 방식으로 만들었다.

Terraform 변수 추가

먼저 GPU Node Group을 켜고 끌 수 있도록 변수들을 추가했다.

variable "EnableGpuNodeGroup" {
  description = "Whether to create an EKS managed node group for NVIDIA GPU workloads."
  type        = bool
  default     = false
}

variable "GpuNodeInstanceTypes" {
  description = "EC2 instance types for the GPU worker node group."
  type        = list(string)
  default     = ["g5.xlarge"]
}

variable "GpuNodeCapacityType" {
  description = "Capacity type for the GPU worker node group. Valid values are ON_DEMAND or SPOT."
  type        = string
  default     = "ON_DEMAND"
}

variable "GpuNodeCount" {
  description = "Fixed number of GPU worker nodes."
  type        = number
  default     = 1
}

variable "GpuNodeVolumesize" {
  description = "Volume size for GPU worker nodes (in GiB)."
  type        = number
  default     = 100
}

variable "EnableNvidiaDevicePlugin" {
  description = "Whether to install the NVIDIA Kubernetes device plugin by Helm after the EKS cluster is created."
  type        = bool
  default     = true
}

GPU Managed Node Group 추가

기존 eks_managed_node_groups에 GPU 노드 그룹을 조건부로 합치는 방식으로 구성했다. EnableGpuNodeGrouptrue일 때만 GPU 노드 그룹이 생성된다.

eks_managed_node_groups = merge({
  primary = {
    ami_type       = "AL2023_x86_64_STANDARD"
    instance_types = [var.WorkerNodeInstanceType]
    desired_size   = var.WorkerNodeCount
    max_size       = var.WorkerNodeCount + 2
    min_size       = var.WorkerNodeCount - 1
  }

  spot = {
    ami_type       = "AL2023_x86_64_STANDARD"
    instance_types = ["t3.medium", "t3.large", "t3a.medium", "t3a.large"]
    capacity_type  = "SPOT"
    desired_size   = 2
    max_size       = 4
    min_size       = 1
  }
}, var.EnableGpuNodeGroup ? {
  gpu = {
    name           = "${var.ClusterBaseName}-ng-gpu"
    ami_type       = "AL2023_x86_64_NVIDIA"
    instance_types = var.GpuNodeInstanceTypes
    capacity_type  = var.GpuNodeCapacityType
    desired_size   = var.GpuNodeCount
    max_size       = var.GpuNodeCount
    min_size       = var.GpuNodeCount
    disk_size      = var.GpuNodeVolumesize

    labels = {
      tier                     = "gpu"
      node-role                = "gpu"
      "nvidia.com/gpu.present" = "true"
    }

    taints = {
      gpu = {
        key    = "nvidia.com/gpu"
        value  = "true"
        effect = "NO_SCHEDULE"
      }
    }
  }
} : {})

여기서 중요한 부분은 ami_typetaints다.

AL2023_x86_64_NVIDIA AMI는 NVIDIA 드라이버가 포함된 EKS Optimized AMI다. 일반 AL2023_x86_64_STANDARD AMI로 GPU 인스턴스를 띄우면 GPU 장치는 있어도 드라이버 구성이 빠져 있어 Kubernetes에서 바로 쓰기 어렵다.

또 GPU 노드에는 nvidia.com/gpu=true:NoSchedule taint를 걸었다. 이렇게 하면 GPU가 필요 없는 일반 Pod가 비싼 GPU 노드에 올라가는 일을 줄일 수 있다.

📌 핵심 요약

GPU 노드는 일반 워커 노드와 다르게 봐야 한다.
AMI는 NVIDIA AMI를 쓰고, taint로 일반 Pod 스케줄링을 막고, GPU Pod에는 toleration을 명시하는 흐름이 가장 깔끔하다.

NVIDIA Device Plugin 설치

GPU 노드가 올라왔다고 해서 Kubernetes가 바로 GPU를 리소스로 인식하는 것은 아니다. nvidia.com/gpu 리소스를 kubelet에 등록하려면 NVIDIA Device Plugin이 필요하다.

이번 구성에서는 Terraform의 null_resourcelocal-exec를 사용해 Helm으로 device plugin을 설치했다.

resource "null_resource" "nvidia_device_plugin" {
  count = var.EnableGpuNodeGroup && var.EnableNvidiaDevicePlugin ? 1 : 0

  triggers = {
    cluster_name       = module.eks.cluster_name
    plugin_config_hash = "gpu-taint-toleration-v1"
    region             = var.TargetRegion
  }

  provisioner "local-exec" {
    interpreter = ["/bin/bash", "-c"]
    command     = <<-EOT
      set -euo pipefail
      aws eks --region ${self.triggers.region} update-kubeconfig --name ${self.triggers.cluster_name}
      helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
      helm repo update nvdp
      helm upgrade --install nvdp nvdp/nvidia-device-plugin \
        --namespace nvidia \
        --create-namespace \
        --set gfd.enabled=true \
        --set tolerations[0].key=nvidia.com/gpu \
        --set tolerations[0].operator=Exists \
        --set tolerations[0].effect=NoSchedule \
        --set gfd.tolerations[0].key=nvidia.com/gpu \
        --set gfd.tolerations[0].operator=Exists \
        --set gfd.tolerations[0].effect=NoSchedule
    EOT
  }
}

처음에 놓치기 쉬운 부분은 toleration이다. GPU 노드에 nvidia.com/gpu=true:NoSchedule taint를 걸었기 때문에 NVIDIA Device Plugin DaemonSet도 이 taint를 tolerate해야 GPU 노드에 올라갈 수 있다.

만약 toleration이 없으면 device plugin이 정작 GPU 노드에 스케줄되지 못하고, 결과적으로 Allocatablenvidia.com/gpu가 나오지 않을 수 있다.

배포하기

GPU Node Group은 기본적으로 꺼져 있으므로 apply할 때 변수를 켜준다.

cd /Users/kpkim/gasida/aews/4w
terraform init
terraform apply -var EnableGpuNodeGroup=true

기존 클러스터가 있는데 Terraform이 다시 만들려고 하면서 다음 에러가 난 적도 있었다.

ResourceInUseException: Cluster already exists with name: myeks

원인은 module.eks.aws_eks_cluster.this[0] 리소스가 Terraform state에서 tainted 상태였기 때문이다. tainted 리소스는 Terraform이 교체 대상으로 보기 때문에 같은 이름의 EKS 클러스터를 다시 생성하려고 했다.

기존 클러스터를 살릴 거라면 taint를 제거한다.

terraform -chdir=/Users/kpkim/gasida/aews/4w untaint 'module.eks.aws_eks_cluster.this[0]'
terraform -chdir=/Users/kpkim/gasida/aews/4w plan -var EnableGpuNodeGroup=true

삽질 포인트

Cluster already exists가 나왔다고 바로 AWS 콘솔에서 클러스터를 지우면 더 꼬일 수 있다.
먼저 terraform state list, terraform state show, terraform show -json으로 state가 tainted인지 확인하는 편이 낫다.

NVIDIA 관련 Pod 확인

설치 후 nvidia namespace를 보면 다음과 같은 Pod들이 올라온다.

kubectl -n nvidia get pods -o wide

대략 이런 구성이다.

Pod 역할
nvdp-nvidia-device-plugin-* GPU 장치를 찾아 kubelet에 nvidia.com/gpu 리소스로 등록
nvdp-nvidia-device-plugin-gpu-feature-discovery-* GPU 모델, 드라이버, capability 같은 정보를 node label로 등록
nvdp-node-feature-discovery-master-* Node Feature Discovery 제어 역할
nvdp-node-feature-discovery-worker-* 각 노드의 CPU, OS, PCI device 등 feature 수집
nvdp-node-feature-discovery-gc-* 오래된 feature label과 관련 리소스 정리

단순히 GPU 리소스 등록만 필요하다면 device plugin이 핵심이다. GFD와 NFD는 GPU/노드 특성을 label로 잘 보여주는 보조 구성요소라고 보면 된다.

GPU 리소스 확인

GPU 노드가 Ready 상태이고 device plugin이 정상적으로 올라오면 kubectl describe node에서 Capacity 또는 Allocatable 섹션에 nvidia.com/gpu가 보인다.

kubectl get nodes -l node-role=gpu
kubectl describe node <gpu-node-name> | grep -A10 -E "Capacity|Allocatable"

최종적으로 확인한 출력은 다음과 같았다.

Capacity:
  cpu:                4
  ephemeral-storage:  20893676Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             16164764Ki
  nvidia.com/gpu:     1
  pods:               58

여기서 nvidia.com/gpu: 1이 보이면 Kubernetes가 GPU를 리소스로 인식하고 있다는 뜻이다.

주의할 점은 Allocated resources 섹션이다.

Allocated resources:
  Resource           Requests   Limits
  nvidia.com/gpu     0          0

이 값은 GPU가 등록되지 않았다는 뜻이 아니다. 현재 GPU를 요청해서 사용 중인 Pod가 없다는 뜻이다. 실제 GPU를 요청하는 Pod를 띄우면 Allocated resources 쪽에도 GPU 사용량이 잡힌다.

GPU 테스트 Pod 실행

실제로 GPU를 잡고 nvidia-smi를 실행하려면 GPU limit과 toleration을 같이 넣는다.

apiVersion: v1
kind: Pod
metadata:
  name: gpu-test
spec:
  restartPolicy: Never
  tolerations:
    - key: nvidia.com/gpu
      operator: Equal
      value: "true"
      effect: NoSchedule
  containers:
    - name: gpu-test
      image: nvidia/cuda:12.4.1-base-ubuntu22.04
      command: ["nvidia-smi"]
      resources:
        limits:
          nvidia.com/gpu: 1

위 내용을 바로 적용하려면 다음처럼 실행한다.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: gpu-test
spec:
  restartPolicy: Never
  tolerations:
    - key: nvidia.com/gpu
      operator: Equal
      value: "true"
      effect: NoSchedule
  containers:
    - name: gpu-test
      image: nvidia/cuda:12.4.1-base-ubuntu22.04
      command: ["nvidia-smi"]
      resources:
        limits:
          nvidia.com/gpu: 1
EOF

로그를 확인한다.

kubectl logs gpu-test

nvidia-smi 표가 나오면 GPU 할당과 컨테이너 내부 인식까지 성공이다.

테스트가 끝나면 Pod를 삭제한다.

kubectl delete pod gpu-test

벤치마크 2: llama.cpp CUDA로 추론 성능 측정

GPU 리소스가 정상적으로 등록된 것까지 확인했으니, 다음으로 실제 LLM 추론 벤치마크를 돌려봤다.

최근 g4dn.xlarge의 NVIDIA T4에서 자체 개발 추론 엔진으로 Llama 3.2 3B Q4_K_M 모델을 63.4 tok/s까지 끌어올렸다는 PoC 글을 봤다. 그 결과와 직접적으로 같은 엔진을 비교할 수는 없지만, 현재 EKS GPU 노드에서 공개 기준선인 llama.cpp CUDA를 실행해 어느 정도 성능이 나오는지 확인해볼 수는 있다.

이번 비교는 다음처럼 이해해야 한다.

항목 참고 PoC 이번 테스트
인스턴스 g4dn.xlarge g5.xlarge
GPU NVIDIA T4 NVIDIA A10G
모델 Llama 3.2 3B Q4_K_M Llama 3.2 3B Q4_K_M
엔진 Sovereign Engine llama.cpp CUDA
비교 성격 자체 엔진 PoC 공개 엔진 기준선

⚠️ 주의

이 비교는 순수한 엔진 성능 비교가 아니다.
GPU도 다르고 엔진도 다르다.
따라서 "llama.cpp가 더 빠르다" 또는 "Sovereign Engine이 느리다" 같은 결론을 바로 내리면 안 된다.
여기서 얻을 수 있는 결론은 "현재 내 EKS g5.xlarge 환경에서 llama.cpp CUDA 기준 어느 정도 tok/s가 나오는가"이다.

벤치마크 Job 구성

Kubernetes Job으로 벤치마크를 실행했다. 모델은 Hugging Face의 GGUF 파일을 initContainer에서 다운로드하고, 메인 컨테이너에서 llama-bench를 실행하는 구조다.

벤치마크용 파일은 다음 위치에 만들었다.

/Users/kpkim/gasida/aews/4w/bench/llama-cpp-gpu-bench.yaml
/Users/kpkim/gasida/aews/4w/bench/run-llama-cpp-gpu-bench.sh

Job의 핵심 설정은 GPU 노드에만 스케줄되도록 nodeSelector를 넣고, GPU taint를 통과하도록 toleration을 추가한 부분이다.

nodeSelector:
  node-role: gpu
tolerations:
  - key: nvidia.com/gpu
    operator: Equal
    value: "true"
    effect: NoSchedule
containers:
  - name: llama-bench
    image: ghcr.io/ggml-org/llama.cpp:full-cuda
    resources:
      limits:
        nvidia.com/gpu: 1

모델은 Llama-3.2-3B-Instruct-Q4_K_M.gguf를 사용했다.

벤치마크 스크립트는 아래와 같다.

#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 1 ]]; then
  echo "Usage: $0 <Llama-3.2-3B-Instruct-Q4_K_M.gguf download URL>" >&2
  exit 1
fi

model_url="$1"
namespace="${NAMESPACE:-default}"
manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
manifest="${manifest_dir}/llama-cpp-gpu-bench.yaml"

if [[ "${model_url}" == *"..."* ]]; then
  echo "ERROR: MODEL_URL contains '...'. Pass a real, reachable GGUF download URL." >&2
  exit 1
fi

kubectl -n "${namespace}" delete job llama-cpp-gpu-bench --ignore-not-found
kubectl -n "${namespace}" create configmap llama-cpp-gpu-bench \
  --from-literal=MODEL_URL="${model_url}" \
  --from-literal=MODEL_FILE="Llama-3.2-3B-Instruct-Q4_K_M.gguf" \
  --dry-run=client -o yaml | kubectl -n "${namespace}" apply -f -
kubectl -n "${namespace}" apply -f "${manifest}"

kubectl -n "${namespace}" wait --for=condition=complete job/llama-cpp-gpu-bench --timeout=60m
kubectl -n "${namespace}" logs job/llama-cpp-gpu-bench --all-containers=true
cd /Users/kpkim/gasida/aews/4w/bench

./run-llama-cpp-gpu-bench.sh \
  "https://huggingface.co/hugging-quants/Llama-3.2-3B-Instruct-Q4_K_M-GGUF/resolve/main/llama-3.2-3b-instruct-q4_k_m.gguf"

모델 다운로드는 약 1.9GB였고, 이번 테스트에서는 다운로드 속도가 꽤 잘 나왔다.

100 1925M  100 1925M    0     0   358M      0  0:00:05  0:00:05 --:--:--  377M
real    0m 5.37s

-rw-r--r--    1 curl_user curl_group    1.9G May 16 08:57 /models/Llama-3.2-3B-Instruct-Q4_K_M.gguf

다만 현재 Job은 emptyDir에 모델을 저장하기 때문에 Pod를 새로 만들 때마다 모델을 다시 다운로드한다. 반복 벤치마크를 할 거라면 PVC를 붙여 모델을 캐시하는 편이 좋다.

GPU 확인 결과

벤치마크 컨테이너에서 nvidia-smi를 실행했을 때 GPU는 NVIDIA A10G로 정상 인식됐다.

NVIDIA-SMI 580.159.03
Driver Version: 580.159.03
CUDA Version: 13.0

GPU  Name         Memory-Usage
0    NVIDIA A10G  0MiB / 23028MiB

llama.cpp도 CUDA backend를 정상적으로 로드했다.

ggml_cuda_init: found 1 CUDA devices (Total VRAM: 22836 MiB):
  Device 0: NVIDIA A10G, compute capability 8.6, VMM: yes, VRAM: 22836 MiB
load_backend: loaded CUDA backend from /app/libggml-cuda.so
load_backend: loaded CPU backend from /app/libggml-cpu-haswell.so

여기서 compute capability 8.6은 A10G가 Ampere 계열 GPU임을 보여준다.

llama-bench 결과

벤치마크는 다음 조건으로 실행했다.

/app/llama-bench \
  -m "/models/Llama-3.2-3B-Instruct-Q4_K_M.gguf" \
  -ngl 99 \
  -p 512 \
  -n 128 \
  -r 5

각 옵션의 의미는 다음과 같다.

옵션 의미
-ngl 99 가능한 많은 레이어를 GPU로 offload
-p 512 prompt processing 테스트 토큰 수
-n 128 token generation 테스트 토큰 수
-r 5 반복 측정 횟수

실제 결과는 다음과 같았다.

| model                  | size     | params | backend | ngl | test  | t/s              |
| ---------------------- | -------: | -----: | ------- | --: | ----: | ---------------: |
| llama 3B Q4_K - Medium | 1.87 GiB | 3.21 B | CUDA    |  99 | pp512 | 6859.38 ± 562.02 |
| llama 3B Q4_K - Medium | 1.87 GiB | 3.21 B | CUDA    |  99 | tg128 | 177.26 ± 0.32    |

여기서 실제 응답 생성 속도 비교에 주로 보는 값은 tg128이다. pp512는 입력 프롬프트를 처리하는 속도이고, tg128은 모델이 새 토큰을 생성하는 속도다.

이번 테스트의 핵심 수치는 다음이다.

Llama 3.2 3B Q4_K_M
g5.xlarge / NVIDIA A10G / llama.cpp CUDA
token generation: 177.26 tok/s

T4 PoC 수치와 비교

참고한 글의 수치는 g4dn.xlarge의 NVIDIA T4에서 63.4 tok/s였다. 이번 테스트의 A10G 결과는 177.26 tok/s였다.

단순 성능 배율은 다음과 같다.

177.26 / 63.4 = 2.80

즉 현재 테스트 환경에서는 생성 속도만 놓고 보면 약 2.8배 높게 나왔다.

항목 T4 PoC A10G 테스트
GPU T4 A10G
인스턴스 g4dn.xlarge g5.xlarge
생성 속도 63.4 tok/s 177.26 tok/s
성능 배율 1x 약 2.8x

하지만 이 결과를 해석할 때는 조심해야 한다. T4와 A10G는 GPU 체급이 다르고, 사용한 추론 엔진도 다르다. 따라서 "T4보다 A10G가 2.8배 빠르다"가 아니라, "이번 조건에서 A10G + llama.cpp CUDA가 177 tok/s를 보였다"가 정확한 표현이다.

가격 비교: g4dn.xlarge vs g5.xlarge

서울 리전 ap-northeast-2 On-Demand 기준으로도 비교해봤다.

인스턴스 GPU 시간당 비용 월 730h 환산
g4dn.xlarge T4 16GB $0.647/hr $472.31/mo
g5.xlarge A10G 22GB $1.237/hr $903.01/mo

가격 배율은 다음과 같다.

1.237 / 0.647 = 1.91

g5.xlargeg4dn.xlarge보다 약 1.9배 비싸다.

이번 실측 성능까지 같이 놓고 보면 비용 대비 tok/s도 계산할 수 있다.

T4 PoC:       63.4 / 0.647  = 98.0 tok/s per $/hr
A10G 테스트: 177.26 / 1.237 = 143.3 tok/s per $/hr

정리하면 이번 측정값 기준으로는 g5.xlarge가 시간당 비용은 더 비싸지만, tok/s 증가폭이 더 커서 비용 대비 생성 처리량도 더 높게 나왔다.

항목 T4 PoC A10G 테스트
시간당 비용 $0.647 $1.237
생성 속도 63.4 tok/s 177.26 tok/s
비용 배율 1x 약 1.91x
성능 배율 1x 약 2.8x
tok/s per $/hr 약 98.0 약 143.3

📌 핵심 요약

이번 테스트만 놓고 보면 A10G는 T4보다 비싸지만, Llama 3.2 3B Q4_K_M 생성 처리량 증가폭이 가격 증가폭보다 컸다.
다만 엔진과 GPU가 모두 다르기 때문에 동일 조건 비교는 아니다.

벤치마크에서 배운 점

첫째, GPU가 Kubernetes에 nvidia.com/gpu로 등록되는 것과 실제 LLM 추론이 잘 도는 것은 별개의 확인 포인트다. Capacity에 GPU가 보여도 실제 CUDA backend가 로드되는지, 모델이 GPU offload되는지 확인해야 한다.

둘째, Allocated resources에서 nvidia.com/gpu 0으로 보이는 것은 GPU가 없는 게 아니라 현재 GPU를 점유한 Pod가 없다는 뜻이다. 벤치마크 Job처럼 limits.nvidia.com/gpu: 1을 요청하는 Pod가 올라오면 해당 노드에서 GPU 할당이 잡힌다.

셋째, 모델 다운로드는 벤치마크의 잡음이 될 수 있다. 이번에는 Hugging Face에서 1.9GB 모델을 약 5초에 받았지만, 반복 측정에서는 PVC나 S3 캐시를 쓰는 편이 좋다.

넷째, LLM 추론 성능 비교에서는 pptg를 구분해야 한다. 사용자가 체감하는 응답 생성 속도는 보통 tg, 즉 token generation 수치에 더 가깝다.

마무리

EKS에서 GPU 노드를 구성할 때는 세 가지를 같이 봐야 한다. 첫째, GPU 드라이버가 포함된 NVIDIA AMI를 사용해야 한다. 둘째, Kubernetes가 GPU를 리소스로 인식하도록 NVIDIA Device Plugin을 설치해야 한다. 셋째, GPU 노드에 taint를 걸었다면 device plugin과 GPU workload 모두 toleration을 가져야 한다.

이번 테스트에서는 Capacitynvidia.com/gpu: 1이 표시되는 것에서 멈추지 않고, 실제 llama.cpp CUDA 벤치마크까지 실행했다. 그 결과 g5.xlarge의 NVIDIA A10G에서 Llama 3.2 3B Q4_K_M 모델이 177.26 tok/s의 token generation 성능을 보였다.

다음 단계로는 같은 조건을 g4dn.xlarge의 T4에서도 직접 측정해서, 엔진을 제외한 GPU/인스턴스 차이를 더 공정하게 비교해보면 좋다. 추가로 PVC 캐시, Spot 인스턴스, 여러 quantization 옵션까지 비교하면 실제 운영 관점의 비용 대비 성능표를 만들 수 있을 것 같다.