Skip to content

Commit 6e20ed7

Browse files
Implement app clustering (#17752)
* Install throng for easy cluster management * Extract the Express app construction into its own file * Switch server.js to use app clustering for deployed environments * Worker count is based on the lesser of process.env.WEB_CONCURRENCY and the count of CPUs * Reading clustered output is difficult, let's prefix the std{out,err} streams Co-authored-by: Jason Etcovitch <jasonetco@github.com>
1 parent b6321bb commit 6e20ed7

12 files changed

Lines changed: 162 additions & 41 deletions

File tree

app.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"env": {
44
"NODE_ENV": "production",
55
"NPM_CONFIG_PRODUCTION": "true",
6-
"ENABLED_LANGUAGES": "en"
6+
"ENABLED_LANGUAGES": "en",
7+
"WEB_CONCURRENCY": "1"
78
},
89
"buildpacks": [
910
{ "url": "heroku/nodejs" }

lib/app.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const express = require('express')
2+
3+
const app = express()
4+
require('../middleware')(app)
5+
module.exports = app

lib/check-node-version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ const { engines } = require('../package.json')
55
if (!semver.satisfies(process.version, engines.node)) {
66
console.error(`\n\nYou're using Node.js ${process.version}, but ${engines.node} is required`)
77
console.error('Visit nodejs.org to download an installer for the latest LTS version.\n\n')
8-
process.exit()
8+
process.exit(1)
99
}

lib/prefix-stream-write.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module.exports = function prefixStreamWrite (writableStream, prefix) {
2+
const oldWrite = writableStream.write
3+
4+
function newWrite (...args) {
5+
const [chunk, encoding] = args
6+
7+
// Prepend the prefix if the chunk is either a string or a Buffer.
8+
// Otherwise, just leave it alone to be safe.
9+
if (typeof chunk === 'string') {
10+
// Only prepend the prefix is the `encoding` is safe or not provided.
11+
// If it's a function, it is third arg `callback` provided as optional second
12+
if (!encoding || encoding === 'utf8' || typeof encoding === 'function') {
13+
args[0] = prefix + chunk
14+
}
15+
} else if (Buffer.isBuffer(chunk)) {
16+
args[0] = Buffer.concat([Buffer.from(prefix), chunk])
17+
}
18+
19+
return oldWrite.apply(this, args)
20+
}
21+
22+
writableStream.write = newWrite
23+
24+
return writableStream
25+
}

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"slash": "^3.0.0",
9898
"strip-html-comments": "^1.0.0",
9999
"style-loader": "^1.2.1",
100+
"throng": "^5.0.0",
100101
"unified": "^8.4.2",
101102
"unist-util-visit": "^2.0.3",
102103
"uuid": "^8.3.0",

script/enterprise-server-deprecations/archive-version.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const fs = require('fs')
44
const path = require('path')
55
const { execSync } = require('child_process')
6-
const server = require('../../server')
6+
const app = require('../lib/app')
77
const port = '4001'
88
const host = `http://localhost:${port}`
99
const scrape = require('website-scraper')
@@ -155,7 +155,7 @@ async function main () {
155155
plugins: [new RewriteAssetPathsPlugin(version, tempDirectory)]
156156
}
157157

158-
server.listen(port, async () => {
158+
app.listen(port, async () => {
159159
console.log(`started server on ${host}`)
160160

161161
await scrape(scraperOptions).catch(err => {

server.js

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,116 @@
11
require('dotenv').config()
22

3+
const throng = require('throng')
4+
const os = require('os')
5+
const portUsed = require('port-used')
6+
const prefixStreamWrite = require('./lib/prefix-stream-write')
7+
8+
// Intentionally require these for both cluster primary and workers
39
require('./lib/check-node-version')
410
require('./lib/handle-exceptions')
511
require('./lib/feature-flags')
612

7-
const express = require('express')
8-
const portUsed = require('port-used')
9-
const warmServer = require('./lib/warm-server')
10-
const port = Number(process.env.PORT) || 4000
11-
const app = express()
12-
13-
require('./middleware')(app)
14-
15-
// prevent the app from starting up during tests
16-
/* istanbul ignore next */
17-
if (!module.parent) {
18-
// check that the development server is not already running
19-
portUsed.check(port).then(async status => {
20-
if (status === false) {
21-
// If in a deployed environment, warm the server at the start
22-
if (process.env.NODE_ENV === 'production') {
23-
// If in a true production environment, wait for the cache to be fully warmed.
24-
if (process.env.HEROKU_PRODUCTION_APP || process.env.GITHUB_ACTIONS) {
25-
await warmServer()
26-
}
27-
}
28-
29-
// workaround for https://github.com/expressjs/express/issues/1101
30-
const server = require('http').createServer(app)
31-
server.listen(port, () => console.log(`app running on http://localhost:${port}`))
32-
.on('error', () => server.close())
33-
} else {
34-
console.log(`\n\n\nPort ${port} is not available. You may already have a server running.`)
35-
console.log('Try running `killall node` to shut down all your running node processes.\n\n\n')
36-
console.log('\x07') // system 'beep' sound
37-
process.exit(1)
38-
}
13+
const { PORT, NODE_ENV } = process.env
14+
const port = Number(PORT) || 4000
15+
16+
function main () {
17+
// Spin up a cluster!
18+
throng({
19+
master: setupPrimary,
20+
worker: setupWorker,
21+
count: calculateWorkerCount()
22+
})
23+
}
24+
25+
// Start the server!
26+
main()
27+
28+
// This function will only be run in the primary process
29+
async function setupPrimary () {
30+
process.on('beforeExit', () => {
31+
console.log('Shutting down primary...')
32+
console.log('Exiting!')
33+
})
34+
35+
console.log('Starting up primary...')
36+
37+
// Check that the development server is not already running
38+
const portInUse = await portUsed.check(port)
39+
if (portInUse) {
40+
console.log(`\n\n\nPort ${port} is not available. You may already have a server running.`)
41+
console.log('Try running `killall node` to shut down all your running node processes.\n\n\n')
42+
console.log('\x07') // system 'beep' sound
43+
process.exit(1)
44+
}
45+
}
46+
47+
// IMPORTANT: This function will be run in a separate worker process!
48+
async function setupWorker (id, disconnect) {
49+
let exited = false
50+
51+
// Wrap stdout and stderr to include the worker ID as a static prefix
52+
// console.log('hi') => '[worker.1]: hi'
53+
const prefix = `[worker.${id}]: `
54+
prefixStreamWrite(process.stdout, prefix)
55+
prefixStreamWrite(process.stderr, prefix)
56+
57+
process.on('beforeExit', () => {
58+
console.log('Exiting!')
3959
})
60+
61+
process.on('SIGTERM', shutdown)
62+
process.on('SIGINT', shutdown)
63+
64+
console.log('Starting up worker...')
65+
66+
// Load the server in each worker process and share the port via sharding
67+
const app = require('./lib/app')
68+
const warmServer = require('./lib/warm-server')
69+
70+
// If in a deployed environment...
71+
if (NODE_ENV === 'production') {
72+
// If in a true production environment, wait for the cache to be fully warmed.
73+
if (process.env.HEROKU_PRODUCTION_APP || process.env.GITHUB_ACTIONS) {
74+
await warmServer()
75+
}
76+
}
77+
78+
// Workaround for https://github.com/expressjs/express/issues/1101
79+
const server = require('http').createServer(app)
80+
server
81+
.listen(port, () => console.log(`app running on http://localhost:${port}`))
82+
.on('error', () => server.close())
83+
84+
function shutdown () {
85+
if (exited) return
86+
exited = true
87+
88+
console.log('Shutting down worker...')
89+
disconnect()
90+
}
4091
}
4192

42-
module.exports = app
93+
function calculateWorkerCount () {
94+
// Heroku's recommended WEB_CONCURRENCY count based on the WEB_MEMORY config
95+
const { WEB_CONCURRENCY } = process.env
96+
97+
const recommendedCount = parseInt(WEB_CONCURRENCY, 10) || 1
98+
const cpuCount = os.cpus().length
99+
100+
// Ensure the recommended count is AT LEAST 1 for safety
101+
let workerCount = Math.max(recommendedCount, 1)
102+
103+
// Let's do some math...
104+
// If in a deployed environment...
105+
if (NODE_ENV === 'production') {
106+
// If WEB_MEMORY or WEB_CONCURRENCY values were configured in Heroku, use
107+
// the smaller value between their recommendation vs. the CPU count
108+
if (WEB_CONCURRENCY) {
109+
workerCount = Math.min(recommendedCount, cpuCount)
110+
} else {
111+
workerCount = cpuCount
112+
}
113+
}
114+
115+
return workerCount
116+
}

tests/helpers/supertest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const cheerio = require('cheerio')
55
const supertest = require('supertest')
6-
const app = require('../../server')
6+
const app = require('../../lib/app')
77

88
const helpers = {}
99

tests/rendering/events.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const request = require('supertest')
22
const nock = require('nock')
33
const cheerio = require('cheerio')
4-
const app = require('../../server')
4+
const app = require('../../lib/app')
55

66
describe('POST /events', () => {
77
jest.setTimeout(60 * 1000)

0 commit comments

Comments
 (0)