Lutflow / Docs

vLLM en GKE con una L4

Receta manual para levantar vLLM en una GPU L4 de GKE, medirlo con el SDK publicado de Lutflow y apagarlo

Receta manual de investigación

Cada paso se hace a mano con gcloud y kubectl. El nodo con GPU genera cargos mientras exista: termina siempre con el paso 5. Manifiestos derivados de una configuración verificada el 14-oct-2026. El E2E del CLI de deploy del 16 de octubre validó measurable, check y limpieza; no ejecutó todos los pasos de esta receta manual separada.

Para medir con lutflow necesitas un servidor vLLM. Si no tienes uno, esta receta levanta Qwen3-4B-Instruct-2507 con vLLM v0.30.0 en una GPU NVIDIA L4 de GKE, lo mide desde adentro del cluster y apaga todo al final. La imagen está fijada por digest y el modelo por revisión.

Antes de empezar

  • Un cluster GKE Standard zonal y permiso para crear node pools en él. La receta no crea el cluster.
  • gcloud y kubectl configurados contra ese cluster.
  • Cuota de GPU NVIDIA L4 en la región del cluster.
  • Salida a internet desde los nodos: el pod descarga la imagen de Docker Hub, el modelo de Hugging Face y lutflow de PyPI.
  • El precio de lista de una g2-standard-4 en tu región, consultado el día que la uses, si quieres cifras en dólares.

1. Variables

PROYECTO='<PROYECTO>'   # ID de tu proyecto de GCP
CLUSTER='<CLUSTER>'     # nombre del cluster
ZONA='<ZONA>'           # zona del cluster, por ejemplo us-central1-a
REGION='<REGION>'       # region de esa zona, por ejemplo us-central1

Reemplaza cada valor entre <>. No los publiques en issues ni capturas.

Comprueba en qué zonas de la región hay L4:

gcloud compute accelerator-types list --project "$PROYECTO" \
  --filter="name=nvidia-l4 AND zone~$REGION"

Antes de crear recursos, confirma el contexto y comprueba que vllm-l4 y gpu-l4 no se usan. No apliques esta receta sobre recursos existentes: la limpieza borra ese namespace y ese pool. Registra las VMs y el uso regional de cuota L4 antes de empezar para compararlos al terminar.

kubectl config current-context
kubectl get namespace vllm-l4
gcloud container node-pools list --cluster "$CLUSTER" --zone "$ZONA" --project "$PROYECTO" \
  --filter="name=gpu-l4" --format="value(name)"
gcloud compute instances list --project "$PROYECTO"
gcloud compute regions describe "$REGION" --project "$PROYECTO" \
  --format="value(quotas)"

La consulta del namespace debe responder NotFound; la del pool no debe devolver un nombre. Si alguno existe, detente y elige un entorno de pruebas aislado. Un error de permisos o de conexión no demuestra que el recurso esté ausente.

2. Crear el pool de GPU

Tener cuota no garantiza que haya una L4 libre. El 14 de octubre no logramos obtener una L4 On-Demand en las zonas de us-central1 que probamos. Por eso el pool abarca varias zonas, arranca en 0 nodos, crece hasta 1 como máximo y usa --location-policy ANY: el autoscaler toma la primera zona que libere una L4.

gcloud container node-pools create gpu-l4 --cluster "$CLUSTER" --zone "$ZONA" --project "$PROYECTO" \
  --node-locations "$REGION-a,$REGION-b,$REGION-c" --num-nodes 0 \
  --enable-autoscaling --total-min-nodes 0 --total-max-nodes 1 --location-policy ANY \
  --machine-type g2-standard-4 --accelerator type=nvidia-l4,count=1,gpu-driver-version=default \
  --image-type UBUNTU_CONTAINERD --disk-type pd-ssd --disk-size 100 \
  --node-taints nvidia.com/gpu=present:NoSchedule

Si alguna de las zonas a, b o c no apareció en la lista anterior, cambia --node-locations por las zonas que sí aparecieron.

3. Desplegar vLLM

Guarda estos dos manifiestos. vllm.yaml crea el namespace vllm-l4, el Deployment vllm y un Service ClusterIP:

vllm.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: vllm-l4
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm
  namespace: vllm-l4
  labels: {app: vllm}
