diff --git a/Dockerfile b/Dockerfile index 3370efd..e8792b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the application -FROM node:22.21.1-bookworm-slim AS builder +FROM node:22.21.1-alpine3.23 AS builder WORKDIR /app @@ -14,13 +14,9 @@ COPY entry.sh ./ RUN npm run build # Stage 2: Create the production image -FROM node:22.21.1-bookworm-slim +FROM node:22.21.1-alpine3.23 -RUN apt-get update && \ - apt-get install -yqq --no-install-recommends wget && \ - apt-get autoremove && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache wget WORKDIR /app @@ -35,4 +31,5 @@ HEALTHCHECK --interval=30s \ EXPOSE 3000 -CMD ["/entry.sh"] +CMD ["npm", "run", "start"] +ENTRYPOINT ["/entry.sh"] \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index d70975d..fa47b8b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -35,7 +35,7 @@ const TaskScheduler = require("./classes/task-scheduler-singleton"); // const tasks = require("./tasks/tasks"); // websocket -const { setupWebSocketServer } = require("./ws"); +const { setupWebSocketServer, shutdownWebSocketServer } = require("./ws"); const writeEnvVariables = require("./classes/env"); process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres"; @@ -231,6 +231,7 @@ async function authenticate(req, res, next) { } // start server +let server; try { createdb.createDatabase().then((result) => { if (result) { @@ -240,7 +241,7 @@ try { } db.migrate.latest().then(() => { - const server = http.createServer(app); + server = http.createServer(app); setupWebSocketServer(server, BASE_NAME); server.listen(PORT, LISTEN_IP, async () => { @@ -255,3 +256,89 @@ try { } catch (error) { console.log("[JELLYSTAT] An error has occured on startup: " + error); } + +function runStep(name, fn) { + return (async () => { + console.log(`[JELLYSTAT] Beginnging shutdown step ${name}`); + try { + await fn(); + console.log(`[JELLYSTAT] Shutdown step ${name} complete.`); + } catch (err) { + console.error(`[JELLYSTAT] Shutdown step ${name} failed.`, err); + throw err; + } + })(); +} + +function shutdownKnex() { + if (db && typeof db.destroy === "function") { + console.info("[JELLYSTAT] Destroying knex connection."); + return db.destroy(); + } + + return Promise.resolve(); +} + +function shutdownHttpServer() { + return new Promise((resolve, reject) => { + // Stop new connections + server.close((err) => { + if (err) return reject(err); + resolve(); + }); + + if (typeof server.closeIdleConnections === "function") { + console.info(`[JELLYSTAT] Closing idle HTTP connections`); + server.closeIdleConnections(); + } + }); +} + +function withTimeout(promise, ms, label = "shutdown") { + let timeoutId; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`[JELLYSTAT] ${label} timed out after ${ms}ms`)); + }, ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); +} + +// shutdown server +let shuttingDown = false; +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + + console.log(`[JELLYSTAT] Recieved ${signal}, beginning shutdown process`); + + const timeLimit = 9000; + + const shutdownTasks = [ + runStep("httpServer", async () => shutdownHttpServer(signal)), + runStep("webSocketServer", async () => shutdownWebSocketServer(signal)), + runStep("database", async () => shutdownKnex()), + ]; + + try { + await withTimeout( + Promise.allSettled(shutdownTasks), + timeLimit, + "graceful shutdown" + ); + + console.log("[JELLYSTAT] Graceful shutdown complete"); + process.exit(0); + } catch (err) { + console.error(`[JELLYSTAT] Error occurred while attempting to gracefully shut down.`, err); + process.exit(1); + } +} + +// register signal handlers +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); \ No newline at end of file diff --git a/backend/socket-io-client.js b/backend/socket-io-client.js index 16269d5..ce65cb5 100644 --- a/backend/socket-io-client.js +++ b/backend/socket-io-client.js @@ -11,6 +11,10 @@ class SocketIoClient { this.client = io(this.serverUrl, this.options); // Pass options to io() } + disconnect() { + this.client?.disconnect(); + } + waitForConnection() { return new Promise((resolve) => { if (this.client && this.client.connected) { diff --git a/backend/ws.js b/backend/ws.js index cd9687e..2f87eb9 100644 --- a/backend/ws.js +++ b/backend/ws.js @@ -7,6 +7,7 @@ const jwt = require("jsonwebtoken"); const token = jwt.sign({ user: "internal" }, process.env.JWT_SECRET); const socketClient = new SocketIoClient("http://127.0.0.1:3000", { auth: { token } }); let io; // Store the socket.io server instance +let shuttingDown = false; const JWT_SECRET = process.env.JWT_SECRET; const setupWebSocketServer = (server, namespacePath) => { @@ -46,6 +47,24 @@ const setupWebSocketServer = (server, namespacePath) => { webSocketServerSingleton.setInstance(io); }; +const shutdownWebSocketServer = async (signal) => { + if (!io) return; + + try { + io.emit("server_shutdown", { reason: signal }); + + socketClient?.disconnect(); + + return new Promise((resolve) => { + io.close(() => { + resolve(); + }); + }); + } catch (err) { + console.error("[JELLYSTAT] WebSocket shutdown error. ", err); + } +} + const sendToAllClients = (message) => { const ioInstance = webSocketServerSingleton.getInstance(); if (ioInstance) { @@ -71,4 +90,4 @@ const sendUpdate = async (tag, message) => { } }; -module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate }; +module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate, shutdownWebSocketServer }; diff --git a/entry.sh b/entry.sh index ac5856b..aae7690 100644 --- a/entry.sh +++ b/entry.sh @@ -31,4 +31,4 @@ load_secrets() { # Load secrets load_secrets # Launch Jellystat -npm run start +exec "$@"