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 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 elidno cambia. Si el servidor acaba enstoppedsin que tú lo hayas detenido,errordice 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 untrapo en unfinallysin comprobaciones. - Los errores llegan como
{"error": "...", "code": "..."}. Decide en función decode.402significa saldo insuficiente,409que no hay ninguna máquina libre de ese tipo, y429,502y503merecen 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
scpo 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:
- Primero, el coste esperado. Antes de lanzar, lee el precio en vivo con
GET /api/offers?gpu=rtx-4090&count=1(el campopriceson euros por hora para la máquina entera, con todas sus tarjetas). El coste esperado es precio × horas previstas, más el tráfico. FijaMAX_EURen 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. - Topes de tiempo y de dinero en el bucle.
cost_eurcrece con cada minuto de ejecución, ytraffic_eursuma 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. - Una detención en cada salida.
trap ... EXITen bash,finallyen Python. Detener dos veces no hace daño. - 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).
- 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.