spec:
  replicas: 1
  strategy: {type: Recreate}
  selector:
    matchLabels: {app: vllm}
  template:
    metadata:
      labels: {app: vllm}
    spec:
      # Obligatorio: el Service "vllm" inyectaria VLLM_PORT=tcp://... y vLLM la leeria como su puerto.
      enableServiceLinks: false
      nodeSelector:
        cloud.google.com/gke-accelerator: nvidia-l4
      tolerations:
        - {key: nvidia.com/gpu, operator: Equal, value: present, effect: NoSchedule}
      containers:
        - name: vllm
          # vLLM v0.30.0 fijado por digest; el modelo, por revision.
          image: docker.io/vllm/vllm-openai@sha256:8a69ffad015f138d7170c4ddc429e230a3bc1c1719f67e14324749df200a4b90
          command: ["python3", "-m", "vllm.entrypoints.openai.api_server"]
          args:
            - --model=Qwen/Qwen3-4B-Instruct-2507
            - --revision=cdbee75f17c01a7cc42f958dc650907174af0554
            - --tokenizer-revision=cdbee75f17c01a7cc42f958dc650907174af0554
            - --max-model-len=8192
            - --gpu-memory-utilization=0.85
            - --max-num-seqs=16
            - --host=0.0.0.0
            - --port=8000
          env:
            - {name: HF_HOME, value: /models/hf}
            - {name: VLLM_NO_USAGE_STATS, value: "1"}
            - {name: DO_NOT_TRACK, value: "1"}
          ports:
            - {name: http, containerPort: 8000}
          resources:
            requests: {cpu: "3", memory: 11Gi, nvidia.com/gpu: "1"}
            limits: {memory: 13Gi, nvidia.com/gpu: "1"}
          readinessProbe:
            httpGet: {path: /health, port: 8000}
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 90
          volumeMounts:
            - {name: modelos, mountPath: /models}
            - {name: shm, mountPath: /dev/shm}
            # Cache de torch.compile en el volumen del pod: si el contenedor se reinicia, no recompila.
            - {name: modelos, mountPath: /root/.cache/vllm, subPath: vllm-cache}
      volumes:
        - name: modelos
          emptyDir: {sizeLimit: 30Gi}
        - name: shm
          emptyDir: {medium: Memory, sizeLimit: 2Gi}
---
apiVersion: v1
kind: Service
metadata:
  name: vllm
  namespace: vllm-l4
spec:
  type: ClusterIP
  selector: {app: vllm}
  ports:
    - {name: http, port: 8000, targetPort: 8000}

cliente.yaml es un pod para medir desde adentro del cluster:

cliente.yaml
apiVersion: v1
kind: Pod
metadata:
  name: cliente
  namespace: vllm-l4
  labels: {app: cliente}
spec:
  enableServiceLinks: false
  restartPolicy: Never
  nodeSelector:
    cloud.google.com/gke-accelerator: nvidia-l4
  tolerations:
    - {key: nvidia.com/gpu, operator: Equal, value: present, effect: NoSchedule}
  containers:
    - name: cliente
      # La misma imagen de vLLM, ya descargada en el nodo: trae python3, pip y uv.
      image: docker.io/vllm/vllm-openai@sha256:8a69ffad015f138d7170c4ddc429e230a3bc1c1719f67e14324749df200a4b90
      command: ["sleep", "7200"]
      resources:
        requests: {cpu: 250m, memory: 512Mi}
        limits: {memory: 1536Mi}

Aplica los dos y espera a que vLLM esté listo:

kubectl apply -f vllm.yaml -f cliente.yaml
kubectl -n vllm-l4 get pods -o wide
kubectl -n vllm-l4 rollout status deploy/vllm --timeout=30m

Los pods quedan en Pending hasta que el autoscaler consigue una L4. El 14 de octubre, una vez conseguida, el nodo estuvo listo en unos 2 minutos, la imagen bajó en unos 5 y vLLM quedó listo unos 4 minutos después: unos 11 minutos en total. Si rollout status vence porque todavía no hay L4, vuelve a ejecutarlo.

4. Medir desde adentro del cluster

Mide desde el pod cliente, no desde tu máquina. Con kubectl port-forward, cada lectura de /metrics tardó entre 0,7 y 1,5 segundos y el túnel se trabó bajo carga; desde adentro del cluster, una lectura tardó 12 ms en ese ensayo. Estos tiempos describen la configuración del 14 de octubre; no son una garantía de latencia.

kubectl -n vllm-l4 exec -it cliente -- bash

Dentro del pod, comprueba primero que vLLM publica la identidad de su proceso:

curl -fsS http://vllm:8000/metrics | grep -E '^(process_start_time_seconds|vllm:(prompt|generation)_tokens_created)'

Tienen que aparecer tres líneas: process_start_time_seconds, vllm:prompt_tokens_created y vllm:generation_tokens_created. Con ellas, lutflow detecta un reinicio del motor dentro de la ventana y la rechaza. Si no aparece ninguna, un reinicio cuyos contadores sigan subiendo pasa sin rechazo: el informe solo lo advierte. Con estos manifiestos, el 14 de octubre aparecieron las tres.

Con el SDK publicado, ejecuta primero check desde este pod. No genera carga; un PASS evalúa esa exposición, no una ventana futura. Ver salidas y límites de check.

uvx --from 'lutflow>=0.3.2,<0.4' lutflow check --endpoint http://vllm:8000

Después, comprueba la versión de lutflow y observa el servidor:

