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 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
+ })