Zaroz
Guías de Workloads

Monta una red de Minecraft con Workers

Una red de juego tiene una puerta y muchas salas. Los jugadores se conectan a una sola dirección pública; detrás, un proxy (Velocity o BungeeCord) envía a cada jugador a un lobby, un minijuego o un servidor survival. En Zaroz son dos tipos de Worker en un mismo proyecto:

Code
jugadores ──TCP 25565──▶ Worker proxy (1 unidad, un puerto público) │ nombres privados, nunca en internet ├──▶ u1.lobby:25565 ├──▶ u2.lobby:25565 Worker lobby (N unidades, sin puerto público) └──▶ u3.lobby:25565 └──▶ u1.games:25565 Worker games (N unidades, sin puerto público)
  • El Worker proxy ejecuta una unidad y posee el único puerto público.
  • Cada Worker backend ejecuta tantas unidades como necesites. No tiene dirección pública: el proxy llega a cada unidad por su nombre privado, <clave>.<slug>.
  • El proxy encuentra los backends mediante la API de Units, así que subir o bajar un backend no exige editar ninguna configuración.

Un Worker es el producto para cualquier programa de larga duración que no sea una web. Puede tener puertos públicos TCP y UDP raw y puede ejecutar muchas unidades. Los endpoints HTTPS son para webs y un Worker no los ofrece. Los términos se explican en Units, Puertos públicos y Nombres privados.

Antes de empezar

Necesitas una organización con un proyecto, una imagen de proxy de Minecraft y una imagen de backend en un registro (la guía para desplegar un Workload explica cómo desplegar desde una imagen) y un token de API personal. Las llamadas de abajo usan el ayudante de la introducción a la API de Units: $ZAROZ_API, $AUTH, $PROJECT.

1. Crea los dos Workers en un proyecto

Crea ambos Workers en el mismo proyecto, porque los nombres privados solo funcionan dentro de un proyecto. Dale a cada uno un slug que empiece por una letra, ya que el slug pasa a ser el nombre (mira las reglas):

Workerslugunidadespúblico
Proxy Velocityproxy1TCP 25565 (y UDP 19132 para Bedrock)
Lobbieslobby4ninguno

Elige un plan cuya memoria quepa en la JVM: un proxy Velocity es ligero, un servidor Paper necesita su heap más margen. Zaroz define MEMORY_LIMIT_MIB en el entorno de cada unidad, y tu comando de arranque puede usarlo para dimensionar el heap.

Exporta los dos ids de servicio, que aparecen en la URL de la consola de cada Worker:

TerminalCode
export PROXY="<uuid de la instancia de servicio del proxy>" export LOBBY="<uuid de la instancia de servicio de lobby>"

2. Abre el único puerto público en el proxy

Abre un puerto TCP raw. internal_port es el puerto en el que escucha Velocity dentro del contenedor; Zaroz asigna la dirección y el número públicos y los devuelve.

curl

TerminalCode
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/endpoints" \ -H "$AUTH" -H "Content-Type: application/json" \ -d '{"kind":"port","internal_port":25565,"protocol":"tcp"}' # {"id": "...", "public_ip": "203.0.113.10", "public_port": 30421, "state": "active", ...}

TypeScript

TypeScriptCode
const port = await call("POST", `/projects/${PROJECT}/service-instances/${PROXY}/endpoints`, { kind: "port", internal_port: 25565, protocol: "tcp", }); console.log(`${port.body.public_ip}:${port.body.public_port}`, port.body.state);

Python

Code
status, port = call("POST", f"/projects/{PROJECT}/service-instances/{PROXY}/endpoints", {"kind": "port", "internal_port": 25565, "protocol": "tcp"}) print(f"{port['public_ip']}:{port['public_port']}", port["state"])

Los jugadores entran por public_ip:public_port. Cada puerto raw cuesta 1 EUR al mes, facturado por las horas que se mantiene (mira la factura). La petición se rechaza con not_ready hasta que la unidad del proxy esté en marcha, así que despliega primero y abre el puerto después.

Estado de la API

