Logotipo de VRAMfarm VRAMfarm
ES
Registrarse

12 de noviembre de 2026 · Equipo de VRAMfarm

API de GPU para trabajos por lotes: lanza un servidor de noche, ejecuta el trabajo y detenlo

Un reloj nocturno que pone en marcha un servidor GPU, que ejecuta un trabajo por lotes y se apaga solo: API de GPU para trabajos por lotes

Un trabajo nocturno que necesita 40 minutos en una RTX 4090 cuesta 0,39 € por noche si un script lanza el servidor, ejecuta el trabajo y lo detiene. Si dejas ese mismo servidor encendido las 24 horas para que el trabajo pueda ejecutarse a las 2 de la madrugada, cuesta 14,16 € al día. La diferencia son cuatro llamadas a la API y una línea de cron. Los scripts de este artículo lo hacen en curl y en Python, y añaden lo que casi todos los ejemplos se saltan: un tope de gasto que detiene el servidor pase lo que pase.

Antes de la primera ejecución: token, clave y saldo

Un token. En Ajustes → Tokens de API, crea un token solo para este trabajo, con un nombre como «nightly-batch». Empieza por gf_live_ y solo se muestra una vez, así que guárdalo enseguida: en los secretos de tu CI o en un archivo que solo pueda leer tu usuario, cargado como GF_TOKEN. Puedes tener hasta 10 tokens activos y revocar cada uno por separado.

Un token puede lanzar, listar y detener servidores. No puede cambiar tu contraseña ni tu clave SSH, ni crear tokens nuevos, así que un token filtrado puede costarte tiempo de GPU, pero no quedarse con la cuenta. La columna Último uso muestra cuándo se usó cada token por última vez. Llama a la API desde tu propio servidor, tu portátil o tu CI, nunca desde una página web, donde cualquiera podría leer el token.

Una clave SSH en Ajustes → Acceso al servidor. Se instala en cada servidor nuevo, y el script la usa para ejecutar el trabajo y traer los resultados.

Un saldo que cubra al menos una hora de todos tus servidores en marcha, incluido el que vas a lanzar. Es una comprobación, no un cargo: pagas los minutos que usas.

Comprueba las tres cosas con una sola llamada:

curl -s https://vramfarm.com/api/account -H "Authorization: Bearer $GF_TOKEN"

Lo que quieres ver es "verified": true, "ssh_key_set": true y un balance_eur que cubra el trabajo de esta noche.

Las cuatro llamadas

Llamada Qué hace Qué usas de la respuesta
POST /api/instances Lanza un servidor id
GET /api/instances/{id} Estado de un servidor status, access.ssh, cost_eur, traffic_eur, error
GET /api/instances?status=running Tus servidores en marcha lo que haya quedado de una ejecución fallida
POST /api/instances/stop Detiene un servidor {"ok": true}

El cuerpo del lanzamiento lleva gpu (por ejemplo, rtx-4090) y, opcionalmente, count, template, image, cmd, disk y ws_restore. La respuesta llega al momento con "status": "provisioning". Preparar la máquina lleva 2–5 minutos con nuestras plantillas y 8–15 con tu propia imagen, y nada de eso se factura. La facturación empieza cuando el estado pasa a running, por minuto, con un mínimo de 15 minutos por lanzamiento.

Algunos comportamientos que conviene conocer antes de escribir el bucle:

  • El estado pasa por provisioning → running → stopped. Si una máquina no arranca, cogemos otra sin coste y el id no cambia. Si el servidor acaba en stopped sin que tú lo hayas detenido, error dice por qué.
  • Detener dos veces no da problemas. Detener un servidor que ya está detenido devuelve {"ok": true, "already": true}, así que la llamada de detención puede ir en un trap o en un finally sin comprobaciones.
  • Los errores llegan como {"error": "...", "code": "..."}. Decide en función de code. 402 significa saldo insuficiente, 409 que no hay ninguna máquina libre de ese tipo, y 429, 502 y 503 merecen un reintento tras una pausa.
  • Límites: 60 peticiones por minuto y token, 10 lanzamientos por minuto y hasta 4 servidores a la vez. Consulta el estado cada 5–10 segundos, no más a menudo.

