Skip to main content
BlogDevOps

Autoscale vLLM di Kubernetes dengan KEDA: Queue Depth, Bukan GPU %

ScaledObject EKS dari AWS untuk vLLM dibaca teliti: trigger queue depth dan p95, activation threshold, load test di balik 25 dan 5, dan apa yang rusak lebih dulu.

Matthews Wong

Software Engineer

Oktober 202612 menit baca
Autoscale vLLM di Kubernetes dengan KEDA: Queue Depth, Bukan GPU %

Ringkasan Utama

Untuk autoscale vLLM di Kubernetes dengan KEDA, scale berdasarkan queue depth dan p95 latency, bukan GPU utilisation. Walkthrough EKS dari AWS memakai vllm:num_requests_waiting dengan threshold 25 per pod, activation threshold 1, dan guardrail p95 5 detik, scale up cepat dan scale down pelan. Anggap angka itu contoh dan ukur ulang untuk model dan GPU Anda sendiri.

Hal pertama yang saya cek di autoscaler inference adalah apa yang dipantau. Grafik GPU yang angkanya terlihat nyaman tidak memberi tahu apa pun tentang user yang request-nya sudah menunggu sepuluh detik, dan replica yang butuh beberapa menit untuk siap dipakai tidak bisa ditambah setelah keluhan datang.

Post ini adalah pembacaan dekat atas panduan AWS EKS tentang autoscaling AI inference dengan HPA dan KEDA, halaman pendampingnya tentang mencari threshold, halaman referensi KEDA dan Kubernetes yang menjadi dasarnya, serta release notes llm-d v0.9.0. Saya belum menjalankannya di cluster. Setiap angka di bawah dibaca dari sebuah halaman dan disebutkan sumbernya, dan bila saya sedang bernalar dan bukan mengutip, saya katakan.

Mengapa queue depth dan p95 latency, bukan GPU utilisation

AWS men-scale Deployment vllm-inference-app berdasarkan dua sinyal Prometheus. Queue depth disebut sinyal permintaan utama dan p95 end-to-end latency disebut guardrail SLO. GPU utilisation sama sekali tidak muncul di manifest. Halaman desain metrics vLLM membingkai masalah dasarnya: metrics sering dipakai untuk automated scaling, tujuannya mengekspos apa yang dibutuhkan untuk mendeteksi saturation point, dan topik ini tidak sederhana.

Penalaran saya, bukan klaim dari kedua halaman: GPU melaporkan seberapa sibuk ia, bukan berapa request yang menunggu. Pada load test AWS sendiri, jumlah request menunggu tetap mendekati nol sampai replica saturasi lalu melonjak ke ratusan, sebuah lompatan yang jauh lebih bersih daripada yang dijanjikan grafik utilisation. Tabel berikut mendaftar arti tiap kandidat sinyal menurut halaman yang saya baca.

SinyalKata sumberPeran di walkthrough AWSCatatan saya
vllm:num_requests_waitingRequest yang menunggu diproses setelah replica saturasi (halaman thresholds AWS)Trigger utama: 25 per pod, activation 1Dikeluarkan oleh pod vLLM, jadi tidak berbicara apa pun saat pod berjumlah nol
vllm:e2e_request_latency_secondsHistogram end-to-end request latency (halaman metrics vLLM); AWS menyebut ia bisa naik sebelum queue menumpukTrigger guardrail: p95 di atas 5 detikBergantung pada bentuk request seperti panjang output, jadi threshold dari satu workload tidak bisa dipindah
vllm:kv_cache_usage_percFraksi blok KV cache yang terpakai, 0 sampai 1 (halaman metrics vLLM)Tidak dipakaiLayak digrafikkan di samping queue; saya tidak menemukan threshold bersumber, jadi tidak saya berikan
GPU utilisationTidak dibahas di halaman mana pun yang saya bacaTidak dipakaiApakah ia mengikuti saturasi untuk workload Anda adalah hasil pengukuran, bukan asumsi

Dari mana 25 dan 5 berasal: load test-nya

Halaman thresholds memakai Ministral-3-8B-Instruct-2512 di g6e.4xlarge dan k6 dengan model constant-arrival-rate pada 10, 20, 30, 40, 50, 60 dan 70 request per detik, 60 detik per rate dengan jeda 30 detik di antaranya. GPU di-warm-up dulu dengan 100 request berurutan, karena vLLM menangkap CUDA graph, meng-compile dan autotune kernel, serta mengalokasikan pool KV cache pada inference pertama sementara GPU menaikkan clock-nya. Jadi replica yang Ready belum tentu hangat.

