Skip to content

Commit db7f279

Browse files
HTTP-Server: Graceful shutdown (elastic#97223)
Co-authored-by: Kibana Machine <[email protected]>
1 parent 948aa3a commit db7f279

17 files changed

+196
-22
lines changed

packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { Server } from '@hapi/hapi';
1010
import { EMPTY } from 'rxjs';
11+
import moment from 'moment';
1112
import supertest from 'supertest';
1213
import {
1314
getServerOptions,
@@ -35,6 +36,7 @@ describe('BasePathProxyServer', () => {
3536
config = {
3637
host: '127.0.0.1',
3738
port: 10012,
39+
shutdownTimeout: moment.duration(30, 'seconds'),
3840
keepaliveTimeout: 1000,
3941
socketTimeout: 1000,
4042
cors: {

packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ it('passes correct args to sub-classes', () => {
108108
"bar",
109109
"baz",
110110
],
111-
"gracefulTimeout": 5000,
111+
"gracefulTimeout": 30000,
112112
"log": <TestLog>,
113113
"mapLogLine": [Function],
114114
"script": <absolute path>/scripts/kibana,

packages/kbn-cli-dev-mode/src/cli_dev_mode.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Rx.merge(
4444
.subscribe(exitSignal$);
4545

4646
// timeout where the server is allowed to exit gracefully
47-
const GRACEFUL_TIMEOUT = 5000;
47+
const GRACEFUL_TIMEOUT = 30000;
4848

4949
export type SomeCliArgs = Pick<
5050
CliArgs,

packages/kbn-cli-dev-mode/src/config/http_config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema';
1010
import { ICorsConfig, IHttpConfig, ISslConfig, SslConfig, sslSchema } from '@kbn/server-http-tools';
11+
import { Duration } from 'moment';
1112

1213
export const httpConfigSchema = schema.object(
1314
{
@@ -22,6 +23,7 @@ export const httpConfigSchema = schema.object(
2223
maxPayload: schema.byteSize({
2324
defaultValue: '1048576b',
2425
}),
26+
shutdownTimeout: schema.duration({ defaultValue: '30s' }),
2527
keepaliveTimeout: schema.number({
2628
defaultValue: 120000,
2729
}),
@@ -47,6 +49,7 @@ export class HttpConfig implements IHttpConfig {
4749
host: string;
4850
port: number;
4951
maxPayload: ByteSizeValue;
52+
shutdownTimeout: Duration;
5053
keepaliveTimeout: number;
5154
socketTimeout: number;
5255
cors: ICorsConfig;
@@ -57,6 +60,7 @@ export class HttpConfig implements IHttpConfig {
5760
this.host = rawConfig.host;
5861
this.port = rawConfig.port;
5962
this.maxPayload = rawConfig.maxPayload;
63+
this.shutdownTimeout = rawConfig.shutdownTimeout;
6064
this.keepaliveTimeout = rawConfig.keepaliveTimeout;
6165
this.socketTimeout = rawConfig.socketTimeout;
6266
this.cors = rawConfig.cors;

packages/kbn-cli-dev-mode/src/dev_server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export class DevServer {
103103
/**
104104
* Run the Kibana server
105105
*
106-
* The observable will error if the child process failes to spawn for some reason, but if
106+
* The observable will error if the child process fails to spawn for some reason, but if
107107
* the child process is successfully spawned then the server will be run until it completes
108108
* and restart when the watcher indicates it should. In order to restart the server as
109109
* quickly as possible we kill it with SIGKILL and spawn the process again.

packages/kbn-server-http-tools/src/get_server_options.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Side Public License, v 1.
77
*/
88

9+
import moment from 'moment';
910
import { ByteSizeValue } from '@kbn/config-schema';
1011
import { getServerOptions } from './get_server_options';
1112
import { IHttpConfig } from './types';
@@ -24,6 +25,7 @@ const createConfig = (parts: Partial<IHttpConfig>): IHttpConfig => ({
2425
port: 5601,
2526
socketTimeout: 120000,
2627
keepaliveTimeout: 120000,
28+
shutdownTimeout: moment.duration(30, 'seconds'),
2729
maxPayload: ByteSizeValue.parse('1048576b'),
2830
...parts,
2931
cors: {

packages/kbn-server-http-tools/src/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { ByteSizeValue } from '@kbn/config-schema';
10+
import type { Duration } from 'moment';
1011

1112
export interface IHttpConfig {
1213
host: string;
@@ -16,6 +17,7 @@ export interface IHttpConfig {
1617
socketTimeout: number;
1718
cors: ICorsConfig;
1819
ssl: ISslConfig;
20+
shutdownTimeout: Duration;
1921
}
2022

2123
export interface ICorsConfig {

src/core/server/http/__snapshots__/http_config.test.ts.snap

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/server/http/http_config.test.ts

+29
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,35 @@ test('can specify max payload as string', () => {
108108
expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024);
109109
});
110110

111+
describe('shutdownTimeout', () => {
112+
test('can specify a valid shutdownTimeout', () => {
113+
const configValue = config.schema.validate({ shutdownTimeout: '5s' });
114+
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000);
115+
});
116+
117+
test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => {
118+
const configValue = config.schema.validate({ shutdownTimeout: '1s' });
119+
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000);
120+
});
121+
122+
test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => {
123+
const configValue = config.schema.validate({ shutdownTimeout: '2m' });
124+
expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000);
125+
});
126+
127+
test('should error if below 1s', () => {
128+
expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow(
129+
'[shutdownTimeout]: the value should be between 1 second and 2 minutes'
130+
);
131+
});
132+
133+
test('should error if over 2 minutes', () => {
134+
expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow(
135+
'[shutdownTimeout]: the value should be between 1 second and 2 minutes'
136+
);
137+
});
138+
});
139+
111140
describe('basePath', () => {
112141
test('throws if missing prepended slash', () => {
113142
const httpSchema = config.schema;

src/core/server/http/http_config.ts

+12
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { IHttpConfig, SslConfig, sslSchema } from '@kbn/server-http-tools';
1111
import { hostname } from 'os';
1212
import url from 'url';
1313

14+
import type { Duration } from 'moment';
1415
import { ServiceConfigDescriptor } from '../internal_types';
1516
import { CspConfigType, CspConfig, ICspConfig } from '../csp';
1617
import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url';
@@ -35,6 +36,15 @@ const configSchema = schema.object(
3536
validate: match(validBasePathRegex, "must start with a slash, don't end with one"),
3637
})
3738
),
39+
shutdownTimeout: schema.duration({
40+
defaultValue: '30s',
41+
validate: (duration) => {
42+
const durationMs = duration.asMilliseconds();
43+
if (durationMs < 1000 || durationMs > 2 * 60 * 1000) {
44+
return 'the value should be between 1 second and 2 minutes';
45+
}
46+
},
47+
}),
3848
cors: schema.object(
3949
{
4050
enabled: schema.boolean({ defaultValue: false }),
@@ -188,6 +198,7 @@ export class HttpConfig implements IHttpConfig {
188198
public externalUrl: IExternalUrlConfig;
189199
public xsrf: { disableProtection: boolean; allowlist: string[] };
190200
public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] };
201+
public shutdownTimeout: Duration;
191202

192203
/**
193204
* @internal
@@ -227,6 +238,7 @@ export class HttpConfig implements IHttpConfig {
227238
this.externalUrl = rawExternalUrlConfig;
228239
this.xsrf = rawHttpConfig.xsrf;
229240
this.requestId = rawHttpConfig.requestId;
241+
this.shutdownTimeout = rawHttpConfig.shutdownTimeout;
230242
}
231243
}
232244

src/core/server/http/http_server.test.ts

+80-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { HttpServer } from './http_server';
2626
import { Readable } from 'stream';
2727
import { RequestHandlerContext } from 'kibana/server';
2828
import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
29+
import moment from 'moment';
30+
import { of } from 'rxjs';
2931

3032
const cookieOptions = {
3133
name: 'sid',
@@ -65,6 +67,7 @@ beforeEach(() => {
6567
cors: {
6668
enabled: false,
6769
},
70+
shutdownTimeout: moment.duration(500, 'ms'),
6871
} as any;
6972

7073
configWithSSL = {
@@ -79,7 +82,7 @@ beforeEach(() => {
7982
},
8083
} as HttpConfig;
8184

82-
server = new HttpServer(loggingService, 'tests');
85+
server = new HttpServer(loggingService, 'tests', of(config.shutdownTimeout));
8386
});
8487

8588
afterEach(async () => {
@@ -1431,3 +1434,79 @@ describe('setup contract', () => {
14311434
});
14321435
});
14331436
});
1437+
1438+
describe('Graceful shutdown', () => {
1439+
let shutdownTimeout: number;
1440+
let innerServerListener: Server;
1441+
1442+
beforeEach(async () => {
1443+
shutdownTimeout = config.shutdownTimeout.asMilliseconds();
1444+
const { registerRouter, server: innerServer } = await server.setup(config);
1445+
innerServerListener = innerServer.listener;
1446+
1447+
const router = new Router('', logger, enhanceWithContext);
1448+
router.post(
1449+
{
1450+
path: '/',
1451+
validate: false,
1452+
options: { body: { accepts: 'application/json' } },
1453+
},
1454+
async (context, req, res) => {
1455+
// It takes to resolve the same period of the shutdownTimeout.
1456+
// Since we'll trigger the stop a few ms after, it should have time to finish
1457+
await new Promise((resolve) => setTimeout(resolve, shutdownTimeout));
1458+
return res.ok({ body: { ok: 1 } });
1459+
}
1460+
);
1461+
registerRouter(router);
1462+
1463+
await server.start();
1464+
});
1465+
1466+
test('any ongoing requests should be resolved with `connection: close`', async () => {
1467+
const [response] = await Promise.all([
1468+
// Trigger a request that should hold the server from stopping until fulfilled
1469+
supertest(innerServerListener).post('/'),
1470+
// Stop the server while the request is in progress
1471+
(async () => {
1472+
await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
1473+
await server.stop();
1474+
})(),
1475+
]);
1476+
1477+
expect(response.status).toBe(200);
1478+
expect(response.body).toStrictEqual({ ok: 1 });
1479+
// The server is about to be closed, we need to ask connections to close on their end (stop their keep-alive policies)
1480+
expect(response.header.connection).toBe('close');
1481+
});
1482+
1483+
test('any requests triggered while stopping should be rejected with 503', async () => {
1484+
const [, , response] = await Promise.all([
1485+
// Trigger a request that should hold the server from stopping until fulfilled (otherwise the server will stop straight away)
1486+
supertest(innerServerListener).post('/'),
1487+
// Stop the server while the request is in progress
1488+
(async () => {
1489+
await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
1490+
await server.stop();
1491+
})(),
1492+
// Trigger a new request while shutting down (should be rejected)
1493+
(async () => {
1494+
await new Promise((resolve) => setTimeout(resolve, (2 * shutdownTimeout) / 3));
1495+
return supertest(innerServerListener).post('/');
1496+
})(),
1497+
]);
1498+
expect(response.status).toBe(503);
1499+
expect(response.body).toStrictEqual({
1500+
statusCode: 503,
1501+
error: 'Service Unavailable',
1502+
message: 'Kibana is shutting down and not accepting new incoming requests',
1503+
});
1504+
expect(response.header.connection).toBe('close');
1505+
});
1506+
1507+
test('when no ongoing connections, the server should stop without waiting any longer', async () => {
1508+
const preStop = Date.now();
1509+
await server.stop();
1510+
expect(Date.now() - preStop).toBeLessThan(shutdownTimeout);
1511+
});
1512+
});

0 commit comments

Comments
 (0)