Todos los campos y errores están en la documentación de la API.

Nuestra plantilla por SSH, o tu propia imagen de Docker

Hay dos formas de llevar tu código al servidor.

template: "pytorch" te da PyTorch, CUDA y Jupyter, listos en 2–5 minutos. El script copia las entradas por scp, lanza el trabajo por SSH y se trae las salidas. Si el trabajo necesita una carpeta guardada, ws_restore (activado por defecto) la recupera, y "save": true al detener la conserva.

template: "custom" con image ejecuta tu propia imagen pública de Docker Hub o de GitHub Container Registry, con todas las dependencias dentro. Fija una etiqueta como ghcr.io/you/batch:2026-11-12, no latest, para que el trabajo de anoche y el de esta noche ejecuten el mismo código. cmd es un comando (de hasta 4000 caracteres) que se ejecuta con bash en segundo plano en cuanto el contenedor está arriba, con su salida en /var/log/gf-cmd.log, así que la imagen necesita bash. Para automatizar importan tres diferencias:

  • el contenedor sigue en marcha cuando termina cmd, así que tu script tiene que detenerlo igualmente;
  • la parada automática por inactividad nunca detiene servidores con tu propia imagen, así que uno olvidado sigue funcionando hasta que lo detengas o se acabe el saldo;
  • la carpeta guardada todavía no funciona con imágenes propias, así que los resultados tienen que salir del servidor antes de detenerlo, por scp o subidos por el propio trabajo.

Esa última opción es el patrón de «envía un script y recibe la salida» sin SSH: la imagen ejecuta el trabajo, sube los resultados a tu bucket o a un repositorio de Hugging Face y escribe un archivo marcador cuando termina. Cómo meter y sacar datos lo explicamos en mover datasets a un servidor GPU.

El script nocturno en bash

Esta versión usa la plantilla de PyTorch. Necesita curl, jq y OpenSSH en la máquina que lo ejecuta.

#!/usr/bin/env bash
set -euo pipefail
API=https://vramfarm.com/api
H="Authorization: Bearer $GF_TOKEN"; J="Content-Type: application/json"
MAX_EUR=3.00      # detener si esta ejecución ya ha costado esto
MAX_MIN=120       # o si el trabajo lleva este tiempo en marcha

ID=$(curl -sf "$API/instances" -H "$H" -H "$J" \
  -d '{"gpu":"rtx-4090","template":"pytorch","ws_restore":false}' | jq -r .id)
trap 'curl -s "$API/instances/stop" -H "$H" -H "$J" -d "{\"id\":$ID}" >/dev/null' EXIT

for i in $(seq 1 120); do                       # esperar hasta 20 minutos
  S=$(curl -sf "$API/instances/$ID" -H "$H")
  ST=$(jq -r .status <<<"$S")
  [ "$ST" = running ] && break
  [ "$ST" = stopped ] && { echo "did not start: $(jq -r .error <<<"$S")"; exit 1; }
  sleep 10
done
[ "$ST" = running ] || { echo "still not running, giving up"; exit 1; }

