ニフティのN1! Machine Learning Product Engineer 中村です。
最近はRustを書いていて、TerraformとRustの組み合わせでの知見がネット上にないなと思ったので書き残します。
TerraformでLambdaのデプロイを完了させる
業務利用からプライベート開発に至るまで、自分自身はLambdaのデプロイは多くをTerraformで完結させています。
Terraformにアプリケーションコードを含めたくないという声があるのは知っているのですが、Lambdaはちょっとしたコードを書くことも多く、特にプライベートの開発などにおいてはLambda1個をデプロイするためにCI/CDを整備したりするのは大袈裟すぎるなと考えています。そこで、自分自身はLambdaのアプリケーションコードもインフラの一部だと考えて、terraform applyのみでデプロイまで完結させるようにしています。
今回はその手順を記します。
ここからの手順は以下のレポジトリにコードを残していますので、こちらも参照してください。
Cargo LambdaでRustコードを準備する
Rustのコード管理にはCargo Lambdaを用います。
LambdaにRustコードをデプロイするためには、クロスコンパイルなどで適切な設定をする必要があるのですが、cargo-lambdaはそれらの設定をやりやすくしてくれます。
今回はTerraformでデプロイするので、実はcargo-lambdaを使う必要性は薄い(自前でRustのコンパイル設定などを調整すればそれでもデプロイ可能)のですが、プロジェクトのセッティングなどでも手間が少なくなるので、cargo-lambdaを使用します。
cargo lambdaのインストール
Macの場合は以下でcargo-lambdaをインストールします。
1 2 |
brew tap cargo-lambda/cargo-lambda brew install cargo-lambda |
そのほかのシステムにおいては、以下を参考にインストールしてください
https://www.cargo-lambda.info/guide/installation.html
cargo lambda new
cargo-lambdaを使ってLambda関数のテンプレートを作成します。
1 |
cargo lambda new --http rust-lambda |
今回はAWS Lambda Function URLsを使いたいので、HTTP functionを統合するようにしてテンプレートを作成します。(lambda_httpが使える状態でテンプレートが作成されます)
ビルドしてみる
作成されたフォルダに移動して、プロジェクトをビルドしてみましょう。
1 2 |
cd rust-lambda cargo lambda build --release --arm64 |
コードのコンパイルが無事に完了すれば、コードの準備は完了です。
1 |
Finished release [optimized] target(s) in 30.26s |
TerraformでLambdaをデプロイする準備をする
次にTerraformを整備していきます。今回想定しているフォルダ構成は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
. ├── rust-lambda │ ├── Cargo.lock │ ├── Cargo.toml │ ├── src │ │ └── main.rs │ └── target └── terraform ├── lambda.tf ├── lambda_archive ├── modules │ └── lambda_rust_module │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── outputs.tf └── variables.tf |
先ほどcargo-lambdaで作成したrust-lambdaフォルダと同じ階層にterraformフォルダを作成し、そこからterraform apply
を行うことでデプロイできるようにします。
Terraformの設定
それではterraformフォルダ内にファイルを作成し、インフラの設定を行なっていきます。
以下のようにTerraformを設定します。S3 Remote Stateなどが必要であれば適宜設定を追加します。AWSのプロファイルはローカルマシンなどに設定されているプロファイル名を指定してください。
terraform/variables.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
provider "aws" { region = "ap-northeast-1" shared_credentials_files = ["~/.aws/credentials"] profile = "[AWSのプロファイル名]" } terraform { required_version = ">= 1.0.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } variable "name" { default = "rust-lambda" } |
Lambdaのモジュールを作成する
RustでLambdaにデプロイするためのモジュールを作成します。このようにモジュールを作成しておくことで、Lambdaごと分離したい・Lambdaを別の用途で増やしたいという場合に手軽に増やすことができて便利です。
modulesというフォルダの中にlambda_rust_moduleフォルダを作成し、以下の記述をします。
terraform/modules/lambda_rust_module/variables.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
variable "function_name" { description = "The name of the Lambda function." type = string } variable "role" { description = "The ARN of the IAM role to be used by Lambda function." type = string } variable "environment_variables" { description = "A map of environment variables to pass to the Lambda function." type = map(string) default = {} } variable "rust_src_path" { description = "The path to the Lambda function's Rust project." type = string } variable "cargo_lambda_env_name" { description = "name in cargo lambda new [name]" type = string } variable "lambda_zip_local_path" { description = "The path where the Lambda function's zip archive will be saved." type = string } |
terraform/modules/lambda_rust_module/main.tf
1 2 3 |
output "lambda_function_url" { value = aws_lambda_function_url.this.function_url } |
terraform/modules/lambda_rust_module/variables.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# Lambdaの定義 resource "aws_lambda_function" "this" { function_name = var.function_name filename = data.archive_file.this.output_path source_code_hash = data.archive_file.this.output_base64sha256 role = var.role architectures = ["arm64"] handler = "bootstrap" runtime = "provided.al2" timeout = 30 environment { variables = var.environment_variables } } resource "aws_lambda_function_url" "this" { function_name = aws_lambda_function.this.function_name authorization_type = "NONE" } # ローカル環境でcargo-lambdaでビルドするための設定 # ファイルのsha256ハッシュを全て連結させ、連結した文字列からsha512ハッシュを計算し、差分があればビルドする resource "null_resource" "rust_build" { triggers = { code_diff = sha512(join("", [ for file in fileset(var.rust_src_path, "**/*.rs") : filesha256("${var.rust_src_path}/${file}") ])) } provisioner "local-exec" { working_dir = var.rust_src_path command = "cargo lambda build --release --arm64" } } # バイナリをzip化 data "archive_file" "this" { type = "zip" source_file = "${var.rust_src_path}/target/lambda/${var.cargo_lambda_env_name}/bootstrap" output_path = var.lambda_zip_local_path depends_on = [ null_resource.rust_build ] } |
このTerraformコードの中では、ローカル環境でビルドを行い、バイナリをzip化し、Function URLs付きのLambda関数としてデプロイするという構成が含まれています。
ファイルについてハッシュを計算することで、差分があった時のみデプロイするということを可能にしています。
Lambdaを設定する
モジュールが完成したらLambdaを作成します。
terraform/lambda.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
module "api_lambda" { source = "./modules/lambda_rust_module" function_name = "${var.name}-api" role = aws_iam_role.lambda_iam_role.arn rust_src_path = "../rust-lambda" cargo_lambda_env_name = "rust-lambda" lambda_zip_local_path = "../lambda_archive/api.zip" environment_variables = { RUST_BACKTRACE = 1 } } |
Lambdaに付与するIAM権限も同時に作成します。Lambdaから使うサービスが増えてきたら、ここの権限を追加するといいでしょう。
terraform/iam.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# lambda用Roleの設定 resource "aws_iam_role" "lambda_iam_role" { name = "${var.name}-iam-role" assume_role_policy = <<POLICY { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": "lambda.amazonaws.com" }, "Effect": "Allow", "Sid": "" } ] } POLICY } # lambda用Policyの作成 resource "aws_iam_role_policy" "lambda_access_policy" { name = "${var.name}-access-policy" role = aws_iam_role.lambda_iam_role.id policy = <<POLICY { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogStream", "logs:CreateLogGroup", "logs:PutLogEvents" ], "Resource": "*" } ] } POLICY } |
実際にデプロイを行う
terraformフォルダ内から実際にデプロイしてみましょう。
1 2 |
$ terraform init $ terraform apply |
最後に出てくるURLにアクセスして、Hello worldが表示されると完成です。
1 2 3 4 5 6 |
Apply complete! Resources: 5 added, 0 changed, 0 destroyed. Outputs: api_lambda_function_url = "<https://4wrzwdaows4h3z7vcdc2xlodou0wogpb.lambda-url.ap-northeast-1.on.aws/>" |
WebAPIを作成するためのLambdaテンプレート
Rustを使用したLambdaの場合に比較的需要があるのはWebAPIの作成だと思います。しかし、Lambdaを利用した場合にはactix-web, axum, Rocketなどのフレームワークをそのまま適用することができません。また、lambda_httpを使ったサンプルがWeb上に少なく、構築に苦労するケースが多いです。(苦労しました)
そこで以下のようにmain.rsを記述することで、Lambda上で比較的扱いやすくWebAPIを構築できるという例を載せておきます。
rust-lambda/main.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
use std::future::Future; use std::pin::Pin; use lambda_http::{Body, lambda_runtime::Error, Request, RequestExt, Response, run, service_fn}; use serde_json::{json, Value}; type LambdaResult = Result<Response<String>, Error>; fn build_response(status_code: u16, message: Value) -> LambdaResult { let response = Response::builder() .status(status_code) .header("Content-Type", "application/json") .body(message.to_string()) .map_err(Box::new)?; Ok(response) } async fn echo_query(event: Request) -> LambdaResult { let params = event.query_string_parameters(); let name = params.first("name").unwrap_or("world"); let message = json!({ "message": format!("Hello, {}!", name) }); build_response(200, message) } async fn echo_body(event: Request) -> LambdaResult { let body = event.body(); let body_str = match body { Body::Text(text) => text, _ => return build_response(400, json!({ "error": "Invalid request body" })), }; let data: Value = serde_json::from_str(body_str)?; let response = json!({ "message": "Received POST request", "data": data }); build_response(200, response) } async fn not_found() -> LambdaResult { build_response(404, json!({ "error": "Not Found" })) } async fn hello_world() -> LambdaResult { build_response(200, json!({ "message": "Hello, world!" })) } fn route_request(event: Request) -> Pin<Box<dyn Future<Output = LambdaResult> + Send>> { Box::pin(async move { match (event.method().as_str(), event.uri().path()) { ("GET", "/hello") => hello_world().await, ("GET", "/echo") => echo_query(event).await, ("POST", "/echo") => echo_body(event).await, _ => not_found().await, } }) } #[tokio::main] async fn main() -> Result<(), Error> { run(service_fn(route_request)).await } |
rust-lambda/Cargo.toml
1 2 3 4 5 6 7 8 9 10 11 |
[package] name = "rust-lambda" version = "0.1.0" edition = "2021" [dependencies] lambda_http = "0.11.1" tokio = { version = "1", features = ["macros"] } serde_json = "1.0.115" serde = { version = "1.0.197", features = ["derive"] } |
また、axumはlambda_httpと統合して利用できるようなので、こちらを利用してもいいかもしれません。(この記事のレビュー中に社内で教わりました…!)
まとめ
今回はcargo-lambdaとTerraformを用いてRustでLambdaの環境を手軽に構築する方法について解説しました。
勉強がてらRustに触れてみたのですが、AWSサービスとの組み合わせにおいてもRustの速度は非常に高速で、学習コストは高いというデメリットはありますが、高速に動作すると言う何者にも変え難いメリットを得ることができます。(特に従量課金型のLambdaという環境においては高速に動作するというのは金銭的にもメリットがあると言えると思います)
みなさんも良きRustライフをお送りください。