Hasilnya, dengan kata-kata halaman itu: queue depth tetap mendekati nol sampai 50 concurrent request, lalu naik tajam di 60, mencapai sekitar 150 sampai 350 request antre, sementara p95 end-to-end latency naik dari sekitar 2,5 detik yang stabil menjadi 6 sampai 10 detik. Halaman itu memberi label langkah dalam request per detik tetapi menyebutnya concurrent request, jadi saya anggap lompatan 50 ke 60 sebagai knee dan tidak membaca presisi lebih dari itu.

Dari knee itu diturunkan tiga titik awal: scale up bila queue depth melebihi 25 selama 30 sampai 60 detik, scale up sebagai sinyal sekunder bila p95 melebihi 5 detik, dan scale down hanya bila queue 0 dan p95 di bawah 3 detik selama 5 menit. Halaman itu menyatakan terang-terangan bahwa ini titik awal dan latihannya harus diulang untuk model, GPU dan pola request Anda. Berikut load generator yang menghasilkan kurvanya.

// k6 script from the AWS thresholds page (stored in the vllm-loadtest-script ConfigMap).
// constant-arrival-rate holds the request RATE fixed, so when the GPU cannot keep up
// the excess shows up as vllm:num_requests_waiting instead of slowing the client down.
import http from 'k6/http';

// Random leading word varies the prompt so prefix caching does not flatter the result.
const words = ['revolution', 'technology', 'javascript', 'kubernetes', 'basketball'];

export const options = {
  noConnectionReuse: true,
  scenarios: {
    constant_request_rate: {
      executor: 'constant-arrival-rate',
      rate: __ENV.TARGET_RPS || 10,   // AWS steps this 10, 20, ... 70
      timeUnit: '1s',
      duration: __ENV.DURATION || '60s',
      preAllocatedVUs: 50,
      maxVUs: 500,
    },
  },
};

export default function () {
  const word = words[Math.floor(Math.random() * words.length)];
  const payload = JSON.stringify({
    model: __ENV.MODEL,
    prompt: 'Starting with the word "' + word + '", write a brief technical explanation about cloud computing concepts.',
    max_tokens: 100,
    temperature: 0.7,
  });
  http.post('http://vllm-inference-svc:8000/v1/completions', payload,
    { headers: { 'Content-Type': 'application/json' }, timeout: '240s' });
}

ScaledObject, baris demi baris

KEDA di-install dengan Helm ke namespace sendiri, dan ScaledObject di bawah adalah manifest AWS dengan komentar saya. KEDA membuat dan mengelola HPA pendukung dengan nama yang sama, jadi Anda tidak menulis HPA itu sendiri. Komentar menandai pilihan yang penting.

helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
kubectl get pods -n keda   # operator, metrics-apiserver, admission-webhooks

kubectl apply -f - <<'EOF'
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-inference-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-inference-app
  minReplicaCount: 1          # 1 means the scaler is always "active" (see activation section)
  maxReplicaCount: 5
  advanced:
    horizontalPodAutoscalerConfig:
      name: vllm-inference-app   # the HPA KEDA creates and owns; do not edit it by hand
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 30
          policies:
            - type: Pods
              value: 2
              periodSeconds: 60
          selectPolicy: Max
        scaleDown:
          stabilizationWindowSeconds: 300   # GPU pods are slow to start; do not shed fast
          policies:
            - type: Pods
              value: 1
              periodSeconds: 120
          selectPolicy: Min
  triggers:
    # Primary: demand exceeds capacity. AverageValue divides by replicas => 25 PER POD.
    - type: prometheus
      metricType: AverageValue
      metadata:
        serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
        # Wrong: sum(vllm:num_requests_waiting)  -> empty result when nothing is waiting
        # Right: add "or vector(0)" so the trigger always reports a number
        query: sum(vllm:num_requests_waiting) or vector(0)
        threshold: "25"
        activationThreshold: "1"
    # Guardrail: p95 end-to-end latency in seconds over a 1-minute rate window.
    - type: prometheus
      metricType: AverageValue
      metadata:
        serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
        query: histogram_quantile(0.95, sum(rate(vllm:e2e_request_latency_seconds_bucket[1m])) by (le)) or vector(0)
        threshold: "5"
