Skip to content

Commit

Permalink
CLOUD-3497 Add terminus
Browse files Browse the repository at this point in the history
  • Loading branch information
sethidden committed Aug 29, 2024
1 parent c7befab commit 48b431f
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/metal-guests-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vue-storefront/middleware": minor
---

[ADDED] New GET /readyz endpoint for middleware for using with Kubernetes readiness probes.
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;

Check warning on line 44 in packages/middleware/__tests__/integration/apiClientFactory.spec.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration / Run CI

'app' is already declared in the upper scope on line 6 column 7

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
62 changes: 62 additions & 0 deletions packages/middleware/__tests__/unit/terminus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { HealthCheckError } from "@godaddy/terminus";
import {
createMemoryFullnessReadinessCheck,
createReadyzHandler,
} from "../../src/terminus";

describe("terminus", () => {
describe("terminus: createMemoryFUllnessReadinessCheck", () => {
it("throws if memory usage over threshold", () => {
expect.assertions(1);
const memoryLimit = 3;
const memoryLimitThreshold = 0.5;

jest
.spyOn(process, "memoryUsage")
// @ts-expect-error other props not needed
.mockImplementationOnce(() => ({ rss: 2 }));

const memoryFullnessReadinessCheck = createMemoryFullnessReadinessCheck(
memoryLimit,
memoryLimitThreshold
);

expect(memoryFullnessReadinessCheck()).rejects.toThrow();
});
it("doesn't throw if memory usage is under threshold", () => {
const memoryLimit = 3;
const memoryLimitThreshold = 0.5;

jest
.spyOn(process, "memoryUsage")
// @ts-expect-error other props not needed
.mockImplementationOnce(() => ({ rss: 1 }));

const memoryFullnessReadinessCheck = createMemoryFullnessReadinessCheck(
memoryLimit,
memoryLimitThreshold
);

expect(memoryFullnessReadinessCheck()).resolves.toBe(undefined);
});
});

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.readinessChecks));
consola.success("Middleware created!");
return app;
return server;
}

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

/*
* When running in Alokai Cloud, middleware pods are ran with a `ALOKAI_MEMORY_LIMIT` environment variable
* This limit represents what is the Kubernetes resource limit on memory of the pod (a number in kilobytes), which if crossed, will kill the app
* Here we're using that env var to monitor the current memory usage vs. the limit
* The existence of this env var is a workaround for the lack of `process.availableMemory()` in Node 18 (available in Node 20, which e.g. Alokai bootstrapper doesn't use yet), which retrieves the memory limit from kernel cgroups
*/
export const createMemoryFullnessReadinessCheck =
(memoryLimit: number, memoryLimitThreshold: number): ReadinessCheck =>
async () => {
const memoryUtilization = process.memoryUsage().rss / memoryLimit;
if (memoryUtilization > memoryLimitThreshold) {
throw new Error(
`Process memory usage over threshold - current is ${memoryUtilization} where ALOKAI_MEMORY_LIMIT_THRESHOLD is ${memoryLimitThreshold}`
);
}
};

// ALOKAI_MEMORY_LIMT won't be set in local dev envs etc.
const defaultReadinessChecks = process.env.ALOKAI_MEMORY_LIMT
? [
createMemoryFullnessReadinessCheck(
Number.parseInt(process.env.ALOKAI_MEMORY_LIMIT, 10),
Number.parseFloat(process.env.ALOKAI_MEMORY_LIMIT_THRESHOLD ?? "0.8")
),
]
: [];

export const createReadyzHandler =
(readinessChecks: ReadinessCheck[]) => 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: ReadinessCheck[] = defaultReadinessChecks
): 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 process readiness.
* @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 ReadinessCheck = () => 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
*/
readinessChecks?: ReadinessCheck[];
}
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 48b431f

Please sign in to comment.