From 3899f6532deb7a488fca26e451550148238e408e Mon Sep 17 00:00:00 2001 From: Jonathan Rosenbaum Date: Wed, 5 Feb 2025 15:54:00 -0500 Subject: [PATCH] First Commit! --- Dockerfile | 31 +++++ PM2Manager | 340 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 0 docker-compose.yml | 39 ++++++ server.js | 22 +++ 5 files changed, 432 insertions(+) create mode 100644 Dockerfile create mode 100644 PM2Manager create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..460dbff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# prerender/prerender - Node server that uses Headless Chrome to render a javascript-rendered page as HTML. To be used in conjunction with prerender middleware. + +# LTS version +# FROM node:22-slim +FROM node:21-bookworm + +COPY --chown=node:node . /home/node + +EXPOSE 3000 +EXPOSE 9222 + +# Set Chrome flags for running in container +ENV CHROME_BIN=/usr/bin/chromium +ENV CHROME_PATH=/usr/bin/chromium +ENV CHROME_LOCATION=/usr/bin/chromium +ENV CHROME_FLAGS="--headless --disable-gpu --no-sandbox --disable-dev-shm-usage --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222" + +WORKDIR /home/node + +RUN apt-get update && apt-get install -y \ + chromium \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Already has git +# RUN apt-get update -y +RUN yarn add prerender prerender-memory-cache + +USER node:node + +ENTRYPOINT ["node", "server.js"] \ No newline at end of file diff --git a/PM2Manager b/PM2Manager new file mode 100644 index 0000000..887b8b9 --- /dev/null +++ b/PM2Manager @@ -0,0 +1,340 @@ +// To use this enhanced version, you'll need to: + +// Install additional dependencies: +// npm install winston nodemailer node-fetch + +// Set up environment variables: +// export NOTIFICATION_EMAIL=alerts@yourdomain.com +// export ADMIN_EMAIL=admin@yourdomain.com +// export SMTP_HOST=smtp.yourdomain.com +// export SMTP_PORT=587 +// export SMTP_USER=your-user +// export SMTP_PASS=your-password + + +const { spawn, exec } = require('child_process'); +const pm2 = require('pm2'); +const fs = require('fs'); +const path = require('path'); +const winston = require('winston'); +const nodemailer = require('nodemailer'); + +// Configuration file +const config = { + app: { + name: 'server', + script: 'server.js', + instances: 4, + max_memory_restart: '300M', + max_restarts: 10, + exp_backoff_restart_delay: 100 + }, + monitoring: { + healthCheckInterval: 30000, + healthCheckEndpoint: 'http://localhost:3000/health', + metricsInterval: 60000, + maxRestartAttempts: 5, + criticalErrors: ['page timed out', 'parse html timed out', 'Timed out waiting for'] + }, + notifications: { + email: { + enabled: true, + from: process.env.NOTIFICATION_EMAIL, + to: process.env.ADMIN_EMAIL, + smtp: { + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + } + } + }, + logging: { + dir: 'logs', + maxSize: '10m', + maxFiles: '7d' + } +}; + +// Save config to file +fs.writeFileSync('pm2-config.json', JSON.stringify(config, null, 2)); + +// Initialize logger +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console(), + new winston.transports.File({ + filename: path.join(config.logging.dir, 'error.log'), + level: 'error', + maxsize: 5242880, // 5MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: path.join(config.logging.dir, 'combined.log'), + maxsize: 5242880, + maxFiles: 5 + }) + ] +}); + +// Metrics tracking +class MetricsTracker { + constructor() { + this.metrics = { + startTime: Date.now(), + restarts: 0, + lastRestart: null, + uptimePercentage: 100, + memoryUsage: [], + healthCheckFailures: 0, + criticalErrors: 0 + }; + + // Create metrics directory if it doesn't exist + if (!fs.existsSync('metrics')) { + fs.mkdirSync('metrics'); + } + } + + updateMetrics(type, value) { + switch(type) { + case 'restart': + this.metrics.restarts++; + this.metrics.lastRestart = new Date().toISOString(); + break; + case 'memory': + this.metrics.memoryUsage.push({ + timestamp: new Date().toISOString(), + value: value + }); + // Keep only last 100 measurements + if (this.metrics.memoryUsage.length > 100) { + this.metrics.memoryUsage.shift(); + } + break; + case 'healthCheck': + if (!value) this.metrics.healthCheckFailures++; + break; + case 'criticalError': + this.metrics.criticalErrors++; + break; + } + + // Calculate uptime percentage + const totalTime = Date.now() - this.metrics.startTime; + const downtime = this.metrics.restarts * 10000; // Assuming 10s downtime per restart + this.metrics.uptimePercentage = ((totalTime - downtime) / totalTime * 100).toFixed(2); + + // Save metrics to file + fs.writeFileSync( + path.join('metrics', 'metrics.json'), + JSON.stringify(this.metrics, null, 2) + ); + } +} + +const metrics = new MetricsTracker(); + +// Notification system +const notifier = nodemailer.createTransport(config.notifications.email.smtp); + +async function sendNotification(subject, message, critical = false) { + if (!config.notifications.email.enabled) return; + + try { + await notifier.sendMail({ + from: config.notifications.email.from, + to: config.notifications.email.to, + subject: `[${critical ? 'CRITICAL' : 'INFO'}] ${subject}`, + text: message + }); + logger.info(`Notification sent: ${subject}`); + } catch (error) { + logger.error('Failed to send notification:', error); + } +} + +// Health check function +async function performHealthCheck() { + try { + const response = await fetch(config.monitoring.healthCheckEndpoint); + const healthy = response.status === 200; + metrics.updateMetrics('healthCheck', healthy); + + if (!healthy) { + logger.warn('Health check failed'); + sendNotification('Health Check Failed', 'Application health check failed. Investigating...'); + return false; + } + return true; + } catch (error) { + logger.error('Health check error:', error); + metrics.updateMetrics('healthCheck', false); + return false; + } +} + +let yourCommand; +let isRestarting = false; + +async function cleanup() { + return new Promise(async (resolve) => { + try { + if (process.env.CHROME_PID) { + exec(`kill ${process.env.CHROME_PID}`); + } + + if (yourCommand) { + yourCommand.stdout.removeAllListeners('data'); + yourCommand.kill(); + } + + await new Promise(resolve => pm2.disconnect(resolve)); + logger.info('Cleanup completed successfully'); + } catch (error) { + logger.error('Cleanup error:', error); + } + resolve(); + }); +} + +async function pm2Start() { + if (isRestarting) { + logger.info('Restart already in progress, skipping...'); + return; + } + + try { + isRestarting = true; + await cleanup(); + + await new Promise((resolve, reject) => { + pm2.connect((err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Start the application with config + await new Promise((resolve, reject) => { + pm2.start(config.app, (err, apps) => { + if (err) reject(err); + else resolve(apps); + }); + }); + + // Start monitoring processes + yourCommand = spawn('pm2', ['log']); + + yourCommand.on('error', (error) => { + logger.error('PM2 log process error:', error); + metrics.updateMetrics('criticalError'); + restartWithDelay(); + }); + + yourCommand.on('exit', (code, signal) => { + if (code !== 0) { + logger.error(`PM2 log process exited with code ${code}, signal: ${signal}`); + restartWithDelay(); + } + }); + + yourCommand.stdout.on('data', (data) => { + const dataStr = data.toString(); + if (config.monitoring.criticalErrors.some(err => dataStr.includes(err))) { + logger.error("Critical error detected in logs:", dataStr); + metrics.updateMetrics('criticalError'); + sendNotification( + 'Critical Error Detected', + `Error in application logs: ${dataStr}`, + true + ); + restartWithDelay(); + } + }); + + // Start health check interval + setInterval(async () => { + const isHealthy = await performHealthCheck(); + if (!isHealthy) restartWithDelay(); + }, config.monitoring.healthCheckInterval); + + // Start metrics collection interval + setInterval(() => { + pm2.describe(config.app.name, (err, processDescription) => { + if (!err && processDescription[0]) { + metrics.updateMetrics('memory', processDescription[0].monit.memory); + } + }); + }, config.monitoring.metricsInterval); + + logger.info('Application started successfully'); + sendNotification('Application Started', 'The application has been started successfully'); + + } catch (error) { + logger.error('PM2 start error:', error); + metrics.updateMetrics('criticalError'); + sendNotification( + 'Application Start Failed', + `Failed to start application: ${error.message}`, + true + ); + await cleanup(); + restartWithDelay(); + } finally { + isRestarting = false; + } +} + +let restartAttempts = 0; + +function restartWithDelay() { + if (restartAttempts >= config.monitoring.maxRestartAttempts) { + logger.error('Maximum restart attempts reached. Exiting...'); + sendNotification( + 'Maximum Restart Attempts Reached', + 'Application has reached maximum restart attempts and will now exit.', + true + ); + process.exit(1); + } + + const delay = Math.min(1000 * Math.pow(2, restartAttempts), 30000); + restartAttempts++; + metrics.updateMetrics('restart'); + + logger.info(`Scheduling restart in ${delay}ms. Attempt ${restartAttempts}/${config.monitoring.maxRestartAttempts}`); + setTimeout(pm2Start, delay); +} + +// Process termination handlers +process.on('SIGTERM', async () => { + logger.info('Received SIGTERM. Cleaning up...'); + await cleanup(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + logger.info('Received SIGINT. Cleaning up...'); + await cleanup(); + process.exit(0); +}); + +// Create necessary directories +if (!fs.existsSync(config.logging.dir)) { + fs.mkdirSync(config.logging.dir); +} + +// Start the application +pm2Start().catch(error => { + logger.error('Fatal error:', error); + sendNotification('Fatal Error', `Fatal error occurred: ${error.message}`, true); + process.exit(1); +}); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79fb138 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# prerender server + +services: + prerender: + build: . + image: ${IMAGE:-bikeshopi/prerender} + container_name: ${CONTAINER_NAME:-bikeshopi-prerender} + user: node + restart: unless-stopped + init: true # makes all the difference, no more timeout / restarts without chrome alive + expose: + - "3000" + - "9222" + environment: + # - PROXY_READ_TIMEOUT=60s + # - PROXY_SET_HEADER_X_REAL_IP=$$remote_addr + # - PROXY_SET_HEADER_X_FORWARDED_FOR=$$proxy_add_x_forwarded_for + # - PROXY_SET_HEADER_HOST=$$http_host + # - PROXY_SET_HEADER_USER_AGENT=$$http_user_agent + - VIRTUAL_PORT=3000 + - VIRTUAL_HOST=${VIRTUAL_HOST:-prerender.bikeshopi.org} + - LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-prerender.bikeshopi.org} + - LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-jr@bikeshopi.org} + - LETSENCRYPT_TEST=${LETSENCRYPT_TEST:-false} + - CHROME_LOCATION=/usr/bin/chromium + - PAGE_LOAD_TIMEOUT=3000 # default 20000 (20 seconds) + # - CHROME_FLAGS=--headless --disable-gpu --no-sandbox --disable-dev-shm-usage --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 + networks: + letsencrypt: + default: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + letsencrypt: + external: true \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..4e20d68 --- /dev/null +++ b/server.js @@ -0,0 +1,22 @@ +const prerender = require('prerender'); +const server = prerender({ + chromeFlags: [ + '--headless', + '--disable-gpu', + '--no-sandbox', + '--disable-dev-shm-usage', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=9222', + '--hide-scrollbars', + '--ignore-certificate-errors', + ], + chromeLocation: process.env.CHROME_LOCATION, + logRequests: true, + browserDebuggingPort: 9222, +}); +server.use(require('prerender-memory-cache')) +server.use(prerender.httpHeaders()); +server.use(prerender.removeScriptTags()); +server.use(prerender.sendPrerenderHeader()); +server.start(); +// "--blink-settings=imagesEnabled=false", \ No newline at end of file