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:
Cohee
2026-01-18 16:36:37 +02:00
committed by GitHub
parent 1ff98e76f8
commit 2c09d32b5b
5 changed files with 81 additions and 0 deletions
+3
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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'),
+40
View File
@@ -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);
}
+23
View File
@@ -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';