Skip to content

EKS에서 GPU 노드 구성과 GitLab Runner 연동

이번 글에서는 EKS 클러스터에 GPU 노드를 GitLab Runner로 사용할 수 있도록 구성했던 경험을 정리한다.

GPU 노드를 EKS에 올리는 것 자체는 어렵지 않다. 인스턴스 타입을 g5나 p3로 바꾸고 노드 그룹을 하나 추가하면 된다.

하지만 GPU를 어떤 워크로드에 얼마만큼 할당할 것인지, GitLab Runner에서 GPU를 인식시키려면 뭘 해야 하는지 이런 부분들이 생각보다 번거로웠다.

왜 GPU 노드가 필요했는가

기존에는 GitLab Runner가 CPU 기반 파이프라인만 처리하고 있었다. 빌드, 테스트, 배포 이런 작업들은 CPU와 메모리만 있으면 충분했다.

그런데 팀에서 ML 모델을 서빙하는 서비스가 생기면서, CI/CD 파이프라인 안에서 모델 학습이나 추론 테스트를 돌려야 하는 상황이 생겼다.

로컬 머신에서 학습 스크립트를 돌리고 결과를 수동으로 검증하던 방식은 재현성이 떨어졌고, 파이프라인에 통합해달라는 요청이 여러 팀에서 들어왔다.

처음에는 SageMaker 같은 별도의 외부 서비스를 쓰는 것도 고려했다.

하지만 이미 GitLab CI/CD 기반으로 배포 체계가 잡혀있는 상황에서, 학습과 추론 테스트만 별도 플랫폼으로 빼는 건 개발자들의 경험 측면에서도, 관리 측면에서도 오히려 더 번거롭다고 판단하였다.

그래서 EKS 클러스터에 GPU 노드 그룹을 추가하고, GitLab Runner가 GPU Job을 처리할 수 있도록 구성하는 방향으로 결정했다.

전체 구성 흐름

GPU 노드를 도입해서 GitLab Runner가 GPU Job을 처리하기까지의 전체 흐름은 이렇다.

flowchart LR
    A[GitLab CI/CD Pipeline] -->|GPU Job 트리거| B[GitLab Runner]
    B -->|GPU 요청 Pod 생성| C[GPU Node Group]
    C --> D[NVIDIA Device Plugin]
    D -->|GPU 할당| E[ML 학습/추론 Pod]
    E -->|결과 업로드| A

순서대로 보면, 개발자가 .gitlab-ci.yml에 GPU를 요청하는 Job을 정의하고, Runner가 해당 Job을 실행할 때 nvidia.com/gpu 리소스를 포함한 Pod를 생성한다. 이 Pod가 GPU 노드에 스케줄되면 NVIDIA Device Plugin이 GPU 디바이스를 컨테이너에 매핑해주고, 컨테이너 안에서 CUDA 기반 작업이 실행되는 구조다.

GPU 노드 그룹 구성

인스턴스 타입 선택

AWS에서 GPU 인스턴스를 고를 때 가장 먼저 봐야 하는 건 용도에 맞는 GPU 종류다.

인스턴스 패밀리 GPU 주요 용도
p3 NVIDIA V100 대규모 학습, 분산 학습
g4dn NVIDIA T4 추론, 경량 학습, 영상 처리
g5 NVIDIA A10G 학습과 추론 겸용, 비용 대비 성능이 좋음
g6 NVIDIA L4 최신 세대, 전력 효율이 높고 추론에 최적화

CI/CD 파이프라인에서 돌리는 작업 대부분은 대규모 학습이 아니라 모델 검증이나 소규모 fine-tuning이었다. V100은 과한 선택이었고, T4는 VRAM이 16GB라 일부 모델에서 부족했다. 결국 g5(A10G, VRAM 24GB)를 메인으로 선택했다. 비용 대비 성능이 가장 균형 잡혀있었다.

Terraform으로 노드 그룹 추가

기존 EKS 모듈에 GPU 전용 노드 그룹을 추가했다. 핵심은 GPU 노드를 일반 워크로드와 분리하는 것이다.

