Running arbitrary code on a schedule is a very common use case. A common way of doing that is by using cron jobs. But how would you do that in the cloud (specifically AWS) without having to run a Linux instance or something similar?
What We Are Going to Build
One way of doing that is by leveraging Amazon EventBridge and Lambda. We will be creating a reusable AWS CDK construct that deploys a Lambda which will be executed on a configurable schedule. We will use that construct to deploy an example Lambda which fetches a quote from a 3rd-party API and logs it to the console. This is how it looks like:
You can find the finished example on GitHub.
đĄ I'll add some tips that are optional and just reflect the way I like doing stuff. They are formatted like this paragraph.
Creating the Construct
We'll create the Lambda part of the construct first. We'll be getting to the EventBridge part after that. The construct will be called ScheduledLambda
.
But first of all, let's get started by initializing the project using aws-cdk
and creating the file for the construct by executing the following commands:
npx aws-cdk init app --language typescript
touch lib/scheduled-lambda.ts
đĄ I always use
npx aws-cdk
instead of installing the CLI globally. That way I always use the most recent version of the CLI. I don't mind the little extra time it takes because I don't initialize projects that often.
Adding the Lambda
I default to use NodejsFunction
instead of Lambda
because it comes with some handy additional features. It uses esbuild
to transpile and bundle the Lambda code. That way you can use TypeScript and install dependencies using a dedicated package.json
without worrying about anything. I love it! We'll use node-fetch
later on in the example in order to make use of it.
đĄ I install
esbuild
as a dev dependency. Without having that installed, a Docker container containing esbuild will be used which takes more time and space.
You can read more about NodejsFunction
in the docs.
You can install the required dependencies by executing:
npm install @aws-cdk/aws-lambda @aws-cdk/aws-lambda-nodejs
npm i -D @esbuild
Let me show you the code for the construct with just the Lambda part. I'll go through it step by step after that.
// lib/scheduled-lambda.ts
import * as cdk from '@aws-cdk/core';
import {Construct} from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import {NodejsFunction, NodejsFunctionProps} from "@aws-cdk/aws-lambda-nodejs";
import {LogGroup, LogGroupProps} from '@aws-cdk/aws-logs';
interface LambdaProps extends NodejsFunctionProps {
// name is determined by the lambda created, it should not be changed
logGroupProps?: Omit<LogGroupProps, "logGroupName">;
}
interface ScheduledLambdaProps {
lambdaProps?: LambdaProps;
}
const defaultLambdaProps: Partial<NodejsFunctionProps> = {
handler: 'handler',
memorySize: 128,
runtime: lambda.Runtime.NODEJS_14_X,
}
export class ScheduledLambda extends cdk.Construct {
lambda: NodejsFunction;
constructor(scope: Construct, id: string, props?: ScheduledLambdaProps) {
super(scope, id);
this.lambda = this.createLambda(props?.lambdaProps);
}
private createLambda(lambdaProps?: ScheduledLambdaProps["lambdaProps"]) {
const lambda = new NodejsFunction(this, "Lambda", {
...defaultLambdaProps,
...(lambdaProps ?? {}),
});
new LogGroup(this, 'LogGroup', {
// this name makes it replace the default log group
logGroupName: '/aws/lambda/' + lambda.functionName,
...(lambdaProps?.logGroupProps ?? {})
});
return lambda;
}
}
As you can see it is defining the ScheduledLambda
construct by extending from cdk.Construct
. The constructor
takes some custom props, which are optional because NodejsFunction
doesn't require any props due to reasonable defaults.
đĄ I prefer to define some defaults just to make them obvious and keep them stable just in case any default will change in the future. In this case, it does not make any difference because my defaults are exactly the same being used by
NodejsFunction
.đĄ I always create a
LogGroup
alongside any Lambda. That is why I added the optionallogGroupProps
to theLambdaProps
. Why am I doing that? As a default, there will be a Log Group automatically created for you. But the logs will never expire and the Log Group will not be deleted when destroying the stack. I get more control over the Log Group by creating it myself. The Log Group is associated with the Lambda by using the name of the Lambda prefixed with'/aws/lambda/'
as thelogGroupName
.
The Lambda and the associated Log Group are created by calling the createLambda
method and passing lambdaProps
. Also, we assign the Lambda to the public property lambda
which makes the Lambda accessible outside of the construct.
Add the Scheduling Functionality
As already mentioned, we'll be using an EventBridge Rule that is running on a schedule. First of all, we have to install the required dependencies:
npm i @aws-cdk/aws-events @aws-cdk/aws-events-targets
There are three different ways of creating a schedule: using an expression, a rate or cron (learn more). Here are the three static factory methods for creating a schedule from the @aws-cdk/aws-events
source code:
// @aws-cdk/aws-events/lib/schedule.ts
export declare abstract class Schedule {
/**
* Construct a schedule from a literal schedule expression.
*
* @param expression The expression to use.
* @stability stable
*/
static expression(expression: string): Schedule;
/**
* Construct a schedule from an interval and a time unit.
*
* @stability stable
*/
static rate(duration: Duration): Schedule;
/**
* Create a schedule from a set of cron fields.
*
* @stability stable
*/
static cron(options: CronOptions): Schedule;
/**
* Retrieve the expression for this schedule.
*
* @stability stable
*/
abstract readonly expressionString: string;
/**
* @stability stable
*/
protected constructor();
}
We'll let the user of the construct pass an instance of Schedule
. That way it is up to the user to decide about how to describe the schedule. Here is the finished construct:
import * as cdk from '@aws-cdk/core';
import {Construct} from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import {NodejsFunction, NodejsFunctionProps} from "@aws-cdk/aws-lambda-nodejs";
import * as events from "@aws-cdk/aws-events";
import * as targets from "@aws-cdk/aws-events-targets";
import {LogGroup, LogGroupProps} from '@aws-cdk/aws-logs';
interface LambdaProps extends NodejsFunctionProps {
// name is determined by the lambda created
logGroupProps?: Omit<LogGroupProps, "logGroupName">;
}
// no targets, because the lambda is the only target
interface RuleProps extends Omit<events.RuleProps, "targets"> {
// schedule is always required instead of optional
schedule: events.Schedule;
}
interface ScheduledLambdaProps {
lambdaProps?: LambdaProps;
ruleProps: RuleProps;
}
const defaultLambdaProps: Partial<NodejsFunctionProps> = {
handler: 'handler',
memorySize: 128,
runtime: lambda.Runtime.NODEJS_14_X,
}
export class ScheduledLambda extends cdk.Construct {
lambda: NodejsFunction;
constructor(scope: Construct, id: string, props: ScheduledLambdaProps) {
super(scope, id);
this.lambda = this.createLambda(props.lambdaProps);
this.scheduleLambda(props.ruleProps);
}
private createLambda(lambdaProps?: ScheduledLambdaProps["lambdaProps"]) {
const lambda = new NodejsFunction(this, "Lambda", {
...defaultLambdaProps,
...(lambdaProps ?? {}),
});
new LogGroup(this, 'LogGroup', {
// this name makes it replace the default log group
logGroupName: '/aws/lambda/' + lambda.functionName,
...(lambdaProps?.logGroupProps ?? {})
});
return lambda;
}
private scheduleLambda(ruleProps: ScheduledLambdaProps["ruleProps"]) {
const rule = new events.Rule(this, 'Schedule', ruleProps);
rule.addTarget(new targets.LambdaFunction(this.lambda));
}
}
We narrow down the type of the RuleProps
. We want to make it mandatory to pass a schedule
and not allow the user to define targets
because the Lambda should be the only target.
In the scheduleLambda
method, we instantiate the rule and assign the previously created Lambda as a target to it. That's it. The construct is ready for being used. That is what we'll be doing next in the example.
Using the Construct
Okay, let's use the construct. As mentioned in the beginning, we'll be creating a Lambda fetching a random quote from an API. First of all, we'll create the files for the Lambda and install node-fetch
:
mkdir -p lambdas/random-quote
cd lambdas/random-quote
npm init -y
npm install node-fetch
touch index.ts
Alright, next up create the Lambda:
// lambdas/random-quote/index.ts
import fetch from "node-fetch";
type Quote = {
_id: string;
tags: string[];
content: string;
author: string;
authorSlug: string;
length: number;
dateAdded: string;
dateModified: string;
}
export const handler = async () => {
const randomQuoteResponse = await fetch("https://api.quotable.io/random");
const quote = await randomQuoteResponse.json() as Quote; // using unknown + type guard would be more bullet proof
console.log(quote.content);
}
Nothing special here. We don't need any information about the event
or context
. Therefore we don't pass any arguments to the handler
.
This is how you use the construct in order to create the underlying resources:
// lib/scheduled-lambda-stack.ts
import * as path from "path";
import * as cdk from '@aws-cdk/core';
import {Duration, RemovalPolicy} from '@aws-cdk/core';
import {ScheduledLambda} from "./scheduled-lambda";
import * as events from "@aws-cdk/aws-events";
import {RetentionDays} from "@aws-cdk/aws-logs";
export class ScheduledLambdaStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new ScheduledLambda(this, "DailyRandomQuote", {
lambdaProps: {
entry: path.join(__dirname, "..", "lambdas", "random-quote", "index.ts"),
logGroupProps: {
removalPolicy: RemovalPolicy.DESTROY,
retention: RetentionDays.ONE_WEEK,
}
},
ruleProps: {
schedule: events.Schedule.rate(Duration.minutes(5))
}
});
}
}
Make sure to pass the correct entry
prop pointing to the Lambda. If you want to name your handler function used in the Lambda differently, you must also pass the handler
alongside the entry
.
đĄ Note how I pass the
removalPolicy
and theretention
to theScheduledLambda
constructor
. That way we don't clutter our AWS account as previously mentioned in the tip about the Log Group.
In this example, we invoke the Lambda every 5min by using the Schedule.rate
factory function. That is it. Simple API, right? Let's build and deploy:
# in the project root
npm run build
npx cdk deploy
âšī¸
npx cdk deploy
will deploy the stack to the account you are currently logged in. You can learn here about how to log in using the AWS CLI.
That is it. The example will be deployed and you can see the random quotes in the AWS Console under CloudWatch -> Log groups.
â ī¸ Make sure to destroy the stack afterwards using
npx cdk destroy
in order to remove the resources. Otherwise, the Lambda will be executed every 5min.
Conclusion
It is relatively straightforward to create a reusable construct in order to schedule the execution of a Lambda. You could also publish the construct and make it available for other or other projects of yourself. (Probably there is something similar available somewhere?).
I personally use exact this pattern to buy some cryptos every week. The use cases for executing code on a schedule are almost endless.
Top comments (0)