Saltar al contenido principal

Caso Real: Orquestación de Servidores Minecraft

El Problema

Gestionar una flota de servidores Minecraft (Forge) en contenedores Docker crea una brecha de observabilidad:

  • Los logs se escriben en stdout dentro del contenedor — son efímeros y no estructurados
  • La detección de crashes requiere hacer polling a los contenedores o ejecutar un daemon de monitoreo separado
  • Enviar alertas a Discord requiere desplegar un bot (OAuth, permisos, gestión de tokens)
  • Escalar a múltiples instancias Forge multiplica todos los problemas anteriores

La solución típica de homelab implica 3–5 herramientas separadas (Prometheus, Grafana, un bot de Discord, agregadores de logs). Esa es una cadena de dependencias que se rompe.

La Solución TinyMQ

Un sidecar recolector de logs ligero lee el stdout de cada servidor, parsea eventos y los publica en TinyMQ. La funcionalidad de Webhook nativo de TinyMQ entrega las alertas de crash directamente a la URL de Incoming Webhook de Discord — sin necesidad de bot de Discord.

Arquitectura

Paso 1: Registrar el Webhook de Discord

Discord ofrece URLs de Incoming Webhook nativas para los canales — no se necesita bot, solo una URL.

En Discord: Configuración del Canal → Integraciones → Webhooks → Nuevo Webhook → Copiar URL

Regístrala en TinyMQ:

curl -X POST http://localhost:7800/webhook/mc.crashes \
-H "Content-Type: application/json" \
-d '{"url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"}'

Ahora cualquier mensaje publicado en mc.crashes se enviará mediante POST directamente al endpoint webhook de Discord.

Formato del Webhook de Discord

Los webhooks de Discord esperan un payload JSON específico con un campo content (o embeds). El worker recolector de logs debe formatear el payload de TinyMQ de manera acorde antes de publicar en mc.crashes.

Paso 2: Sidecar Recolector de Logs (Go)

Este sidecar lee el archivo de log del servidor Minecraft (o el stream de log de Docker), clasifica los eventos y los publica en TinyMQ.

package main

import (
"bufio"
"encoding/json"
"log"
"os"
"strings"
"time"

"github.com/x-name15/tinymq/client"
)

type LogEvent struct {
ServerID string `json:"server_id"`
Level string `json:"level"` // "INFO", "WARN", "ERROR", "CRASH"
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}

func classifyLine(line string) string {
upper := strings.ToUpper(line)
switch {
case strings.Contains(upper, "CRASH") || strings.Contains(upper, "FATAL"):
return "CRASH"
case strings.Contains(upper, "ERROR") || strings.Contains(upper, "EXCEPTION"):
return "ERROR"
case strings.Contains(upper, "WARN"):
return "WARN"
default:
return "INFO"
}
}

func main() {
serverID := os.Getenv("SERVER_ID") // e.g., "forge-survival-1"
logPath := os.Getenv("LOG_PATH") // e.g., "/logs/latest.log"
brokerURL := os.Getenv("TINYMQ_URL")
if brokerURL == "" {
brokerURL = "http://tinymq:7800"
}

mq := client.NewClient(brokerURL)

f, err := os.Open(logPath)
if err != nil {
log.Fatalf("Cannot open log file: %v", err)
}
defer f.Close()

// Seek to end — only new lines
f.Seek(0, 2)

log.Printf("[%s] Collector started. Tailing %s...\n", serverID, logPath)

scanner := bufio.NewScanner(f)
for {
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}

event := LogEvent{
ServerID: serverID,
Level: classifyLine(line),
Message: line,
Timestamp: time.Now(),
}

payload, _ := json.Marshal(event)

// Publish all logs to general topic
mq.Publish("mc.logs", payload)

// Escalate crashes to dedicated topic (which has Discord webhook)
if event.Level == "CRASH" || event.Level == "ERROR" {
// Format for Discord
discordPayload, _ := json.Marshal(map[string]string{
"content": "🚨 **[" + serverID + "]** Server Event `" + event.Level + "`:\n```\n" + line + "\n```",
})
mq.Publish("mc.crashes", discordPayload)
}
}

// File hasn't grown — wait 500ms before next scan
time.Sleep(500 * time.Millisecond)
}
}

Paso 3: Docker Compose — Stack Completo

# docker-compose.yml

services:
tinymq:
image: ghcr.io/x-name15/tinymq:latest
ports:
- "7800:7800"
volumes:
- ./data:/root/data
restart: unless-stopped

# Minecraft Forge Server 1
mc-survival:
image: itzg/minecraft-server
environment:
- EULA=TRUE
- TYPE=FORGE
- VERSION=1.20.1
- MEMORY=4G
ports:
- "25565:25565"
volumes:
- ./servers/survival:/data
restart: unless-stopped

# Log Collector Sidecar for mc-survival
collector-survival:
build: ./collector
environment:
- SERVER_ID=forge-survival-1
- LOG_PATH=/logs/latest.log
- TINYMQ_URL=http://tinymq:7800
volumes:
- ./servers/survival/logs:/logs:ro # Read-only access to server logs
depends_on:
- tinymq
- mc-survival
restart: unless-stopped

# Minecraft Forge Server 2
mc-creative:
image: itzg/minecraft-server
environment:
- EULA=TRUE
- TYPE=FORGE
- VERSION=1.20.1
- MEMORY=2G
ports:
- "25566:25565"
volumes:
- ./servers/creative:/data
restart: unless-stopped

collector-creative:
build: ./collector
environment:
- SERVER_ID=forge-creative-1
- LOG_PATH=/logs/latest.log
- TINYMQ_URL=http://tinymq:7800
volumes:
- ./servers/creative/logs:/logs:ro
depends_on:
- tinymq
- mc-creative
restart: unless-stopped

Paso 4: Monitorear desde la CLI

# See all active queues — how many logs are pending?
tmq status

# Peek at the latest crash events without consuming them
tmq peek mc.crashes --limit=5

# Live-tail all log events in real-time
tmq tail mc.logs

# Check if any server crashed in the last hour
tmq sub mc.crashes --limit=20 --auto-ack=false --timeout=1s

Qué Obtienes

CapacidadImplementación
Stream de logs unificadoTodos los servidores publican en mc.logs
Detección de crashesPattern matching en el collector → publica en mc.crashes
Alertas en DiscordWebhook nativo de TinyMQ → Discord Incoming Webhook
Persistencia de logsWAL garantiza que los logs sobrevivan a reinicios de TinyMQ
Fan-in multi-servidorTodos los collectors publican al mismo topic
Sin bot requeridoLa URL de Discord webhook es simplemente un endpoint HTTP
Observabilidad/dashboard muestra conteos de mensajes y salud de los consumers

Extendiendo el Patrón

Esta arquitectura se extiende de forma natural:

  • Tracking de join/leave de jugadores: Parsear [INFO] PlayerName joined the game → publicar en mc.players
  • Monitoreo de rendimiento: Parsear avisos de TPS → publicar en mc.performance con TTL de 1h
  • Disparadores de backup: Publicar en mc.maintenance cuando se detecta [INFO] Saving world → disparar un worker de backup en S3
  • Soporte multi-juego: Sustituir Minecraft por Valheim, Palworld o cualquier servidor que escriba logs en stdout