EOF

kubectl get scaledobject vllm-inference-app   # READY True, ACTIVE False while idle
kubectl get hpa vllm-inference-app            # TARGETS shows both: x/25 (avg), y/5 (avg)

Tiga detail menopang desainnya. metricType AverageValue membuat KEDA membagi tiap metric ke seluruh replica, jadi 25 berarti 25 request menunggu per pod walau query menjumlahkan semuanya. Klausa or vector(0) mengembalikan 0 alih-alih hasil kosong saat tidak ada yang menunggu, yang menurut AWS mencegah trigger menjadi inactive. Dan KEDA melakukan scale ke trigger yang meminta replica terbanyak, jadi salah satu sinyal saja sudah bisa menambah kapasitas.

Karpenter adalah separuh cerita lainnya di cluster ini: menurut AWS ia menyediakan node GPU baru bila tidak ada yang tersedia, dan replica baru baru Ready setelah model termuat ke memori GPU. KEDA memutuskan berapa pod yang diinginkan; ia tidak membuat pod muncul cepat.

Activation threshold versus scaling threshold

Halaman scaling-deployments KEDA menjelaskan dua fase. Fase activation memutuskan antara 0 dan 1 replica berdasarkan apakah scaler aktif, dan fase scaling menyerahkan 1 sampai N ke HPA memakai nilai target. Activation membutuhkan nilai yang benar-benar lebih besar dari activation threshold, defaultnya 0, dan ia berprioritas: dengan threshold 10 dan activationThreshold 50, metric 40 membuat scaler inactive dan scale ke nol walau HPA sebenarnya menginginkan 4 instance.

Ada jebakan yang saya temukan dengan menaruh dua halaman berdampingan. Halaman KEDA yang sama menyebut bahwa bila minReplicaCount 1 atau lebih, scaler selalu aktif dan nilai activation diabaikan. Manifest AWS menetapkan minReplicaCount 1, tetapi teksnya mengatakan activationThreshold 1 menahan deployment di minReplicaCount sampai ada request menunggu. Dibaca bersama, activationThreshold di manifest itu mendokumentasikan maksud tetapi tidak menahan apa pun. Saya belum menguji pembacaan mana yang berlaku di cluster nyata. Perhatikan juga kata benar-benar lebih besar: activation 1 membutuhkan sedikitnya 2 request menunggu.

Halaman spec KEDA menyebut cooldownPeriod (default 300 detik) hanya berlaku saat scale ke 0, dan idleReplicaCount hanya menerima 0. Jika ingin scale to zero sungguhan, set minReplicaCount ke 0 dan perlakukan activation threshold sebagai aktif. Lalu tanyakan apa yang membangunkan pod pertama, karena masalah di paragraf berikutnya muncul seketika.

Inferensi saya: vllm:num_requests_waiting dikeluarkan oleh pod vLLM, jadi dengan nol pod tidak ada series yang bisa melewati activation threshold, dan request ke Service tanpa endpoint tidak pernah sampai ke queue vLLM untuk dihitung. Scale from zero membutuhkan sinyal dari sesuatu yang berada di depan pod, seperti gateway atau message queue, dan annotation force-activation di KEDA ada untuk memaksa scale up secara manual. Untuk GPU serving yang sensitif latency, saya akan menjaga minReplicaCount di 1 dan menerima biaya idle, yang juga dilakukan manifest AWS.

Cepat naik, pelan turun: behaviour window

HPA mengambil jumlah replica yang diinginkan terbesar di antara semua metric, itulah sebabnya pelanggaran latency bisa menambah pod saat queue masih tampak baik. Kubernetes mendokumentasikan stabilisasi scale-down memakai rekomendasi tertinggi dalam window, default 5 menit, jadi 300 detik di AWS sama dengan default itu. Sisi scale-up diatur terpisah: window 30 detik dan policy hingga 2 pod per 60 detik.

PengaturanScale upScale downAlasan menurut AWS
stabilizationWindowSeconds30300Queuing berarti user sudah menunggu; pod GPU lambat start
policiesPods, 2 per 60 detikPods, 1 per 120 detikHindari membuang kapasitas yang dibutuhkan lagi sesaat kemudian
selectPolicyMaxMinKubernetes: Max memilih policy dengan perubahan terbesar, Min yang terkecil

