Rustで書いたコードをAWS Lambdaにデプロイする

| 11 min read
Author: noboru-kudo noboru-kudoの画像

最近のRustの勢いはホントにすごいですね。新サービス・プロダクトの実装言語としてRustを採用したというニュースをあちこちで聞くようになりましたね。
この勢いだとシステムプログラミングの領域だけでなく、エンタープライズ系のシステムにもRustの快進撃が波及してきそうです。

今更感ありますが、筆者もRustaceanになるべくRustのキャッチアップを始めたところです。
最近はバックエンド処理にAWS Lambdaを使うことが多いので、手始めにRustがAWS Lambdaでも動くのかを試してみました。

現時点で、AWS Lambdaのランタイム環境としてRustはネイティブサポートされていません。
とはいえ、Lambdaのカスタムランタイムを使えばRustも動作するはずです。
カスタムランタイムはセットアップが面倒ですが、以下を使えば簡単に実現できそうです。

cargo-lambdaは、Rust向けのLambdaランタイム(aws-lambda-rust-runtime)を使った構成をサポートするCargoサブコマンド群を提供しています。

今回はこれらのツールを使って、RustのコードをLambda関数としてAWSにデプロイしてみたいと思います。

cargo-lambdaをインストールする

#

まずはcargo-lambdaをインストールします[1]

brew tap cargo-lambda/cargo-lambda
brew install cargo-lambda
# cargo-lambdaのバージョンチェック
cargo lambda --version

上記はmacOSのインストール手順です。それ以外の環境は公式ドキュメントを参照してください。
ここでは、現時点で最新のv0.17.2をインストールしました。

RustのLambdaパッケージを作成する

#

cargo-lambdaを使ってLambda関数のテンプレートを作成します。
以下のコマンドを実行します。

# API Gateway(REST)向けのプロジェクト
cargo lambda new sample-rust-lambda --http-feature=apigw_rest

--http-feature=apigw_restオプションで、API Gateway(REST API)経由のLambda構成になります[2]
コマンド実行が終わると、sample-rust-lambdaというディレクトリに以下のRustパッケージ(バイナリクレート)が作成されます。

.
├── Cargo.toml
└── src
    └── main.rs

