From 724768466a176c3c33b0dc1cf62c751a408daa54 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 30 Dec 2025 13:18:19 +0800 Subject: [PATCH] feat(cluster): support reusePort on server listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SO_REUSEPORT socket option support for server listen, which allows multiple server sockets to bind to the same port with the OS distributing incoming connections. This improves load balancing in cluster scenarios. - Add reusePort option to ClusterOptions interface - Add platform validation (linux, freebsd, sunos, aix) - Use server.listen({ port, reusePort, host }) when reusePort is enabled - Handle message routing for reusePort (cluster doesn't emit 'listening' event) - Support reusePort in worker_threads mode (all workers share same port) - Add test fixtures and tests for reusePort functionality Synced from eggjs/cluster#115 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/cluster/src/app_worker.ts | 49 ++++++++++++++++--- packages/cluster/src/utils/messenger.ts | 6 +++ .../src/utils/mode/impl/process/app.ts | 10 ++++ .../src/utils/mode/impl/worker_threads/app.ts | 33 +++++++++---- packages/cluster/src/utils/options.ts | 6 +++ packages/cluster/test/app_worker.test.ts | 24 +++++++++ .../fixtures/apps/app-listen-reusePort/app.js | 4 ++ .../apps/app-listen-reusePort/app/router.js | 9 ++++ .../config/config.default.js | 9 ++++ .../apps/app-listen-reusePort/package.json | 3 ++ packages/egg/src/config/config.default.ts | 3 ++ packages/egg/src/lib/types.ts | 6 +++ 12 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 packages/cluster/test/fixtures/apps/app-listen-reusePort/app.js create mode 100644 packages/cluster/test/fixtures/apps/app-listen-reusePort/app/router.js create mode 100644 packages/cluster/test/fixtures/apps/app-listen-reusePort/config/config.default.js create mode 100644 packages/cluster/test/fixtures/apps/app-listen-reusePort/package.json 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; }; };