Sedikit hitungan saya sendiri, bukan dari kedua halaman: dari 1 ke maxReplicaCount 5 butuh empat pod tambahan, dan pada 2 per 60 detik itu sedikitnya dua periode policy sebelum provisioning node atau model load. Saat turun, empat penghapusan pada satu per 120 detik tersebar sedikitnya sekitar enam menit. Asimetri ini disengaja, tetapi biayanya nyata: sisi yang lambat dibayar dalam jam GPU.

Satu celah lagi: halaman thresholds AWS mengusulkan scale down saat queue 0 dan p95 di bawah 3 detik selama 5 menit, tetapi ScaledObject hanya meng-encode window 5 menit terhadap target 25 dan 5. Kondisi 3 detik itu saran, bukan konfigurasi.

Apa yang rusak lebih dulu

Berikut titik kegagalan yang ditunjuk sumber, diurutkan menurut seberapa cepat saya perkirakan akan bertemu.

  • Replica yang Ready belum tentu hangat. Halaman warm-up menunjukkan latency request pertama turun dari sekitar 2,6 detik ke baseline stabil, sehingga traffic ke replica baru bisa memicu guardrail p95 Anda sendiri dan memicu scale up lagi.
  • Tidak ada node GPU yang bebas. Karpenter harus menyediakannya sebelum pod bisa mulai memuat weights, jadi waktu nyata menuju kapasitas adalah node ditambah model load, jauh di atas window scale-up 30 detik.
  • Scale-in di tengah request. Halaman KEDA memperingatkan bahwa HPA bisa menghentikan replica di tengah tugas panjang dan menyarankan penanganan SIGTERM, hook preStop atau menjalankan pekerjaan sebagai Job. Untuk streaming completion itu perhatian nyata.
  • Prometheus senyap. ignoreNullValues pada scaler default-nya true, jadi hasil kosong diabaikan; menetapkannya false membuat scaler error saat target hilang. Bagian fallback KEDA mendefinisikan jumlah replica yang dipertahankan saat scaler berstatus error, dan tidak didukung untuk trigger CPU dan memory.
  • Threshold hasil salin. 25 dan 5 berasal dari satu model di satu tipe instance dengan satu bentuk prompt. Model lebih besar atau max_tokens lebih panjang menggeser knee, dan guardrail lalu menyala terus atau tidak pernah.

Semua isi post ini berasal dari dokumentasi yang saya baca, bukan dari cluster yang saya jalankan. Anggap 25 dan 5 sebagai nilai contoh AWS sendiri dan jalankan load test di hardware Anda sebelum angka itu mengendalikan sesuatu yang berbiaya.

llm-d memindahkan panduannya ke KEDA

Release notes llm-d v0.9.0 tidak memuat prosa tentang autoscaling, hanya judul pull request, jadi saya kutip itu saja: convert EPP autoscaling guide to KEDA; migrate nightly dan guide dari Prometheus Adapter ke KEDA; add KEDA support dan shared-cluster setup; serta authenticate KEDA ke Thanos dan assert the metric path. Notes itu juga mencantumkan workload variant autoscaler di v0.9.0.

Yang saya tarik darinya, sebagai inferensi dari judul: argumen AWS bahwa KEDA membaca Prometheus langsung tanpa metrics adapter terpisah untuk dikelola adalah argumen yang sama dengan yang diambil proyek serving saat meninggalkan Prometheus Adapter. Perubahan auth Thanos mengingatkan bahwa di shared cluster endpoint metric yang di-query KEDA biasanya tidak terbuka, jadi siapkan autentikasi di trigger.

Yang dikerjakan hari Senin

Kerjakan berurutan. Tiap langkah menggantikan angka yang kalau tidak akan Anda salin.

  1. Jalankan warm-up, lalu load test bertahap ke satu replica model Anda sendiri dan catat queue depth dan p95 di tiap rate.
  2. Tetapkan threshold queue di atas titik saat queue pertama kali tetap positif, dan threshold latency di bawah SLO Anda.
  3. Putuskan minReplicaCount dengan sengaja, 1 atau 0, dan bila 0, sebutkan sinyal yang membangunkan pod pertama.
  4. Pastikan kubectl get hpa menampilkan kedua metric di TARGETS dan baca event-nya dengan kubectl describe.
  5. Latih hari buruknya: hentikan load dan ukur waktu scale-down terhadap window Anda, lalu rusak alamat Prometheus dan lihat apa yang dilakukan fallback.
