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>
Signed| @@ -38,6 +38,9 @@ browserLaunch: | ||
| 38 | 38 | avoidLocalhost: false |
| 39 | 39 | # Server port |
| 40 | 40 | port: 8000 |
| 41 | +# Interval in seconds to write a heartbeat file. Set to 0 to disable. | |
| 42 | +# This is used primarily for Docker healthchecks. | |
| 43 | +heartbeatInterval: 0 | |
| 41 | 44 | # -- SSL options -- |
| 42 | 45 | ssl: |
| 43 | 46 | # Enable SSL/TLS encryption |
| @@ -7,6 +7,7 @@ services: | ||
| 7 | 7 | environment: |
| 8 | 8 | - NODE_ENV=production |
| 9 | 9 | - FORCE_COLOR=1 |
| 10 | + - SILLYTAVERN_HEARTBEATINTERVAL=30 | |
| 10 | 11 | ports: |
| 11 | 12 | - "8000:8000" |
| 12 | 13 | volumes: |
| @@ -14,4 +15,10 @@ services: | ||
| 14 | 15 | - "./data:/home/node/app/data" |
| 15 | 16 | - "./plugins:/home/node/app/plugins" |
| 16 | 17 | - "./extensions:/home/node/app/public/scripts/extensions/third-party" |
| 18 | + healthcheck: | |
| 19 | + test: ["CMD", "node", "src/healthcheck.js"] | |
| 20 | + interval: 30s | |
| 21 | + timeout: 10s | |
| 22 | + start_period: 20s | |
| 23 | + retries: 3 | |
| 17 | 24 | restart: unless-stopped |
| @@ -18,6 +18,7 @@ import { initConfig } from './config-init.js'; | ||
| 18 | 18 | * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed) |
| 19 | 19 | * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed) |
| 20 | 20 | * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS |
| 21 | + * @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable. | |
| 21 | 22 | * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser |
| 22 | 23 | * @property {string} browserLaunchHostname Browser launch hostname |
| 23 | 24 | * @property {number} browserLaunchPort Browser launch port override (-1 is use server port) |
| @@ -62,6 +63,7 @@ export class CommandLineParser { | ||
| 62 | 63 | enableIPv4: true, |
| 63 | 64 | enableIPv6: false, |
| 64 | 65 | dnsPreferIPv6: false, |
| 66 | + heartbeatInterval: 0, | |
| 65 | 67 | browserLaunchEnabled: false, |
| 66 | 68 | browserLaunchHostname: 'auto', |
| 67 | 69 | browserLaunchPort: -1, |
| @@ -229,6 +231,11 @@ export class CommandLineParser { | ||
| 229 | 231 | type: 'array', |
| 230 | 232 | describe: 'Request proxy bypass list (space separated list of hosts)', |
| 231 | 233 | }) |
| 234 | + .option('heartbeatInterval', { | |
| 235 | + type: 'number', | |
| 236 | + default: null, | |
| 237 | + describe: 'Interval in seconds to write a heartbeat file. 0 to disable.', | |
| 238 | + }) | |
| 232 | 239 | /* DEPRECATED options */ |
| 233 | 240 | .option('autorun', { |
| 234 | 241 | type: 'boolean', |
| @@ -289,6 +296,7 @@ export class CommandLineParser { | ||
| 289 | 296 | enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4, |
| 290 | 297 | enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6, |
| 291 | 298 | dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'), |
| 299 | + heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'), | |
| 292 | 300 | browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'), |
| 293 | 301 | browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname), |
| 294 | 302 | browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'), |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import fs from 'fs'; | |
| 2 | +import path from 'path'; | |
| 3 | +import { serverDirectory } from './server-directory.js'; | |
| 4 | + | |
| 5 | +// Default to 0 seconds (disabled) if not set | |
| 6 | +const intervalSeconds = parseInt(process.env.SILLYTAVERN_HEARTBEATINTERVAL || '0'); | |
| 7 | +const intervalMs = intervalSeconds * 1000; | |
| 8 | + | |
| 9 | +// Heartbeat disabled | |
| 10 | +if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { | |
| 11 | + process.exit(0); | |
| 12 | +} | |
| 13 | + | |
| 14 | +// Allow a grace period (2 missed beats) | |
| 15 | +const threshold = intervalMs * 2; | |
| 16 | + | |
| 17 | +const dataRoot = process.env.SILLYTAVERN_DATAROOT || path.join(serverDirectory, 'data'); | |
| 18 | +const heartbeatFile = path.join(dataRoot, 'heartbeat.json'); | |
| 19 | + | |
| 20 | +try { | |
| 21 | + if (!fs.existsSync(heartbeatFile)) { | |
| 22 | + console.error(`Heartbeat file not found at: ${heartbeatFile}`); | |
| 23 | + process.exit(1); | |
| 24 | + } | |
| 25 | + | |
| 26 | + const stats = fs.statSync(heartbeatFile); | |
| 27 | + const lastModified = stats.mtimeMs; | |
| 28 | + const now = Date.now(); | |
| 29 | + const diff = now - lastModified; | |
| 30 | + | |
| 31 | + if (diff > threshold) { | |
| 32 | + console.error(`Server is unresponsive. Last heartbeat was ${Math.round(diff / 1000)} seconds ago.`); | |
| 33 | + process.exit(1); | |
| 34 | + } | |
| 35 | + | |
| 36 | + process.exit(0); | |
| 37 | +} catch (err) { | |
| 38 | + console.error('Healthcheck error:', err.message); | |
| 39 | + process.exit(1); | |
| 40 | +} | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | // native node modules |
| 2 | +import fs from 'node:fs'; | |
| 2 | 3 | import path from 'node:path'; |
| 3 | 4 | import util from 'node:util'; |
| 4 | 5 | import net from 'node:net'; |
| @@ -349,6 +350,28 @@ async function postSetupTasks(result) { | ||
| 349 | 350 | } |
| 350 | 351 | } |
| 351 | 352 | |
| 353 | + if (cliArgs.heartbeatInterval > 0) { | |
| 354 | + // Convert seconds to milliseconds for the timer | |
| 355 | + const intervalMs = cliArgs.heartbeatInterval * 1000; | |
| 356 | + const heartbeatPath = path.join(globalThis.DATA_ROOT, 'heartbeat.json'); | |
| 357 | + | |
| 358 | + console.log(`Heartbeat enabled. Updating ${color.green(heartbeatPath)} every ${cliArgs.heartbeatInterval} seconds`); | |
| 359 | + | |
| 360 | + const writeHeartbeat = () => { | |
| 361 | + try { | |
| 362 | + fs.writeFileSync(heartbeatPath, JSON.stringify({ timestamp: Date.now() })); | |
| 363 | + } catch (err) { | |
| 364 | + console.error(`Failed to write heartbeat file at ${color.green(heartbeatPath)}:`, err.message); | |
| 365 | + } | |
| 366 | + }; | |
| 367 | + | |
| 368 | + // Write immediately | |
| 369 | + writeHeartbeat(); | |
| 370 | + | |
| 371 | + // Loop using the converted milliseconds | |
| 372 | + setInterval(writeHeartbeat, intervalMs).unref(); | |
| 373 | + } | |
| 374 | + | |
| 352 | 375 | setWindowTitle('SillyTavern WebServer'); |
| 353 | 376 | |
| 354 | 377 | let logListen = 'SillyTavern is listening on'; |