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 | avoidLocalhost: false | 38 | avoidLocalhost: false |
| 39 | # Server port | 39 | # Server port |
| 40 | port: 8000 | 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 | # -- SSL options -- | 44 | # -- SSL options -- |
| 42 | ssl: | 45 | ssl: |
| 43 | # Enable SSL/TLS encryption | 46 | # Enable SSL/TLS encryption |
| @@ -7,6 +7,7 @@ services: | |||
| 7 | environment: | 7 | environment: |
| 8 | - NODE_ENV=production | 8 | - NODE_ENV=production |
| 9 | - FORCE_COLOR=1 | 9 | - FORCE_COLOR=1 |
| 10 | - SILLYTAVERN_HEARTBEATINTERVAL=30 | ||
| 10 | ports: | 11 | ports: |
| 11 | - "8000:8000" | 12 | - "8000:8000" |
| 12 | volumes: | 13 | volumes: |
| @@ -14,4 +15,10 @@ services: | |||
| 14 | - "./data:/home/node/app/data" | 15 | - "./data:/home/node/app/data" |
| 15 | - "./plugins:/home/node/app/plugins" | 16 | - "./plugins:/home/node/app/plugins" |
| 16 | - "./extensions:/home/node/app/public/scripts/extensions/third-party" | 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 | restart: unless-stopped | 24 | restart: unless-stopped |
| @@ -18,6 +18,7 @@ import { initConfig } from './config-init.js'; | |||
| 18 | * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed) | 18 | * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed) |
| 19 | * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed) | 19 | * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed) |
| 20 | * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS | 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 | * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser | 22 | * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser |
| 22 | * @property {string} browserLaunchHostname Browser launch hostname | 23 | * @property {string} browserLaunchHostname Browser launch hostname |
| 23 | * @property {number} browserLaunchPort Browser launch port override (-1 is use server port) | 24 | * @property {number} browserLaunchPort Browser launch port override (-1 is use server port) |
| @@ -62,6 +63,7 @@ export class CommandLineParser { | |||
| 62 | enableIPv4: true, | 63 | enableIPv4: true, |
| 63 | enableIPv6: false, | 64 | enableIPv6: false, |
| 64 | dnsPreferIPv6: false, | 65 | dnsPreferIPv6: false, |
| 66 | heartbeatInterval: 0, | ||
| 65 | browserLaunchEnabled: false, | 67 | browserLaunchEnabled: false, |
| 66 | browserLaunchHostname: 'auto', | 68 | browserLaunchHostname: 'auto', |
| 67 | browserLaunchPort: -1, | 69 | browserLaunchPort: -1, |
| @@ -229,6 +231,11 @@ export class CommandLineParser { | |||
| 229 | type: 'array', | 231 | type: 'array', |
| 230 | describe: 'Request proxy bypass list (space separated list of hosts)', | 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 | /* DEPRECATED options */ | 239 | /* DEPRECATED options */ |
| 233 | .option('autorun', { | 240 | .option('autorun', { |
| 234 | type: 'boolean', | 241 | type: 'boolean', |
| @@ -289,6 +296,7 @@ export class CommandLineParser { | |||
| 289 | enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4, | 296 | enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4, |
| 290 | enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6, | 297 | enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6, |
| 291 | dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'), | 298 | dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'), |
| 299 | heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'), | ||
| 292 | browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'), | 300 | browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'), |
| 293 | browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname), | 301 | browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname), |
| 294 | browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'), | 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 | // native node modules | 1 | // native node modules |
| 2 | import fs from 'node:fs'; | ||
| 2 | import path from 'node:path'; | 3 | import path from 'node:path'; |
| 3 | import util from 'node:util'; | 4 | import util from 'node:util'; |
| 4 | import net from 'node:net'; | 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 | setWindowTitle('SillyTavern WebServer'); | 375 | setWindowTitle('SillyTavern WebServer'); |
| 353 | 376 | ||
| 354 | let logListen = 'SillyTavern is listening on'; | 377 | let logListen = 'SillyTavern is listening on'; |