Mehrere Docker-Hosts: Matches verteilt auf die Hosts starten
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.
This commit is contained in:
1 parent
1a0ab7ef00
commit
436f605356
9 files changed
+297
-5
No files matched your search
+3
-1
@@ -20,7 +20,9 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
const service = new MatchService({ db, pools, config, notifier });
|
||||
|
||||
let manager;
|
||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => 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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user