Las rutas de endpoints las usa la consola y son solo de consola en la referencia de la API: aceptan tu token de sesión o un token de API personal, y rechazan a un service principal. Haz este paso tú, o desde la consola, no desde el proxy.

Los backends no tienen ningún endpoint. Siguen siendo privados.

3. Escala el backend a N unidades

Una política fija ejecuta exactamente N unidades. Sus claves son u1 a uN, y sus nombres privados u1.lobby a uN.lobby:

curl

TerminalCode
curl -sS -X PATCH "$ZAROZ_API/projects/$PROJECT/service-instances/$LOBBY/units/policy" \ -H "$AUTH" -H "Content-Type: application/json" \ -d '{"mode":"fixed","min_units":4,"max_units":4}'

TypeScript

TypeScriptCode
await call("PATCH", `/projects/${PROJECT}/service-instances/${LOBBY}/units/policy`, { mode: "fixed", min_units: 4, max_units: 4 });

Python

Code
call("PATCH", f"/projects/{PROJECT}/service-instances/{LOBBY}/units/policy", {"mode": "fixed", "min_units": 4, "max_units": 4})

Si quieres nombres que elijas tú, como survival y skyblock, o una unidad por partida de minijuego, usa el modo code y crea las unidades por clave: una unidad creada con la clave skyblock es accesible como skyblock.lobby. El resto de esta guía funciona igual en ambos casos, porque el proxy lee los nombres de la API.

4. Apunta Velocity a los backends

La configuración más simple del proxy nombra los backends de forma estática. En velocity.toml:

Code
bind = "0.0.0.0:25565" player-info-forwarding-mode = "modern" [servers] u1 = "u1.lobby:25565" u2 = "u2.lobby:25565" u3 = "u3.lobby:25565" u4 = "u4.lobby:25565" try = ["u1", "u2", "u3", "u4"]

Cada backend debe escuchar en el 25565 dentro de su contenedor y confiar en el secreto de reenvío de Velocity (forwarding.secret en el proxy, [proxies.velocity] en la configuración global de Paper). Como los backends no tienen dirección pública, online-mode=false en ellos es seguro: solo tu propio proyecto puede conectarse.

El nombre lobby a secas también es válido y llega a todas las unidades listas, pero un proxy de Minecraft necesita una dirección por servidor para poder enviar a un jugador a uno concreto, así que lista los nombres con clave.

5. Descubre los backends dinámicamente

Una lista estática se rompe en cuanto escalas a cinco. En su lugar, dale al Worker proxy un service principal con units.read sobre el Worker lobby, y deja que un plugin o un script auxiliar en el proxy liste las unidades y registre un servidor de Velocity por cada unidad lista.

Crea el principal y la concesión

curl

TerminalCode
P="$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/principal" curl -sS -X PUT "$P" -H "$AUTH" curl -sS -X PUT "$P/grants" -H "$AUTH" -H "Content-Type: application/json" -d "{ \"grants\": [{\"target_service_instance_id\": \"$LOBBY\", \"capability\": \"units.read\"}] }"

TypeScript

TypeScriptCode
const P = `/projects/${PROJECT}/service-instances/${PROXY}/principal`; await call("PUT", P); await call("PUT", `${P}/grants`, { grants: [{ target_service_instance_id: LOBBY, capability: "units.read" }], });

Python

Code
P = f"/projects/{PROJECT}/service-instances/{PROXY}/principal" call("PUT", P) call("PUT", f"{P}/grants", {"grants": [ {"target_service_instance_id": LOBBY, "capability": "units.read"}, ]})

units.read basta para descubrir backends. Usa units.control solo si el proxy también debe arrancar y parar servidores (por ejemplo, despertar una unidad de minijuego parada cuando un jugador entra en cola). Activar un principal cambia la plantilla del Worker, así que se aplica cuando la unidad del proxy se reinicia.

Cada unidad del proxy tiene ahora ZAROZ_API_URL y ZAROZ_PROJECT_ID en su entorno y un token en /zaroz/credentials/token. El token rota: lee el archivo en cada petición.

Lista las unidades

GET .../units devuelve de cada unidad su key, private_name, state, labels y accepting. Un backend es utilizable cuando tiene private_name y está en running:

