Skip to content

Commit

Permalink
CLOUD-3497 Add terminus (#7234)
Browse files Browse the repository at this point in the history
  • Loading branch information
sethidden authored Sep 2, 2024
1 parent 492567c commit 2960fee
Show file tree
Hide file tree
Showing 13 changed files with 159 additions and 13 deletions.
18 changes: 18 additions & 0 deletions .changeset/metal-guests-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@vue-storefront/middleware": major
---

- [CHANGED] [BREAKING] Changed return type of `createServer()` from `Express` to `Server` (from built-in `node:http` package). Both of those types' interfaces have the `.listen()` method with the same shape. In some older templates for starting the middleware (`middleware.js` in your repo) you come across:

```ts
async function runMiddleware(app: Express) {
```
If you're using that older template, please change the `Express` type to `Server`:
```diff
+ import { Server } from "node:http"
+ async function runMiddleware(app: Server) {
- async function runMiddleware(app: Express) {
```
- [ADDED] New GET /readyz endpoint for middleware for using with Kubernetes readiness probes. Please see https://docs.alokai.com/middleware/guides/readiness-probes for more information
29 changes: 29 additions & 0 deletions docs/content/3.middleware/2.guides/9.readiness-probes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Readiness probes

Users of Alokai Cloud have their middleware deployed in Kubernetes. Readiness probes allow an application to temporarily mark itself as unable to serve requests in a Kubernetes cluster.

As of version 5.0.0 of the `@vue-storefront/middleware` package, you can launch the middleware and send a GET request to the `http://localhost:4000/readyz` endpoint. The response will contain either a success message or a list of errors describing why the readiness probe failed.

The same endpoint is queried automatically by Alokai Cloud every few seconds to check whether requests should be routed to a particular middleware replica. One such case is if a middleware instance is being killed (if it receives a `SIGTERM` signal), it should stop accepting traffic, but wait a few seconds before shutting down to handle any requests that are still being handledby that instance.

You can read more about Kubernetes readiness probes in the [official documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes).



### Adding custom readiness probes

To add custom readiness probes, pass them to the `readinessProbes` property when calling `createServer`.

```ts
const customReadinessProbe = async () => {
const dependentServiceRunning = await axios.get('http://someservice:3000/healthz');
if(dependentServiceRunning.status !== 200) {
throw new Error('Service that the middleware depends on is offline. The middleware is temporarily not ready to accept connections.')
}
}
const app = await createServer(config, { readinessProbes: [customReadinessProbe]});
```

In order for custom readiness probes to be implemented correctly, they need to do two things:
1. they must all be async or return a promise (the return value is not checked, it's expected to be void/undefined)
2. they must all throw an exception when you want a readiness probe to fail
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express, { Express } from "express";
import express from "express";
import request from "supertest";
import { Server } from "http";
import { createServer } from "../../src";

const app = express();
Expand Down Expand Up @@ -40,7 +41,7 @@ const myExtension = {
};

describe("POST /test_integration/testEndpoint", () => {
let app: Express;
let app: Server;

beforeEach(() => {
jest.clearAllMocks();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Express } from "express";
import request from "supertest";
import { Server } from "http";
import { createServer } from "../../src/index";

const Logger = {
Expand Down Expand Up @@ -59,7 +59,7 @@ const cachingExtension = {
* for cookie-independent requests to the GET endpoints in our integrations
*/
describe("[Integration] Caching extension", () => {
let app: Express;
let app: Server;

beforeEach(() => {
jest.clearAllMocks();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Express } from "express";
import request from "supertest";
import { Server } from "http";
import { createServer } from "../../src/index";
import { success } from "./bootstrap/api";

describe("[Integration] Create server", () => {
let app: Express;
let app: Server;

beforeEach(async () => {
app = await createServer({
Expand Down
23 changes: 23 additions & 0 deletions packages/middleware/__tests__/unit/terminus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HealthCheckError } from "@godaddy/terminus";
import { createReadyzHandler } from "../../src/terminus";

describe("terminus", () => {
describe("createReadyzHandler", () => {
it("throws on failing ready check", () => {
const readinessChecks = [
async () => {
throw new Error();
},
];
const readyzHandler = createReadyzHandler(readinessChecks);

expect(readyzHandler()).rejects.toThrow(HealthCheckError);
});
it("doesn't throw on succeeding ready check", () => {
const readinessChecks = [async () => undefined];
const readyzHandler = createReadyzHandler(readinessChecks);

expect(readyzHandler()).resolves.toBe(undefined);
});
});
});
3 changes: 2 additions & 1 deletion packages/middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.1",
"helmet": "^5.1.1"
"helmet": "^5.1.1",
"@godaddy/terminus": "^4.12.1"
},
"devDependencies": {
"@types/body-parser": "^1.19.2",
Expand Down
12 changes: 9 additions & 3 deletions packages/middleware/src/createServer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import consola from "consola";
import cookieParser from "cookie-parser";
import cors from "cors";
import type { Express } from "express";
import express from "express";
import type { HelmetOptions } from "helmet";
import helmet from "helmet";
import http, { Server } from "node:http";
import { createTerminus } from "@godaddy/terminus";

import { registerIntegrations } from "./integrations";
import type {
Helmet,
Expand All @@ -18,6 +20,7 @@ import {
prepareArguments,
callApiFunction,
} from "./handlers";
import { createTerminusOptions } from "./terminus";

const defaultCorsOptions: CreateServerOptions["cors"] = {
credentials: true,
Expand All @@ -29,7 +32,7 @@ async function createServer<
>(
config: MiddlewareConfig<TIntegrationContext>,
options: CreateServerOptions = {}
): Promise<Express> {
): Promise<Server> {
const app = express();

app.use(express.json(options.bodyParser));
Expand Down Expand Up @@ -78,12 +81,15 @@ async function createServer<
callApiFunction
);

// This could instead be implemented as a healthcheck within terminus, but we don't want /healthz to change response if app received SIGTERM
app.get("/healthz", (_req, res) => {
res.end("ok");
});

const server = http.createServer(app);
createTerminus(server, createTerminusOptions(options.readinessProbes));
consola.success("Middleware created!");
return app;
return server;
}

export { createServer };
2 changes: 1 addition & 1 deletion packages/middleware/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from "./types";

export * from "./createServer";
export * from "./apiClientFactory";
export * from "./terminus";
44 changes: 44 additions & 0 deletions packages/middleware/src/terminus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { HealthCheckError, TerminusOptions } from "@godaddy/terminus";
import { setTimeout } from "node:timers/promises";
import { ReadinessProbe } from "./types";

export const createReadyzHandler =
(readinessChecks: ReadinessProbe[]) => async () => {
// call all provided readiness checks in parallel
// warning: because Promise.allSettled (also happens for .all()) is used,
// all readiness checks need to return a promise (that is, need to be async functions)
const calledReadinessChecks = await Promise.allSettled(
readinessChecks.map((fn) => fn())
);

const readinessCheckFailureReasons = calledReadinessChecks.reduce<
unknown[]
>(
(failureReasons, settledReadinessCheck) =>
settledReadinessCheck.status === "rejected"
? [...failureReasons, settledReadinessCheck.reason]
: failureReasons,
[]
);

if (readinessCheckFailureReasons.length) {
throw new HealthCheckError(
"Readiness check failed",
readinessCheckFailureReasons
);
}
};

export const createTerminusOptions = (
readinessChecks: ReadinessProbe[]
): TerminusOptions => {
return {
useExit0: true,
// In case some requests are still being handled when SIGTERM was received, naively wait in hopes that they will be resolved in that time, and only then shut down the process
beforeShutdown: () => setTimeout(10 ** 4),
healthChecks: {
verbatim: true,
"/readyz": createReadyzHandler(readinessChecks),
},
};
};
13 changes: 13 additions & 0 deletions packages/middleware/src/types/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ export type CreateApiProxyFn = <CONFIG, API, CLIENT>(
customApi?: any
) => ApiInstance<CONFIG, API, CLIENT>;

/**
* Function that will be called to determine readiness of middleware to accept connections
* @returns Return value is never considered - only thrown exceptions
* @throws The implementation *must* throw an exception at some point in the code, which means that the readiness check should fail
*/
export type ReadinessProbe = () => Promise<void>;

export interface CreateServerOptions {
/**
* The options for the `express.json` middleware.
Expand Down Expand Up @@ -159,4 +166,10 @@ export interface CreateServerOptions {
* @see https://www.npmjs.com/package/cors
*/
cors?: CorsOptions | CorsOptionsDelegate;
/**
* Array of functions that will be called in parallel every time the /readyz endpoint receives a GET request
* If at least one function throws an exception, the response from the /readyz endpoint will report an error
* @see https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes
*/
readinessProbes?: ReadinessProbe[];
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Express } from "express";
import { Server } from "http";
import { createServer } from "@vue-storefront/middleware";
import { ExtendedGlobalThis } from "./types";

async function runMiddleware(app: Express): Promise<Server> {
async function runMiddleware(app: Server): Promise<Server> {
return new Promise((resolve) => {
const server = app.listen(8181, async () => {
resolve(server);
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,13 @@
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==

"@godaddy/terminus@^4.12.1":
version "4.12.1"
resolved "https://registry.yarnpkg.com/@godaddy/terminus/-/terminus-4.12.1.tgz#c4fdc280a4ac9655d4734250e22299a4ad46c54c"
integrity sha512-Tm+wVu1/V37uZXcT7xOhzdpFoovQReErff8x3y82k6YyWa1gzxWBjTyrx4G2enjEqoXPnUUmJ3MOmwH+TiP6Sw==
dependencies:
stoppable "^1.1.0"

"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
Expand Down Expand Up @@ -17014,6 +17021,11 @@ std-env@^3.3.3, std-env@^3.5.0, std-env@^3.7.0:
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2"
integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==

stoppable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b"
integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==

stream-combiner@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14"
Expand Down

0 comments on commit 2960fee

Please sign in to comment.