uvx --from 'lutflow>=0.3.2,<0.4' lutflow --version
uvx --from 'lutflow>=0.3.2,<0.4' lutflow measure --endpoint http://vllm:8000 \
  --duration 120 --interval 5 \
  --json /tmp/medicion.json

measure solo observa. Si nadie le envía solicitudes a este vLLM, el costo por token útil queda indefinido. Para generar carga sintética acotada, usa probe; muestra un plan de carga y pide escribir yes antes de empezar:

uvx --from 'lutflow>=0.3.2,<0.4' lutflow probe --endpoint http://vllm:8000 \
  --model Qwen/Qwen3-4B-Instruct-2507 \
  --concurrency 8 --duration 120 --interval 5 \
  --json /tmp/medicion.json

Para cifras en dólares, agrega a measure o probe estas cuatro opciones, con el precio por hora de la g2-standard-4 que verificaste. Ese precio cubre la VM entera con su única GPU:

--gpu-hourly-rate '<USD_POR_HORA>' --rate-basis instance --gpus-in-basis 1 --gpu-count 1

Sin ellas, los campos en dólares quedan unavailable; el resto del informe sale igual.

Si uvx no está disponible en el pod, instala lutflow en un entorno virtual y usa /tmp/l/bin/lutflow en lugar de uvx --from 'lutflow>=0.3.2,<0.4' lutflow:

python3 -m venv /tmp/l && /tmp/l/bin/pip install -q 'lutflow>=0.3.2,<0.4'
/tmp/l/bin/lutflow --version

Para copiar el JSON a tu máquina, desde otra terminal:

kubectl -n vllm-l4 cp cliente:/tmp/medicion.json ./medicion.json

Con vLLM v0.30.0, la 0.3.2 deriva TPOT de vllm:inter_token_latency_seconds si falta la familia anterior: es una media por intervalo entre tokens, no por solicitud. La p95 sigue interpolada dentro de su bucket; ahora el informe agrega una advertencia con sus límites, sin cambiar el valor ni el esquema. Ver limitaciones conocidas.

5. Apagar y comprobar

kubectl delete namespace vllm-l4
gcloud container node-pools delete gpu-l4 --cluster "$CLUSTER" --zone "$ZONA" --project "$PROYECTO" --quiet

Que los comandos terminen bien no prueba que la VM se haya ido. Comprueba el estado:

gcloud compute instances list --project "$PROYECTO"
gcloud compute regions describe "$REGION" --project "$PROYECTO" \
  --format='value(quotas)' | tr ';' '\n' | grep "'NVIDIA_L4_GPUS'"

La primera lista no debe mostrar ninguna VM del pool gpu-l4 de esta receta. Compara el uso de cuota con el valor registrado antes de crearlo. Solo se espera cero si no había otras asignaciones L4 ni cambiaron durante el ensayo; otras cargas pueden mantener el uso por encima de cero. Comprueba también que el namespace y el pool ya no existan.

Opcional: DCGM gestionado

En un cluster compatible, revisa primero los requisitos de GKE y los paquetes de monitorización que ya usas. Este comando configura la recolección a nivel de cluster; conserva otros paquetes que necesites y evita recolectar también con un exporter DCGM autoinstalado:

gcloud container clusters update "$CLUSTER" --project "$PROYECTO" --location "$ZONA" \
  --enable-managed-prometheus --monitoring=SYSTEM,DCGM

En el ensayo del 16 de octubre, los valores se actualizaban cada unos 30 s aunque se leyeran cada 2 s. DCGM mide la GPU entera; la atribución al deployment requiere GPU exclusiva. No incluye CPU, RAM ni red. Esta recolección es independiente del SDK, que todavía no integra estos sensores. La ingestión en Cloud Monitoring tiene costo.

Lecciones del 14 de octubre

  1. enableServiceLinks: false es obligatorio. Un Service llamado vllm inyecta VLLM_PORT=tcp://... en el pod, vLLM lo lee como su propio puerto y no arranca.
  2. Mide desde adentro del cluster. En el ensayo del 14 de octubre, el port-forward se trabó bajo carga.
  3. La L4 puede no estar disponible aunque haya cuota. Un pool en varias zonas con --location-policy ANY y un máximo de 1 nodo toma la primera que se libere.
  4. La carga cambia el costo modelado por token. La misma GPU al mismo precio por hora da costos por token muy distintos según cuántos flujos concurrentes atiende. Puedes verlo repitiendo probe con --concurrency 1 y --concurrency 16.
  5. Comprueba el teardown leyendo el estado. Que el comando termine bien no prueba que la VM se haya ido.
  6. Para apagar vLLM, borra el namespace. Con SIGTERM, el servidor HTTP puede quedarse esperando conexiones abiertas. Al borrar el namespace, Kubernetes manda SIGKILL cuando vence el período de gracia.

On this page