LLM 스터디 7주차 - kind에 llm-d 올리기 — 배포부터 게이트웨이 오버헤드 실측까지

개념편에서 llm-d가 요청마다 목적지를 다시 고른다는 구조를 정리했다. 그 구조에는 값이 붙는다. 매 요청마다 Envoy가 EPP에게 물어보고 답을 기다린다면, 그 대기는 몇 ms인가.

RTX 3050 한 장이 꽂힌 데스크톱에 kind로 클러스터를 만들고 llm-d를 올려 직접 쟀다. 결론은 TTFT 기준 +2.36 ms, prefix cache 적중률 84.5% 다.

공식 가이드의 기준 구성은 Qwen3-32B / H100 80GB / GPU 16장이다. 이 글은 그것을 GPU 1장 6GB로 줄여 올리는 과정과, 그렇게 줄인 환경에서 무엇을 잴 수 있는지를 함께 다룬다.

항목값
호스트Debian 13, NVIDIA driver 595.84
GPUNVIDIA GeForce RTX 3050 6GB 1장
클러스터kind, 단일 control-plane, k8s v1.36.1, containerd 2.3.1
노드CPU 12, memory 32GB, nvidia.com/gpu: 1
device pluginnvcr.io/nvidia/k8s-device-plugin:v0.17.1
llm-d Routerchart llm-d-router-standalone v0, EPP llm-d-router-endpoint-picker:main
Proxyenvoyproxy/envoy:distroless-v1.33.2 (사이드카)
추론 엔진vLLM v0.26.0, Qwen/Qwen3-0.6B, replicas 1
네임스페이스llm-d-lab

kind 노드는 결국 도커 컨테이너다. 컨테이너 안에서 GPU가 보여야 kubelet이 GPU를 자원으로 인식한다.

먼저 NVIDIA Container Toolkit을 도커의 기본 런타임으로 등록한다.

호스트 준비
$ sudo nvidia-ctk runtime configure --runtime=docker --set-as-default
$ sudo systemctl restart docker

그리고 설정 파일에서 볼륨 마운트로 GPU를 지정하는 방식을 허용해야 한다. 이 한 줄이 없으면 다음 절의 kind 설정이 동작하지 않는다.

/etc/nvidia-container-runtime/config.toml
accept-nvidia-visible-devices-as-volume-mounts = true

GPU를 넣는 트릭이 설정 파일 마지막 세 줄에 들어 있다.

kind-gpu.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
  - role: control-plane
    image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
    extraMounts:
      - hostPath: /dev/null
        containerPath: /var/run/nvidia-container-devices/all

/dev/null을 /var/run/nvidia-container-devices/all에 마운트한다. 파일 내용은 의미가 없다. NVIDIA 런타임이 이 경로의 마운트를 보고 “이 컨테이너에 모든 GPU를 넣어라"로 해석한다.

앞 절에서 켠 accept-nvidia-visible-devices-as-volume-mounts가 바로 이 해석을 허용하는 스위치다. 둘은 한 쌍이다.

클러스터 생성
$ kind create cluster --name gpu-lab --config kind-gpu.yaml
$ kubectl get node -o wide
NAME                    STATUS   ROLES           VERSION   CONTAINER-RUNTIME
gpu-lab-control-plane   Ready    control-plane   v1.36.1   containerd://2.3.1

노드 컨테이너에 GPU가 보이더라도, Kubernetes가 nvidia.com/gpu 자원으로 노출하려면 device plugin이 필요하다.

device plugin 배포
$ kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.17.1/deployments/static/nvidia-device-plugin.yml

$ kubectl get node gpu-lab-control-plane -o jsonpath='{.status.capacity}' | tr ',' '\n'
{"cpu":"12"
"ephemeral-storage":"478512880Ki"
"hugepages-1Gi":"0"
"hugepages-2Mi":"0"
"memory":"32683004Ki"
"nvidia.com/gpu":"1"
"pods":"110"}

nvidia.com/gpu: 1이 뜨면 준비가 끝난 것이다. 여기까지가 llm-d와 무관한 GPU 인프라 작업이다.

llm-d 저장소의 optimized-baseline 가이드를 따른다. 가이드는 환경변수로 대상을 정하는 구조다.

환경 변수
export REPO_ROOT=~/llm-d
export GUIDE_NAME=optimized-baseline
export NAMESPACE=llm-d-lab
export ACCELERATOR_TYPE=gpu
export MODEL_SERVER=vllm

