Skip to content

Commit 63c7f79

Browse files
feat: graceful shutdown and systemd socket activation in adapter-node (#11653)
1 parent 3f0526c commit 63c7f79

File tree

5 files changed

+151
-9
lines changed

5 files changed

+151
-9
lines changed

.changeset/early-peas-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/adapter-node": minor
3+
---
4+
5+
feat: add systemd socket activation

.changeset/stale-donkeys-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/adapter-node": major
3+
---
4+
5+
breaking: add graceful shutdown

documentation/docs/25-build-and-deploy/40-adapter-node.md

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ We instead read from the _right_, accounting for the number of trusted proxies.
118118

119119
The maximum request body size to accept in bytes including while streaming. Defaults to 512kb. You can disable this option with a value of `Infinity` (0 in older versions of the adapter) and implement a custom check in [`handle`](hooks#server-hooks-handle) if you need something more advanced.
120120

121+
### `SHUTDOWN_TIMEOUT`
122+
123+
The number of seconds to wait before forcefully closing any remaining connections after receiving a `SIGTERM` or `SIGINT` signal. Defaults to `30`. Internally the adapter calls [`closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections). See [Graceful shutdown](#graceful-shutdown) for more details.
124+
125+
### `IDLE_TIMEOUT`
126+
127+
When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of seconds after which the app is automatically put to sleep when receiving no requests. If not set, the app runs continuously. See [Socket activation](#socket-activation) for more details.
128+
121129
## Options
122130

123131
The adapter can be configured with various options:
@@ -162,6 +170,46 @@ MY_CUSTOM_ORIGIN=https://my.site \
162170
node build
163171
```
164172

173+
## Graceful shutdown
174+
175+
By default `adapter-node` gracefully shuts down the HTTP server when a `SIGTERM` or `SIGINT` signal is received. It will:
176+
177+
1. reject new requests ([`server.close`](https://nodejs.org/api/http.html#serverclosecallback))
178+
2. wait for requests that have already been made but not received a response yet to finish and close connections once they become idle ([`server.closeIdleConnections`](https://nodejs.org/api/http.html#servercloseidleconnections))
179+
3. and finally, close any remaining connections that are still active after [`SHUTDOWN_TIMEOUT`](#environment-variables-shutdown-timeout) seconds. ([`server.closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections))
180+
181+
> If you want to customize this behaviour you can use a [custom server](#custom-server).
182+
183+
## Socket activation
184+
185+
Most Linux operating systems today use a modern process manager called systemd to start the server and run and manage services. You can configure your server to allocate a socket and start and scale your app on demand. This is called [socket activation](http://0pointer.de/blog/projects/socket-activated-containers.html). In this case, the OS will pass two environment variables to your app — `LISTEN_PID` and `LISTEN_FDS`. The adapter will then listen on file descriptor 3 which refers to a systemd socket unit that you will have to create.
186+
187+
> You can still use [`envPrefix`](#options-envprefix) with systemd socket activation. `LISTEN_PID` and `LISTEN_FDS` are always read without a prefix.
188+
189+
To take advantage of socket activation follow these steps.
190+
191+
1. Run your app as a [systemd service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html). It can either run directly on the host system or inside a container (using Docker or a systemd portable service for example). If you additionally pass an [`IDLE_TIMEOUT`](#environment-variables-idle-timeout) environment variable to your app it will gracefully shutdown if there are no requests for `IDLE_TIMEOUT` seconds. systemd will automatically start your app again when new requests are coming in.
192+
193+
```ini
194+
/// file: /etc/systemd/system/myapp.service
195+
[Service]
196+
Environment=NODE_ENV=production IDLE_TIMEOUT=60
197+
ExecStart=/usr/bin/node /usr/bin/myapp/build
198+
```
199+
200+
2. Create an accompanying [socket unit](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html). The adapter only accepts a single socket.
201+
202+
```ini
203+
/// file: /etc/systemd/system/myapp.socket
204+
[Socket]
205+
ListenStream=3000
206+
207+
[Install]
208+
WantedBy=sockets.target
209+
```
210+
211+
3. Make sure systemd has recognised both units by running `sudo systemctl daemon-reload`. Then enable the socket on boot and start it immediately using `sudo systemctl enable --now myapp.socket`. The app will then automatically start once the first request is made to `localhost:3000`.
212+
165213
## Custom server
166214

167215
The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
@@ -191,9 +239,9 @@ app.listen(3000, () => {
191239

192240
## Troubleshooting
193241

194-
### Is there a hook for cleaning up before the server exits?
242+
### Is there a hook for cleaning up before the app exits?
195243

196-
There's nothing built-in to SvelteKit for this, because such a cleanup hook depends highly on the execution environment you're on. For Node, you can use its built-in `process.on(...)` to implement a callback that runs before the server exits:
244+
There's nothing built-in to SvelteKit for this, because such a cleanup hook depends highly on the execution environment you're on. For Node, you can use its built-in `process.on(...)` to implement a callback that runs before the app exits:
197245

198246
```js
199247
// @errors: 2304 2580
@@ -202,6 +250,5 @@ function shutdownGracefully() {
202250
db.shutdown();
203251
}
204252

205-
process.on('SIGINT', shutdownGracefully);
206-
process.on('SIGTERM', shutdownGracefully);
253+
process.on('exit', shutdownGracefully);
207254
```

packages/adapter-node/src/env.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ const expected = new Set([
1010
'PROTOCOL_HEADER',
1111
'HOST_HEADER',
1212
'PORT_HEADER',
13-
'BODY_SIZE_LIMIT'
13+
'BODY_SIZE_LIMIT',
14+
'SHUTDOWN_TIMEOUT',
15+
'IDLE_TIMEOUT'
1416
]);
1517

18+
const expected_unprefixed = new Set(['LISTEN_PID', 'LISTEN_FDS']);
19+
1620
if (ENV_PREFIX) {
1721
for (const name in process.env) {
1822
if (name.startsWith(ENV_PREFIX)) {
@@ -31,6 +35,7 @@ if (ENV_PREFIX) {
3135
* @param {any} fallback
3236
*/
3337
export function env(name, fallback) {
34-
const prefixed = ENV_PREFIX + name;
38+
const prefix = expected_unprefixed.has(name) ? '' : ENV_PREFIX;
39+
const prefixed = prefix + name;
3540
return prefixed in process.env ? process.env[prefixed] : fallback;
3641
}

packages/adapter-node/src/index.js

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,90 @@ export const path = env('SOCKET_PATH', false);
66
export const host = env('HOST', '0.0.0.0');
77
export const port = env('PORT', !path && '3000');
88

9+
const shutdown_timeout = parseInt(env('SHUTDOWN_TIMEOUT', '30'));
10+
const idle_timeout = parseInt(env('IDLE_TIMEOUT', '0'));
11+
const listen_pid = parseInt(env('LISTEN_PID', '0'));
12+
const listen_fds = parseInt(env('LISTEN_FDS', '0'));
13+
// https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html
14+
const SD_LISTEN_FDS_START = 3;
15+
16+
if (listen_pid !== 0 && listen_pid !== process.pid) {
17+
throw new Error(`received LISTEN_PID ${listen_pid} but current process id is ${process.pid}`);
18+
}
19+
if (listen_fds > 1) {
20+
throw new Error(
21+
`only one socket is allowed for socket activation, but LISTEN_FDS was set to ${listen_fds}`
22+
);
23+
}
24+
25+
const socket_activation = listen_pid === process.pid && listen_fds === 1;
26+
27+
let requests = 0;
28+
/** @type {NodeJS.Timeout | void} */
29+
let shutdown_timeout_id;
30+
/** @type {NodeJS.Timeout | void} */
31+
let idle_timeout_id;
32+
933
const server = polka().use(handler);
1034

11-
server.listen({ path, host, port }, () => {
12-
console.log(`Listening on ${path ? path : host + ':' + port}`);
13-
});
35+
function shutdown() {
36+
if (shutdown_timeout_id) return;
37+
38+
// @ts-expect-error this was added in 18.2.0 but is not reflected in the types
39+
server.server.closeIdleConnections();
40+
41+
server.server.close(() => {
42+
if (shutdown_timeout_id) {
43+
shutdown_timeout_id = clearTimeout(shutdown_timeout_id);
44+
}
45+
if (idle_timeout_id) {
46+
idle_timeout_id = clearTimeout(idle_timeout_id);
47+
}
48+
});
49+
50+
shutdown_timeout_id = setTimeout(
51+
// @ts-expect-error this was added in 18.2.0 but is not reflected in the types
52+
() => server.server.closeAllConnections(),
53+
shutdown_timeout * 1000
54+
);
55+
}
56+
57+
server.server.on(
58+
'request',
59+
/** @param {import('node:http').IncomingMessage} req */
60+
(req) => {
61+
requests++;
62+
63+
if (socket_activation && idle_timeout_id) {
64+
idle_timeout_id = clearTimeout(idle_timeout_id);
65+
}
66+
67+
req.on('close', () => {
68+
requests--;
69+
70+
if (requests === 0 && shutdown_timeout_id) {
71+
// when all requests are done, close the connections, so the app shuts down without delay
72+
// @ts-expect-error this was added in 18.2.0 but is not reflected in the types
73+
server.server.closeIdleConnections();
74+
}
75+
if (requests === 0 && socket_activation && idle_timeout) {
76+
idle_timeout_id = setTimeout(shutdown, idle_timeout * 1000);
77+
}
78+
});
79+
}
80+
);
81+
82+
if (socket_activation) {
83+
server.listen({ fd: SD_LISTEN_FDS_START }, () => {
84+
console.log(`Listening on file descriptor ${SD_LISTEN_FDS_START}`);
85+
});
86+
} else {
87+
server.listen({ path, host, port }, () => {
88+
console.log(`Listening on ${path ? path : host + ':' + port}`);
89+
});
90+
}
91+
92+
process.on('SIGTERM', shutdown);
93+
process.on('SIGINT', shutdown);
1494

1595
export { server };

0 commit comments

Comments
 (0)