Skip to content

Commit d03a8c5

Browse files
authored
Add ability to warm server function (#114)
1 parent b737de5 commit d03a8c5

File tree

9 files changed

+888
-438
lines changed

9 files changed

+888
-438
lines changed

.changeset/six-donuts-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"open-next": minor
3+
---
4+
5+
Add ability to warm server function

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ OpenNext aims to support all Next.js 13 features. Some features are work in prog
3636
- [x] Image optimization
3737
- [x] [NextAuth.js](https://next-auth.js.org)
3838
- [x] [Running at edge](#running-at-edge)
39+
- [x] [No cold start](#warmer-function)
3940

4041
## How does OpenNext work?
4142

@@ -55,6 +56,7 @@ my-next-app/
5556
assets/ -> Static files to upload to an S3 Bucket
5657
server-function/ -> Handler code for server Lambda Function
5758
image-optimization-function/ -> Handler code for image optimization Lambda Function
59+
warmer-function/ -> Cron job code to keep server function warm
5860
```
5961

6062
## Deployment
@@ -210,6 +212,57 @@ To configure the CloudFront distribution:
210212
| `/api/*` | API | set `x-forwarded-host`<br />[see why](#workaround-set-x-forwarded-host-header-aws-specific) | server function | - |
211213
| `/*` | catch all | set `x-forwarded-host`<br />[see why](#workaround-set-x-forwarded-host-header-aws-specific) | server function | S3 bucket<br />[see why](#workaround-public-static-files-served-out-by-server-function-aws-specific) |
212214

215+
#### Warmer function
216+
217+
Server functions may experience performance issues due to Lambda cold starts. To mitigate this, the server function can be invoked periodically. Remmember, **Warming is optional** and is only required if you want to keep the server function warm.
218+
219+
To set this up, create a Lambda function using the code in the `.open-next/warmer-function` folder with `index.mjs` as the handler. Ensure the function is configured as follows:
220+
221+
- Set the `FUNCTION_NAME` environment variable with the value being the name of the server Lambda function.
222+
- Set the `CONCURRENCY` environment variable with the value being the number of server functions to warm.
223+
- Grant `lambda:InvokeFunction` permission to allow the warmer to invoke the server function.
224+
225+
Also, create an EventBridge scheduled rule to invoke the warmer function every 5 minutes.
226+
227+
Please note, warming is currently only supported when the server function is deployed to a single region (Lambda).
228+
229+
**Prewarm**
230+
231+
Each time you deploy, a new version of the Lambda function will be generated. All warmed server function instances will be turned off. And there won't be any warm instances until the warmer function runs again at the next 5-minute interval.
232+
233+
To ensure the functions are prewarmed on deploy, create a [CloudFormation Custom Resource](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html) to invoke the warmer function on deployment. The custom resource should be configured as follows:
234+
235+
- Invoke the warmer function on resource `Create` and `Update`.
236+
- Include a timestamp value in the resource property to ensure the custom resource runs on every deployment.
237+
- Grant `lambda:InvokeFunction` permission to allow the custom resource to invoke the warmer function.
238+
239+
**Cost**
240+
241+
There are three components to the cost:
242+
243+
1. EventBridge scheduler: $0.00864
244+
```
245+
Requests cost — 8,640 invocations per month x $1/million = $0.00864
246+
```
247+
1. Warmer function: $0.145728288
248+
```
249+
Requests cost — 8,640 invocations per month x $0.2/million = $0.001728
250+
Duration cost — 8,640 invocations per month x 1GB memory x 1s duration x $0.0000166667/GB-second = $0.144000288
251+
```
252+
1. Server function: $0.0161280288 per warmed instance
253+
```
254+
Requests cost — 8,640 invocations per month x $0.2/million = $0.001728
255+
Duration cost — 8,640 invocations per month x 1GB memory x 100ms duration x $0.0000166667/GB-second = $0.0144000288
256+
```
257+
258+
For example, keeping 50 instances of the server function warm will cost approximately **$0.96 per month**
259+
260+
```
261+
$0.00864 + $0.145728288 + $0.0161280288 x 50 = $0.960769728
262+
```
263+
264+
This cost estimate is based on the `us-east-1` region pricing and does not consider any free tier benefits.
265+
213266
## Limitations and workarounds
214267

215268
#### WORKAROUND: `public/` static files served by the server function (AWS specific)

docs/public/architecture.png

114 KB
Loading

packages/open-next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"keywords": [],
2121
"author": "",
2222
"dependencies": {
23+
"@aws-sdk/client-lambda": "^3.234.0",
2324
"@aws-sdk/client-s3": "^3.234.0",
2425
"@node-minify/core": "^8.0.6",
2526
"@node-minify/terser": "^8.0.6",

packages/open-next/src/adapters/server-adapter.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import type {
99
} from "aws-lambda";
1010
// @ts-ignore
1111
import NextServer from "next/dist/server/next-server.js";
12-
import { loadConfig, setNodeEnv } from "./util.js";
12+
import { generateUniqueId, loadConfig, setNodeEnv } from "./util.js";
1313
import { isBinaryContentType } from "./binary.js";
1414
import { debug } from "./logger.js";
1515
import type { PublicFiles } from "../build.js";
1616
import { convertFrom, convertTo } from "./event-mapper.js";
17+
import { WarmerEvent, WarmerResponse } from "./warmer-function.js";
1718

1819
setNodeEnv();
1920
setNextjsServerWorkingDirectory();
@@ -25,7 +26,8 @@ const publicAssets = loadPublicAssets();
2526
setNextjsPrebundledReact(config);
2627
debug({ nextDir });
2728

28-
// Create a NextServer
29+
// Generate a 6 letter unique server ID
30+
const serverId = `server-${generateUniqueId()}`;
2931
const requestHandler = new NextServer.default({
3032
hostname: "localhost",
3133
port: Number(process.env.PORT) || 3000,
@@ -42,8 +44,17 @@ const requestHandler = new NextServer.default({
4244
/////////////
4345

4446
export async function handler(
45-
event: APIGatewayProxyEventV2 | CloudFrontRequestEvent | APIGatewayProxyEvent
47+
event:
48+
| APIGatewayProxyEventV2
49+
| CloudFrontRequestEvent
50+
| APIGatewayProxyEvent
51+
| WarmerEvent
4652
) {
53+
// Handler warmer
54+
if ("type" in event) {
55+
return formatWarmerResponse(event);
56+
}
57+
4758
debug("event", event);
4859

4960
// Parse Lambda event and create Next.js request
@@ -164,3 +175,12 @@ function formatAPIGatewayFailoverResponse() {
164175
function formatCloudFrontFailoverResponse(event: CloudFrontRequestEvent) {
165176
return event.Records[0].cf.request;
166177
}
178+
179+
function formatWarmerResponse(event: WarmerEvent) {
180+
console.log(event);
181+
return new Promise((resolve) => {
182+
setTimeout(() => {
183+
resolve({ serverId } satisfies WarmerResponse);
184+
}, event.delay);
185+
});
186+
}

packages/open-next/src/adapters/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export function setNodeEnv() {
66
process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
77
}
88

9+
export function generateUniqueId() {
10+
return Math.random().toString(36).slice(2, 8);
11+
}
12+
913
export function loadConfig(nextDir: string) {
1014
const filePath = path.join(nextDir, "required-server-files.json");
1115
const json = fs.readFileSync(filePath, "utf-8");
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
2+
import type { Context } from "aws-lambda";
3+
import { generateUniqueId } from "./util.js";
4+
const lambda = new LambdaClient({});
5+
const FUNCTION_NAME = process.env.FUNCTION_NAME!;
6+
const CONCURRENCY = parseInt(process.env.CONCURRENCY!);
7+
8+
export interface WarmerEvent {
9+
type: "warmer";
10+
warmerId: string;
11+
index: number;
12+
concurrency: number;
13+
delay: number;
14+
}
15+
16+
export interface WarmerResponse {
17+
serverId: string;
18+
}
19+
20+
export async function handler(_event: any, context: Context) {
21+
const warmerId = `warmer-${generateUniqueId()}`;
22+
console.log({
23+
event: "warmer invoked",
24+
functionName: FUNCTION_NAME,
25+
concurrency: CONCURRENCY,
26+
warmerId,
27+
});
28+
29+
// Warm
30+
const ret = await Promise.all(
31+
Array.from({ length: CONCURRENCY }, (_v, i) => i).map((i) => {
32+
try {
33+
return lambda.send(
34+
new InvokeCommand({
35+
FunctionName: FUNCTION_NAME,
36+
InvocationType: "RequestResponse",
37+
Payload: Buffer.from(
38+
JSON.stringify({
39+
type: "warmer",
40+
warmerId,
41+
index: i,
42+
concurrency: CONCURRENCY,
43+
delay: 75,
44+
} satisfies WarmerEvent)
45+
),
46+
})
47+
);
48+
} catch (e) {
49+
console.error(`failed to warm up #${i}`, e);
50+
// ignore error
51+
}
52+
})
53+
);
54+
55+
// Print status
56+
const warmedServerIds: string[] = [];
57+
ret.forEach((r, i) => {
58+
if (r?.StatusCode !== 200 || !r?.Payload) {
59+
console.error(`failed to warm up #${i}:`, r?.Payload?.toString());
60+
return;
61+
}
62+
const payload = JSON.parse(
63+
Buffer.from(r.Payload).toString()
64+
) as WarmerResponse;
65+
warmedServerIds.push(payload.serverId);
66+
});
67+
console.log({
68+
event: "warmer result",
69+
sent: CONCURRENCY,
70+
success: warmedServerIds.length,
71+
uniqueServersWarmed: [...new Set(warmedServerIds)].length,
72+
});
73+
}

packages/open-next/src/build.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ export async function build() {
2929
// Generate deployable bundle
3030
printHeader("Generating bundle");
3131
initOutputDir();
32+
createAssets();
3233
createServerBundle(monorepoRoot);
3334
createImageOptimizationBundle();
34-
createAssets();
35+
createWarmerBundle();
3536
if (process.env.OPEN_NEXT_MINIFY) {
3637
await minifyServerBundle();
3738
}
@@ -245,6 +246,32 @@ function createServerBundle(monorepoRoot: string) {
245246
injectMiddlewareGeolocation(outputPath, packagePath);
246247
}
247248

249+
function createWarmerBundle() {
250+
console.info(`Bundling warmer function...`);
251+
252+
// Create output folder
253+
const outputPath = path.join(outputDir, "warmer-function");
254+
fs.mkdirSync(outputPath, { recursive: true });
255+
256+
// Build Lambda code
257+
// note: bundle in OpenNext package b/c the adatper relys on the
258+
// "serverless-http" package which is not a dependency in user's
259+
// Next.js app.
260+
esbuildSync({
261+
entryPoints: [path.join(__dirname, "adapters", "warmer-function.js")],
262+
external: ["next"],
263+
outfile: path.join(outputPath, "index.mjs"),
264+
banner: {
265+
js: [
266+
"import { createRequire as topLevelCreateRequire } from 'module';",
267+
"const require = topLevelCreateRequire(import.meta.url);",
268+
"import bannerUrl from 'url';",
269+
"const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));",
270+
].join(""),
271+
},
272+
});
273+
}
274+
248275
async function minifyServerBundle() {
249276
console.info(`Minimizing server function...`);
250277
await minifyAll(path.join(outputDir, "server-function"), {

0 commit comments

Comments
 (0)