みなさんこんにちは、ニフティ株式会社新卒 1 年目の中井です。突然ですが皆さん、業務の新しいバッチ処理を Rust で書いてみようと思ったことはありませんか?ありますよね。私も入社して半年の 9 月に達成しました!!わーい!
実は元々ニフティ社内で Rust が使われていたわけではありません。それなのになぜ入社してこんなにすぐ Rust を導入することができたのかというと、他にも Rust を導入しようと企んでいる先輩がすでにオープン社内 Rust 勉強会を開いてくださっていたからなんです。そこで学んだ知識も活かして、今回はぜひ Rust を書きたいと主張して書かせていただきました!
以前は退勤を失敗して対策を練ったりしていましたが、今回は今風な技術を使ってみたというお話です。やらかしてばかりでもないのです。
Rust とは
Rust という名前だけは聞いたことがある方もいらっしゃるのではないでしょうか。自分も数々のストリーマーがギャンブルに興じている動画を見たことがあります。いえ、そちらではなくて、プログラミング言語の Rust です。
公式サイトが https://www.rust-lang.org/ にあるのですが、簡単にいうと、「速い」「安全な」そして「使いやすい」言語です。言語の習得がちょ〜〜っとだけ難しいという話もありますが、エコシステムとしては本当に完成度が高いです。
- Cargo というパッケージマネージャー兼ビルダーの存在
- Rust Analyzer という随一のエディタ支援機能
Rust の良さは無限に語れてしまうのですが、本題ではないので、とりあえず最強の言語があるということだけ覚えてください。
お題: 画像を S3 から S3 へ移動させる Lambda をつくれ
ニフティには AWS の練習用アカウントを用意して月 100 ドルまで使わせてくれる実弾演習場という制度があります。ということで、Rust でどうやって Lambda を書いていくのか、小さいアプリケーションを使って実際にデプロイしてみましょう。
…待ってください、たしかにこれだけだとただの暇を持て余した人の遊びみたいな感じですが、この Lambda 部分で画像を加工してみたり、文章を要約してみたり、いろいろ夢がひろがるじゃないですか!
Cargo Lambda と Terraform を組み合わせる
作るものは決まったとして、問題はデプロイ方法です。
Lambda を zip ファイルに固めて terraform apply
する、という方法なら比較的簡単です。ただ自動化が大変ですし、そもそも毎回 Terraform を実行するのは怖いです。何かのタイミングでインフラを壊しそうで…。
かといって、世の中には SAM とか Serverless Framework とかいうものもあるとは聞きますが、難しそうなので何も理解していません。
今回は Cargo Lambda を採用します。これは Cargo という Rust のパッケージマネージャーを拡張して、Lambda 用のコマンドを多数増やしてくれるツールです。
https://www.cargo-lambda.info/
これを使うと、デプロイまでの流れはこんな感じ。簡単なので私でも使えます。
cargo lambda new
でプロジェクトを作成cargo lambda build
で Lambda 用のバイナリをビルドcargo lambda deploy
で AWS 上にデプロイ
本当に Lambda 単体がただ動けばよいだけならこれだけで OK です。最低限の IAM ロールを含め、全てをデプロイしてくれます。
ただし今回は、インフラ側は Terraform で管理した上で、うまく Cargo Lambda と組み合わせることにしました。というのも、どうせ S3 が必要になるし、また S3 へのアクセス権など IAM ロール自体の調整もあるからです。すなわち….
- Terraform で S3 や IAM ロール、ダミーの仮の Lambda まで作成してしまう
- Cargo Lambda でホンモノの Lambda を上書きする
これで、Lambda を含めたインフラ全体を Terraform で管理しつつ、日常的な Lambda のデプロイには Terraform を利用しない形にできます。
…ちなみに私は AWS 初心者なので、他にもっといい方法がある気がしています。よければ教えてください。やっぱり SAM 勉強したほうが良いですか?
実際につくってみる
何はともあれ、実際に作ってみましょう。ちなみに完成品はこちらにおいておきます。
Terraform のメインファイルを書く
まずはお決まりのやつです。AWS を使いたいので aws
プロバイダを指定します。
1 2 3 4 5 6 7 8 9 10 |
provider "aws" { region = "ap-northeast-1" shared_credentials_files = ["~/.aws/credentials"] profile = "{your-aws-profile}" default_tags { tags = { managed_by = "terraform" } } } |
main.tf
S3 のバケットやイベントとの連携を用意する
次に S3 関連の設定を書いてしまいます。今回は入力・出力用に 2 つのバケットを用意します。また、Put イベントで Lambda を呼び出すように設定します。{your-prefix-}
には、他の人とぶつからなさそうな、自分だけの好きな文字列を入れてください。S3 の名前は全世界で重複しない必要があるらしいので、私が作った S3 バケットと衝突してエラーになってしまいます。
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 |
# 入力側 resource "aws_s3_bucket" "input" { # 名前は適当なプレフィックスを付けるなど、必ず変更すること bucket = "{your-prefix-}example-aws-terraform-rust-input" } # 出力側 resource "aws_s3_bucket" "output" { # 名前は適当なプレフィックスを付けるなど、必ず変更すること bucket = "{your-prefix-}example-aws-terraform-rust-output" } # 入力側の S3 の Put イベントで Lambda を呼び出す resource "aws_s3_bucket_notification" "put_notification" { bucket = aws_s3_bucket.input.id lambda_function { lambda_function_arn = aws_lambda_function.lambda.arn events = ["s3:ObjectCreated:Put"] } } resource "aws_lambda_permission" "allow_s3_invoke" { statement_id = "AllowS3Invoke" action = "lambda:InvokeFunction" function_name = aws_lambda_function.lambda.function_name principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.input.arn } |
s3.tf
ダミーの Lambda を Terraform で作成する
さて、ここからがトリックの 1 つ目、ダミーの Lambda の作成です。
後で本物の Lambda をデプロイすることになるので、いろいろなオプションは本物の Lambda が動く基準に合わせて作ります。
runtime
:provided.al2
handler
:bootstrap
また、後で Cargo Lambda によりコード部分を上書きするわけですが、それを後でまたダミーに書き戻されてしまっては困ります。これを避けるため、source_code_hash
を ignore_changes
に指定して、その差分を無視してもらうようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
resource "aws_lambda_function" "lambda" { function_name = "example-aws-terraform-rust" handler = "bootstrap" role = aws_iam_role.lambda_role.arn runtime = "provided.al2" filename = data.archive_file.lambda.output_path source_code_hash = data.archive_file.lambda.output_base64sha256 environment { variables = { AWS_OUTPUT_BUCKET_NAME = aws_s3_bucket.output.bucket } } lifecycle { ignore_changes = [source_code_hash] } } # ここではダミーを指定する data "archive_file" "lambda" { type = "zip" source_dir = "dummy_lambda" output_path = "archive/dummy_lambda.zip" } |
lambda.tf
そして、Terraform ではダミーの Lambda をデプロイします。
環境に provided.al2
と bootstrap
を指定しているので、その環境で動くようにダミーの方から合わせてあげる必要があります。要するにダミーには Python とかは使えないわけですが、まあダミーなので中身はなんでも大丈夫です。シェルスクリプトで bootstrap というファイルを作って、簡単にエラー終了するようにしておきました。
1 2 3 4 5 6 7 |
#!/bin/sh echo "WARNING: This is not an actual lambda!" echo "This is a placeholder during terraform setup." echo "Please replace this lambda to the actual function by cargo-lambda." exit 1 |
インフラをデプロイする
terraform apply をして、AWS 環境を確認すると…
S3 バケットあります。
ダミーの Lambda もあります。いい感じです!
この時点でイベントのセットアップなども完了しているので、バケットにファイルを入れると CloudWatch Logs にエラーが流れるはずです。
うまく動いていそうですね!
cargo lambda new で Lambda を作成
次はお待ちかね、Rust での Lambda の作成です。前述の通り、Cargo Lambda というツールを使います。
このツール、インストール方法がしっかり整備してあって、簡単に使い始めることができます。
※ 情報が古くなっている可能性もあるので、公式サイトも合わせてご確認ください。
macOS / Linux をお使いの方であれば、homebrew から簡単に入ります。
1 2 |
brew tap cargo-lambda/cargo-lambda brew install cargo-lambda |
Windows の方は scoop から入れられるらしいですね。
1 2 |
scoop bucket add cargo-lambda <https://github.com/cargo-lambda/scoop-cargo-lambda> scoop install cargo-lambda/cargo-lambda |
PC 環境を汚したくない方に向けては Docker イメージも提供されていますので、公式サイトを確認してみてください。
インストールが終われば、次は早速プロジェクトを作成していきましょう。
今回は actual_lambda
という名前でプロジェクトを作っていきます。おもむろに cargo lambda new actual_lambda
としてみてください。すると…
1 2 3 |
$ cargo lambda new actual_lambda > Is this function an HTTP function? No > AWS Event type that this function receives s3::S3Event |
画面上のいろいろと質問に答えるだけでプロジェクトが完成、最低限のボイラープレートも全部書いてくれています。なんと便利な。
ここから先は、もう普通の Rust プログラミングです。
Rust プロジェクトに AWS SDK を追加する
今回は、Lambda 内から S3 にアクセスしたいんでした。プロジェクトに AWS SDK を追加します。ちなみに Rust の AWS SDK はサービスごとに分離されています。 使いたいサービスを探し、必要なライブラリを追加してください。
今回は S3 なので、ターミナルにこんな感じで打ち込めば完了です。
1 2 |
cargo add aws-config cargo add aws-sdk-s3 |
何気なく aws-config も追加していますが、これは設定の読み込みのためのライブラリで、どのサービスを使うにしても必要なものっぽいです。
本体を Rust で書く
こんどこそ処理を編集して、実際に S3 から S3 へ画像が移動するようにしてみましょう。Rust は src/main.rs
ファイルからスタートなので、このファイルを書き換えていくことになります。
S3 から S3 へ移動させるサンプルコードを下に掲載します。
あっ、コピペしたくなりますよね。わかります。全然コピペでもいいんですけど…。
ただ、もう少しだけ時間があるなら、せっかくなので手で写経してみませんか。Rust の最強支援機能、Rust Analyzer をぜひ使ってみてください。補完から型ヒントまで、とても気持ち良く書けるんです。
VSCode をお使いの方なら、Rust Analyzer は拡張機能から簡単にインストールできます。
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 |
use std::env; use aws_config::load_from_env; use aws_lambda_events::event::s3::S3Event; use aws_sdk_s3::Client; use lambda_runtime::{run, service_fn, Error, LambdaEvent}; /// 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> /// - <https://github.com/aws-samples/serverless-rust-demo/> async fn function_handler(event: LambdaEvent<S3Event>) -> Result<(), Error> { let LambdaEvent { payload, .. } = event; for record in payload.records { let bucket = record.s3.bucket.name.unwrap(); let key = record.s3.object.key.unwrap(); let config = load_from_env().await; let client = Client::new(&config); let output_bucket_name = env::var("AWS_OUTPUT_BUCKET_NAME") .expect("failed to get output bucket name from env var"); let object = client.get_object().bucket(&bucket).key(&key).send().await?; client .put_object() .bucket(&output_bucket_name) .key(&key) .body(object.body) .send() .await?; client .delete_object() .bucket(&bucket) .key(&key) .send() .await?; } Ok(()) } #[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 } |
actual_lambda/src/main.rs
入力側 S3 からオブジェクトを取得して、出力側 S3 に書き込んで、入力側 S3 のオブジェクトを削除する、というコードになっています。
Cargo Lambda でビルドする
出来上がったら、次はビルドをしましょう。
1 2 |
cargo lambda build --release --target x86_64-unknown-linux-gnu |
--target
では、Lambda のタイプを指定します。
x86_64-unknown-linux-gnu
: いわゆる普通の Lambda です。aarch64-unknown-linux-gnu
: ちょっと安い ARM (Graviton) タイプの Lambda です。
こんなスイッチ一つ切り替えるだけでいい感じにネイティブバイナリが作れる、これも Rust のいいところだと思っています。
Cargo Lambda でデプロイする
ビルドがうまくいけば、デプロイです。
1 2 3 |
cargo lambda deploy --profile {your-aws-profile} --binary-name actual_lambda example-aws-terraform-rust |
最後の引数 example-aws-terraform-rust
は Lambda の名前です。忘れないでくださいね!これを忘れてしまうと全く新しく actual_lambda
という名前の Lambda が生成されてしまいます。そうすると IAM ロールなども自動で作成されてしまってゴミが増えるのでご注意を… (n 敗)。
ちなみにサンプルリポジトリの方には Docker を使うバージョンのコマンドを Makefile に書いてあるので、参考になるかもしれません。
さて、デプロイが完了したらもう動くようになっているはずです!
お楽しみの動作確認
入力用の S3 バケットに適当なファイルをアップロードすると….
出力用の S3 バケットに出力されました!わーい。
まとめ
今回は AWS 上に Terraform でインフラを作成し、その中の Lambda を Rust で実装していきました。
Terraform ではダミーの Lambda を作成し、Cargo Lambda を使ってデプロイすることで、インフラとコードのデプロイを分離することに成功しました。また、Rust 側のほとんどの作業は Cargo や Cargo Lambda が面倒を見てくれることも紹介しました。
どうでしょう。Rust で Lambda と聞くと難しそうな印象もあったかもしれませんが、思ったより簡単そうだなと思ってもらえたら幸いです。
ぜひ一度 Rust で書いてみてください。そして Rust を広めていきましょう!!