From 436f605356a58313f32968d4295707115a0bfdd4 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 10:00:19 +0100 Subject: [PATCH] Mehrere Docker-Hosts: Matches verteilt auf die Hosts starten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CS2_DOCKER_HOSTS nimmt eine Liste von Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Ports und merkt sich seinen Host, damit Status, Konfiguration und Stopp dort landen. Ohne die Variable bleibt alles beim bisherigen einzelnen Host. deploy/docker-host-einrichten.sh richtet einen Host per SSH ein. --- .env.example | 3 + README.md | 18 +++++ deploy/docker-host-einrichten.sh | 44 ++++++++++++ server/src/app.js | 4 +- server/src/config.js | 29 ++++++++ server/src/servers/adapter.js | 4 +- server/src/servers/dockerAdapter.js | 108 +++++++++++++++++++++++++++- server/src/servers/serverManager.js | 10 +++ server/test/dockerHostPool.test.js | 82 +++++++++++++++++++++ 9 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 deploy/docker-host-einrichten.sh create mode 100644 server/test/dockerHostPool.test.js diff --git a/.env.example b/.env.example index 6623c5b..9ba47fc 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,9 @@ CS2_PORT_RANGE_END=27115 # Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles CS2_DOCKER_EXTRA_ARGS= +# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST. +# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh +# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] CS2_START_TIMEOUT_MINUTES=20 DOCKER_BIN=docker diff --git a/README.md b/README.md index b47f7c1..388bf4e 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,24 @@ Voraussetzungen: Container heißen `cs2mv-` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`. +### Mehrere Docker-Hosts + +Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host. + +1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image): + + ```bash + bash deploy/docker-host-einrichten.sh cs2@192.168.178.111 + ``` + +2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile: + + ``` + CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}] + ``` + +Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein. + ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`) Für selbst betriebene, dauerhaft laufende CS2-Server: diff --git a/deploy/docker-host-einrichten.sh b/deploy/docker-host-einrichten.sh new file mode 100644 index 0000000..b25c30b --- /dev/null +++ b/deploy/docker-host-einrichten.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH. +# +# Aufruf: bash deploy/docker-host-einrichten.sh [volume] +# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111 +# +# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert. +set -euo pipefail + +TARGET="${1:?Aufruf: $0 [volume]}" +VOLUME="${2:-cs2-mapveto-gamefiles}" +IMAGE="joedwards32/cs2:latest" +HOST_PART="${TARGET#*@}" + +echo "→ Verbindung zu $TARGET prüfen" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true + +echo "→ Docker prüfen" +if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then + echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2 + exit 1 +fi +ssh "$TARGET" "docker info >/dev/null" + +echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)" +ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1" + +echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)" +ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null" + +echo "→ Image $IMAGE laden" +ssh "$TARGET" "docker pull $IMAGE" + +cat < manager.usedPorts() }); + const adapter = adapterOverride ?? createServerAdapter(config, { + getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()), + }); manager = new ServerManager({ db, service, adapter, config, logger }); manager.init(); service.startVetoTimer(); diff --git a/server/src/config.js b/server/src/config.js index 6efc5dd..ba8b605 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -22,6 +22,32 @@ function bool(name, fallback) { return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); } +function parseDockerHosts(raw) { + if (!raw) return null; + let list; + try { + list = JSON.parse(raw); + } catch { + throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON'); + } + if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag'); + const names = new Set(); + return list.map((h) => { + if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost'); + if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`); + names.add(h.name); + return { + name: String(h.name), + dockerHost: h.dockerHost ?? null, + publicHost: String(h.publicHost), + rconHost: h.rconHost ?? h.publicHost, + portRangeStart: h.portStart == null ? undefined : Number(h.portStart), + portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd), + volume: h.volume ?? undefined, + }; + }); +} + function str(name, fallback = '') { const raw = process.env[name]; return raw === undefined || raw === '' ? fallback : raw; @@ -85,6 +111,9 @@ export function loadConfig(overrides = {}) { volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), + // Optional: mehrere Docker-Hosts als JSON-Liste, z. B. + // [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] + hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')), }, staticPool: { diff --git a/server/src/servers/adapter.js b/server/src/servers/adapter.js index 6ef0ae7..3611be9 100644 --- a/server/src/servers/adapter.js +++ b/server/src/servers/adapter.js @@ -29,7 +29,7 @@ */ import { DemoServerAdapter } from './demoAdapter.js'; -import { DockerServerAdapter } from './dockerAdapter.js'; +import { DockerHostPool } from './dockerAdapter.js'; import { StaticPoolServerAdapter } from './staticPoolAdapter.js'; export function createServerAdapter(config, deps) { @@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) { case 'demo': return new DemoServerAdapter(config.demo); case 'docker': - return new DockerServerAdapter(config.docker, deps); + return new DockerHostPool(config.docker, deps); case 'static': return new StaticPoolServerAdapter(config.staticPool, deps); default: diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 11904c7..5804f5a 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -37,7 +37,8 @@ export class DockerServerAdapter { async docker(args, timeout = 60_000) { if (this.runDocker) return this.runDocker(args); - const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 }); + const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env; + const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env }); return stdout.trim(); } @@ -137,7 +138,14 @@ export class DockerServerAdapter { await this.docker(args, 15 * 60_000); return { - handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword }, + handle: { + container, + hostName: this.options.name, + rconHost: this.options.rconHost, + port, + startedAt: Date.now(), + rconPassword: secrets.rconPassword, + }, host: this.options.host, port, tvPort, @@ -192,3 +200,99 @@ export class DockerServerAdapter { await this.docker(['rm', '-f', handle.container]).catch(() => {}); } } +/** + * Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren, + * der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen + * immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen. + */ +export class DockerHostPool { + constructor(options, { getUsedPorts, runDocker } = {}) { + this.name = 'docker'; + this.simulated = false; + this.depsGetUsedPorts = getUsedPorts; + const hosts = options.hosts ?? [{ + name: 'default', + dockerHost: process.env.DOCKER_HOST || null, + publicHost: options.host, + rconHost: options.rconHost, + portRangeStart: options.portRangeStart, + portRangeEnd: options.portRangeEnd, + volume: options.volume, + }]; + // Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen + this.hosts = hosts.map((h) => { + const host = { + name: h.name, + dockerHost: h.dockerHost ?? null, + publicHost: h.publicHost ?? options.host, + rconHost: h.rconHost ?? options.rconHost, + portRangeStart: h.portRangeStart ?? options.portRangeStart, + portRangeEnd: h.portRangeEnd ?? options.portRangeEnd, + volume: h.volume ?? options.volume, + }; + return { ...options, ...host, host: host.publicHost }; + }); + this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, { + getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()), + runDocker, + })])); + } + + /** Freie Port-Paare auf einem Host. */ + freeSlots(host) { + const used = this.getUsedPorts(host.publicHost); + let free = 0; + for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) { + if (!used.has(p) && !used.has(p + 1)) free += 1; + } + return free; + } + + getUsedPorts(publicHost) { + return this.depsGetUsedPorts?.(publicHost) ?? new Set(); + } + + pickHost() { + const best = this.hosts + .map((h) => ({ host: h, free: this.freeSlots(h) })) + .filter((x) => x.free > 0) + .sort((a, b) => b.free - a.free)[0]; + if (!best) throw new Error('Kein Docker-Host mit freien Ports'); + return best.host; + } + + async start(args) { + return this.adapters.get(this.pickHost().name).start(args); + } + + adapterFor(handle) { + const name = handle?.hostName ?? this.hosts[0].name; + const adapter = this.adapters.get(name); + if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`); + return adapter; + } + + async status(handle) { + return this.adapterFor(handle).status(handle); + } + + async configure(handle, plan) { + return this.adapterFor(handle).configure(handle, plan); + } + + async listRoundBackups(handle, matchId) { + return this.adapterFor(handle).listRoundBackups(handle, matchId); + } + + async loadRoundBackup(handle, file) { + return this.adapterFor(handle).loadRoundBackup(handle, file); + } + + async sendCommands(handle, commands) { + return this.adapterFor(handle).sendCommands(handle, commands); + } + + async stop(handle) { + return this.adapterFor(handle).stop(handle); + } +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 7052bf7..5a82c8d 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -160,6 +160,16 @@ export class ServerManager { this.service.changed(matchId); } + /** Ports, die auf einem bestimmten Docker-Host belegt sind. */ + usedPortsOn(host) { + const used = new Set(); + for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) { + used.add(r.port); + used.add(r.tv_port); + } + return used; + } + usedPorts() { const used = new Set(); for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) { diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js new file mode 100644 index 0000000..7ee4223 --- /dev/null +++ b/server/test/dockerHostPool.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DockerHostPool } from '../src/servers/dockerAdapter.js'; + +const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' }; + +const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare +const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare + +function options(hosts) { + return { + bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', + portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '', + startTimeoutMinutes: 1, hosts, + }; +} + +/** Docker-Aufrufe merken, Container gelten sofort als fertig. */ +function fakeDocker() { + const calls = []; + const run = async (args) => { + calls.push(args); + if (args[0] === 'inspect') return 'running'; + if (args[0] === 'logs') return 'Starting CS2 Dedicated Server'; + return ''; + }; + return { run, calls }; +} + +/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */ +function pool(hosts, used = {}) { + const docker = fakeDocker(); + const p = new DockerHostPool(options(hosts), { + getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []), + runDocker: docker.run, + }); + for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {}; + return { pool: p, calls: docker.calls }; +} + +test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => { + const { pool: p } = pool(null); + assert.equal(p.hosts.length, 1); + assert.equal(p.hosts[0].publicHost, '10.0.0.9'); +}); + +test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => { + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei + const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets }); + assert.equal(r.handle.hostName, 'b'); + assert.equal(r.host, '10.0.0.2'); +}); + +test('Ein voller Host wird übersprungen', async () => { + const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA }); + const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets }); + assert.equal(r.handle.hostName, 'b'); +}); + +test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => { + const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); + const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i); + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB }); + await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/); +}); + +test('Status, Stopp und Befehle gehen an den Host des Matches', async () => { + const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] }); + const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets }); + calls.length = 0; + await p.stop(r.handle); + assert.deepEqual(calls.at(-1)[0], 'rm'); + assert.equal(calls.at(-1).at(-1), r.handle.container); + // Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt + await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/); +}); + +test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => { + const { pool: p } = pool([hostA, hostB]); + assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a')); +});