egg-cluster
Version:
cluster manager for egg
628 lines (547 loc) • 19.8 kB
JavaScript
'use strict';
const os = require('os');
const v8 = require('v8');
const util = require('util');
const path = require('path');
const fs = require('fs');
const EventEmitter = require('events');
const ready = require('get-ready');
const { detectPort } = require('detect-port');
const ConsoleLogger = require('egg-logger').EggConsoleLogger;
const utility = require('utility');
const terminalLink = require('terminal-link');
const Manager = require('./utils/manager');
const parseOptions = require('./utils/options');
const Messenger = require('./utils/messenger');
const { AgentUtils: ProcessAgentWorker } = require('./utils/mode/impl/process/agent');
const { AppUtils: ProcessAppWorker } = require('./utils/mode/impl/process/app');
const { AgentUtils: WorkerThreadsAgentWorker } = require('./utils/mode/impl/worker_threads/agent');
const { AppUtils: WorkerThreadsAppWorker } = require('./utils/mode/impl/worker_threads/app');
const PROTOCOL = Symbol('Master#protocol');
const REAL_PORT = Symbol('Master#real_port');
const APP_ADDRESS = Symbol('Master#appAddress');
class Master extends EventEmitter {
/**
* @class
* @param {Object} options
* - {String} [framework] - specify framework that can be absolute path or npm package
* - {String} [baseDir] directory of application, default to `process.cwd()`
* - {Object} [plugins] - customized plugins, for unittest
* - {Number} [workers] numbers of app workers, default to `os.cpus().length`
* - {Number} [port] listening port, default to 7001(http) or 8443(https)
* - {Number} [debugPort] listening a debug port on http protocol
* - {Object} [https] https options, { key, cert, ca }, full path
* - {Array|String} [require] will inject into worker/agent process
* - {String} [pidFile] will save master pid to this file
* - {String} [env] custom env, default is process.env.EGG_SERVER_ENV
*/
constructor(options) {
super();
this.options = parseOptions(options);
this.workerManager = new Manager();
this.messenger = new Messenger(this, this.workerManager);
ready.mixin(this);
this.isProduction = isProduction(this.options);
this.agentWorkerIndex = 0;
this.closed = false;
this[REAL_PORT] = this.options.port;
this[PROTOCOL] = this.options.https ? 'https' : 'http';
// app started or not
this.isStarted = false;
this.logger = new ConsoleLogger({ level: process.env.EGG_MASTER_LOGGER_LEVEL || 'INFO' });
this.logMethod = 'info';
if (this.options.env === 'local' || process.env.NODE_ENV === 'development') {
this.logMethod = 'debug';
}
// get the real framework info
const frameworkPath = this.options.framework;
const frameworkPkg = utility.readJSONSync(path.join(frameworkPath, 'package.json'));
// set app & agent worker impl
if (this.options.startMode === 'worker_threads') {
this.startByWorkerThreads();
} else {
this.startByProcess();
}
this.log(`[master] =================== ${frameworkPkg.name} start =====================`);
this.logger.info(`[master] node version ${process.version}`);
/* istanbul ignore next */
if (process.alinode) this.logger.info(`[master] alinode version ${process.alinode}`);
this.logger.info(`[master] ${frameworkPkg.name} version ${frameworkPkg.version}`);
if (this.isProduction) {
this.logger.info('[master] start with options:%s%s',
os.EOL, JSON.stringify(this.options, null, 2));
} else {
this.log('[master] start with options: %j', this.options);
}
this.log('[master] start with env: isProduction: %s, EGG_SERVER_ENV: %s, NODE_ENV: %s',
this.isProduction, this.options.env, process.env.NODE_ENV);
const startTime = Date.now();
this.ready(() => {
this.isStarted = true;
const stickyMsg = this.options.sticky ? ' with STICKY MODE!' : '';
const startedURL = terminalLink(this[APP_ADDRESS], this[APP_ADDRESS], { fallback: false });
this.logger.info('[master] %s started on %s (%sms)%s',
frameworkPkg.name, startedURL, Date.now() - startTime, stickyMsg);
if (this.options.debugPort) {
const url = getAddress({ port: this.options.debugPort, protocol: 'http' });
const debugPortURL = terminalLink(url, url, { fallback: false });
this.logger.info('[master] %s started on %s', frameworkPkg.name, debugPortURL);
}
const action = 'egg-ready';
this.messenger.send({
action,
to: 'parent',
data: {
port: this[REAL_PORT],
debugPort: this.options.debugPort,
address: this[APP_ADDRESS],
protocol: this[PROTOCOL],
},
});
this.messenger.send({
action,
to: 'app',
data: this.options,
});
this.messenger.send({
action,
to: 'agent',
data: this.options,
});
// start check agent and worker status
if (this.isProduction) {
this.workerManager.startCheck();
}
});
this.on('agent-exit', this.onAgentExit.bind(this));
this.on('agent-start', this.onAgentStart.bind(this));
this.on('app-exit', this.onAppExit.bind(this));
this.on('app-start', this.onAppStart.bind(this));
this.on('reload-worker', this.onReload.bind(this));
// fork app workers after agent started
this.once('agent-start', this.forkAppWorkers.bind(this));
// get the real port from options and app.config
// app worker will send after loading
this.on('realport', ({ port, protocol }) => {
if (port) this[REAL_PORT] = port;
if (protocol) this[PROTOCOL] = protocol;
});
// https://nodejs.org/api/process.html#process_signal_events
// https://en.wikipedia.org/wiki/Unix_signal
// kill(2) Ctrl-C
process.once('SIGINT', this.onSignal.bind(this, 'SIGINT'));
// kill(3) Ctrl-\
process.once('SIGQUIT', this.onSignal.bind(this, 'SIGQUIT'));
// kill(15) default
process.once('SIGTERM', this.onSignal.bind(this, 'SIGTERM'));
process.once('exit', this.onExit.bind(this));
// write pid to file if provided
if (this.options.pidFile) {
fs.mkdirSync(path.dirname(this.options.pidFile), { recursive: true });
fs.writeFileSync(this.options.pidFile, process.pid.toString(), 'utf-8');
}
this.detectPorts()
.then(() => {
this.forkAgentWorker();
});
// exit when agent or worker exception
this.workerManager.on('exception', ({
agent,
worker,
}) => {
const err = new Error(`[master] ${agent} agent and ${worker} worker(s) alive, exit to avoid unknown state`);
err.name = 'ClusterWorkerExceptionError';
err.count = {
agent,
worker,
};
this.logger.error(err);
process.exit(1);
});
}
startByProcess() {
this.agentWorker = new ProcessAgentWorker(this.options, {
log: this.log.bind(this),
logger: this.logger,
messenger: this.messenger,
});
this.appWorker = new ProcessAppWorker(this.options, {
log: this.log.bind(this),
logger: this.logger,
messenger: this.messenger,
isProduction: this.isProduction,
});
}
startByWorkerThreads() {
this.agentWorker = new WorkerThreadsAgentWorker(this.options, {
log: this.log.bind(this),
logger: this.logger,
messenger: this.messenger,
});
this.appWorker = new WorkerThreadsAppWorker(this.options, {
log: this.log.bind(this),
logger: this.logger,
messenger: this.messenger,
isProduction: this.isProduction,
});
}
detectPorts() {
// Detect cluster client port
return detectPort()
.then(port => {
this.options.clusterPort = port;
// If sticky mode, detect worker port
if (this.options.sticky) {
return detectPort();
}
})
.then(port => {
if (this.options.sticky) {
this.options.stickyWorkerPort = port;
}
})
.catch(/* istanbul ignore next */ err => {
this.logger.error(err);
process.exit(1);
});
}
log(...args) {
this.logger[this.logMethod](...args);
}
startMasterSocketServer(cb) {
// Create the outside facing server listening on our port.
require('net').createServer({
pauseOnConnect: true,
}, connection => {
// We received a connection and need to pass it to the appropriate
// worker. Get the worker for this connection's source IP and pass
// it the connection.
/* istanbul ignore next */
if (!connection.remoteAddress) {
// This will happen when a client sends an RST(which is set to 1) right
// after the three-way handshake to the server.
// Read https://en.wikipedia.org/wiki/TCP_reset_attack for more details.
connection.destroy();
} else {
const worker = this.stickyWorker(connection.remoteAddress);
worker.instance.send('sticky-session:connection', connection);
}
}).listen(this[REAL_PORT], cb);
}
stickyWorker(ip) {
const workerNumbers = this.options.workers;
const ws = this.workerManager.listWorkerIds();
let s = '';
for (let i = 0; i < ip.length; i++) {
if (!isNaN(ip[i])) {
s += ip[i];
}
}
s = Number(s);
const pid = ws[s % workerNumbers];
return this.workerManager.getWorker(pid);
}
forkAgentWorker() {
this.agentWorker.on('agent_forked', agent => this.workerManager.setAgent(agent));
this.agentWorker.fork();
}
forkAppWorkers() {
this.appWorker.on('worker_forked', worker => this.workerManager.setWorker(worker));
this.appWorker.fork();
}
/**
* close agent worker, App Worker will closed by cluster
*
* https://www.exratione.com/2013/05/die-child-process-die/
* make sure Agent Worker exit before master exit
*
* @param {number} timeout - kill agent timeout
* @return {Promise} -
*/
async killAgentWorker(timeout) {
await this.agentWorker.kill(timeout);
}
async killAppWorkers(timeout) {
await this.appWorker.kill(timeout);
}
/**
* Agent Worker exit handler
* Will exit during startup, and refork during running.
* @param {Object} data
* - {Number} code - exit code
* - {String} signal - received signal
*/
onAgentExit(data) {
if (this.closed) return;
this.messenger.send({
action: 'egg-pids',
to: 'app',
data: [],
});
const agentWorker = this.agentWorker;
this.workerManager.deleteAgent(agentWorker);
const err = new Error(util.format('[master] agent_worker#%s:%s died (code: %s, signal: %s)',
agentWorker.instance.id, agentWorker.instance.workerId, data.code, data.signal));
err.name = 'AgentWorkerDiedError';
this.logger.error(err);
// remove all listeners to avoid memory leak
agentWorker.clean();
if (this.isStarted) {
this.log('[master] try to start a new agent_worker after 1s ...');
setTimeout(() => {
this.logger.info('[master] new agent_worker starting...');
this.forkAgentWorker();
}, 1000);
this.messenger.send({
action: 'agent-worker-died',
to: 'parent',
});
} else {
this.logger.error('[master] agent_worker#%s:%s start fail, exiting with code:1',
agentWorker.instance.id, agentWorker.instance.workerId);
process.exit(1);
}
}
onAgentStart() {
this.agentWorker.instance.status = 'started';
// Send egg-ready when agent is started after launched
if (this.appWorker.isAllWorkerStarted) {
this.messenger.send({
action: 'egg-ready',
to: 'agent',
data: this.options,
});
}
this.messenger.send({
action: 'egg-pids',
to: 'app',
data: [ this.agentWorker.instance.workerId ],
});
// should send current worker pids when agent restart
if (this.isStarted) {
this.messenger.send({
action: 'egg-pids',
to: 'agent',
data: this.workerManager.getListeningWorkerIds(),
});
}
this.messenger.send({
action: 'agent-start',
to: 'app',
});
this.logger.info('[master] agent_worker#%s:%s started (%sms)',
this.agentWorker.instance.id, this.agentWorker.instance.workerId, Date.now() - this.agentWorker.startTime);
}
/**
* App Worker exit handler
* @param {Object} data
* - {String} workerId - worker id
* - {Number} code - exit code
* - {String} signal - received signal
*/
onAppExit(data) {
if (this.closed) return;
const worker = this.workerManager.getWorker(data.workerId);
if (!worker.isDevReload) {
const signal = data.signal;
const message = util.format(
'[master] app_worker#%s:%s died (code: %s, signal: %s, suicide: %s, state: %s), current workers: %j',
worker.id, worker.workerId, worker.exitCode, signal,
worker.exitedAfterDisconnect, worker.state,
this.workerManager.getWorkers()
);
if (this.options.isDebug && signal === 'SIGKILL') {
// exit if died during debug
this.logger.error(message);
this.logger.error('[master] worker kill by debugger, exiting...');
setTimeout(() => this.close(), 10);
} else {
const err = new Error(message);
err.name = 'AppWorkerDiedError';
this.logger.error(err);
}
}
// remove all listeners to avoid memory leak
worker.clean();
this.workerManager.deleteWorker(data.workerId);
// send message to agent with alive workers
this.messenger.send({
action: 'egg-pids',
to: 'agent',
data: this.workerManager.getListeningWorkerIds(),
});
if (this.appWorker.isAllWorkerStarted) {
// cfork will only refork at production mode
this.messenger.send({
action: 'app-worker-died',
to: 'parent',
});
} else {
// exit if died during startup
this.logger.error('[master] app_worker#%s:%s start fail, exiting with code:1',
worker.id, worker.workerId);
process.exit(1);
}
}
/**
* after app worker
* @param {Object} data
* - {String} workerId - worker id
* - {Object} address - server address
*/
onAppStart(data) {
const worker = this.workerManager.getWorker(data.workerId);
const address = data.address;
// worker should listen stickyWorkerPort when sticky mode
if (this.options.sticky) {
if (String(address.port) !== String(this.options.stickyWorkerPort)) {
return;
}
// worker should listen REALPORT when not sticky mode
} else if (this.options.startMode !== 'worker_threads' &&
!isUnixSock(address) &&
(String(address.port) !== String(this[REAL_PORT]))) {
return;
}
// send message to agent with alive workers
this.messenger.send({
action: 'egg-pids',
to: 'agent',
data: this.workerManager.getListeningWorkerIds(),
});
this.appWorker.startSuccessCount++;
const remain = this.appWorker.isAllWorkerStarted ? 0 : this.options.workers - this.appWorker.startSuccessCount;
this.log('[master] app_worker#%s:%s started at %s, remain %s (%sms)',
worker.id, worker.workerId, address.port, remain, Date.now() - this.appWorker.startTime);
// Send egg-ready when app is started after launched
if (this.appWorker.isAllWorkerStarted) {
this.messenger.send({
action: 'egg-ready',
to: 'app',
data: this.options,
});
}
// if app is started, it should enable this worker
if (this.appWorker.isAllWorkerStarted) {
worker.disableRefork = false;
}
if (this.appWorker.isAllWorkerStarted || this.appWorker.startSuccessCount < this.options.workers) {
return;
}
this.appWorker.isAllWorkerStarted = true;
// enable all workers when app started
for (const id of this.workerManager.getWorkers()) {
const worker = this.workerManager.getWorker(id);
worker.disableRefork = false;
}
address.protocol = this[PROTOCOL];
address.port = this.options.sticky ? this[REAL_PORT] : address.port;
this[APP_ADDRESS] = getAddress(address);
if (this.options.sticky) {
this.startMasterSocketServer(err => {
if (err) return this.ready(err);
this.ready(true);
});
} else {
this.ready(true);
}
}
/**
* master exit handler
*/
onExit(code) {
if (this.options.pidFile && fs.existsSync(this.options.pidFile)) {
try {
fs.unlinkSync(this.options.pidFile);
} catch (err) {
/* istanbul ignore next */
this.logger.error('[master] delete pidfile %s fail with %s', this.options.pidFile, err.message);
}
}
// istanbul can't cover here
// https://github.com/gotwarlost/istanbul/issues/567
const level = code === 0 ? 'info' : 'error';
this.logger[level]('[master] exit with code:%s', code);
}
onSignal(signal) {
if (this.closed) return;
this.logger.info('[master] master is killed by signal %s, closing', signal);
// logger more info
const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
this.logger.info('[master] system memory: total %s, free %s', os.totalmem(), os.freemem());
this.logger.info('[master] process info: heap_limit %s, heap_used %s', heap_size_limit, used_heap_size);
this.close();
}
/**
* reload workers, for develop purpose
*/
onReload() {
this.log('[master] reload workers...');
for (const id of this.workerManager.getWorkers()) {
const worker = this.workerManager.getWorker(id);
worker.isDevReload = true;
}
require('cluster-reload')(this.options.workers);
}
async close() {
this.closed = true;
try {
await this._doClose();
this.log('[master] close done, exiting with code:0');
process.exit(0);
} catch (e) /* istanbul ignore next */ {
this.logger.error('[master] close with error: ', e);
process.exit(1);
}
}
async _doClose() {
// kill app workers
// kill agent worker
// exit itself
const legacyTimeout = process.env.EGG_MASTER_CLOSE_TIMEOUT || 5000;
const appTimeout = process.env.EGG_APP_CLOSE_TIMEOUT || legacyTimeout;
const agentTimeout = process.env.EGG_AGENT_CLOSE_TIMEOUT || legacyTimeout;
this.logger.info('[master] send kill SIGTERM to app workers, will exit with code:0 after %sms', appTimeout);
this.logger.info('[master] wait %sms', appTimeout);
try {
await this.killAppWorkers(appTimeout);
} catch (e) /* istanbul ignore next */ {
this.logger.error('[master] app workers exit error: ', e);
}
this.logger.info('[master] send kill SIGTERM to agent worker, will exit with code:0 after %sms', agentTimeout);
this.logger.info('[master] wait %sms', agentTimeout);
try {
await this.killAgentWorker(agentTimeout);
} catch (e) /* istanbul ignore next */ {
this.logger.error('[master] agent worker exit error: ', e);
}
}
}
module.exports = Master;
function isProduction(options) {
if (options.env) {
return options.env !== 'local' && options.env !== 'unittest';
}
return process.env.NODE_ENV === 'production';
}
function getAddress({
addressType,
address,
port,
protocol,
}) {
// unix sock
// https://nodejs.org/api/cluster.html#cluster_event_listening_1
if (addressType === -1) return address;
let hostname = address;
if (!hostname && process.env.HOST && process.env.HOST !== '0.0.0.0') {
hostname = process.env.HOST;
}
if (!hostname) {
hostname = '127.0.0.1';
}
return `${protocol}://${hostname}:${port}`;
}
function isUnixSock(address) {
return address.addressType === -1;
}