src/main.rsがLambda関数本体です。
なお、src/bin/*.rsという形の複数バイナリクレートのパッケージにすれば、複数Lambdaを1パッケージで作成できます。

ここで生成されたソースコードは、以下のようになっていました。

use lambda_http::{run, service_fn, Body, Error, Request, RequestExt, Response};

/// This is the main body for the function.
/// Write your code inside it.
/// There are some code example in the following URLs:
/// - https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/examples
async fn function_handler(_event: Request) -> Result<Response<Body>, Error> {
// Extract some useful information from the request

// Return something that implements IntoResponse.
// It will be serialized to the right response event automatically by the runtime
let resp = Response::builder()
.status(200)
.header("content-type", "text/html")
.body("Hello AWS Lambda HTTP request".into())
.map_err(Box::new)?;
Ok(resp)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
// disable printing the name of the module in every log line.
.with_target(false)
// disabling time is handy because CloudWatch will add the ingestion time.
.without_time()
.init();

run(service_fn(function_handler)).await
}

main関数がLambdaの初期化処理、function_handler関数がイベントハンドラになります。

HTTP以外のイベントを処理するLambda関数

HTTP以外の場合はイベントタイプ(--event-type)を指定すると、各種イベントに対応したパッケージが作成できます。

例えば、S3イベントでトリガーするLambda関数を作成する場合は以下のようになります。

cargo lambda new s3-rust-lambda --event-type=s3::S3Event

また、パラメータを指定しなければ、対話形式で出力するパッケージのタイプを選択できます。
それ以外に、カスタムテンプレートから作成もできます。詳細は以下公式ドキュメントを参照してください。

RustでサンプルのLambda関数を実装する

#

RustでLambda関数を実装してみます。
今回はHTTPリクエストボディ(JSON)の値をDynamoDBに保存する関数を作ってみます。

まずは、Cargoで必要なライブラリを追加します。

cargo add aws_sdk_dynamodb aws-config serde uuid --features uuid/v4

src/main.rsは、以下のように変更しました。

use std::env;
use std::fmt::Debug;
use lambda_http::{run, service_fn, Body, Error, Request, Response};
use aws_sdk_dynamodb::Client;
use aws_sdk_dynamodb::model::AttributeValue;
use lambda_http::aws_lambda_events::serde_json;
use uuid::Uuid;

#[derive(serde::Deserialize, serde::Serialize, Debug)]
struct User {
name: String,
age: u16
}

/// Lambdaイベントハンドラ
async fn function_handler(db_client: &Client, event: Request) -> Result<Response<Body>, Error> {
let json = std::str::from_utf8(event.body()).expect("illegal body");
tracing::info!(payload = %json, "JSON Payload received");
let user = serde_json::from_str::<User>(json).expect("parse error");

let user_id = Uuid::new_v4();
let dynamo_req = db_client.put_item()
.table_name(env::var("USER_TABLE").expect("env(USER_TABLE) not found"))
.item("user_id", AttributeValue::S(user_id.to_string().into()))
.item("name", AttributeValue::S(user.name))
.item("age", AttributeValue::N(user.age.to_string()));

tracing::info!(user_id = ?user_id,"Sending request to DynamoDB...");
let result = dynamo_req.send().await.expect("dynamodb error");
tracing::info!(result = ?result, "DynamoDB Output");
let resp = Response::builder()
.status(200)
.header("content-type", "text/plain")
.body(user_id.to_string().into())
.map_err(Box::new)?;
Ok(resp)
}

/// Lambda初期化処理
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing::info!("Initializing lambda function(Cold Start)...");
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.without_time()
.init();

let client = Client::new(&aws_config::load_from_env().await);
tracing::info!(client = ?client, "Created DynamoDB");

run(service_fn(|event| async {
function_handler(&client, event).await
})).await
}

本題ではないので詳細省きますが、以下のことをしています。

  • 初期化処理(main関数) - トレーシング情報初期化 / DynamoDBクライアント作成
  • イベントハンドラ(function_handler関数) - リクエストボディ(JSON)をUser構造体にパース / DynamoDBに保存

実際のLambda関数へのビルドは、cargo-lambdaのコマンドを実行します。

cargo lambda build --release

Cargoのリリースビルド(--release)が実行され、target/lambda配下にLambdaカスタムランタイムのバイナリが出力されます。

cargo-lambda ビルド結果

これをLambdaに配置してあげれば良さそうです。
Lambdaへのデプロイもcargo-lambdaのコマンドで実行できます。

cargo lambda deploy

AWSコンソールからも、以下のようにデプロイされたことを確認できました。

AWS console - Lambda単体

AWS CDKを使ってデプロイする

#

先程cargo-lambdaを使って、Rustで作成したLambda関数をビルドし、AWS環境へのデプロイまで簡単にできました。
とはいえ、API GatewayやDynamoDBといった関連リソースを作成していないため、そのままでは動作しません。

実運用を見据えると、cargo-lambdaでLambda単体をデプロイするのではなく、IaCツールで関連リソースと一緒にまとめて構成管理したいところです。

調べてみると、cargo-lambdaでAWS CDKのコンストラクトが提供されていました。

こちらを使ってみます。事前に先程cargo lambda deployで作成したLambdaは、手動で削除しておきます。
ここではRustパッケージ内に、以下コマンドでAWS CDKプロジェクトを作成します。なお、AWS CDKはセットアップ済みであることを前提としています[3]

mkdir cdk && cd cdk
# TypeScriptのappタイプで作成
cdk init app --language typescript
# cargo-lambdaのコンストラクト導入。ここでは現時点で最新の`0.0.6`
npm install cargo-lambda-cdk

生成されたsample-rust-lambda/cdk/lib/cdk-stack.tsに、AWSリソースの構成を記述していきます。

import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Effect } from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { RustFunction } from 'cargo-lambda-cdk';

export class CdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

const stage = this.node.tryGetContext('stage');

// DynamoDBテーブル
const userTable = new dynamodb.Table(this, 'UserTable', {
tableName: `user-table-${stage}`,
partitionKey: {
type: AttributeType.STRING,
name: 'user_id'
},
billingMode: BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.DESTROY,
});

// Lambda実行ロール
const role = new iam.Role(this, 'RustLambdaRole', {
roleName: 'MyRustLambdaRole',
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
inlinePolicies: {
UserTablePut: new iam.PolicyDocument({
statements: [new iam.PolicyStatement({
actions: ['dynamodb:PutItem'],
effect: Effect.ALLOW,
resources: [`arn:aws:dynamodb:${this.region}:${this.account}:table/user-table-*`]
})]
})
}
});

// cargo-lambda: Rust Lambda関数
const func = new RustFunction(this, 'sample-rust-lambda', {
manifestPath: '../Cargo.toml',
functionName: 'sample-rust-lambda',
description: 'Sample Rust Lambda Function',
environment: {
USER_TABLE: userTable.tableName
},
role
});

// API Gateway(エンドポイント: POST /user)
const api = new apigateway.RestApi(this, 'MyRustAPI', {
deployOptions: {
stageName: stage
}
});
api.root.addResource('user')
.addMethod('POST', new apigateway.LambdaIntegration(func));

// CFn Outputs
new cdk.CfnOutput(this, 'LambdaName', {
value: func.functionName
});
new cdk.CfnOutput(this, 'ApiEndpoint', {
value: api.urlForPath('/user') // APIエンドポイント出力
});
}
}

少し長いですが、記述内容は自明だと思います。

真ん中くらいにあるRustFunctionが、cargo-lambdaが提供するコンストラクトです。
これがcargo-lambdaのビルドを実行し、CloudFormation経由でLambdaにデプロイします。

それ以外は、AWS CDKで提供されている各コンストラクトを使ってAWSリソース全体の構成を記述しています。

1パッケージ内に複数Lambdaがある場合(複数バイナリクレート)

Rustパッケージを複数バイナリクレート構成にして複数Lambdaをデプロイする場合は、デプロイする数分だけRustFunctionを記述します。
このとき第2引数のパッケージ名にはバイナリクレート名を入れます。

例えば、/userでPOST/GETのエンドポイント(Lambda)を用意する場合は、以下のようになります。

// Lambda: POST /user, バイナリクレート: /src/bin/post-user.rs
const postUserFn = new RustFunction(this, 'post-user', {
manifestPath: '../Cargo.toml',
functionName: 'sample-rust-lambda-post-user',
description: 'User Creation Lambda Function',
environment: {
USER_TABLE: userTable.tableName
},
role
});
// Lambda: GET /user, バイナリクレート: /src/bin/get-user.rs
const getUserFn = new RustFunction(this, 'get-user', {
manifestPath: '../Cargo.toml',
functionName: 'sample-rust-lambda-get-user',
description: 'User Retrieval Lambda Function',
environment: {
USER_TABLE: userTable.tableName
},
role
});
// API Gateway
const api = new apigateway.RestApi(this, 'MyRustAPI', {
deployOptions: {
stageName: stage
}
});
const user = api.root.addResource('user')
user.addMethod('POST', new apigateway.LambdaIntegration(postUserFn));
user.addMethod('GET', new apigateway.LambdaIntegration(getUserFn));

後はデプロイするだけです。

cdk deploy --context stage=dev

実行が終わると、RustのLambdaに加えて、API GatewayやDynamoDB等の関連するリソースがデプロイされます。
管理コンソールからLambdaの状態を確認してみます。

AWS Console - Lambda CDK

カスタムランタイムとしてLambdaがデプロイされている様子が分かります。
また、これのイベントソースとしてAPI Gatewayも紐付けられています。ここでは掲載しませんがDynamoDBのテーブルももちろん作成されています。

デプロイ時に出力されたAPI Gatewayのエンドポイント(ApiEndpoint)にcurlでアクセスしてみます。

curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello -d '{"name": "mamezou", "age": 40}'
> 06d84b8e-7d7c-4088-8bfa-87329501ce3e

期待通りユーザーIDとしてランダムなUUIDが返却されました。

AWSコンソールよりDynamoDBを見ると該当レコードが追加されていることも分かります。

AWS Console - DynamoDB

CloudWatchでLambdaの実行ログも確認しておきます。

AWS Console - CloudWatch Logs

いつも通り初期化(Init)フェーズ、実行(Invoke)フェーズそれぞれのログが確認できます。

まとめ

#

今回はcargo-lambdaを使って、Rustで書いたコードをLambdaで動かしてみました。
Lambdaのカスタムランタイムを使っていますが、まるでネイティブサポートされているかのように実装からデプロイまでスムーズにできた感触があります。

Rustの現状の人気を見ると、Lambdaのランタイムとしてサポートされるのは時間の問題とは思いますが、現時点でも十分に実用的な感じがしました。


  1. もちろん、Rust自体は事前にこちらで準備しておきます。 ↩︎

  2. 具体的には、lambda_httpクレートのフィーチャーフラグに影響していました。 ↩︎

  3. 未セットアップの場合は、AWS CDKの公式ドキュメントを参照してください。セットアップ後はcdk bootstrapでCDKの実行に必要なリソースを事前に配置しておく必要があります。 ↩︎

豆蔵デベロッパーサイト - 先週のアクセスランキング
  1. ChatGPTのベースになった自然言語処理モデル「Transformer」を調べていたら「Hugging Face」に行き着いた (2023-03-20)
  2. ChatGPTに自然言語処理モデル「GPT2-Japanese」の使用方法を聞きながら実装したら想像以上に優秀だった件 (2023-03-22)
  3. 基本から理解するJWTとJWT認証の仕組み (2022-12-08)
  4. AWS認定資格を12個すべて取得したので勉強したことなどをまとめます (2022-12-12)
  5. 自然言語処理初心者が「GPT2-japanese」で遊んでみた (2022-07-08)
  6. Nuxt3入門(第8回) - Nuxt3のuseStateでコンポーネント間で状態を共有する (2022-10-28)
  7. Nuxt3入門(第4回) - Nuxtのルーティングを理解する (2022-10-09)
  8. 直感が理性に大反抗!「モンティ・ホール問題」 (2022-07-04)
  9. Nuxt3入門(第1回) - Nuxtがサポートするレンダリングモードを理解する (2022-09-25)
  10. ORマッパーのTypeORMをTypeScriptで使う (2022-07-27)