curl

TerminalCode
curl -sS "$ZAROZ_API_URL/projects/$ZAROZ_PROJECT_ID/service-instances/$LOBBY/units" \ -H "Authorization: Bearer $(cat /zaroz/credentials/token)" \ | jq -r '.data[] | select(.state == "running" and .private_name != null) | "\(.key) \(.private_name)"' # u1 u1.lobby # u2 u2.lobby

TypeScript

TypeScriptCode
import { readFileSync } from "node:fs"; const token = () => readFileSync("/zaroz/credentials/token", "utf8").trim(); const base = `${process.env.ZAROZ_API_URL}/projects/${process.env.ZAROZ_PROJECT_ID}`; type Unit = { key: string; private_name: string | null; state: string; labels: Record<string, string> }; export async function backends(lobbyId: string): Promise<{ name: string; address: string }[]> { const res = await fetch(`${base}/service-instances/${lobbyId}/units`, { headers: { Authorization: `Bearer ${token()}` }, }); if (!res.ok) throw new Error(`units: ${res.status}`); const { data } = (await res.json()) as { data: Unit[] }; return data .filter((u) => u.state === "running" && u.private_name) .map((u) => ({ name: u.key, address: `${u.private_name}:25565` })); }

Python

Code
import os import requests def token() -> str: with open("/zaroz/credentials/token") as f: return f.read().strip() def backends(lobby_id: str) -> list[dict]: base = f"{os.environ['ZAROZ_API_URL']}/projects/{os.environ['ZAROZ_PROJECT_ID']}" r = requests.get(f"{base}/service-instances/{lobby_id}/units", headers={"Authorization": "Bearer " + token()}, timeout=10) r.raise_for_status() return [ {"name": u["key"], "address": f"{u['private_name']}:25565"} for u in r.json()["data"] if u["state"] == "running" and u["private_name"] ]

Filtra por etiqueta para separar tipos de servidor: dale a las unidades lobby {"kind":"lobby"} (mira las etiquetas) y lista con ?label=kind:lobby.

Reacciona a los cambios

Consulta la lista cada pocos segundos, o sigue el flujo de eventos con unit.ready, unit.draining, unit.stopped y unit.destroyed (con wait=30 la petición vuelve en cuanto ocurre algo), y vuelve a listar cuando llegue uno. El flujo encaja mejor que un webhook aquí, porque los webhooks se entregan a una URL pública y el proxy solo expone su puerto de juego. En cada cambio, compara la lista con los servidores registrados y llama a registerServer / unregisterServer de Velocity para la diferencia. unit.ready incluye el nombre privado de la unidad, así que ni siquiera necesitas una segunda lectura.

Librería auxiliar

zaroz-minecraft-helper, un plugin de Velocity y Paper que hace los pasos 4 y 5 por ti, llegará próximamente. Hasta que se publique, las llamadas de arriba son todo lo que hace.

Jugadores de Bedrock con Geyser

Geyser traduce a los jugadores de Bedrock a Java, así que usan los mismos backends. Ejecútalo como plugin en el proxy (Geyser-Velocity) y abre un segundo puerto en el Worker proxy:

TerminalCode
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/endpoints" \ -H "$AUTH" -H "Content-Type: application/json" \ -d '{"kind":"port","internal_port":19132,"protocol":"udp"}'

Es un puerto raw más, 1 EUR al mes. (tcp_udp es un endpoint que lleva ambos protocolos por un único número público, para cuando Java y Bedrock deban compartir número de puerto.)

El tráfico de Bedrock es UDP, y UDP no tiene conexión, así que el borde mantiene a cada jugador en el mismo backend por flujo: un flujo nuevo elige un backend a partir del último byte de la IPv4 del jugador, y un flujo ya iniciado se queda donde está hasta que termina, aunque cambie el conjunto de backends. Para un juego esto significa dos cosas: mantén el proxy en una unidad, que es lo que incluye el plan del Worker y lo que hace irrelevante la cuestión, y no esperes que un jugador UDP se mueva entre unidades a mitad de sesión. Entre el proxy y tus backends Java no hay UDP en absoluto, porque Geyser habla el protocolo Java por TCP con ellos.

