概要
この記事はRustでAWS Lambda開発を行うにあたっての全体プロセスのガイドです。RustでLambda関数を実装し、そのコードを実行するためのDockerコンテナイメージを作成します。次にそのイメージを自動でビルドしLambda関数を更新するCI/CDをCodePipelineで構築します。他に複数のLambda関数を一つのクレートで管理し、それらを一括でデプロイする方法についても解説します。
なぜRustでLambdaを実装するのか?
まず、Rustは素晴らしいプログラミング言語です。静的型付けによってバグの少ないコードを書くことができます。またcargoという洗練されたパッケージマネージャにより、依存関係の解決やビルドを柔軟かつストレスレスに行うことができます。
Rustで処理を実装することで(他の言語と比較して)処理時間を短く、またメモリフットプリントを小さくすることができます。Lambdaは実行時間とメモリ割当量によって課金されるため、コスト面で非常に有利です。
参考:
筆者の実感として、RustでLambda関数を実装するとほとんどの場合でメモリ割当量を最低限に設定しても問題なく処理することができます。もちろんファイルなど大きなデータをメモリ上で扱う場合はメモリ割当量を増やす必要がありますが、その場合でもRustなら所有権ルールによってメモリ割り当て/解放が明示的なのでメモリの使用量をある程度予測することができます。そのためLambda関数のメモリ割り当て量の設定が容易かつ無駄がありません。この点でもあらかじめ関数に割り当てるメモリ量を指定する必要があるLambdaの仕組みとRustは相性がいいと言えます。
さらにRustから各AWSサービスを利用するためのSDKが公式に提供されています。これによりS3やDynamoDBなどのAWSサービスを簡単に利用することができます。このAWS SDK for Rustは長らく開発途中でしたが、最近になって安定版がリリースされました。またAWSサービスの機能拡充に合わせて高頻度で更新されています。
こうした外部ライブラリなどの依存関係についても、コンパイルによってバイナリを作成するRustでは全て同梱した形でLambda関数をデプロイすることができます。PythonやNode.jsなどのスクリプト言語で開発する場合は依存ライブラリをどう含めてデプロイするかが課題となりますが、Rust(などコンパイル言語)ではそれが容易です。
Rustのコンパイラやcargoは以下のコマンドでまとめてインストールすることができます。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
以下ではそんなRustでLambda関数を開発していく手順について説明していきます。
RustでのLambda関数の実装
実装の全体像
ではRustでのLambda関数の実装の具体的な内容に入っていきます。AWS公式ドキュメントにあるRustでのLambda関数のハンドラの基本的な例は以下になります。
use lambda_runtime::{service_fn, LambdaEvent, Error};
use serde_json::{json, Value};
async fn handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
let payload = event.payload;
let first_name = payload["firstName"].as_str().unwrap_or("world");
Ok(json!({ "message": format!("Hello, {first_name}!") }))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
lambda_runtime::run(service_fn(handler)).await
}
lambda_runtimeというクレートとtokioランタイムを使ってハンドラ関数を実行します。lambda_runtimeもAWS公式のクレートで、ハンドラ関数だけでなくLambdaへの入力イベントの型定義なども提供しています。
クレートのセットアップ
では実際に手元でLambda関数を実装していきましょう。以下のコマンドで新しいクレートを作成します。
cargo new --bin rust-lambda-example
cd rust-lambda-example
次にCargo.tomlに必要なライブラリを追加していきます。先述のlambda_runtimeとaws_lambda_eventsはLambda関数の実装のために必要なクレートです。aws_lambda_eventsクレートでさまざまなサービスからの入力イベントを扱うことができます。
Lambda関数のハンドラは非同期関数となるため、それを実行するためにtokioクレートを利用します。dotenvyは環境変数を読み込むためのクレートです。serde、serde_jsonクレートはJSONと構造体の相互変換を行うために利用します。
また今回は例としてDynamoDBを利用するため、AWS SDK for Rustのクレートであるaws-configとaws-sdk-dynamodbも追加します。
[package]
name = "rust-lambda-example"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
lambda_runtime = "0.9.1"
aws_lambda_events = "0.13.1"
dotenvy = "0.15.7"
serde = { version = "1.0.196", features = ["derive"] }
serde_json = "1.0.113"
aws-config = "1.1.5"
aws-sdk-dynamodb = "1.14.0"
Lambda関数ハンドラの実装
次にLambda関数のハンドラを実装していきます。例えばEventBridgeのイベントを受け取るLambda関数を想定すると、以下のように実装することができます。aws_lambda_eventsを使うことでEventBridgeのイベントの型定義(EventBridgeEvent
)を得ることができます。この例ではイベントの時間を標準出力に出力しています。
use aws_lambda_events::eventbridge::EventBridgeEvent;
use lambda_runtime::{service_fn, LambdaEvent};
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(service_fn(lambda_handler)).await?;
Ok(())
}
async fn lambda_handler(
event: LambdaEvent<EventBridgeEvent<serde_json::Value>>,
) -> Result<(), lambda_runtime::Error> {
let event_time = event.payload.time;
println!("Event time: {:?}", event_time);
Ok(())
}
他サービスと連携せずシンプルにJSONを受け取るLambda関数を実装する場合はハンドラ関数でLambdaEvent<serde_json::Value>
を受け取るか、または独自に入力の型定義を行うこともできます。以下のようにserde::Deserialize
を実装した構造体であればハンドラの入力として利用することができます。
use lambda_runtime::{service_fn, LambdaEvent};
use serde::Deserialize;
#[derive(Deserialize)]
struct LambdaEventPayload {
message: String,
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(service_fn(lambda_handler)).await?;
Ok(())
}
async fn lambda_handler(
event: LambdaEvent<LambdaEventPayload>,
) -> Result<(), lambda_runtime::Error> {
let message = event.payload.message;
println!("{}", message);
Ok(())
}
上の例ではmain()から何も値を返していませんが、戻り値がある場合はLambda関数からのレスポンスとなります。その場合戻り値の型はserde_json::Value
となります(ResultでラップするのでResult<serde_json::Value, lambda_runtime::Error>
)。
具体的に処理を実装する場合はLambdaのハンドラ関数でその具体的な処理を行う関数をラップすることをおすすめします。
async fn lambda_handler(
event: LambdaEvent<LambdaEventPayload>,
) -> Result<(), lambda_runtime::Error> {
let payload = event.payload;
execute(payload).await?;
Ok(())
}
async fn execute(payload: LambdaEventPayload) -> Result<(), lambda_runtime::Error> {
println!("{}", payload.message);
Ok(())
}
こうすることでLambda関数のランタイムに関わらない純粋な処理内容だけをユニットテストすることができます。テストコードは以下のようになります。
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_execute_process() {
let payload = LambdaEventPayload {
message: "Hello, world!".to_string(),
};
let result = execute(payload).await;
assert!(result.is_ok());
}
}
また例えばアーキテクチャ要件が変わり、LambdaではなくECSなどで処理することになったとしてもこの関数はそのまま利用することができます。
次にDynamoDBを利用するLambda関数を実装してみましょう。以下の例ではDynamoDBのテーブルにアイテムを追加するLambda関数を実装しています。まずaws_configクレートを使って環境変数からAWSの認証情報を読み込みます。次にaws-sdk-dynamodbクレートを使ってDynamoDBのクライアントを作成し、put_itemメソッドを使ってアイテムを追加します。
use aws_config::BehaviorVersion;
use aws_sdk_dynamodb::types::AttributeValue;
use lambda_runtime::{service_fn, LambdaEvent};
use serde::Deserialize;
#[derive(Deserialize)]
struct LambdaEventPayload {
message: String,
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(service_fn(lambda_handler)).await?;
Ok(())
}
async fn lambda_handler(
event: LambdaEvent<LambdaEventPayload>,
) -> Result<(), lambda_runtime::Error> {
let message = event.payload.message;
execute(&message).await?;
Ok(())
}
async fn execute(message: &str) -> Result<(), lambda_runtime::Error> {
let aws_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
let dynamodb_client = aws_sdk_dynamodb::Client::new(&aws_config);
let output = dynamodb_client
.put_item()
.table_name("example")
.item("message", AttributeValue::S(message.to_string()))
.send()
.await?;
println!("{:?}", output);
Ok(())
}
この処理をローカル環境でテストするためには、DynamoDBにアクセスできるユーザーを作成してその認証情報を環境変数に設定する必要があります。.envファイルを作成して以下のように設定してください。
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=...
dotenvyクレートを使って.envファイルを読み込んでからユニットテストを実行します。
#[cfg(test)]
mod tests {
use super::*;
use dotenvy::dotenv;
#[tokio::test]
async fn test_execute_process() {
dotenv().ok();
let message = "Hello, world!";
let result = execute(message).await;
assert!(result.is_ok());
}
}
ビルド/コンテナイメージの作成
次にこのLambda関数を実行するためのDockerコンテナイメージを作成し、実際のAWS環境にデプロイする方法について説明します。
Dockerイメージを利用することで、Lambda関数の実行に必要なものをコンパクトにまとめてデプロイすることができます。ソースコードから作成したバイナリ以外にも、例えばCLIツールを利用する場合はそれらをインストールしたイメージを作成してそのままデプロイすることが可能です。
まずDockerfileを作成します。以下のようにマルチステージビルドを利用してビルド環境と実行環境を分けています。こうすることで実行環境にRustのコンパイラなど不要なものを含めないようにすることができます。また後述する複数のLambda関数を一つのクレートで管理する場合にも有用です。
ビルド環境はRust公式のイメージをベースイメージとしてソースコードのコピーとビルドを実行します。お手元のRustコンパイラと同じバージョンのタグを指定してください。
実行環境はpublic.ecr.aws/lambda/providedをベースイメージとしてビルドしたバイナリをコピーして作成します。lambda/providedイメージでは${LAMBDA_RUNTIME_DIR}/bootstrap
にバイナリを配置しlambda-handler
というコマンドを実行することでLambda関数を実行することができます。
FROM amd64/rust:1.73 as builder
WORKDIR /usr/src/rust-lambda-example
COPY . .
RUN cargo build --release
FROM public.ecr.aws/lambda/provided:al2023.2023.11.18.01 as rust-lambda-example
COPY --from=builder \
/usr/src/rust-lambda-example/target/release/rust-lambda-example \
${LAMBDA_RUNTIME_DIR}/bootstrap
CMD [ "lambda-handler" ]
CI/CDの構築
次にCodePipelineを使ってビルドとデプロイを自動化する方法について説明します。
CI/CDの全体像
今回構築するCI/CDの全体像は以下のようになります。CodePipelineを使ってソースコードの取得、ビルド、デプロイを自動化します。GitHubから取得したソースコードをもとにCodeBuildを使ってDockerイメージをビルドし、ECRにプッシュします。次のステージで同じくCodeBuildを使ってECRにプッシュされたイメージをLambda関数にデプロイします。
ソースコードの取得
まずCodePipelineプロジェクトを作成します。詳細な手順を以下をご覧ください。
あらかじめGitHubリポジトリを作成して上記のソースコードやDockerfile、以下で説明するbuildspec.ymlファイルをプッシュしておきます。CodePipelineをGitHubと連携して、ソースに作成したGitHubリポジトリを指定します。以下の手順でGitHubと連携させることができます。
ビルド
次にビルドステージでCodeBuildを使って上記のDockerイメージをビルドします。CodePipelineのビルドステージで「Build provider」にCodeBuildを指定して新しいプロジェクトを作成します。このプロジェクトに環境変数でECR_NAME
(000000000000.dkr.ecr.ap-northeast-1.amazonaws.com
)を設定しておきます。具体的な内容については利用しているAWSアカウントのECRドメインを参照してください。またDocker Hubのユーザー名(DOCKER_USERNAME
)とトークン(DOCKER_TOKEN
)も環境変数として設定しておきます。
Docker Hubのトークンは以下の手順で取得することができます。
これらの環境変数はbuildspec.ymlファイルで利用します。またこれらの環境変数はCodeBuildプロジェクトに設定しても、CodePipelineのビルドステージの環境変数に設定しても大丈夫です。
「Buildspec」は「Use a buildspec file」を選択しておいてください。ビルドの内容をリポジトリ内のbuildspec.ymlファイルで指示することができます。
デプロイステージについてはひとまずスキップしてCodePipelineプロジェクトの作成を完了させておいてください。
ではソースコードに戻ってビルドの内容を指示するbuildspec.ymlファイルを作成して以下の内容を記述していきます。
pre_buildフェーズではgitのコミットハッシュを取得してイメージのタグとして利用します。CodePipelineが取り込んだコミットのハッシュはCODEBUILD_RESOLVED_SOURCE_VERSION
という環境変数に格納されています。
buildフェーズではDockerイメージをビルドします。まずDockerイメージをpullするためにDocker Hubにログインします。次にDockerfileを使ってイメージをビルドします。マルチステージビルドを使って--target
で実行環境のイメージを指定して作成します。作成するイメージにはECRの名前をプレフィックスとするイメージ名とlatest
およびコミットハッシュをタグとしたイメージ名を付けます。具体的には000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/rust-lambda-example:latest
といったイメージ名になります。これがLambdaにデプロイする際に必要なイメージURIとなります。
post_buildフェーズではECRにイメージをプッシュします。aws ecr get-login-password
コマンドを使ってECRのログインパスワードを取得し、docker login
コマンドでログインします。次にdocker push
コマンドでbuildフェーズでビルドしたイメージをプッシュします。
version: 0.2
phases:
pre_build:
commands:
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH}
build:
commands:
- docker login --username $DOCKER_USERNAME --password $DOCKER_TOKEN
- docker build
--target rust-lambda-example
-t ${ECR_NAME}/rust-lambda-example:latest
-t ${ECR_NAME}/rust-lambda-example:${IMAGE_TAG}
-f Dockerfile .
post_build:
commands:
- aws ecr get-login-password --region $AWS_DEFAULT_REGION |
docker login --username AWS --password-stdin $ECR_NAME
- docker push ${ECR_NAME}/rust-lambda-example:latest
- docker push ${ECR_NAME}/rust-lambda-example:${IMAGE_TAG}
実行する前にECRリポジトリ(rust-lambda-example
)を作成しておく必要があります。またCodeBuildプロジェクトに付与されたIAMロールにECRへのアクセス権限を付与しておいてください。細かい権限管理が必要ないならマネージドポリシーであるAmazonEC2ContainerRegistryPowerUserを付与しておくと良いでしょう。
ここまでできたら一度CodePipelineを実行してみてください。ビルドが実行され、ECRにイメージがプッシュされるはずです。
デプロイ
次にECRにプッシュされたイメージからLambda関数を作成します。AWS CLIコマンドが利用できる環境で以下のコマンドでLambda関数を作成します。IAMロールにはLambdaの実行権限を持った適当なロールを指定してください。
aws lambda create-function \
--function-name rust-lambda-example \
--package-type Image \
--code ImageUri=000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/rust-lambda-example:latest \
--role arn:aws:iam::000000000000:role/... \
--region ap-northeast-1
最後にLambda関数を更新するためのCodeBuildプロジェクトを作成します。まずはデプロイ処理の内容を指示するdeploylambda.ymlファイルを作成して以下の内容を記述していきます。
ビルドステージと同様にpre_buildフェーズでコミットハッシュを取得し、buildフェーズでLambda関数を更新します。aws lambda update-function-code
コマンドを使ってECRにプッシュされたイメージをLambda関数にデプロイします。--image-uri
オプションでイメージのURIを指定します。
version: 0.2
phases:
pre_build:
commands:
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH}
build:
commands:
- aws lambda update-function-code
--function-name rust-lambda-example
--image-uri ${ECR_NAME}/rust-lambda-example:${IMAGE_TAG}
CodePipelineに新しいステージを追加します。このステージの「Input artifacts」には「Source Artifact」を、「Action provider」CodeBuildを指定して新しくプロジェクトを作成します。新しく作成したCodeBuildプロジェクトのBuildspecファイル名はdeploylambda.ymlを指定します。またビルドステージと同様にECR_NAME環境変数を設定しておきます。
このCodeBuildプロジェクトのIAMロールにはLambda関数を更新するための権限を付与しておく必要があります。細かい権限管理が必要ないならマネージドポリシーのAWSLambda_FullAccessポリシーを付与しておくと良いでしょう。
複数のLambda関数の実装
一つのプロジェクトで複数のLambda関数を実装したい場面は多くあります。その場合には一つのクレートの中で複数のバイナリを作成することで共通のコードを再利用することができます。Rustで一つのクレートに複数のバイナリを作成するためには、クレートのソースコードの中にbinディレクトリを作成しその中にmain関数を記述したファイルを配置します。今回の場合はそれぞれにLambda関数のハンドラを実装します。
クレートのディレクトリ構成は以下のようになります。
src/
├── bin/
│ ├── lambda1.rs
│ └── lambda2.rs
├── .env
├── main.rs
├── Cargo.lock
└── Cargo.toml
cargo build
コマンドを実行するとそれぞれのファイルに対応したバイナリが作成されます。Dockerイメージ作成ではマルチステージビルドを使ってbuilderステージで以下のように全てのバイナリを一括でビルドします。次にそれぞれのLambda関数に対応した実行環境のイメージを作成します。builderステージでビルドしたバイナリをそれぞれの実行環境のイメージにコピーします。
FROM amd64/rust:1.73 as builder
WORKDIR /usr/src/rust-lambda-example
COPY . .
RUN cargo build --release
FROM public.ecr.aws/lambda/provided:al2023.2023.11.18.01 as rust-lambda-example-1
COPY --from=builder \
/usr/src/rust-lambda-example/target/release/lambda1 \
${LAMBDA_RUNTIME_DIR}/bootstrap
CMD [ "lambda-handler" ]
FROM public.ecr.aws/lambda/provided:al2023.2023.11.18.01 as rust-lambda-example-2
COPY --from=builder \
/usr/src/rust-lambda-example/target/release/lambda2 \
${LAMBDA_RUNTIME_DIR}/bootstrap
CMD [ "lambda-handler" ]
CodeBuildでは以下のようにそれぞれのDockerイメージをビルドとプッシュを行います。docker build
コマンドではDockerのキャッシュ機能によって一度ビルドしたイメージが再利用されるため、ソースコードを複数回コンパイルする必要なく各イメージを作成することができます。
version: 0.2
phases:
pre_build:
commands:
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH}
build:
commands:
- docker login --username $DOCKER_USERNAME --password $DOCKER_TOKEN
- docker build
--target rust-lambda-example-1
-t ${ECR_NAME}/rust-lambda-example-1:latest
-t ${ECR_NAME}/rust-lambda-example-1:${IMAGE_TAG}
-f Dockerfile .
- docker build
--target rust-lambda-example-2
-t ${ECR_NAME}/rust-lambda-example-2:latest
-t ${ECR_NAME}/rust-lambda-example-2:${IMAGE_TAG}
-f Dockerfile .
post_build:
commands:
- aws ecr get-login-password --region $AWS_DEFAULT_REGION |
docker login --username AWS --password-stdin $ECR_NAME
- docker push ${ECR_NAME}/rust-lambda-example-1:latest
- docker push ${ECR_NAME}/rust-lambda-example-1:${IMAGE_TAG}
- docker push ${ECR_NAME}/rust-lambda-example-2:latest
- docker push ${ECR_NAME}/rust-lambda-example-2:${IMAGE_TAG}
Lambda関数の更新についても以下のようにdeploylambda.ymlファイルにコマンドを追加してそれぞれのイメージを更新します。ただしLambda関数が作成されていない場合はあらかじめ作成しておく必要があります。CodePipelineの実行を一度ビルドステージで停止しておいて、ECRにイメージをプッシュしてからLambda関数を作成しておきましょう。その後にデプロイステージを実行すると正しく動作します。
version: 0.2
phases:
pre_build:
commands:
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH}
build:
commands:
- aws lambda update-function-code
--function-name rust-lambda-example-1
--image-uri ${ECR_NAME}/rust-lambda-example-1:${IMAGE_TAG}
- aws lambda update-function-code
--function-name rust-lambda-example-2
--image-uri ${ECR_NAME}/rust-lambda-example-2:${IMAGE_TAG}
まとめ
この記事ではRustでのLambda開発の全体像を説明しました。Rustの特長を活かしながらLambda関数を実装することができ、またDockerやCodePipelineを利用することでデプロイも容易に可能です。さらにAWS SDK for Rustの存在によって各種AWSサービスとの連携もスムーズに行うことができます。ぜひLambda開発にRustを取り入れてみてください。