본문 바로가기
삵
AI

쿠버네티스에서 GPU로 LLM 추론 서빙하기

oopennova·2026년 10월 9일·조회 3

GPU 노드를 쿠버네티스에 붙여달라는 요청은 요즘 거의 LLM 추론 때문에 온다. 모델 하나 띄우자고 A100 한 장을 통째로 파드에 묶어두면 아깝고, 그렇다고 CPU로 돌리자니 토큰이 안 나온다. 예전에 머신러닝 학습 잡을 배치로 돌리던 때와 달리, 추론 서빙은 항상 떠 있어야 하고 GPU 활용률은 한참 남는 경우가 많다. 이 글은 그 중간을 메우는 설정을 정리한다.

결론부터 말하자면, 쿠버네티스에서 파드에 GPU를 주려면 세 가지가 순서대로 필요하다. 노드에 nvidia-container-toolkit을 설치해 컨테이너 런타임이 GPU를 보게 하고, NVIDIA device plugin을 데몬셋으로 올려 nvidia.com/gpu라는 리소스를 노출시키고, 파드의 resources.limits에서 그 리소스를 요청한다. GPU 한 장을 여러 파드가 나눠 쓰려면 time-slicing(시간 분할) 또는 MIG(물리 분할)를 추가로 설정한다.

1. 용어부터 짚는다

device plugin은 쿠버네티스가 CPU, 메모리 외의 특수 장치(GPU, FPGA 등)를 스케줄링 단위로 다루게 해주는 확장 방식이다. kubelet이 플러그인과 통신해 노드에 몇 개의 GPU가 있는지 보고받고, 파드가 그걸 요청하면 할당한다. NVIDIA device plugin은 노드의 GPU 개수를 세어 nvidia.com/gpu라는 이름으로 등록한다.

nvidia-container-toolkit은 컨테이너 런타임(containerd, Docker)이 컨테이너 안에서 GPU 드라이버와 디바이스 파일을 쓸 수 있도록 중계하는 런타임 훅이다. 이게 없으면 device plugin이 GPU를 등록해도 컨테이너 안에서는 nvidia-smi조차 안 돈다.

time-slicing은 GPU 한 장의 시간을 잘게 쪼개 여러 파드가 번갈아 쓰게 하는 방식이고, MIG(Multi-Instance GPU)는 A100, H100 같은 일부 GPU를 하드웨어 수준에서 격리된 여러 조각으로 쪼개는 방식이다. 둘의 차이는 뒤에서 자세히 다룬다.

2. nvidia-container-toolkit 설치와 containerd 설정

전제 조건부터 확인한다. 노드에 NVIDIA 드라이버가 이미 설치돼 있어야 한다. 호스트에서 nvidia-smi가 정상적으로 GPU를 보여주는지 먼저 본다.

$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 550.90.07    Driver Version: 550.90.07    CUDA Version: 12.4     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
|   0  NVIDIA A100-SXM4-40GB  On | 00000000:00:1E.0 Off |                    0 |
+-------------------------------+----------------------+----------------------+

드라이버가 보이면 toolkit을 올린다. 쿠버네티스 노드의 기본 런타임은 요즘 거의 containerd라서 containerd 기준으로 적는다. Ubuntu 기준 apt 저장소 등록 방법이다.

$ curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

$ curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

$ sudo apt-get update
$ sudo apt-get install -y nvidia-container-toolkit

설치했으면 containerd가 NVIDIA 런타임을 쓰도록 설정을 바꾼다. nvidia-ctk가 설정 파일을 알아서 수정해준다.

$ sudo nvidia-ctk runtime configure --runtime=containerd
INFO[0000] Loading config from /etc/containerd/config.toml
INFO[0000] Wrote updated config to /etc/containerd/config.toml

$ sudo systemctl restart containerd

여기서 한 번 걸리는 지점이 있다. toolkit 버전은 device plugin이 요구하는 1.7.0 이상이어야 한다. 오래된 노드 이미지를 그대로 쓰면 이 조건을 못 맞춰 파드가 뜨긴 뜨는데 컨테이너 안에서 CUDA 초기화가 실패한다. apt-cache policy nvidia-container-toolkit로 설치된 버전을 확인하는 습관을 들이는 게 좋다.

3. NVIDIA device plugin 올리기

toolkit이 준비됐으면 device plugin을 데몬셋으로 배포한다. 간단히 띄울 때는 정적 매니페스트를 바로 적용한다.

$ kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.17.1/deployments/static/nvidia-device-plugin.yml
daemonset.apps/nvidia-device-plugin-daemonset created

운영 환경이라면 Helm으로 올리는 편을 권한다. time-slicing이나 MIG 설정을 values로 관리하기 쉽고, 버전 고정과 롤백이 깔끔하다.

$ helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
$ helm repo update
$ helm upgrade -i nvdp nvdp/nvidia-device-plugin \
    --namespace nvidia-device-plugin \
    --create-namespace \
    --version 0.17.1

버전 번호는 저장소 상태에 따라 다를 수 있으니 릴리스 노트에서 최신 안정판을 확인한다.

잘 올라왔는지는 노드가 GPU 리소스를 광고하는지로 확인한다.

$ kubectl get nodes -o json \
  | jq '.items[].status.capacity["nvidia.com/gpu"]'
"1"

이 값이 null이면 플러그인 파드가 CrashLoopBackOff거나 toolkit 설정이 빠진 것이다. kubectl logs -n nvidia-device-plugin ds/nvdp-nvidia-device-plugin으로 로그를 먼저 본다.

4. 파드에서 GPU 요청하기

노드가 nvidia.com/gpu를 광고하기 시작하면 파드는 resources.limits에서 그걸 요청한다. GPU는 CPU처럼 초과 할당(overcommit)이 안 되는 정수 리소스라서 requests 없이 limits만 적으면 된다.

apiVersion: v1
kind: Pod
metadata:
  name: gpu-check
spec:
  restartPolicy: Never
  containers:
  - name: cuda
    image: nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0
    resources:
      limits:
        nvidia.com/gpu: 1
$ kubectl apply -f gpu-check.yaml
$ kubectl logs gpu-check
[Vector addition of 50000 elements]
Test PASSED

실제 LLM 추론 서빙은 vLLM 같은 서버를 디플로이먼트로 올린다. 아래는 GPU 한 장을 통째로 쓰는 vLLM 예시다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-qwen
spec:
  replicas: 1
  selector:
    matchLabels: { app: vllm-qwen }
  template:
    metadata:
      labels: { app: vllm-qwen }
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args:
        - --model
        - Qwen/Qwen2.5-7B-Instruct
        - --max-model-len
        - "8192"
        ports:
        - containerPort: 8000
        resources:
          limits:
            nvidia.com/gpu: 1
        volumeMounts:
        - name: cache
          mountPath: /root/.cache/huggingface
      volumes:
      - name: cache
        emptyDir: {}

모델 가중치는 재시작마다 다시 받으면 느리니 emptyDir 대신 PVC로 캐시를 영속화하는 게 낫다. 모델 크기에 맞는 GPU 메모리가 있는지도 미리 계산해둔다. 7B 모델을 FP16으로 올리면 가중치만 14GB 안팎이라 KV 캐시까지 감안하면 24GB급 이상이 편하다.

5. time-slicing으로 한 장을 나눠 쓰기

7B 모델 하나가 A100 40GB의 절반도 안 쓰는데 GPU를 통째로 점유하면 낭비다. time-slicing은 GPU 하나를 여러 개의 가상 리소스로 광고해서, 여러 파드가 같은 물리 GPU를 번갈아 쓰게 한다.

설정은 ConfigMap으로 공유 정책을 정의하고 플러그인에 넘긴다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config
  namespace: nvidia-device-plugin
data:
  any: |-
    version: v1
    sharing:
      timeSlicing:
        renameByDefault: false
        failRequestsGreaterThanOne: true
        resources:
        - name: nvidia.com/gpu
          replicas: 4

세 개의 키를 짚는다.

  • replicas: 4 - 물리 GPU 한 장을 4개의 할당 슬롯으로 광고한다. 노드의 nvidia.com/gpu capacity가 1에서 4로 바뀐다.
  • failRequestsGreaterThanOne: true - 파드가 슬롯을 2개 이상 요청하면 거부한다. 공유 슬롯 여러 개를 한 파드가 묶어 써도 성능이 늘지 않으니 켜두는 게 맞다.
  • renameByDefault: false - true로 바꾸면 공유 리소스가 nvidia.com/gpu.shared라는 별도 이름으로 광고된다. 전용 GPU 노드와 공유 GPU 노드를 한 클러스터에서 구분하고 싶을 때 쓴다.

Helm으로 이 설정을 적용한다.

$ kubectl create -f time-slicing-config.yaml
$ helm upgrade -i nvdp nvdp/nvidia-device-plugin \
    --namespace nvidia-device-plugin \
    --version 0.17.1 \
    --set config.name=time-slicing-config \
    --set config.default=any

꼭 알아둘 함정이 있다. time-slicing은 메모리를 격리하지 않는다. 공식 문서도 같은 물리 GPU에서 슬롯을 받은 워크로드끼리 격리가 없고 모두 같은 GPU 메모리에 접근한다고 못 박는다. 파드 두 개가 각각 KV 캐시를 크게 잡으면 한쪽이 OOM을 내거나 다른 파드를 끌어내린다. 그래서 공유는 GPU를 실제로 적게 쓰는 소형 모델이나 트래픽이 뜸한 추론용으로 한정하고, 파드별 GPU 메모리 상한은 애플리케이션 레벨에서 제어해야 한다. 예를 들어 vLLM이면 --gpu-memory-utilization 0.45처럼 각 파드가 절반 이하만 쓰도록 명시한다.

