diff --git a/packages/cluster/src/app_worker.ts b/packages/cluster/src/app_worker.ts index 1456df1aaf..9d1cffdf09 100644 --- a/packages/cluster/src/app_worker.ts +++ b/packages/cluster/src/app_worker.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import { createServer as createHttpServer, type Server } from 'node:http'; import { createServer as createHttpsServer } from 'node:https'; -import type { Socket } from 'node:net'; +import type { Socket, ListenOptions } from 'node:net'; +import os from 'node:os'; import { debuglog } from 'node:util'; import { importModule } from '@eggjs/utils'; @@ -13,6 +14,12 @@ import { AppThreadWorker } from './utils/mode/impl/worker_threads/app.ts'; const debug = debuglog('egg/cluster/app_worker'); +// https://nodejs.org/api/net.html#serverlistenoptions-callback +// https://github.com/nodejs/node/blob/main/node.gypi#L310 +// https://docs.python.org/3/library/sys.html#sys.platform +// This option is available only on some platforms, such as Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. +const REUSE_PORT_SUPPORTED_PLATFORMS = ['linux', 'freebsd', 'sunos', 'aix']; + async function main() { // $ node app_worker.js options-json-string const options = JSON.parse(process.argv[2]) as { @@ -25,6 +32,7 @@ async function main() { https?: object; sticky?: boolean; stickyWorkerPort?: number; + reusePort?: boolean; }; if (options.require) { // inject @@ -89,13 +97,26 @@ async function main() { const port = (app.options.port = options.port || listenConfig.port); const debugPort = options.debugPort; const protocol = httpsOptions.key && httpsOptions.cert ? 'https' : 'http'; + + // Check reusePort option and validate platform support + let reusePort = options.reusePort ?? listenConfig.reusePort ?? false; + if (reusePort && !REUSE_PORT_SUPPORTED_PLATFORMS.includes(os.platform())) { + reusePort = false; + debug( + '[app_worker:%s] platform %s is not supported for reusePort, set reusePort to false', + process.pid, + os.platform(), + ); + } + debug( - '[app_worker:%s] listenConfig: %j, real port: %o, protocol: %o, debugPort: %o', + '[app_worker:%s] listenConfig: %j, real port: %o, protocol: %o, debugPort: %o, reusePort: %o', process.pid, listenConfig, port, protocol, debugPort, + reusePort, ); AppWorker.send({ @@ -158,12 +179,23 @@ async function main() { exitProcess(); return; } - const args = [port]; - if (listenConfig.hostname) { - args.push(listenConfig.hostname); + if (reusePort) { + // https://nodejs.org/api/net.html#serverlistenoptions-callback + // Use options object when reusePort is enabled + const listenOptions: ListenOptions = { port, reusePort }; + if (listenConfig.hostname) { + listenOptions.host = listenConfig.hostname; + } + debug('[app_worker:%s] listen with reusePort options %j', process.pid, listenOptions); + server.listen(listenOptions); + } else { + const args = [port]; + if (listenConfig.hostname) { + args.push(listenConfig.hostname); + } + debug('listen options %j', args); + server.listen(...args); } - debug('listen options %j', args); - server.listen(...args); } if (debugPortServer) { debug('listen on debug port: %s', debugPort); @@ -181,7 +213,7 @@ async function main() { addressType: -1, }; } - debug('[app_worker:%s] listening at %j', process.pid, address); + debug('[app_worker:%s] listening at %j, reusePort: %o', process.pid, address, reusePort); AppWorker.send({ to: 'master', action: 'app-start', @@ -189,6 +221,7 @@ async function main() { address, workerId: AppWorker.workerId, }, + reusePort, }); }); } diff --git a/packages/cluster/src/utils/messenger.ts b/packages/cluster/src/utils/messenger.ts index ff7720d290..dfe303cb39 100644 --- a/packages/cluster/src/utils/messenger.ts +++ b/packages/cluster/src/utils/messenger.ts @@ -19,6 +19,12 @@ export interface MessageBody { receiverPid?: string; receiverWorkerId?: string; senderWorkerId?: string; + /** + * Whether reusePort is enabled for server listen. + * When reusePort is true, cluster won't get `listening` event, + * so we need to use cluster `message` event instead. + */ + reusePort?: boolean; } /** diff --git a/packages/cluster/src/utils/mode/impl/process/app.ts b/packages/cluster/src/utils/mode/impl/process/app.ts index 129e422ff6..0c9895a94c 100644 --- a/packages/cluster/src/utils/mode/impl/process/app.ts +++ b/packages/cluster/src/utils/mode/impl/process/app.ts @@ -1,4 +1,5 @@ import cluster, { type Worker as ClusterProcessWorker } from 'node:cluster'; +import { debuglog } from 'node:util'; import { cfork } from 'cfork'; import { graceful as gracefulExit, type Options as gracefulExitOptions } from 'graceful-process'; @@ -8,6 +9,8 @@ import type { MessageBody } from '../../../messenger.ts'; import { terminate } from '../../../terminate.ts'; import { BaseAppWorker, BaseAppUtils } from '../../base/app.ts'; +const debug = debuglog('egg/cluster/utils/mode/impl/process/app'); + export class AppProcessWorker extends BaseAppWorker { get id(): number { return this.instance.id; @@ -45,6 +48,13 @@ export class AppProcessWorker extends BaseAppWorker { static send(message: MessageBody): void { message.senderWorkerId = String(process.pid); + // cluster won't get `listening` event when reusePort is true, + // use cluster `message` event instead + if (message.action === 'app-start' && message.reusePort) { + debug('send app-start message with reusePort, use cluster.worker.send()'); + cluster.worker!.send(message); + return; + } process.send!(message); } diff --git a/packages/cluster/src/utils/mode/impl/worker_threads/app.ts b/packages/cluster/src/utils/mode/impl/worker_threads/app.ts index 7d931a05e2..0166af68f2 100644 --- a/packages/cluster/src/utils/mode/impl/worker_threads/app.ts +++ b/packages/cluster/src/utils/mode/impl/worker_threads/app.ts @@ -140,17 +140,30 @@ export class AppThreadUtils extends BaseAppUtils { this.startTime = Date.now(); this.startSuccessCount = 0; - const ports = this.options.ports ?? []; - if (!ports.length) { - ports.push(this.options.port!); + if (this.options.reusePort) { + // When reusePort is enabled, all workers share the same port + // and each worker has its own socket + if (!this.options.port) { + throw new Error('options.port must be specified when reusePort is enabled'); + } + for (let i = 0; i < this.options.workers; i++) { + const argv = [JSON.stringify(this.options)]; + this.#forkSingle(this.getAppWorkerFile(), { argv }, i + 1); + } + } else { + // Normal mode: each worker can have a different port + const ports = this.options.ports ?? []; + if (!ports.length) { + ports.push(this.options.port!); + } + this.options.workers = ports.length; + let i = 0; + do { + const options = Object.assign({}, this.options, { port: ports[i] }); + const argv = [JSON.stringify(options)]; + this.#forkSingle(this.getAppWorkerFile(), { argv }, ++i); + } while (i < ports.length); } - this.options.workers = ports.length; - let i = 0; - do { - const options = Object.assign({}, this.options, { port: ports[i] }); - const argv = [JSON.stringify(options)]; - this.#forkSingle(this.getAppWorkerFile(), { argv }, ++i); - } while (i < ports.length); return this; } diff --git a/packages/cluster/src/utils/options.ts b/packages/cluster/src/utils/options.ts index 3ae0780eab..1eb714e844 100644 --- a/packages/cluster/src/utils/options.ts +++ b/packages/cluster/src/utils/options.ts @@ -78,6 +78,12 @@ export interface ClusterOptions { * sticky mode server */ sticky?: boolean; + /** + * enable SO_REUSEPORT socket option for server listen, default is `false`. + * Only available on Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. + * @see https://nodejs.org/api/net.html#serverlistenoptions-callback + */ + reusePort?: boolean; /** customized plugins, for unittest */ plugins?: object; isDebug?: boolean; diff --git a/packages/cluster/test/app_worker.test.ts b/packages/cluster/test/app_worker.test.ts index deb85a3a89..b77f906ab8 100644 --- a/packages/cluster/test/app_worker.test.ts +++ b/packages/cluster/test/app_worker.test.ts @@ -285,6 +285,30 @@ describe.skipIf(process.version.startsWith('v24') || process.platform === 'win32 const sock = encodeURIComponent(sockFile); await request(`http+unix://${sock}`).get('/').expect('done').expect(200); }); + + it.skipIf(process.platform !== 'linux')('should use reusePort in config on Linux', async () => { + app = cluster('apps/app-listen-reusePort', { port: 0, workers: 2 }); + // app.debug(); + await app.ready(); + + app.expect('code', 0); + app.expect('stdout', /egg started on http:\/\/127.0.0.1:17010/); + + await request('http://127.0.0.1:17010').get('/').expect('done').expect(200); + await request('http://127.0.0.1:17010').get('/port').expect('17010').expect(200); + }); + + it('should set reusePort=true in config (non-Linux will fallback to false)', async () => { + app = cluster('apps/app-listen-reusePort', { port: 0 }); + // app.debug(); + await app.ready(); + + app.expect('code', 0); + app.expect('stdout', /egg started on http:\/\/127.0.0.1:17010/); + + await request('http://127.0.0.1:17010').get('/').expect('done').expect(200); + await request('http://127.0.0.1:17010').get('/port').expect('17010').expect(200); + }); }); it('should exit when EADDRINUSE', async () => { diff --git a/packages/cluster/test/fixtures/apps/app-listen-reusePort/app.js b/packages/cluster/test/fixtures/apps/app-listen-reusePort/app.js new file mode 100644 index 0000000000..f37a7c71e6 --- /dev/null +++ b/packages/cluster/test/fixtures/apps/app-listen-reusePort/app.js @@ -0,0 +1,4 @@ +module.exports = (app) => { + // don't use the port that egg-mock defined + app._options.port = undefined; +}; diff --git a/packages/cluster/test/fixtures/apps/app-listen-reusePort/app/router.js b/packages/cluster/test/fixtures/apps/app-listen-reusePort/app/router.js new file mode 100644 index 0000000000..9fac69984c --- /dev/null +++ b/packages/cluster/test/fixtures/apps/app-listen-reusePort/app/router.js @@ -0,0 +1,9 @@ +module.exports = (app) => { + app.get('/', (ctx) => { + ctx.body = 'done'; + }); + + app.get('/port', (ctx) => { + ctx.body = ctx.app.options.port; + }); +}; diff --git a/packages/cluster/test/fixtures/apps/app-listen-reusePort/config/config.default.js b/packages/cluster/test/fixtures/apps/app-listen-reusePort/config/config.default.js new file mode 100644 index 0000000000..eb4d18c85f --- /dev/null +++ b/packages/cluster/test/fixtures/apps/app-listen-reusePort/config/config.default.js @@ -0,0 +1,9 @@ +module.exports = { + keys: '123', + cluster: { + listen: { + port: 17010, + reusePort: true, + }, + }, +}; diff --git a/packages/cluster/test/fixtures/apps/app-listen-reusePort/package.json b/packages/cluster/test/fixtures/apps/app-listen-reusePort/package.json new file mode 100644 index 0000000000..f78929ee52 --- /dev/null +++ b/packages/cluster/test/fixtures/apps/app-listen-reusePort/package.json @@ -0,0 +1,3 @@ +{ + "name": "app-listen-reusePort" +} diff --git a/packages/egg/src/config/config.default.ts b/packages/egg/src/config/config.default.ts index 8896ba747b..608440dbf8 100644 --- a/packages/egg/src/config/config.default.ts +++ b/packages/egg/src/config/config.default.ts @@ -359,12 +359,15 @@ const factory: EggConfigFactory = defineConfigFactory((appInfo): PartialEggConfi * @property {String} listen.path - set a unix sock path when server listen * @property {Number} listen.port - set a port when server listen * @property {String} listen.hostname - set a hostname binding server when server listen + * @property {Boolean} listen.reusePort - enable SO_REUSEPORT socket option, default is `false`. + * Only available on Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. */ config.cluster = { listen: { path: '', port: 7001, hostname: '', + reusePort: false, }, }; diff --git a/packages/egg/src/lib/types.ts b/packages/egg/src/lib/types.ts index fde90cf34f..33f92bb44f 100644 --- a/packages/egg/src/lib/types.ts +++ b/packages/egg/src/lib/types.ts @@ -258,6 +258,12 @@ export interface EggAppConfig extends EggCoreAppConfig { path: string; port: number; hostname: string; + /** + * enable SO_REUSEPORT socket option for server listen, default is `false`. + * Only available on Linux 3.9+, DragonFlyBSD 3.6+, FreeBSD 12.0+, Solaris 11.4, and AIX 7.2.5+. + * @see https://nodejs.org/api/net.html#serverlistenoptions-callback + */ + reusePort?: boolean; }; };