Cuando los jugadores deben llegar a un servidor concreto

A veces un servidor necesita su propia dirección pública: un servidor creativo que comparte un streamer, un servidor de torneo cuya dirección repartes. Un nombre privado no puede, porque nunca es público. Abre en su lugar un endpoint por unidad (kind: unit) en el Worker backend:

TerminalCode
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$LOBBY/endpoints" \ -H "$AUTH" -H "Content-Type: application/json" \ -d '{"kind":"unit","internal_port":25565,"protocol":"tcp"}'

Cada unidad del Worker recibe entonces su propia dirección y puerto públicos para ese puerto del contenedor, que llegan solo a esa unidad, y una unidad nueva recibe los suyos al añadirse. El campo public_endpoints de cada unidad (public_ip, public_port, protocol, internal_port) te dice la dirección, y se mantiene igual entre reinicios de la unidad. Se factura como un puerto por unidad, así que cuatro unidades son cuatro puertos; cerrar el endpoint quita el puerto de todas las unidades. Úsalo para los pocos servidores que lo necesiten, no para todos los backends: el proxy más los nombres privados es más barato y mantiene los backends fuera de internet.

La factura

Cada cifra de abajo sale de las reglas de Lo que cuestan las unidades y de las reglas de endpoints. Los precios de plan son provisionales (el precio del Worker aún se está fijando; es el mismo plan de ejemplo de 7,20 EUR al mes de la página de facturación, con una unidad incluida); lee el precio real de tu plan en su página o en el endpoint de cotización. La estructura de la factura no cambia cuando cambia el precio.

Las reglas:

  1. El precio del plan de un Worker cubre su unidad incluida (una) y se paga por adelantado.
  2. Cada unidad por encima de las incluidas es una unidad extra, facturada por las horas que realmente funciona al precio mensual del plan / 720, sin recargo, y con un mínimo de 5 minutos por arranque.
  3. Cada puerto público raw cuesta 1 EUR al mes, facturado por las horas que se mantiene (1 EUR / 720 por hora), sin puertos gratis en un Worker.
  4. Los nombres privados, la API de Units, los eventos y los service principals son gratis. Los backends sin puerto público no pagan puerto.

La red de arriba, durante un mes entero de 30 días (720 horas):

líneacálculoEUR
plan del proxy (1 unidad incluida)precio de plan provisional7,20
puerto TCP 25565 del proxy1 puerto x 1,00 EUR1,00
plan de lobby (1 unidad incluida)precio de plan provisional7,20
3 unidades lobby extra3 x 720 h x (7,20 / 720)21,60
total37,00

Añade Bedrock y el total es 38,00 EUR (un puerto más). Haz público un servidor creativo con un endpoint por unidad en un Worker lobby de cuatro unidades y son cuatro puertos más, 4,00 EUR. Si escalas el lobby entre 1 y 4 con una política que mantiene tres unidades extra durante 100 horas en el mes, las unidades extra cuestan 3 x 100 x 0,01 = 3,00 EUR ese mes en lugar de 21,60 EUR. Una unidad parada no cuesta nada, así que una unidad de minijuego parada entre rondas solo cuesta las horas que funcionó.

Comprueba la cifra real antes de comprometerte: POST .../units/policy/quote devuelve lo máximo que podría costar una política, sin guardarla.

Solución de problemas

síntomacausa
private_name es nullel slug del Worker empieza por un dígito, renómbralo
u2.lobby no se resuelveesa unidad aún no está lista, o el proxy está en otro proyecto
Velocity dice "connection refused"el backend no escucha en el puerto que escribiste, o solo escucha en 127.0.0.1; enlaza 0.0.0.0
abrir el puerto responde not_readyla unidad del proxy aún no está en marcha
ports_not_offeredel producto elegido no ofrece puertos públicos; elige un Worker
la lista de unidades se rechazael principal no tiene la concesión units.read sobre ese Worker (mira Service principals)
falta el archivo del tokenel principal se acaba de activar; se aplica cuando la unidad del proxy se reinicia
Last modified on