To deploy a lambda, we have to set up CDK code to deploy a function and also write Rust code handle events.
First in lib/infra-stack.js
, add aws_lambda
to the destructured imports.
const { Stack, aws_dynamodb, aws_lambda } = require("aws-cdk-lib");
Then set up a new aws_lambda.Function
.
// https://aws.amazon.com/amazon-linux-2/faqs/
// AL2 holds libc 2.26
const pokemonLambda = new aws_lambda.Function(this, "PokemonHandler", {
runtime: aws_lambda.Runtime.PROVIDED_AL2,
handler: "pokemon-handler",
code: aws_lambda.Code.fromAsset("../lambdas/pokemon-api"),
memorySize: 1024,
});
The Function
constructor takes the current context as the first argument, an arbitrary name for the function as the second argument, and config options as the third.
The runtime
config option is normally where you’d put one of the officially supported language runtimes, like NODEJS_14_X
or PYTHON_3_9
. We’ll be using the underlying operating system that these build on, AL2, since we’re shipping a custom binary. AL2 stands for Amazon Linux 2.
handler
is an arbitrary name.
code
is where we specify what code AWS will execute as the lambda. We’re going to build the Rust binary and put it in a folder we’ll name “lambdas/pokemon-api”. This will cause CDK to zip up that folder and send it to AWS when we deploy our lambda.
Finally the memorySize
is set to 1024mb, which is an ok default size. As the memory available increases, so does our available CPU.
If we run cdk diff now in the infra directory, we’ll see an error about how it can’t find our asset.
npm run cdk diff -- --profile rust-adventure-playground
Cannot find asset at /rust-adventure/aws-dynamo-pokemon-upload/lambdas/pokemon-api
This is expected as we haven’t created our Rust function yet.
Writing the Rust Function
In the root of our project, run cargo new
to create a new binary package in the crates
directory.
cargo new ./crates/pokemon-api --vcs=none
Created binary (application) `./crates/pokemon-api` package
Then we need to add a set of crates as dependencies, notably including tokio for async, lambda_runtime and aws_lambda_events for the actual function, and http, serde, and serde_json for our function response.
cargo add -p pokemon-api aws_lambda_events http lambda_runtime serde_json serde tokio
The Cargo.toml should look something like this. Remember to enable the tokio "full"
feature so we can use the macro.
[package]
name = "pokemon-api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
aws_lambda_events = "0.6.1"
http = "0.2.6"
lambda_runtime = "0.5.1"
serde = "1.0.136"
serde_json = "1.0.79"
tokio = { version = "1.17.0", features = ["full"] }
In pokemon-api/src/main.rs
we can set up a small function.
Our main
function runs when our function cold-starts, and our handler
function runs every time an event comes in, such as an http request.
use aws_lambda_events::{
encodings::Body,
event::apigw::{
ApiGatewayV2httpRequest, ApiGatewayV2httpResponse,
},
};
use http::HeaderMap;
use lambda_runtime::{service_fn, Error, LambdaEvent};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Error> {
let handler_fn = service_fn(handler);
lambda_runtime::run(handler_fn).await?;
Ok(())
}
async fn handler(
event: LambdaEvent<ApiGatewayV2httpRequest>,
) -> Result<ApiGatewayV2httpResponse, Error> {
let (_event, _context) = event.into_parts();
Ok(ApiGatewayV2httpResponse {
status_code: 200,
headers: HeaderMap::new(),
multi_value_headers: HeaderMap::new(),
body: Some(Body::Text(serde_json::to_string(
&json!({
"data": {}
}),
)?)),
is_base64_encoded: Some(false),
cookies: vec![],
})
}
Our event
is a LambdaEvent<ApiGatewayV2httpRequest>
. A LambdaEvent
is a type that allows us to specify the payload we’re expecting, while taking care of some standard lambda fields for us.
The ApiGatewayV2httpRequest
is the event we’re expecting because, as we’ll see later, we’re going to be integrating with the API Gateway service which sends us events with specific fields.
We can split the standard fields and the fields we’ve specified into two variables using event.into_parts()
. which will return our event
as well as the standard context
.
To return an api response we construct a ApiGatewayV2httpResponse
which is the response type that API Gateway is expecting from us.
Building the Rust Binary
To build our lambda binary we can run cargo build
, but this isn’t necessarily built in a way that will run on Amazon Linux 2. Building a binary to run on a mac and building a binary to run on linux will result in two different binaries.
The problem we run into most in this scenario is the “hidden” dependency on something called libc. You can think of libc as a library dependency that is already present on the operating system you’re trying to run your binary on, rather than compiled in.
So we have to compile for the right operating system and for a reasonable version of libc.
There are two ways we can achieve this:
We can skip libc entirely and use musl libc, which would be compiled into our program.
or
We can use cargo-zigbuild to target the right version of libc.
While the musl libc approach works, one critical dependency in the Rust ecosystem doesn’t work with it, so we’ll go with the cargo zigbuild approach.
Cargo Zigbuild
Make sure you’ve installed the zigbuild cargo command.
cargo install cargo-zigbuild
and install the x86_64-unknown-linux-gnu
target using rustup, which is the target we’ll use for Amazon Linux 2.
rustup target add x86_64-unknown-linux-gnu
Then, instead of cargo build
we can run cargo zigbuild
. In this case we also specify the glibc version of our target to match the glibc version on Amazon Linux 2.
We also specify --release
since we’re going to deploy this.
cargo zigbuild --target x86_64-unknown-linux-gnu.2.26 --release -p pokemon-api
You can then find the binary in ./target/x86_64-unknown-linux-gnu/release/pokemon-api
.
Make sure the lambdas/pokemon-api
directory exists because that’s where we’ve chosen to put the binary, and copy the release binary into it.
When we copy the binary into the pokemon-api
directory, we have to rename the binary to bootstrap
because that’s the file AWS will look for to boot up our lambda function.
mkdir -p lambdas/pokemon-api
cp target/x86_64-unknown-linux-gnu/release/pokemon-api lambdas/pokemon-api/bootstrap
Deploying and Testing Lambda
When diffing the changes with the new built asset, we see the lambda resource being created as well as a new service role, which includes few IAM changes to that role that allow our function to actually run.
❯ npm run cdk diff -- --profile rust-adventure-playground
> infra@0.1.0 cdk
> cdk "diff" "--profile" "rust-adventure-playground"
Stack InfraStack
IAM Statement Changes
┌───┬────────────┬────────┬────────────┬────────────┬─────────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼────────────┼────────┼────────────┼────────────┼─────────────┤
│ + │ ${PokemonH │ Allow │ sts:Assume │ Service:la │ │
│ │ andler/Ser │ │ Role │ mbda.amazo │ │
│ │ viceRole.A │ │ │ naws.com │ │
│ │ rn} │ │ │ │ │
└───┴────────────┴────────┴────────────┴────────────┴─────────────┘
IAM Policy Changes
┌───┬──────────────────────────────┬──────────────────────────────┐
│ │ Resource │ Managed Policy ARN │
├───┼──────────────────────────────┼──────────────────────────────┤
│ + │ ${PokemonHandler/ServiceRole │ arn:${AWS::Partition}:iam::a │
│ │ } │ ws:policy/service-role/AWSLa │
│ │ │ mbdaBasicExecutionRole │
└───┴──────────────────────────────┴──────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Resources
[+] AWS::IAM::Role PokemonHandler/ServiceRole PokemonHandlerServiceRoleF58AC6D6
[+] AWS::Lambda::Function PokemonHandler PokemonHandlerC37D7DE3
We don’t yet have a URL that we can use to invoke our lambda, but using the AWS CLI lambda invoke function, we can trigger the lambda manually if we have the name of the function.
Note that because we’ve set the event we’re expecting to be
aws lambda invoke --function-name InfraStack-PokemonHandlerC37D7DE3-Wk07o9gAXaiO output.txt --profile rust-adventure-playground
{
"StatusCode": 200,
"FunctionError": "Unhandled",
"ExecutedVersion": "$LATEST"
}
To send a full working example, save the following example json in a file called sample-payload.json
.
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/my/path",
"rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value",
"cookies": ["cookie1", "cookie2"],
"headers": { "header1": "value1", "header2": "value1,value2" },
"queryStringParameters": {
"parameter1": "value1,value2",
"parameter2": "value"
},
"requestContext": {
"accountId": "123456789012",
"apiId": "api-id",
"authentication": {
"clientCert": {
"clientCertPem": "CERT_CONTENT",
"subjectDN": "www.example.com",
"issuerDN": "Example issuer",
"serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1",
"validity": {
"notBefore": "May 28 12:30:02 2019 GMT",
"notAfter": "Aug 5 09:36:04 2021 GMT"
}
}
},
"authorizer": {
"jwt": {
"claims": { "claim1": "value1", "claim2": "value2" },
"scopes": ["scope1", "scope2"]
}
},
"domainName": "id.execute-api.us-east-1.amazonaws.com",
"domainPrefix": "id",
"http": {
"method": "POST",
"path": "/my/path",
"protocol": "HTTP/1.1",
"sourceIp": "IP",
"userAgent": "agent"
},
"requestId": "id",
"routeKey": "$default",
"stage": "$default",
"time": "12/Mar/2020:19:03:58 +0000",
"timeEpoch": 1583348638390
},
"body": "Hello from Lambda",
"pathParameters": { "parameter1": "value1" },
"isBase64Encoded": false,
"stageVariables": { "stageVariable1": "value1", "stageVariable2": "value2" }
}
and pass it in as the --payload
parameter. Note that while file://
should work... due to encoding differences on different platforms, you may need to use fileb://
to specify the payload json file.
aws lambda invoke --function-name InfraStack-PokemonHandlerC37D7DE3-Wk07o9gAXaiO --payload fileb://sample-payload.json output.txt --profile rust-adventure-playground
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
The response from the function will get saved in output.txt
, which should look like this.
❯ cat ./output.txt
{"statusCode":200,"headers":{},"multiValueHeaders":{},"body":"{\"data\":{}}","isBase64Encoded":false,"cookies":[]}