# 예시
gpu = {
  ami_type      = "AL2023_x86_64_NVIDIA"
  instance_types = ["g5.xlarge"]
  capacity_type = "ON_DEMAND"

  min_size     = 1
  max_size     = 1
  desired_size = 1

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

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

몇 가지 포인트를 짚어보면:

AMI 타입: AL2023_x86_64_NVIDIA를 사용한다. 일반 Amazon Linux 2023 AMI에는 NVIDIA 드라이버가 포함되어 있지 않다. GPU AMI를 선택해야 CUDA 드라이버가 미리 설치된 상태로 노드가 올라온다.

Taint 설정: GPU 인스턴스에는, GPU가 필요한 Pod만 올라올 수 있도록 nvidia.com/gpu Taint를 걸어서 Toleration이 있는 Pod만 배치되도록 했다.

노드 수 고정: Node의 갯수는 n개로 고정하였다.

디스크 크기: ML 컨테이너 이미지는 보통 몇 GB에서 십수 GB까지 가는 경우가 많다. PyTorch + CUDA 런타임이 포함된 이미지를 pull하면 디스크를 빠르게 잡아먹는다. 기본 20~30GB로는 이미지 두세 개 캐시하면 꽉 차기 때문에 생각보다 넉넉하게 지정해야했다.

NVIDIA Device Plugin 설치

GPU 노드가 올라왔다고 바로 GPU를 쓸 수 있는 건 아니다. Kubernetes가 GPU를 리소스로 인식하려면 NVIDIA Device Plugin DaemonSet이 필요하다. 이 플러그인이 노드의 GPU 디바이스를 탐색해서 nvidia.com/gpu 리소스로 kubelet에 등록해주는 역할을 한다.

설치 후 GPU가 정상적으로 인식되는지 확인한다.

# GPU 리소스가 노드에 등록됐는지 확인
kubectl describe node <gpu-node-name> | grep -A5 "Allocatable"

# 출력 예시
# nvidia.com/gpu: 1   <-- 이게 보여야 정상

GPU 자원 분할 — Time-Slicing

g5.xlarge는 GPU 1개가 달려있다. 그런데 CI/CD 파이프라인의 GPU Job들이 전부 GPU를 100% 점유해야 하는 작업은 아니었다. 모델 추론 테스트 같은 가벼운 작업은 GPU의 10~20%만 써도 충분한데, GPU 1개를 통째로 할당받으면 나머지 용량은 놀게 된다.

그래서 NVIDIA Device Plugin의 Time-Slicing 기능을 활용했다.

Time-Slicing은 물리적인 GPU 1개를 여러 개의 가상 GPU 슬라이스로 나눠서, 여러 Pod가 시분할로 GPU를 공유할 수 있게 해준다. MIG(Multi-Instance GPU)와 달리 메모리가 완전히 격리되지는 않지만, A100 같은 고급 GPU 없이도 구성이 가능하다는 점이 CI/CD 환경에서는 실용적이었다.

# nvidia-device-plugin-config ConfigMap example
apiVersion: v1
kind: ConfigMap
metadata:
  name: nvidia-device-plugin-config
  namespace: kube-system
data:
  config.yaml: |
    version: v1
    sharing:
      timeSlicing:
        renameByDefault: false
        resources:
          - name: nvidia.com/gpu
            replicas: 4

replicas: 4로 설정하면 물리 GPU 1개가 nvidia.com/gpu: 4로 보고된다. 즉, 최대 4개의 Pod가 같은 GPU에 함께 스케줄될 수 있다.

동시에 스케줄된다는 건 Pod들이 같은 GPU 위에 함께 올라갈 수 있다는 뜻이지, GPU 연산이 진짜로 병렬 실행된다는 의미는 아니다.

내부적으로는 CUDA의 시분할(time-multiplexing) 메커니즘이 동작하면서, 여러 워크로드가 GPU 시간을 번갈아가며 점유한다.

MIG처럼 물리적으로 GPU를 쪼개는 게 아니라, 시간 축에서 interleaving되는 방식이다.

이 구조에서 또 한 가지 주의할 점이 있다. Pod가 nvidia.com/gpu: 2처럼 time-sliced GPU를 여러 개 요청한다고 해서, 그만큼 비례하는 GPU compute power가 보장되지는 않는다.

또한 NVIDIA 공식 문서에도 명시되어 있는 내용으로 time-sliced replica는 보장된 연산 비율은 아니다. 그래서 우리는 운영 가이드 상 time-sliced 환경에서는 Pod당 nvidia.com/gpu: 1로 요청할 수 있도록 하였다.

kubectl describe node <gpu-node-name> | grep "nvidia.com/gpu"
# Allocatable:
#   nvidia.com/gpu: 4

주의할 점이 있다. Time-Slicing은 메모리를 격리해주지 않는다.

GPU 메모리 24GB인 A10G를 4등분한다고 해서 각 Pod에 6GB가 보장되는 게 아니다. 한 Pod가 메모리를 많이 쓰면 다른 Pod가 OOM killed 될 수 있다.

그래서 각 파이프라인에서 사용하는 모델의 VRAM 사용량을 대략적으로 모니터링 후, 동시 실행 수를 조절하는 방식으로 운영했다.

replicas 값을 너무 올리면 GPU OOM이 빈번해지고, 너무 보수적으로 잡으면 GPU 자원 할당이 비효율 적이 되었다.

GitLab Runner에서 GPU Job 실행하기

Runner 설정

GitLab Runner가 Kubernetes executor로 동작하고 있다면, GPU Job을 실행할 때 Pod에 GPU 리소스 요청과 Toleration, nodeSelector를 추가해야 한다. 이건 Runner의 config.toml이나 Helm values에서 설정한다.

# helm.tofu - GitLab Runner GPU 설정 Example
resource "helm_release" "gitlab_runner" {
  name       = "gitlab-runner"
  repository = "https://charts.gitlab.io"
  chart      = "gitlab-runner"
  namespace  = "gitlab"
  version    = "0.88.2"

  values = [yamlencode({
    runners = {
      config = <<-EOT
        [[runners]]
            # ...
            # Example configuration...
            [runners.kubernetes.node_selector]
              "node-role" = "gpu"
            [runners.kubernetes.node_tolerations]
              "nvidia.com/gpu=true" = "NoSchedule"
            [runners.kubernetes.pod_labels]
              "workload-type" = "gpu-ci"
            [runners.kubernetes.resource_requests]
              cpu = "1"
              memory = "4Gi"
              "nvidia.com/gpu" = "1"
            [runners.kubernetes.resource_limits]
              cpu = "4"
              memory = "16Gi"
              "nvidia.com/gpu" = "1"
      EOT
    }
  })]

  depends_on = [helm_release.nvidia_device_plugin]
}

여기서 중요한 건 GPU Runner를 기존 CPU Runner와 분리하는 것이다. 모든 Job이 GPU를 요청하면 GPU 노드가 없을 때 파이프라인이 통째로 Pending에 빠진다.

CPU 전용 Runner와 GPU 전용 Runner를 별도로 등록하고, Job 단위에서 어떤 Runner를 사용할지 tag로 구분하게 한다.

.gitlab-ci.yml에서 GPU Job 정의

파이프라인에서 GPU가 필요한 Job은 tags로 GPU Runner를 지정하고, GPU가 포함된 컨테이너 이미지를 사용한다.

Tip nvidia-smi를 스크립트 첫 줄에 넣으면 디버깅용으로 유용하다

GPU 모니터링

GPU 노드를 운영하면 기존 모니터링만으로는 부족하다. GPU 사용률, VRAM 사용량, 온도 같은 메트릭을 별도로 수집해야 한다.

DCGM Exporter

NVIDIA DCGM(Data Center GPU Manager) Exporter를 DaemonSet으로 배포하면 Prometheus가 GPU 메트릭을 수집할 수 있다.

주요 메트릭

Grafana에서 모니터링할 때 특히 유용했던 메트릭들이다.

메트릭 설명 용도
DCGM_FI_DEV_GPU_UTIL GPU 코어 사용률 (%) Job이 실제로 GPU를 사용하고 있는지 확인
DCGM_FI_DEV_FB_USED 프레임버퍼(VRAM) 사용량 (MiB) OOM 위험 사전 감지
DCGM_FI_DEV_FB_FREE 프레임버퍼 여유량 (MiB) Time-Slicing 시 남은 여유 확인
DCGM_FI_DEV_GPU_TEMP GPU 온도 (°C) 과열 감지
DCGM_FI_DEV_POWER_USAGE 전력 사용량 (W) 비정상 부하 감지

마무리

EKS에 GPU 노드를 추가하는 것 자체는 노드 그룹 하나 더 만드는 것과 크게 다르지 않다. 하지만 GPU를 효율적으로 운영하는 건 별개의 문제다.

GPU 인스턴스는 비싸기 때문에 그냥 올려두고 방치해서는 안 된다. Time-Slicing으로 여러 Job이 나눠 쓰도록 하고, 모니터링으로 실제로 GPU가 놀고 있는 구간이 없는지 확인하고, GPU가 굳이 필요 없는 단계는 CPU Job으로 빼는, 이런 세밀한 운영이 필요했다.

CI/CD의 튜닝이 많이 중요!

GitLab Runner와의 연동은 생각보다 설정이 많았다. Runner 자체의 resource 설정, Toleration, nodeSelector, 컨테이너 이미지 호환성, timeout까지 하나씩 맞춰야 한다.

특히 CUDA 버전과 드라이버 버전 호환성은 처음에 맞추기 전까지 에러 메시지만으로는 원인 파악이 어려웠다.

돌이켜보면 가장 효과가 컸던 건 GPU Runner와 CPU Runner를 완전히 분리한 것과, Time-Slicing으로 가벼운 Job들이 GPU를 공유하도록 한 것이다.

이 두 가지만으로도 GPU 리소스 활용률이 눈에 띄게 개선됐고, 파이프라인 대기 시간도 줄었다.