# My modification of the AWS ScaledObject (spec fields from KEDA's ScaledObject
# reference). NOT applied on a cluster by me. Only the changed parts are shown.
spec:
  minReplicaCount: 1        # set 0 only if something in front of vLLM can wake it
  maxReplicaCount: 5
  pollingInterval: 30       # KEDA default; how often each trigger is checked
  cooldownPeriod: 300       # default; applies ONLY to the 1 -> 0 step
  fallback:                 # replicas to hold when a scaler is in an error state
    failureThreshold: 3
    replicas: 2             # pick from your own load test; not a sourced value
  triggers:
    - type: prometheus
      metricType: AverageValue
      metadata:
        serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
        query: sum(vllm:num_requests_waiting) or vector(0)
        threshold: "25"          # REPLACE with the knee from YOUR load test
        activationThreshold: "1" # strictly greater than: needs 2+ waiting to activate
        ignoreNullValues: "false" # error (and fall back) when Prometheus is lost

# Rehearsal commands
kubectl get hpa vllm-inference-app -w
kubectl describe hpa vllm-inference-app | sed -n '/Events/,$p'
kubectl get pods -l app=vllm-inference-app -w

Fragmen di atas adalah modifikasi saya atas manifest AWS memakai field dari halaman spec KEDA, dan belum saya terapkan. Triggernya sama, ditambah fallback agar Prometheus yang mati mempertahankan kapasitas alih-alih menebak, dan menunjukkan di mana scale to zero akan dinyalakan.

Scale inference server berdasarkan apa yang dirasakan user, yaitu queue dan tail latency, scale up dalam detik dan turun dalam menit, dan anggap setiap threshold yang tidak Anda ukur sendiri sebagai placeholder. Halaman yang saya baca sepakat soal mekanismenya; celahnya ada di detail yang diserahkan kepada Anda, seperti apa yang sebenarnya digerbang activation dan berapa lama GPU menjadi kapasitas.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa scale vLLM berdasarkan queue depth, bukan GPU utilisation?

Walkthrough EKS dari AWS memperlakukan vllm:num_requests_waiting sebagai sinyal permintaan utama dan p95 end-to-end latency sebagai guardrail SLO. Pada load test-nya queue tetap mendekati nol sampai replica saturasi lalu naik ke sekitar 150 sampai 350 request menunggu, sinyal yang jelas. Halaman yang saya baca sama sekali tidak memakai GPU utilisation, jadi apakah ia cocok untuk workload Anda perlu diukur.

Apa fungsi activationThreshold di KEDA ScaledObject?

Ia memutuskan antara 0 dan 1 replica: scaler aktif hanya bila metric benar-benar lebih besar dari activation threshold, dan defaultnya 0. Dokumen KEDA menyebut ia berprioritas di atas scaling threshold. Dokumen itu juga menyebut bila minReplicaCount 1 atau lebih, scaler selalu aktif dan nilai activation diabaikan.

Mengapa AWS scale down jauh lebih pelan daripada scale up?

Manifest memakai window scale-up 30 detik dengan hingga 2 pod per menit, dan window scale-down 300 detik yang menghapus 1 pod tiap 2 menit. Alasan AWS: queuing berarti user sudah menunggu, sementara pod GPU lambat start, sehingga membuang kapasitas yang mungkin dibutuhkan lagi sesaat kemudian itu mahal.

Apa yang terjadi bila dua trigger KEDA berbeda hasil?

HPA Kubernetes menghitung jumlah replica yang diinginkan untuk tiap metric dan memilih yang terbesar. AWS menyatakan KEDA melakukan scale untuk memenuhi trigger yang meminta replica terbanyak, jadi pelanggaran p95 latency bisa menambah pod walau queue masih pendek.

Bisakah KEDA scale vLLM ke nol di EKS?

KEDA bisa scale ke nol bila minReplicaCount 0, tetapi manifest AWS sendiri menetapkannya 1. Inferensi saya, metric queue vLLM tidak bisa membangunkan deployment tanpa pod karena podnyalah yang mengeluarkannya, jadi Anda butuh sinyal dari gateway atau message queue di depan. Pod GPU yang dingin juga harus menunggu provisioning node dan model load.