export ROUTER_STANDALONE_CHART=oci://ghcr.io/llm-d/charts/llm-d-router-standalone
export ROUTER_CHART_VERSION=v0
export ROUTER_BASE_VALUES="${REPO_ROOT}/guides/recipes/router/base.values.yaml"
export ROUTER_VALUES="${REPO_ROOT}/guides/${GUIDE_NAME}/router/${GUIDE_NAME}.values.yaml"
export MONITORING_VALUES=""

MONITORING_VALUES를 비워두는 것이 중요하다. 이 값을 채우면 차트가 ServiceMonitor를 만드는데, Prometheus Operator CRD가 없으면 helm 검증 단계에서 실패한다.

Router 설치 (Standalone)
$ helm install ${GUIDE_NAME} \
    ${ROUTER_STANDALONE_CHART} \
    -f ${ROUTER_BASE_VALUES} \
    ${MONITORING_VALUES} \
    -f ${ROUTER_VALUES} \
    -n ${NAMESPACE} --version ${ROUTER_CHART_VERSION}

Standalone 모드는 Gateway API를 요구하지 않는다. 실제로 이 클러스터에는 Gateway API CRD가 설치돼 있지 않고, kubectl get gateway는 리소스 타입이 없다는 에러를 낸다.

설치된 values에서 핵심은 세 군데다.

helm get values optimized-baseline (발췌)
router:
  proxy:
    proxyType: envoy          # 같은 파드에 사이드카로
    args: ["--service-node", "envoy-sidecar", "--concurrency", "8", ...]
  extraServicePorts:
    - name: http
      port: 80
      targetPort: 8081        # 서비스 80 → Envoy 8081
  inferencePool:
    failureMode: FailOpen     # EPP 장애 시 요청을 막지 않는다
  modelServers:
    matchLabels:
      llm-d.ai/guide: optimized-baseline   # 이 라벨이 붙은 파드가 후보

여기가 이 실습에서 손이 가장 많이 간 부분이다. 가이드 기본값은 Qwen3-32B, replicas 8, TP=2, GPU 16장이라 그대로는 올라가지 않는다.

가이드는 모델 서버를 kustomize 오버레이로 배포하며, 경로에 INFRA_PROVIDER 변수가 들어간다. 그래서 lab-3050이라는 오버레이를 새로 만들어 그 자리에 끼웠다.

modelserver/gpu/vllm/lab-3050/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../../../../recipes/modelserver/base/single-host/default
namePrefix: optimized-baseline-nvidia-gpu-vllm-
components:
  - ../../../../../recipes/modelserver/components/images/gpu-vllm/release
labels:
  - pairs:
      llm-d.ai/model: Qwen3-0.6B
      llm-d.ai/guide: optimized-baseline      # Router의 selector와 맞춘다
      llm-d.ai/accelerator-variant: gpu
      llm-d.ai/accelerator-vendor: nvidia
    includeSelectors: true
    includeTemplates: true
patches:
  - path: patch-vllm.yaml

llm-d.ai/guide: optimized-baseline 라벨이 InferencePool의 selector와 이어지는 접점이다. 이 라벨이 없으면 EPP가 후보를 찾지 못한다.

패치에서 모델과 자원을 줄인다.

modelserver/gpu/vllm/lab-3050/patch-vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: decode
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: modelserver
          command: ["vllm", "serve"]
          args:
            - "Qwen/Qwen3-0.6B"
            - "--disable-access-log-for-endpoints=/health,/metrics,/v1/models"
            - "--max-model-len=2048"
            - "--gpu-memory-utilization=0.6"
            - "--enforce-eager"
          env:
            - name: HF_TOKEN
              valueFrom:
                secretKeyRef: {name: llm-d-hf-token, key: HF_TOKEN}
          resources:
            limits: {cpu: '4', memory: 8Gi, nvidia.com/gpu: 1}
            requests: {cpu: '1', memory: 4Gi, nvidia.com/gpu: 1}
          startupProbe:
            initialDelaySeconds: 10
            periodSeconds: 10
            failureThreshold: 60          # 최대 10분까지 기동을 기다린다
      volumes:
        - name: shm
          emptyDir: {medium: Memory, sizeLimit: 2Gi}

바꾼 값마다 이유가 있다. --gpu-memory-utilization=0.6은 6GB 중 3.6GB만 쓰겠다는 뜻이고, --max-model-len=2048은 KV 캐시가 감당할 컨텍스트 상한이다.

--enforce-eager는 CUDA 그래프 캡처를 끈다. 캡처에 쓰는 추가 메모리를 아끼고 기동 시간을 줄이는 대신 약간의 성능을 포기하는 선택이다.