6. MIG로 하드웨어 격리해 나누기

메모리까지 확실히 격리하고 싶으면 MIG를 쓴다. A100, H100 같은 GPU는 하드웨어에서 최대 7개의 독립 인스턴스로 쪼개지고, 각 조각은 자기 몫의 연산 유닛과 메모리를 전용으로 갖는다. 한 조각에서 터져도 다른 조각에 영향이 없다.

먼저 노드에서 MIG 모드를 켜고 프로파일을 만든다. 이건 드라이버 레벨 작업이라 노드에서 직접 한다.

$ sudo nvidia-smi -mig 1
$ sudo nvidia-smi mig -cgi 1g.5gb -C
Successfully created GPU instance ID  7 on GPU  0
Successfully created compute instance ID  0 on GPU  0 GPU instance ID  7

그다음 device plugin에 MIG 전략을 알려준다.

$ helm upgrade -i nvdp nvdp/nvidia-device-plugin \
    --namespace nvidia-device-plugin \
    --version 0.17.1 \
    --set migStrategy=mixed

전략은 세 가지다.

  • none - 기본값. MIG를 무시하고 GPU를 통짜로 본다.
  • single - 노드의 모든 MIG 조각이 같은 크기일 때 쓴다. 조각들을 여전히 nvidia.com/gpu 이름으로 광고한다.
  • mixed - 크기가 섞인 조각을 각각 다른 이름으로 광고한다. 예를 들어 nvidia.com/mig-1g.5gb 같은 리소스가 생긴다.

mixed 전략에서는 파드가 원하는 조각 크기를 이름으로 지정해 요청한다.

    resources:
      limits:
        nvidia.com/mig-1g.5gb: 1

7. time-slicing과 MIG, 무엇을 고르나

메모리 격리가 필요하면 MIG, 아니면 time-slicing이다. 트래픽이 뜸한 소형 모델 여러 개를 한 장에 몰아넣어 활용률만 올리려는 거라면 설정이 가벼운 time-slicing으로 충분하다. 반면 고객사별로 분리된 추론 파드를 한 GPU에 태우거나, 한 파드의 메모리 폭주가 옆 파드를 죽이면 안 되는 상황이라면 MIG로 하드웨어 격리를 걸어야 한다. MIG는 A100, H100 등 지원 GPU에서만 되고 조각 크기도 고정 프로파일 안에서만 고를 수 있다는 제약이 있으니, 모델 메모리 요구량을 먼저 재서 프로파일에 맞는지 확인하는 게 순서다.

자주 묻는 질문

device plugin을 올렸는데 노드에 nvidia.com/gpu가 안 보인다.

대부분 nvidia-container-toolkit 설정이 빠졌거나 버전이 1.7.0 미만이다. 호스트에서 nvidia-smi가 GPU를 보이는지, nvidia-ctk runtime configure로 containerd 설정을 바꾸고 재시작했는지 확인한다. 그래도 안 되면 device plugin 파드 로그(kubectl logs)를 본다. CrashLoopBackOff면 런타임 연동 문제다.

GPU를 requests와 limits에 둘 다 적어야 하나.

nvidia.com/gpu는 초과 할당이 안 되는 정수 리소스라 limits에만 적으면 된다. 쿠버네티스가 limits 값을 requests에도 자동으로 복사한다. 소수점이나 0보다 작은 값은 쓸 수 없다.

time-slicing으로 나눠 쓰면 메모리도 나뉘나.

아니다. time-slicing은 GPU의 처리 시간만 분할하고 메모리는 격리하지 않는다. 같은 물리 GPU를 공유하는 파드들은 전체 GPU 메모리에 함께 접근하므로, 한 파드가 메모리를 많이 잡으면 다른 파드가 OOM을 낼 수 있다. 메모리 격리가 필요하면 MIG를 쓴다.

MIG는 어떤 GPU에서 되나.

A100, A30, H100 등 데이터센터급 Ampere 이후 일부 GPU에서만 지원한다. 소비자용 GPU나 구형 모델은 MIG가 없으니 time-slicing으로 공유해야 한다. 조각 크기도 1g.5gb처럼 GPU별로 정해진 프로파일 안에서만 고를 수 있다.

7B 규모 LLM 추론에 GPU 메모리가 얼마나 필요한가.

FP16 기준 가중치만 14GB 안팎이고 KV 캐시와 활성화 메모리를 더하면 환경에 따라 다르지만 24GB급 이상이 편하다. vLLM이면 --gpu-memory-utilization으로 파드가 쓸 비율을 제한할 수 있어 공유 환경에서 특히 유용하다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.