microship 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/package.json +78 -0
- package/src/index.js +113 -0
- package/src/status.js +25 -0
- package/src/worker.js +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# microship
|
|
2
|
+
|
|
3
|
+
Readiness, liveness, and graceful shutdown for a Node process on Kubernetes.
|
|
4
|
+
|
|
5
|
+
`/ready` and `/live` run on a [worker thread](https://nodejs.org/api/worker_threads.html). A busy event loop stays ready. `/live` returns 500 after the loop has not run for 15 seconds. Shutdown marks the process not ready, waits `shutdownDelay` so the kubelet observes it, runs the shutdown handler, then exits.
|
|
6
|
+
|
|
7
|
+
Outside Kubernetes the probe binds a random port, so a local process does not take 3001.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const { createMicroship } = require('microship')
|
|
11
|
+
|
|
12
|
+
const ship = await createMicroship({
|
|
13
|
+
port: 3001,
|
|
14
|
+
shutdownDelay: 1000,
|
|
15
|
+
shutdownHandlerTimeout: 90000,
|
|
16
|
+
terminate: () => process.exit(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
ship.registerShutdownHandler(async () => {
|
|
20
|
+
// drain in-flight work
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
ship.signalReady()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`shutdownHandlerTimeout` forces `terminate` when the handler does not finish. Signals are `SIGTERM`, `SIGHUP`, and `SIGINT`.
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "microship",
|
|
3
|
+
"description": "Readiness, liveness, and graceful shutdown for a Node process on Kubernetes.",
|
|
4
|
+
"homepage": "https://github.com/Kikobeats/microship",
|
|
5
|
+
"version": "0.1.1",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"author": {
|
|
8
|
+
"email": "josefrancisco.verdu@gmail.com",
|
|
9
|
+
"name": "Kiko Beats",
|
|
10
|
+
"url": "https://github.com/Kikobeats"
|
|
11
|
+
},
|
|
12
|
+
"contributors": [],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/kikobeats/microship.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/Kikobeats/microship/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"kubernetes",
|
|
22
|
+
"liveness",
|
|
23
|
+
"readiness",
|
|
24
|
+
"shutdown"
|
|
25
|
+
],
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@commitlint/cli": "latest",
|
|
28
|
+
"@commitlint/config-conventional": "latest",
|
|
29
|
+
"c8": "latest",
|
|
30
|
+
"ci-publish": "latest",
|
|
31
|
+
"conventional-changelog": "latest",
|
|
32
|
+
"finepack": "latest",
|
|
33
|
+
"git-authors-cli": "latest",
|
|
34
|
+
"github-generate-release": "latest",
|
|
35
|
+
"nano-staged": "latest",
|
|
36
|
+
"simple-git-hooks": "latest",
|
|
37
|
+
"standard": "latest",
|
|
38
|
+
"standard-version": "latest"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">= 18"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"contributors": "git-authors-cli && finepack && git add package.json && (git diff --cached --quiet || git commit -m 'build: contributors' --no-verify)",
|
|
48
|
+
"lint": "standard",
|
|
49
|
+
"postrelease": "pnpm release:tags && pnpm release:github && (ci-publish || pnpm publish --access=public)",
|
|
50
|
+
"release": "pnpm release:version && pnpm release:changelog && pnpm release:commit && pnpm release:tag",
|
|
51
|
+
"release:changelog": "conventional-changelog -p conventionalcommits -i CHANGELOG.md",
|
|
52
|
+
"release:commit": "git add package.json CHANGELOG.md && git commit -m \"chore(release): $(node -p \"require('./package.json').version\")\"",
|
|
53
|
+
"release:github": "github-generate-release",
|
|
54
|
+
"release:tag": "git tag -a v$(node -p \"require('./package.json').version\") -m \"v$(node -p \"require('./package.json').version\")\"",
|
|
55
|
+
"release:tags": "git push origin HEAD:master --follow-tags",
|
|
56
|
+
"release:version": "standard-version --skip.changelog --skip.commit --skip.tag",
|
|
57
|
+
"test": "c8 node --test"
|
|
58
|
+
},
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"commitlint": {
|
|
61
|
+
"extends": [
|
|
62
|
+
"@commitlint/config-conventional"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"nano-staged": {
|
|
66
|
+
"*.js": [
|
|
67
|
+
"npx @kikobeats/prettier-standard",
|
|
68
|
+
"standard --fix"
|
|
69
|
+
],
|
|
70
|
+
"package.json": [
|
|
71
|
+
"finepack"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"simple-git-hooks": {
|
|
75
|
+
"commit-msg": "npx commitlint --edit",
|
|
76
|
+
"pre-commit": "npx nano-staged"
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Worker } = require('worker_threads')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const { STALE_MS } = require('./status')
|
|
7
|
+
|
|
8
|
+
const SIGNALS = ['SIGTERM', 'SIGHUP', 'SIGINT']
|
|
9
|
+
const EXIT_DELAY = 1000
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
12
|
+
|
|
13
|
+
const inKubernetes = () => Boolean(process.env.KUBERNETES_SERVICE_HOST)
|
|
14
|
+
|
|
15
|
+
const createMicroship = async ({
|
|
16
|
+
detectKubernetes = true,
|
|
17
|
+
port = 3001,
|
|
18
|
+
shutdownDelay,
|
|
19
|
+
shutdownHandlerTimeout = 5000,
|
|
20
|
+
signals = SIGNALS,
|
|
21
|
+
staleMs = STALE_MS,
|
|
22
|
+
terminate = () => process.exit(0)
|
|
23
|
+
} = {}) => {
|
|
24
|
+
const local = detectKubernetes && !inKubernetes()
|
|
25
|
+
const probePort = local ? 0 : port
|
|
26
|
+
const delay = shutdownDelay === undefined ? (local ? 0 : 5000) : shutdownDelay
|
|
27
|
+
|
|
28
|
+
let ready = false
|
|
29
|
+
let shuttingDown = false
|
|
30
|
+
const handlers = []
|
|
31
|
+
const view = new Int32Array(new SharedArrayBuffer(12))
|
|
32
|
+
|
|
33
|
+
const publish = () => {
|
|
34
|
+
Atomics.store(view, 0, ready && !shuttingDown ? 1 : 0)
|
|
35
|
+
Atomics.store(view, 1, shuttingDown ? 1 : 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const worker = new Worker(path.join(__dirname, 'worker.js'), {
|
|
39
|
+
workerData: { port: probePort, sab: view.buffer, staleMs }
|
|
40
|
+
})
|
|
41
|
+
worker.unref()
|
|
42
|
+
|
|
43
|
+
const bound = await new Promise((resolve, reject) => {
|
|
44
|
+
worker.once('message', message => {
|
|
45
|
+
if (message.error) reject(new Error(message.error))
|
|
46
|
+
else resolve(message.port)
|
|
47
|
+
})
|
|
48
|
+
worker.once('error', reject)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const beat = setInterval(() => {
|
|
52
|
+
publish()
|
|
53
|
+
Atomics.add(view, 2, 1)
|
|
54
|
+
}, 500)
|
|
55
|
+
beat.unref()
|
|
56
|
+
|
|
57
|
+
const signalReady = () => {
|
|
58
|
+
if (shuttingDown) return
|
|
59
|
+
ready = true
|
|
60
|
+
publish()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const signalNotReady = () => {
|
|
64
|
+
ready = false
|
|
65
|
+
publish()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const shutdown = async () => {
|
|
69
|
+
if (shuttingDown) return
|
|
70
|
+
ready = false
|
|
71
|
+
shuttingDown = true
|
|
72
|
+
publish()
|
|
73
|
+
if (delay) await sleep(delay)
|
|
74
|
+
|
|
75
|
+
let forced = false
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
forced = true
|
|
78
|
+
terminate()
|
|
79
|
+
}, shutdownHandlerTimeout)
|
|
80
|
+
timer.unref()
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
for (const handler of handlers) await handler()
|
|
84
|
+
} catch (_) {}
|
|
85
|
+
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
if (!forced) setTimeout(terminate, EXIT_DELAY).unref()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const onSignal = () => {
|
|
91
|
+
shutdown().catch(() => {})
|
|
92
|
+
}
|
|
93
|
+
for (const signal of signals) process.on(signal, onSignal)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
port: bound,
|
|
97
|
+
isServerReady: () => ready && !shuttingDown,
|
|
98
|
+
isServerShuttingDown: () => shuttingDown,
|
|
99
|
+
registerShutdownHandler: handler => {
|
|
100
|
+
handlers.push(handler)
|
|
101
|
+
},
|
|
102
|
+
shutdown,
|
|
103
|
+
signalNotReady,
|
|
104
|
+
signalReady,
|
|
105
|
+
stop: async () => {
|
|
106
|
+
clearInterval(beat)
|
|
107
|
+
for (const signal of signals) process.off(signal, onSignal)
|
|
108
|
+
await worker.terminate()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { createMicroship, STALE_MS }
|
package/src/status.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const STALE_MS = 15000
|
|
4
|
+
|
|
5
|
+
const createState = () => ({ lastBeat: 0, lastBeatAt: 0 })
|
|
6
|
+
|
|
7
|
+
const beatAge = (view, state, now) => {
|
|
8
|
+
const beat = Atomics.load(view, 2)
|
|
9
|
+
if (state.lastBeatAt === 0 || beat !== state.lastBeat) {
|
|
10
|
+
state.lastBeat = beat
|
|
11
|
+
state.lastBeatAt = now
|
|
12
|
+
}
|
|
13
|
+
return now - state.lastBeatAt
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const statusFor = (view, state, url, now, staleMs = STALE_MS) => {
|
|
17
|
+
const shuttingDown = Atomics.load(view, 1) === 1
|
|
18
|
+
const ready = Atomics.load(view, 0) === 1 && !shuttingDown
|
|
19
|
+
const live = !shuttingDown && beatAge(view, state, now) < staleMs
|
|
20
|
+
if (url === '/live') return live ? 200 : 500
|
|
21
|
+
if (url === '/ready') return ready ? 200 : 500
|
|
22
|
+
return 404
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { STALE_MS, createState, statusFor }
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { parentPort, workerData } = require('worker_threads')
|
|
4
|
+
const http = require('http')
|
|
5
|
+
|
|
6
|
+
const { createState, statusFor } = require('./status')
|
|
7
|
+
|
|
8
|
+
const view = new Int32Array(workerData.sab)
|
|
9
|
+
const state = createState()
|
|
10
|
+
|
|
11
|
+
const server = http.createServer((req, res) => {
|
|
12
|
+
const url = (req.url || '/').split('?')[0]
|
|
13
|
+
const code = statusFor(view, state, url, Date.now(), workerData.staleMs)
|
|
14
|
+
res.writeHead(code, { 'content-type': 'text/plain' })
|
|
15
|
+
res.end()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
server.on('error', error => {
|
|
19
|
+
parentPort.postMessage({ error: error.message })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
server.listen(workerData.port, '0.0.0.0', () => {
|
|
23
|
+
parentPort.postMessage({ port: server.address().port })
|
|
24
|
+
})
|