startupProbe의 failureThreshold: 60도 중요하다. 모델을 HuggingFace에서 내려받고 엔진을 초기화하는 동안 파드가 죽지 않게 한다.

모델 서버 배포
$ kubectl create secret generic llm-d-hf-token \
    --from-literal=HF_TOKEN=<your-token> -n ${NAMESPACE}

$ kubectl apply -n ${NAMESPACE} \
    -k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/gpu/vllm/lab-3050/

기동 로그에서 실제로 잡힌 KV 캐시 용량을 확인할 수 있다.

vLLM 기동 로그 (발췌)
[cuda.py:482] Using FLASH_ATTN attention backend
[flash_attn.py:776] Using FlashAttention version 2
[gpu_worker.py:560] Available KV cache memory: 2.14 GiB
[kv_cache_utils.py:2177] GPU KV cache size: 20,032 tokens
[kv_cache_utils.py:2178] Maximum concurrency for 2,048 tokens per request: 9.78x
[core.py:347] init engine (profile, create kv cache, warmup model) took 8.08 s

6GB 중 0.6을 쓰면 KV 캐시로 2.14 GiB, 토큰 20,032개 가 남는다. 요청당 2,048 토큰 기준 동시성 9.78배다.

replica를 늘릴 수는 없다. 두 번째 파드도 3.6GB를 요구하므로 6GB에 들어가지 않는다. 이 글의 측정이 “파드 선택"이 아니라 “경로 비용"에 맞춰진 이유다.