HOST=$(jq -r .access.ssh.host <<<"$S"); PORT=$(jq -r .access.ssh.port <<<"$S")
KH=$(mktemp)    # known_hosts nuevo: con el tiempo, otros servidores reutilizan los puertos
O=(-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$KH" -o ServerAliveInterval=30)

scp -P "$PORT" "${O[@]}" ./input.tar root@"$HOST":/workspace/
ssh -p "$PORT" "${O[@]}" root@"$HOST" 'cd /workspace && tar xf input.tar &&
  (nohup sh -c "python run.py > run.log 2>&1; echo \$? > exit_code" </dev/null >/dev/null 2>&1 &)'

START=$(date +%s)
until ssh -p "$PORT" "${O[@]}" root@"$HOST" test -f /workspace/exit_code; do
  SPENT=$(curl -sf "$API/instances/$ID" -H "$H" | jq '.cost_eur + .traffic_eur')
  MIN=$(( ($(date +%s) - START) / 60 ))
  if awk -v s="$SPENT" -v m="$MAX_EUR" 'BEGIN{exit !(s >= m)}' || [ "$MIN" -ge "$MAX_MIN" ]; then
    echo "cap reached: EUR $SPENT after $MIN min"
    scp -P "$PORT" "${O[@]}" root@"$HOST":/workspace/run.log ./ || true
    exit 2                                        # el trap detiene el servidor
  fi
  sleep 60
done

scp -P "$PORT" "${O[@]}" root@"$HOST":/workspace/run.log root@"$HOST":/workspace/output.tar ./results/
echo "job exit code: $(ssh -p "$PORT" "${O[@]}" root@"$HOST" cat /workspace/exit_code)"

El trabajo arranca con nohup y escribe exit_code al terminar, así que una conexión SSH caída no lo mata y el script solo tiene que consultar un archivo pequeño. El trap ... EXIT detiene el servidor en cualquier salida: éxito, el tope, un scp fallido o Ctrl+C.

Prográmalo con cron, cargando el token desde un archivo que solo puedas leer tú:

0 2 * * *  . $HOME/.gf_token && /opt/batch/nightly.sh >> /opt/batch/nightly.log 2>&1

En GitHub Actions, on: schedule: - cron: "0 2 * * *" hace lo mismo, con el token en secrets.GF_TOKEN y la clave SSH privada en otro secreto.

El mismo trabajo en Python, con tu propia imagen

Aquí el trabajo vive en tu imagen de Docker, y cmd lo arranca. El script nunca copia código: espera al archivo marcador, se trae el resultado y detiene el servidor.

import os, subprocess, tempfile, time, requests

API = "https://vramfarm.com/api"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['GF_TOKEN']}"
MAX_EUR, MAX_MIN = 3.0, 120

def call(method, path, **kw):
    for _ in range(5):
        r = s.request(method, API + path, timeout=30, **kw)
        if r.status_code not in (429, 502, 503):
            break
        time.sleep(int(r.headers.get("Retry-After", 30)))
    if not r.ok:
        raise SystemExit(f"{r.status_code} {r.json().get('code')}: {r.json().get('error')}")
    return r.json()

sid = call("POST", "/instances", json={
    "gpu": "rtx-4090", "template": "custom",
    "image": "ghcr.io/you/batch:2026-11-12",
    "cmd": "mkdir -p /workspace && cd /workspace && python /app/run.py > run.log 2>&1; echo $? > exit_code",
})["id"]
try:
    while (srv := call("GET", f"/instances/{sid}"))["status"] != "running":
        if srv["status"] == "stopped":
            raise SystemExit(f"did not start: {srv['error']}")
        time.sleep(10)
    ssh = srv["access"]["ssh"]
    opts = ["-o", "StrictHostKeyChecking=accept-new",
            "-o", f"UserKnownHostsFile={tempfile.mkstemp()[1]}"]
    target = f"root@{ssh['host']}"
    t0 = time.time()
    while subprocess.run(["ssh", "-p", str(ssh["port"]), *opts, target,
                          "test -f /workspace/exit_code"]).returncode != 0:
        srv = call("GET", f"/instances/{sid}")
        spent = srv["cost_eur"] + srv["traffic_eur"]
        if spent >= MAX_EUR or time.time() - t0 > MAX_MIN * 60:
            raise SystemExit(f"cap reached at EUR {spent:.2f}")
        time.sleep(60)
    subprocess.run(["scp", "-P", str(ssh["port"]), *opts,
                    f"{target}:/workspace/output.tar", "results/"], check=True)
finally:
    call("POST", "/instances/stop", json={"id": sid})

