feat(docker): add robust healthcheck script (#5028)
* feat(docker): add robust healthcheck script - Added `docker/healthcheck.cjs`: A standalone, dependency-free Node.js script for verifying server status. - Updated `Dockerfile`: Added HEALTHCHECK instruction and script copy step. - Features: Auto-detects port from env/config, handles IPv4/IPv6 fallback, auto-retries HTTPS on socket hangup, and sets custom User-Agent. * feat(docker): new healthcheck with /api/health endpoint - Added `GET /api/health` endpoint to `server.js` (unauthenticated) for lightweight status checks. - Update `docker/healthcheck.cjs`: Rewrite - Updated Error handle for `HEALTHCHECK` * feat(docker): switch to heartbeat file healthcheck mechanism - Replaced network-based check with a file-based heartbeat approach. - Updated `src/command-line.js`: Added `heartbeatInterval` argument with explicit ENV override (`SILLYTAVERN_HEARTBEAT_INTERVAL`). - Updated `src/server-main.js`: Added logic to write `heartbeat.json` to data directory at set intervals. - Rewrote `docker/healthcheck.cjs`: Script now monitors the heartbeat file timestamp (zero dependencies, no config parsing required). - Updated `Dockerfile`: Sets default heartbeat interval to 30s and ensures script availability. - Updated `config.yaml`: Added `heartbeatInterval` defaulting to 0 (disabled) for non-Docker users. * Fix variable names * Convert to ESM, use serverDirectory variable * Move file to /src * fix: update heartbeat path to use global DATA_ROOT variable * Pretty colors * Move healthcheck to docker-compose.yml * Comment fixed * Even cleaner diff! --------- Co-authored-by: Pavdig <101715456+Pavdig@users.noreply.github.com>
This commit is contained in:
@@ -38,6 +38,9 @@ browserLaunch:
|
||||
avoidLocalhost: false
|
||||
# Server port
|
||||
port: 8000
|
||||
# Interval in seconds to write a heartbeat file. Set to 0 to disable.
|
||||
# This is used primarily for Docker healthchecks.
|
||||
heartbeatInterval: 0
|
||||
# -- SSL options --
|
||||
ssl:
|
||||
# Enable SSL/TLS encryption
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FORCE_COLOR=1
|
||||
- SILLYTAVERN_HEARTBEATINTERVAL=30
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@@ -14,4 +15,10 @@ services:
|
||||
- "./data:/home/node/app/data"
|
||||
- "./plugins:/home/node/app/plugins"
|
||||
- "./extensions:/home/node/app/public/scripts/extensions/third-party"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "src/healthcheck.js"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -18,6 +18,7 @@ import { initConfig } from './config-init.js';
|
||||
* @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
|
||||
* @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
|
||||
* @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
|
||||
* @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable.
|
||||
* @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser
|
||||
* @property {string} browserLaunchHostname Browser launch hostname
|
||||
* @property {number} browserLaunchPort Browser launch port override (-1 is use server port)
|
||||
@@ -62,6 +63,7 @@ export class CommandLineParser {
|
||||
enableIPv4: true,
|
||||
enableIPv6: false,
|
||||
dnsPreferIPv6: false,
|
||||
heartbeatInterval: 0,
|
||||
browserLaunchEnabled: false,
|
||||
browserLaunchHostname: 'auto',
|
||||
browserLaunchPort: -1,
|
||||
@@ -229,6 +231,11 @@ export class CommandLineParser {
|
||||
type: 'array',
|
||||
describe: 'Request proxy bypass list (space separated list of hosts)',
|
||||
})
|
||||
.option('heartbeatInterval', {
|
||||
type: 'number',
|
||||
default: null,
|
||||
describe: 'Interval in seconds to write a heartbeat file. 0 to disable.',
|
||||
})
|
||||
/* DEPRECATED options */
|
||||
.option('autorun', {
|
||||
type: 'boolean',
|
||||
@@ -289,6 +296,7 @@ export class CommandLineParser {
|
||||
enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,
|
||||
enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,
|
||||
dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),
|
||||
heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'),
|
||||
browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),
|
||||
browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),
|
||||
browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { serverDirectory } from './server-directory.js';
|
||||
|
||||
// Default to 0 seconds (disabled) if not set
|
||||
const intervalSeconds = parseInt(process.env.SILLYTAVERN_HEARTBEATINTERVAL || '0');
|
||||
const intervalMs = intervalSeconds * 1000;
|
||||
|
||||
// Heartbeat disabled
|
||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Allow a grace period (2 missed beats)
|
||||
const threshold = intervalMs * 2;
|
||||
|
||||
const dataRoot = process.env.SILLYTAVERN_DATAROOT || path.join(serverDirectory, 'data');
|
||||
const heartbeatFile = path.join(dataRoot, 'heartbeat.json');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(heartbeatFile)) {
|
||||
console.error(`Heartbeat file not found at: ${heartbeatFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(heartbeatFile);
|
||||
const lastModified = stats.mtimeMs;
|
||||
const now = Date.now();
|
||||
const diff = now - lastModified;
|
||||
|
||||
if (diff > threshold) {
|
||||
console.error(`Server is unresponsive. Last heartbeat was ${Math.round(diff / 1000)} seconds ago.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Healthcheck error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// native node modules
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import util from 'node:util';
|
||||
import net from 'node:net';
|
||||
@@ -349,6 +350,28 @@ async function postSetupTasks(result) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cliArgs.heartbeatInterval > 0) {
|
||||
// Convert seconds to milliseconds for the timer
|
||||
const intervalMs = cliArgs.heartbeatInterval * 1000;
|
||||
const heartbeatPath = path.join(globalThis.DATA_ROOT, 'heartbeat.json');
|
||||
|
||||
console.log(`Heartbeat enabled. Updating ${color.green(heartbeatPath)} every ${cliArgs.heartbeatInterval} seconds`);
|
||||
|
||||
const writeHeartbeat = () => {
|
||||
try {
|
||||
fs.writeFileSync(heartbeatPath, JSON.stringify({ timestamp: Date.now() }));
|
||||
} catch (err) {
|
||||
console.error(`Failed to write heartbeat file at ${color.green(heartbeatPath)}:`, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Write immediately
|
||||
writeHeartbeat();
|
||||
|
||||
// Loop using the converted milliseconds
|
||||
setInterval(writeHeartbeat, intervalMs).unref();
|
||||
}
|
||||
|
||||
setWindowTitle('SillyTavern WebServer');
|
||||
|
||||
let logListen = 'SillyTavern is listening on';
|
||||
|
||||
Reference in New Issue
Block a user