flowchart LR
    C["클라이언트"]

    subgraph POD["optimized-baseline-epp 파드"]
        ENVOY["envoy-proxy
envoy:distroless-v1.33.2
:8081"] EPP["epp
llm-d-router-endpoint-picker:main
:9002 grpc · :9090 metrics"] end VLLM["vllm-decode 파드
Qwen3-0.6B
:8000"] C -->|"svc :80 → :8081"| ENVOY ENVOY <-->|"ext_proc gRPC
127.0.0.1:9002"| EPP ENVOY -->|"x-gateway-destination-endpoint"| VLLM
배포 확인
$ kubectl get pod -n llm-d-lab
NAME                                                         READY   STATUS
optimized-baseline-epp-5fbd7457f9-9xcr7                      2/2     Running
optimized-baseline-nvidia-gpu-vllm-decode-7978d9d5c9-h59gz   1/1     Running

$ kubectl get pod -n llm-d-lab optimized-baseline-epp-5fbd7457f9-9xcr7 \
    -o jsonpath='{range .spec.containers[*]}{.name}: {.image}{"\n"}{end}'
envoy-proxy: docker.io/envoyproxy/envoy:distroless-v1.33.2
epp: ghcr.io/llm-d/llm-d-router-endpoint-picker:main

$ kubectl get svc -n llm-d-lab optimized-baseline-epp \
    -o jsonpath='{range .spec.ports[*]}{.name} {.port}->{.targetPort}{"\n"}{end}'
grpc-ext-proc 9002->9002
http-metrics 9090->9090
http 80->8081

Gateway API는 없지만 InferencePool은 그대로 있다. 후보 파드를 고르는 일은 배포 모드와 무관하기 때문이다.

InferencePool (실제 배포본)
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
  name: optimized-baseline
  namespace: llm-d-lab
spec:
  appProtocol: http
  selector:
    matchLabels:
      llm-d.ai/guide: optimized-baseline
  targetPorts:
    - number: 8000
  endpointPickerRef:
    kind: Service
    name: optimized-baseline-epp
    port:
      number: 9002
    failureMode: FailOpen

개념편에서 정리한 두 갈래 중 근사(approximate) 방식이다. 설정이 짧아서 전부 옮긴다.

optimized-baseline-plugins.yaml
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins:
  - type: approx-prefix-cache-producer
  - type: inflight-load-producer
  - type: prefix-cache-affinity-filter
  - type: token-load-scorer
schedulingProfiles:
  - name: default
    plugins:
      - pluginRef: prefix-cache-affinity-filter
      - pluginRef: token-load-scorer

ZMQ도 토크나이저 사이드카도 없다. EPP가 프롬프트를 문자-토큰 비율로 근사해 해시 체인을 만들고, 자체 LRU 인덱스로 어디에 보냈는지를 기억하는 방식이다.

설정 파일 주석에 경고가 달려 있다. prefix-cache-affinity-filter의 peakPrefillThroughput 기본값 15928은 Qwen3-32B / H100 80GB / TP=2 기준으로 보정된 값이다.

RTX 3050에 0.6B 모델을 올린 이 환경과는 하드웨어가 전혀 다르다. 문서는 이럴 때 guides/recipes/router/calibration으로 직접 측정해 넣으라고 안내한다.

개념편에서 설명한 ORIGINAL_DST 트릭이 실제 ConfigMap에 그대로 들어 있다.

envoy ConfigMap (발췌)
http_filters:
  - name: envoy.filters.http.ext_proc
    typed_config:
      failure_mode_allow: true
      grpc_service:
        envoy_grpc:
          cluster_name: ext_proc
          authority: localhost:9002
        timeout: 10s
      processing_mode:
        request_body_mode: FULL_DUPLEX_STREAMED
        response_body_mode: FULL_DUPLEX_STREAMED
      message_timeout: 1000s

clusters:
  - name: original_destination_cluster
    type: ORIGINAL_DST
    lb_policy: CLUSTER_PROVIDED
    original_dst_lb_config:
      use_http_header: true
      http_header_name: x-gateway-destination-endpoint

failure_mode_allow: true는 InferencePool의 failureMode: FailOpen과 짝을 이룬다. EPP가 죽어도 요청은 계속 흐른다.

ext_proc 클러스터는 127.0.0.1:9002를 가리킨다. Standalone이라 네트워크 홉이 없다.

같은 요청을 두 경로로 보내 비교한다. 대상이 파드 1개뿐이라 “어디로 갈지"는 고정이고, 차이는 순수하게 경로 비용이다.

  • 직접: localhost:8000/v1/completions (vLLM에 바로)
  • EPP 경유: optimized-baseline-epp.llm-d-lab.svc:80 → Envoy → ext_proc → vLLM

캐시 효과가 한쪽에 몰리지 않도록 요청마다 프롬프트를 다르게 했고, 드리프트를 없애려 두 경로를 교차 실행했다. 워밍업 3회 후 각 20회다.

측정 스크립트 (핵심부)
def req(base, prompt, max_tokens=64):
    body = json.dumps({"model": MODEL, "prompt": prompt, "max_tokens": max_tokens,
                       "temperature": 0, "stream": True}).encode()
    r = urllib.request.Request(base + "/v1/completions", data=body,
                               headers={"Content-Type": "application/json"})
    t0 = time.perf_counter(); ttft = None
    with urllib.request.urlopen(r, timeout=180) as resp:
        for raw in resp:
            if raw.startswith(b"data: ") and b"[DONE]" not in raw:
                if ttft is None:                 # 첫 토큰 도착 = TTFT
                    ttft = time.perf_counter() - t0
    return ttft * 1000, (time.perf_counter() - t0) * 1000

for i in range(20):                              # 교차 실행
    p = f"Question {i}: name one benefit of Kubernetes in a single sentence."
    d_ttft.append(req(VLLM, p)[0])
    e_ttft.append(req(EPP, p + " ")[0])

결과는 다음과 같다.

경로TTFT meanTTFT p50TTFT p95E2E mean
직접 호출32.46 ms31.0034.65923.33 ms
EPP 경유34.83 ms34.2537.73930.46 ms

TTFT 차이는 평균 2.36 ms, 비율로 약 7%다. E2E 기준으로는 7.13 ms 차이이고 전체의 0.8%에 불과하다.

해석의 핵심은 무엇이 2.36 ms를 쓰는가이다. 이 환경에서 활성 플러그인은 필터 하나와 스코어러 하나뿐이고, 스코어링 연산 자체는 마이크로초 단위다.

따라서 대부분은 Envoy가 요청을 붙잡고 ext_proc gRPC 왕복을 기다리는 구간이다. 계산 비용이 아니라 왕복 비용이다.

그리고 이 값은 사이드카 기준이다. ext_proc 클러스터가 127.0.0.1을 가리키므로 네트워크 홉이 없다. Gateway 모드로 EPP를 별도 파드에 두면 여기에 파드 간 왕복이 더 붙는다.

한 가지 더. p95를 보면 EPP 경유(37.73 ms)가 직접 호출(34.65 ms)보다 오히려 편차가 작다. 직접 호출의 최댓값은 46.84 ms인데 EPP 경유는 38.13 ms였다. 표본이 20개뿐이라 단정할 수는 없지만, 적어도 게이트웨이가 꼬리 지연을 키우지는 않았다.

같은 prefix를 공유하는 요청을 연속으로 보내면 prefill을 얼마나 건너뛰는지 본다. 3,102자(약 560 토큰)짜리 공통 prefix에 질문만 바꿔 8회 요청했다.

vLLM 메트릭을 요청 전후로 스냅샷해 델타를 계산했다.

prefix cache 메트릭 델타
$ # 8회 요청 전후 vllm:prefix_cache_* 차이
queries delta  4504.0    # 캐시를 조회한 토큰 수
hits    delta  3808.0    # 그중 실제로 재사용한 토큰 수
→ 적중률 84.5%

8회 중 첫 요청은 캐시가 없다. 84.5%는 그 한 번을 포함한 값이다. 요청당 조회 토큰이 평균 563개이므로, 첫 요청분을 빼면 3,808 / 3,941로 약 96.6% 가 된다.

TTFT에서도 같은 장면이 보인다.

요청 순서12345678
TTFT (ms)83.0134.9036.7041.9133.9855.7034.1838.36

첫 요청 83.01 ms가 2회차에 34.90 ms로 떨어진다. 2.4배다. 560 토큰짜리 prefill을 통째로 건너뛴 결과다.

여기서 앞 절의 숫자와 나란히 놓고 볼 만하다. 게이트웨이 경유 비용은 2.36 ms, prefix cache가 아낀 시간은 48 ms다. 캐시를 한 번 맞히면 게이트웨이 비용을 스무 번 치르고도 남는다.

물론 이 환경에서는 파드가 하나라 캐시를 맞히는 데 라우팅이 필요 없었다. 라우팅이 의미를 갖는 것은 파드가 여러 개일 때이고, 그때 EPP가 하는 일이 바로 이 48 ms를 잃지 않도록 요청을 같은 파드로 보내는 것이다.

측정 중 EPP 로그에는 요청 단위로 처리 기록이 남는다.

EPP 로그 (요청 1건)
{"body":"EPP received request",
 "x-request-id":"3f721c05-b714-49c8-ab60-4dc568dd3dff"}
{"body":"EPP sent request body response(s) to proxy",
 "x-request-id":"3f721c05-b714-49c8-ab60-4dc568dd3dff",
 "modelName":"Qwen/Qwen3-0.6B","targetModelName":"Qwen/Qwen3-0.6B"}
{"body":"EPP sent response body back to proxy",
 "x-request-id":"3f721c05-b714-49c8-ab60-4dc568dd3dff"}

세 줄이 한 요청의 생명주기다. 요청 수신 → 목적지를 정해 프록시에 회신 → 응답 바디 통과. 개념편에서 본 “Proxy가 요청을 붙잡고 EPP의 답을 기다린다"가 로그에서는 첫 줄과 둘째 줄 사이의 간격으로 나타난다.

modelName과 targetModelName이 함께 찍히는 것도 눈여겨볼 만하다. 요청한 모델명과 실제로 보낼 대상 모델명을 구분해 기록하는데, LoRA 어댑터나 모델명 가상화를 쓸 때 이 둘이 갈라진다.

GPU 1장짜리 kind 클러스터에 llm-d를 올리고 확인한 것을 정리한다.

GPU를 kind에 넣는 데 필요한 것은 두 줄이다. accept-nvidia-visible-devices-as-volume-mounts = true와 /dev/null extraMount가 한 쌍으로 동작하고, 그 위에 device plugin을 올리면 nvidia.com/gpu: 1이 나온다.

가이드를 축소하는 작업은 kustomize 오버레이 하나로 끝난다. 16장짜리 기준 구성을 1장으로 줄이는 데 바꾼 것은 모델·replicas·메모리 상한·컨텍스트 길이뿐이고, 라벨만 맞으면 Router는 그대로 붙는다.

게이트웨이 경유 비용은 TTFT 기준 2.36 ms였다. E2E로는 0.8%다. 사이드카 배치라 네트워크 홉이 없는 조건에서의 값이므로, EPP를 별도 파드로 분리하면 더 늘어난다.

prefix cache 적중률은 84.5%, 첫 요청 대비 TTFT는 2.4배 빨라졌다. 캐시 한 번의 이득(48 ms)이 게이트웨이 비용의 스무 배다. llm-d가 라우팅에 공을 들이는 이유가 이 비율에 있다.

기본값은 내 하드웨어 기준이 아니다. peakPrefillThroughput 기본값이 H100 80GB에 Qwen3-32B TP=2 기준이라는 주석이 좋은 예다. 라우팅 판단에 쓰이는 임계값은 쓰는 하드웨어에서 다시 재야 한다.

다음으로 확인해볼 것은 파드를 여러 개 띄울 수 있는 환경이다. 이 글의 측정은 목적지가 하나로 고정된 조건이라 EPP의 선택을 재지 못했다. GPU가 넉넉해지면 같은 prefix가 같은 파드로 가는지, 그리고 그때 48 ms가 실제로 지켜지는지를 재볼 수 있다.