Dos apuntes. El servidor tiene SSH aunque uses tu propia imagen, pero scp necesita que la imagen incluya las herramientas habituales de OpenSSH; si la tuya es mínima, haz que el trabajo suba sus resultados a un bucket. Y si el trabajo es de generación de imágenes, ComfyUI tiene su propia API HTTP para poner prompts en cola, que mostramos en ComfyUI en una GPU alquilada.

Cómo plantear el tope de gasto

No hay un ajuste de presupuesto por trabajo, así que el tope vive en tu script. Móntalo por capas, para que un solo fallo no te cueste una noche:

  1. Primero, el coste esperado. Antes de lanzar, lee el precio en vivo con GET /api/offers?gpu=rtx-4090&count=1 (el campo price son euros por hora para la máquina entera, con todas sus tarjetas). El coste esperado es precio × horas previstas, más el tráfico. Fija MAX_EUR en 1,5–2 veces esa cifra. Si el precio de esta noche supera lo que estás dispuesto a pagar, sáltate la ejecución o prueba con otra tarjeta.
  2. Topes de tiempo y de dinero en el bucle. cost_eur crece con cada minuto de ejecución, y traffic_eur suma el tráfico, que se contabiliza con algo de retraso. Comprueba los dos, más un límite de tiempo real, como en los scripts de arriba.
  3. Una detención en cada salida. trap ... EXIT en bash, finally en Python. Detener dos veces no hace daño.
  4. Un barrido por la mañana. Un segundo trabajo de cron lista los servidores en marcha y detiene cualquiera que lleve más tiempo que tu trabajo más largo. Cubre el caso en que la máquina que ejecutaba tu script se cayó a mitad:
# mismas variables API, H y J que en el script nocturno
curl -s "$API/instances?status=running" -H "$H" |
  jq -r --arg cut "$(date -u -d '-4 hours' +%FT%TZ)" '.instances[] | select(.started_at < $cut) | .id' |
  while read -r id; do curl -s "$API/instances/stop" -H "$H" -H "$J" -d "{\"id\":$id}"; done

(date -d es la versión de GNU; en un Mac usa date -u -v-4H +%FT%TZ).

  1. El saldo como muro exterior. El saldo es prepago, así que es el límite absoluto de lo que se puede gastar. Recarga para un mes de trabajos, no para un año. Te enviamos un correo cuando queda menos de una hora de funcionamiento. Pero no cuentes con quedarte sin saldo: una detención con el saldo a cero no guarda nada, y el disco se elimina con los resultados dentro.

Si a las 2 de la madrugada no hay ninguna máquina libre con tu tarjeta (409), espera unos minutos y vuelve a intentarlo, o recurre a una segunda tarjeta con la misma memoria o más. Los precios y la facturación por minuto los explicamos en la guía de facturación. Para la memoria del modelo, el selector de VRAM te dice qué tarjetas sirven para tu trabajo.

Preguntas sobre el tema

¿Hay CLI, SDK o proveedor de Terraform?

No. La API es HTTPS y JSON sin más, así que con curl, requests de Python o cualquier librería HTTP te basta. No hay proveedor de Terraform ni integración con Kubernetes.

¿Puedo consultar precios y disponibilidad antes de lanzar?

Sí. GET /api/offers lista las máquinas disponibles con su precio por hora, su país y su tarifa de tráfico, y GET /api/offer-types da la máquina fiable más barata de cada tarjeta y cuántas hay libres. Los precios van en vivo, así que consúltalos justo antes de lanzar.

¿Mi script puede leer mi saldo?

Sí. GET /api/account devuelve balance_eur, junto con si tu correo está confirmado, si hay una clave SSH configurada y cuántos servidores puedes tener en marcha a la vez.

¿Cómo sabe un script en el servidor cuál es el ID de su propio servidor?

No hay ninguna variable integrada para eso. La respuesta del lanzamiento contiene el id, así que pásaselo tú al servidor, por ejemplo escribiéndolo en un archivo por SSH, si un script de allí necesita llamar a la API.

Encuentra la GPU en la que cabe tu modelo

Elige un modelo y verás la tarjeta más barata con VRAM suficiente, con facturación por minuto en euros.