🚄

AWS SDK for Rust & CDK for Terraform on AWS (CDKTF) を試してみた

2022/12/22に公開

はじめに

私は今年の 9 月にログラスに入社しました。
これまで、アプリ開発に加えて、大きなデータのハンドリング、それを行うための基盤(インフラ)整備、データベース関連全般(設計、構築、運用、チューニングなど)、データ分析、コンテナ活用などに、研究開発と実務の両面から関わってきましたが、現在は社内横断的に高品質で速いプロダクト開発と運用を支援するため、インフラ(AWS)、セキュリティ、運用監視などの環境整備を中心にやっています。世の中でいうところの SRE に近い分野と言えますが、SQL のチューニングなどのアプリ開発に近いこともして、アプリ開発とインフラリソースをつなげる役割を果たそうとしています。

ログラスのバックエンドアプリでは、Kotlin が使われています。私は入社前から、Kotlin と同様に静的型付け言語である Rust に関心を持ち、「コンセプトで理解するRust」(技術評論社)という本を出版させていただきました。
また、私は AWS のサービスを学習するのが楽しくてたまらず、その学習の効果測定の結果として AWS 認定資格全12冠(2020年12月時点)を達成してしまいました。
そんな Rust が好き、AWSサービスが好き、IaC (Infrastructure As Code) が好きな私が試してみたくなるような AWS SDK for RustCDK for Terraform on AWS (CDKTF) が発表されています。
AWS SDK for Rust はまだ「開発者プレビュー」なので本番運用での使用は推奨されていませんが、正式リリースされることを見据え、また、インフラ管理から Rust を使える可能性が探ることも含め、AWS SDK for Rust を使った簡単な Lambda 関数をつくってみました。その Lambda 関数を、必要なリソースとともに CDKTF を使って AWS にデプロイしてみました。

この記事は、そのお試し記録をまとめたものです。
なお、この記事で示すコードはあくまでもサンプルであり、当方の環境でのみ、動作確認をしたものです(そのサンプルについて、いかなる責任も負いません)。
当方の環境は次の通りです。これらのソフトウェアのインストール方法は、各ツールのドキュメントをご覧ください。

OS: MacOS (M1)

$ rustc --version
rustc 1.65.0 (897e37553 2022-11-02)

$ cargo --version
cargo 1.65.0 (4bc8f24d3 2022-10-20)

$ cargo lambda --version
cargo-lambda 0.13.0 (bde228c 2022-11-29Z)

$ terraform --version
Terraform v1.3.6
on darwin_arm64

$ cdk --version
2.53.0 (build 7690f43)

$ cdktf --version
0.14.1

取り上げる題材

この記事では、AWS 上に 2 つの S3 バケット(src, dst)を用意し、src バケットにファイルがアップロードされたら、そのイベントから起動される Lambda 関数を通じて dst バケットにコピーをする、という簡単な仕組みを構築してみます。

使う道具

Rust

Rust は最近注目度が上がってきているプログラミング言語であり、公式ページでは次の特徴が挙げられています。

  • パフォーマンス

    • Rustは非常に高速でメモリ効率が高くランタイムやガベージコレクタがないため、パフォーマンス重視のサービスを実装できますし、組込み機器上で実行したり他の言語との調和も簡単にできます。
  • 信頼性

    • Rustの豊かな型システムと所有権モデルによりメモリ安全性とスレッド安全性が保証されます。さらに様々な種類のバグをコンパイル時に排除することが可能です。
  • 生産性

    • Rustには優れたドキュメント、有用なエラーメッセージを備えた使いやすいコンパイラ、および統合されたパッケージマネージャとビルドツール、多数のエディタに対応するスマートな自動補完と型検査機能、自動フォーマッタといった一流のツール群が数多く揃っています。

特に、「静的型付け言語」であること、そして「所有権モデル」によってメモリ安全性とスレッド安全性の確保されており、実行速度、メモリ効率に優れた実行モジュールを開発できることが大きな特徴です。

AWS Lambda

AWS Lambda は「サーバーレスのイベント駆動型のコンピューティングサービス」です。AWS サービスからの様々なイベント(たとえば、API Gateway からのリクエストイベント、S3 にファイルがアップロードされたイベント、時刻によるイベントなど)から起動されて、処理を実行することができます。自前でサーバーを用意せずに(サーバーレス)、プログラムを実行できることが大きな利点です。
AWS Lambda を使うためにはイベントから起動される関数(Lambda 関数)を開発します。その関数の実行時間(最大 15 分)とメモリ確保量、実行回数によって課金されますが、非常に安価[1]であり、自前でサーバーを用意してプログラムを実行するよりも AWS Lambda を用いることで大幅にコストを削減することができた、という話をよく聞きます。

AWS Lambda は実行時間、メモリ確保量によって課金が変わるため、高速でメモリ効率がよいプログラムほど低コストで実行することができます。そのため、AWS Lambda と Rust は非常に相性がよいと考えており、以前に同じ処理を実施する AWS Lambda の関数を複数のプログラミング言語で実装して性能評価をしたことがあります[2]。実際に、メモリ使用量、実行時間も短さとバラツキの小ささを確認できました。

AWS SDK

AWS のリソースの操作は、REST API によって行われます(AWS マネジメントコンソールでポチポチやっている裏では、REST API がコールされています)。その REST API のコールをプログラミング言語を使ってできるようにするためのライブラリが AWS SDK (Software Development Kit)です。
ライブラリが提供されているプログラミング言語としては、Python, JavaScript (TypeScript も使用可), Go, Java, Ruby などがあり[3]、2021年12月に Rust, Kotlin, Swift が開発者プレビューとして AWS から発表されています。

AWS Lambda によって AWS のリソースの操作をする場合には、Lambda 関数の実装に AWS SDK を使用します。

AWS SDK for Rust

Rust 向けの AWS SDK は、AWS から提供される以前に非公式の SDK として rusoto と呼ばれるものが開発されており、長い歴史を持っています。先に紹介した AWS Lambda を複数のプログラミング言語で実装したときの性能評価でも、 rusoto を使って Rust の AWS Lambda を実行していました。

そのような中で、AWS から Rust 向けの公式の SDK として発表されたのが、AWS SDK for Rust です。
後述のように、rusoto では入力パラメータの型のインスタンスにパラメータをセットして、そのインスタンスをメソッドに渡していましたが、AWS SDK for Rust では入力パラメータの値をメソッドチェーンで設定する Fluent Builder のスタイルが採用されています。

Rust で AWS Lambda の関数を作る方法

cargo lambda でローカルのプロジェクトを作成する

Rust のビルドツールおよびパッケージマネージャである cargo の拡張としてcargo-lambdaが開発されています。
これを導入すると、cargo のサブコマンドである cargo lambda を使い、Lambda 関数開発のためのプロジェクトの作成(main.rs に Lambda 関数向けのテンプレートを作ってくれます)、ビルド、デプロイができます。
詳細は aws-lambda-rust-runtime の GitHubをご覧ください。

cargo lambda を用いて、Lambda 関数を作成するためのプロジェクトを作ってみます。
cargo lambda のインストールができたら、プロジェクトを作成します。プロジェクトの名前は sdk_s3 としておきます。

$ cargo lambda new sdk_s3

これを実行すると、いくつか質問がされます。
まず、これは HTTP 関数 (API Gateway などからトリガーされる関数)であるかを聞かれるので、今回は'N'とします。
そうすると、次にどのようなイベントを Lambda 関数が受け取るのかを聞かれるので、↓でスクロールさせて、 s3::S3Event を選択します。
これで終わりです。そうすると、sdk_s3 というディレクトリが作成されて、その中に Cargo.tomlsrc/main.rs が作成されます。
src/main.rs には次のようなテンプレートが作成されています。

src/main.rs
use aws_lambda_events::event::s3::S3Event;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> {
    // Extract some useful information from the request

    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
}

このテンプレートの中の function_handler の部分に Lambda 関数のロジックを記述していけば Lambda 関数を作ることができます。main の部分は変更する必要がありません。
なお、aws_lambda_events::event::s3::S3Event も自動的に追加され、function_handler の引数 event の型に使われていますが、Cargo.toml には aws_lambda_events は自動追加されていなかったので、自分で追加する必要がありました。

Lambda 関数のメインロジックを書く

それでは、src バケットにオブジェクト(ファイル)が作成されたイベントを受け取って dst バケットにコピーするという、Lambda 関数のメインのロジックを実装していきましょう。
rusoto と AWS SDK for Rust の間のコードの違いを見るため、両方のライブラリを使って記述してみます。
両者とも次のように実装します。

  • S3 の API として、CopyObjectを使います。
    • この API の必須パラメータは次の 3 つです。
      • Bucket: コピー先のバケット名(文字列)
      • Key: コピー先のキー名(文字列)
      • x-amz-copy-source: コピー元のバケット名とキー名を / で連結したもの(文字列)
  • S3 バケットにオブジェクト(ファイル)が作成されたときに発生するイベントは次のようなものであり[4](一部をマスク処理)、Lambda 関数のハンドラの引数に渡されます。
{
   "Records":[
      {
         "eventVersion":"2.1",
         "eventSource":"aws:s3",
         "awsRegion":"ap-northeast-1",
         "eventTime":"2022-12-10T14:38:59.098Z",
         "eventName":"ObjectCreated:Put",
         "userIdentity":{
            "principalId":"XXXXXXXXXXX"
         },
         "requestParameters":{
            "sourceIPAddress":"XXXXXXXXXXX"
         },
         "responseElements":{
            "x-amz-request-id":"XXXXXXXXXXX",
            "x-amz-id-2":"XXXXXXXXXXX"
         },
         "s3":{
            "s3SchemaVersion":"1.0",
            "configurationId":"XXXXXXXXXXX",
            "bucket":{
               "name":"s3-event-test",
               "ownerIdentity":{
                  "principalId":"XXXXXXXXXXX"
               },
               "arn":"arn:aws:s3:::s3-event-test"
            },
            "object":{
               "key":"Cargo.toml",
               "size":911,
               "eTag":"XXXXXXXXX",
               "sequencer":"XXXXX"
            }
         }
      }
   ]
}
  • このイベントは、s3-event-test という S3 バケットに、Cargo.toml というキーを持つオブジェクトが作成されたときに発行されたイベントです。Records は配列になっていて、複数のオブジェクトが記述される可能性があります。

  • (サンプルの趣旨を確保しつつコードを短くするため)エラーハンドリングは省略しています (unwrap() を使っており、エラーが発生した場合には panic になります)

rusoto を使う

まずは rusoto です。冒頭の use の部分と、function_handler は次のようになります(main はテンプレートで作成されたものをそのまま利用可)。

main.rs
use aws_lambda_events::event::s3::S3Event;
use aws_sdk_s3::Client;
use lambda_runtime::{run, service_fn, Error, LambdaEvent};
use rusoto_core::Region;
use rusoto_s3::{CopyObjectRequest, S3Client, S3};

async fn function_handler(event: LambdaEvent<S3Event>) -> Result<(), Error> {
    let client = S3Client::new(Region::ApNortheast1);
    let dest_bucket = std::env::var("DEST_BUCKET").expect("DEST_BUCKET is not set");
    let results = event.payload.records.into_iter().map(|rec| {
        let source_bucket = rec.s3.bucket.name.unwrap();
        let key = rec.s3.object.key.unwrap();
        let request = CopyObjectRequest {
            copy_source: String::new() + &source_bucket + "/" + &key,
            bucket: dest_bucket.clone(),
            key,
            ..Default::default()
        };
        client.copy_object(request)
    });
    for rr in results {
        let _result = rr.await.unwrap();
    }

    Ok(())
}

あわせて Cargo.toml も示しておきます。

Cargo.toml
[package]
name = "rusoto_s3"
version = "0.1.0"
edition = "2021"

[dependencies]

lambda_runtime = "0.7"
aws_lambda_events = "^0.5.0"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }

rusoto_core = "0.48.0"
rusoto_s3 = "0.48.0"
tokio = { version = "1", features = ["full"] }

クレート aws_lambda_events::event::s3::S3Event によって、S3 から渡されるイベントのスキーマが型として実装されています。event.payload がイベントの内容(上で示した JSON)に対応していて、型として定義されているので、あとはエディタの補完機能も活用して、簡単にイベントの詳細(records の配列要素の s3.bucket.name, s3.object.keyなど)を取り出すことができます。

Records の配列のそれぞれの要素(上のコードの rec に対応)について、CopyObjectRequest 型 のインスタンスを作成し(bucket, key, copy_source をデフォルトから上書き)、そのインスタンスを client.copy_object() に渡しています。

なお、コピー先の S3 バケットは環境変数 DEST_BUCKET で与えるようにして、key はコピー元のものをそのまま使っています。

このように、入力パラメータのインスタンスを作成してパラメータを埋めて、それを API に対応するメソッドに渡すという記述方法は、Go 言語、JavaScript、Kotlin などの AWS SDK と共通しています。

AWS SDK for Rust を使う

それでは、同じ機能を AWS SDK for Rust を使って書いてみます。

main.rs
use aws_lambda_events::event::s3::S3Event;
use aws_sdk_s3::Client;
use lambda_runtime::{run, service_fn, Error, LambdaEvent};

async fn function_handler(event: LambdaEvent<S3Event>) -> Result<(), Error> {
    let shared_config = aws_config::load_from_env().await;
    let client = Client::new(&shared_config);
    let dest_bucket = std::env::var("DEST_BUCKET").expect("DEST_BUCKET is not set");
    let results = event.payload.records.into_iter().map(|rec| {
        let source_bucket = rec.s3.bucket.name.unwrap();
        let key = rec.s3.object.key.unwrap();
        client
            .copy_object()
            .bucket(&dest_bucket)
            .key(&key)
            .copy_source(String::new() + &source_bucket + "/" + &key)
            .send()
    });
    for rr in results {
        let _result = rr.await.unwrap();
    }

    Ok(())
}
Cargo.toml
[package]
name = "sdk_s3"
version = "0.1.0"
edition = "2021"

lambda_runtime = "0.7"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }
aws_lambda_events = "^0.5.0"
aws-config = "0.51.0"
aws-sdk-s3 = "0.21.0"
tokio = { version = "1", features = ["full"] }

イベントから情報を取り出す部分は、上の rusoto で記述したコードから変わりありません。
AWS SDK for Rust では、入力データに値を設定するのはclient.copy_object()のメソッドになっており(Fluent Builder スタイル)、入力データのインスタンスを作成する過程はユーザーからは隠蔽されています。
このような Fluent Builder スタイルによる記述方法は、Java の AWS SDK と共通しています。
rusoto と AWS SDK for Rust で記述スタイルは異なりますが、上のような違いがあることがわかってしまえば、rusoto からの移行はスムーズにできるのではないかと思います。

なお、rusoto では、CopyObjectRequest 型のドキュメントで Option型になっていないパラメータは必須、Option型になっているパラメータは必須ではない、ということが読み取れたのですが、AWS SDK for Rust の client.copy_object()のドキュメントからは、どれが必須パラメータであるかが読み取れません。どのパラメータが必須であるかは、AWS の API のドキュメント(CopyObject の API ドキュメントであればこちら)を確認する必要がありそうです。

以下では、AWS SDK for Rust で記述されたこの Lambda 関数をビルド、デプロイしていきます。

cargo lambda で Lambda 関数をビルドする

それでは、記述したコードから Lambda 関数の実行モジュールをビルドします。
次のコマンドを実行すれば、Rust のコードのコンパイルが実行され、target/lambda/sdk_s3/bootstrap というファイルが生成されます。これが Lambda 関数の実行モジュールです。

$ cargo lambda build --release --arm64

このコマンドでは、リリース向け(最適化がされている)に arm アーキテクチャで実行するための実行モジュールを作成します。
cargo lambda はクロスコンパイル(コンパイラが動作している環境(OS, CPU)とは異なる環境向けの実行モジュールを作成すること)が可能であり、--arm64 の代わりに --x86-64 を指定すれば、x86 アーキテクチャ向けの実行モジュールを作成することができます。

なお、ビルドの際に次のようなメッセージが出ることがあります。

run pkg_config fail: "pkg-config has not been configured to support cross-compilation.

Install a sysroot for the target platform and configure it via
PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_PATH, or install a
cross-compiling wrapper for pkg-config and set it via
PKG_CONFIG environment variable."

その際には Cargo.toml[dependencies]に次の記述をすることで回避できることがあります。

openssl = { version = "0.10.35", features = ["vendored"] }

こんなに簡単に Rust で Lambda 関数が書けて、ビルドまでできてしまうのです!!
さらに、Terraform を使えば、簡単にこの Lambda 関数をデプロイできます。

S3 バケットを作成して、Lambda 関数をデプロイする

次に、必要な AWS のリソースを構築します。

IaC のためのツール

CloudFormation, AWS CDK

私は AWS リソースの IaC (Infrastructure As Code)のためのツールとして、AWS のプロダクトである CloudFormationAWS CDK を使ってきました。
CloudFormation は YAML または JSON のテンプレートでリソースを記述します。また、AWS CDK は CloudFormation よりもリソースをより抽象化して、また、TypeScript, Python などのプログラミング言語によってリソースを記述し、そのコードから CloudFormation のテンプレートを生成するものです。
CloudFormation では、必要なすべてのリソースを明示的に記述する必要がありますが、AWS CDK の L2 コンストラクタを使うと、短いコードの記述で必要な複数のリソースを記述することができます。

たとえば、AWS のコンテナサービス ECS 上でコンテナをデプロイできる環境を作成するためには、ECS クラスタ、VPC, EC2 インスタンス、ロードバランサ、IAM ロールやポリシー、Auto Scaling グループなどのリソースが必要ですが、AWS CDK の L2 コンストラクタを用いると、わずか数行の記述でこれらのリソースを構築することができます[5]

また、CloudFormation のリソースに対応したより低水準のコンストラクタは「L1 コンストラクタ」と呼ばれており、L2 コンストラクタで対応していないこと、または L2 コンストラクタの想定が自分が構築したいものと異なるときには、L2 コンストラクタを使うことを諦め、L1 コンストラクタでリソースを記述することはよくあります。

AWS CDK のコードを静的型付け言語である TypeScript を使って記述すると、型のチェックが行われるとともに、エディタの補完機能が使えるので、効率よくコードを書くことができました。

Terraform

AWS リソースの IaC ツールとして Terraform があることは知ってはいましたが、特に移行する必要性がなく、これまで触らずじまいでした。

そんな中、ログラスに入社してから、ログラスでは Terraform を使っていたため、Terraform の学習を始めました。
そして、私は Terraform 推しへと変わりました。
「実践Terraform AWSにおけるシステム設計とベストプラクティス」の執筆者で Terraform の経験が豊富な野村友規さんがログラスの技術顧問として参画してくださっていること[6]も、私のTerraform への習熟の後押しになりました。)

CloudFormation や AWS CDK と比べて Terraform がよいと思ったところは次の点です。

「状態」が手元にある

CloudFormation (CloudFormation のテンプレートに変換する AWS CDK を含む)や Terraform のいずれも「宣言的」です。すなわち、「状態」とコードなどによるリソースの記述を比較し、その差分を埋めるようにリソースの操作が行われます。

CloudFormation では「状態」は AWS 側にあり、ユーザーが直接見ることができません。そのため、リソースの操作に失敗した原因の調査が難しくなることがあります。
一方、Terraform では「状態」は JSON で記述されたファイルとして手元にあり、何が起こっているかの追跡がしやすく、(推奨はされませんが)「状態」を自分で編集してしまうことも可能です。
加えて、Terraform は GitHub にソースコードがあるので、いざとなればソースコードレベルで挙動を追跡することが可能です。

失敗したらサービスに影響を与えるようなリソースの操作が必要になることがありますが、ブラックボックスになっている部分がない、という安心感が大きいです。

既存のリソースの情報を簡単に取得できる

リソースが IaC で管理されていない場合に、ある一部分から IaC 化を進めたい、と言う場面があります。そのような場合に、Terraform では データソース (data ブロック)を用いて、既存のリソースの情報を取得し、その情報を使って、リソースを記述するということが簡単にできます。

CloudFormation には既存のリソースの情報を動的に取得できる仕組みがないため、既存の IaC 化されていないリソースに対して CloudFormation で操作をする場合には、AWS のリソースの ID である ARN などを明示的に与える必要がありました。AWS CDK では、一部のリソースで既存リソースの情報の取得に対応していることもありますが、それができるリソースは限定されています。また、AWS CDK のコードの中で AWS SDK の関数を用いれば既存リソースの情報取得をすることができますが、AWS SDK の習熟が必要になります。

既存リソースのインポートが簡単

Terraform で管理されていないリソースを Terraform の「状態」に簡単にインポートすることができます。
terraform import コマンドを用いれば、そのリソースの情報を Terraform の「状態」に追加してくれます。terraform import を実行したあとに、その「状態」を見ながら terraform plan で差分が出ないようにそのリソースのコードを書けば Terraform で管理されたリソースにすることができます。
terraform import コマンドの引数の指定方法はリソースによって異なるのでドキュメントをご覧ください)

CloudFormation にも既存のリソースをインポートする機能はありますが、そのリソースの「状態」をテンプレートで記述しておく必要があります。「状態」が見えない中で、テンプレートを埋めなければいけない作業は大変なことがあります。

Terraform によるS3 バケットの構築や Lambda 関数のデプロイ

まず、Terraform を使って、S3 バケットの構築や Lambda 関数のデプロイをやってみます。

上で Lambda 関数を作るために作成した sdk_s3 というディレクトリと同じレベルに terraform というディレクトリを作成して、その中に main.tf という Terraform のコードを次のように記述しました。

main.tf
# (1) 2 つのバケットの名前を指定
locals {
  src_bucket_name = "s3-event-test-src-tf"
  dst_bucket_name = "s3-event-test-dst-tf"
}

# (2) provider を指定
## terraform の場合は archive は省略可能
provider "aws" {
  region = "ap-northeast-1"
}

provider "archive" {}

# (3) src バケットを作る
resource "aws_s3_bucket" "src_bucket" {
  bucket = local.src_bucket_name
}
resource "aws_s3_bucket_public_access_block" "src_bucket" {
  bucket = aws_s3_bucket.src_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# (4) dst バケットを作る
resource "aws_s3_bucket" "dst_bucket" {
  bucket = local.dst_bucket_name
}
resource "aws_s3_bucket_public_access_block" "dst_bucket" {
  bucket = aws_s3_bucket.dst_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# (5) AWS Lambda のサービスが AssumeRole するための IAM ポリシー(信頼関係)を記述
data "aws_iam_policy_document" "lambda_assumed_policy" {
  statement {
    effect = "Allow"
    actions = [
      "sts:AssumeRole"
    ]
    principals {
      type = "Service"
      identifiers = [
        "lambda.amazonaws.com"
      ]
    }
  }
}

# (6) Lambda 関数に与えられる基本的な権限の IAM ポリシー
data "aws_iam_policy" "lambda_basic_exec_policy" {
  name = "AWSLambdaBasicExecutionRole"
}

# (7) Lambda 関数に与えられる S3 の権限の IAM ポリシーのドキュメント
## src バケットから GetObject, dst バケットに PutObject を許可
data "aws_iam_policy_document" "lambda_policy" {
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject"
    ]
    resources = [
      "${aws_s3_bucket.src_bucket.arn}/*"
    ]
  }

  statement {
    effect = "Allow"
    actions = [
      "s3:PutObject"
    ]
    resources = [
      "${aws_s3_bucket.dst_bucket.arn}/*"
    ]
  }
}

# (8) AWS Lambda サービスが AssumeRole する IAM ロール
resource "aws_iam_role" "lambda_role" {
  assume_role_policy = data.aws_iam_policy_document.lambda_assumed_policy.json
  managed_policy_arns = [
    data.aws_iam_policy.lambda_basic_exec_policy.arn
  ]
  inline_policy {
    name   = "LambdaS3Policy"
    policy = data.aws_iam_policy_document.lambda_policy.json
  }
}

# (9) Lambda 関数を zip にアーカイブ
data "archive_file" "lambda_archive" {
  type        = "zip"
  output_path = "/tmp/myLambdaFunc.zip"
  source_dir  = "../sdk_s3/target/lambda/sdk_s3"
}

# (10) Lambda 関数
resource "aws_lambda_function" "lambda_func" {
  function_name    = "MyLambdaFuncTF"
  role             = aws_iam_role.lambda_role.arn
  filename         = data.archive_file.lambda_archive.output_path
  source_code_hash = data.archive_file.lambda_archive.output_base64sha256
  architectures    = ["arm64"]
  handler          = "bootstrap"
  runtime          = "provided.al2"
  environment {
    variables = {
      DEST_BUCKET = aws_s3_bucket.dst_bucket.bucket
    }
  }
}

# (11) S3 サービスが Lambda 関数を実行する許可権限を与える
resource "aws_lambda_permission" "lambda_permission" {
  principal     = "s3.amazonaws.com"
  function_name = aws_lambda_function.lambda_func.function_name
  action        = "lambda:InvokeFunction"
  source_arn    = aws_s3_bucket.src_bucket.arn
}

# (12) S3 にオブジェクトが作成されたら Lambda 関数を実行するイベントを設定
resource "aws_s3_bucket_notification" "notification" {
  bucket = aws_s3_bucket.src_bucket.id
  lambda_function {
    lambda_function_arn = aws_lambda_function.lambda_func.arn
    events              = ["s3:ObjectCreated:*"]
  }
}

少々長いですが、個々のリソースの記述は簡単です。
このコードの中で、(10) の Lambda 関数のところだけ、補足しておきます。

  • cargo lambda でビルドした Lambda 関数を aws_lambda_function でデプロイする場合、handlerbootstrapに、runtimeprovided.al2Lambda ランタイムの Amazon Linux 2 を OS とするカスタムランタイム)にします。
  • source_code_hash を付けておくことで、zip アーカイブの内容が変化したときに source_code_hash の値も変化し、その差分を埋めるために Lambda 関数の再デプロイが行われます。

このコードを使って、terraform plan コマンドで構築されるリソースを確認したのち、terraform apply を実行することで、これらのリソースが構築されます。

そして、src バケットにファイルをアップロードすると、dst バケットにファイルがコピーされることが確認できます。

CDK for Terraform on AWS (CDKTF) を使ってみる

2022 年 8 月に CDK for Terraform on AWS が GA (一般提供)になることがアナウンスされました。
CDK も Terraform も触ってきた私は CDKTF が気になってしまい、触ってみました。

AWS CDK では TypeScript や Python などのプログラミング言語で記述したコードから CloudFormation のテンプレートを生成しますが、CDKTF では TypeScript や Python などのプログラミング言語で記述したコードから Terraform のコードを生成します。

AWS CDK を使う場合には、対象となるリソースの API リファレンス(たとえば S3 であればこちら)を見ながらコードを作成していきます。CDKTF のドキュメントにあるチュートリアルを見ると、AWS CDK のコンストラクタとは異なるようなので、そのドキュメントを探したりしてしまいました。しかし、従来の Terraform のリソース名などをある規則に従って変換すれば、コンストラクタの名前がわかります。

  • resource "aws_lambda_function" のような resource ブロックにあるリソース名は、"aws_" を削除した上で、Camel Case にする。この場合には LambdaFunctionになる。
  • data "aws_iam_policy_document" のような data ブロックにあるデータソース名は、"aws_"を削除し Camel Case にした上で、先頭に "Data" を付ける。この場合には、DataIamPolicyDocument になる。
  • パラメータの名前は lower Camel Case にする。たとえば、function_namefunctionName になる。

これらのルールに従って、(9), (10) の部分を CDKTF の TypeScript のコードにしてみると、次のようになります。
(CDKTF のインストール、プロジェクトの作成などは CDKTF のドキュメントにあるチュートリアルをご覧ください)

main.ts
    // (9) Lambda 関数を zip にアーカイブ
    const lambdaArchive = new DataArchiveFile(this, "LambdaArchive", {
      type: "zip",
      outputPath: "/tmp/myLambdaFunc.zip",
      sourceDir: path.join(__dirname, '../sdk_s3/target/lambda/sdk_s3')
    })
    
    // (10) Lambda 関数
    const lambdaFunc = new LambdaFunction(this, "LambdaFunc", {
      functionName: "MyLambdaFunc",
      role: lambdaRole.arn,
      filename: lambdaArchive.outputPath,
      sourceCodeHash: lambdaArchive.outputBase64Sha256,
      architectures: ["arm64"],
      handler: "bootstrap",
      runtime: "provided.al2",
      environment: {
        variables: {
            DEST_BUCKET: dstBucket.bucket
        }
      }
    })

全文はこの下にあります。Terraform のコードを変換規則に沿って置き換えていけばいいので、記述はきれいに 1:1 に対応します。
(import で参照しているライブラリは、インストールされていなければ npm install 等でインストールしてください。以下同様)

Terraform のコードを CDKTF (TypeScript) で記述したもの(全文)
main.ts
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket";
import { S3BucketNotification } from "@cdktf/provider-aws/lib/s3-bucket-notification";
import { IamRole } from "@cdktf/provider-aws/lib/iam-role";
import { DataAwsIamPolicyDocument } from "@cdktf/provider-aws/lib/data-aws-iam-policy-document";
import { DataAwsIamPolicy } from "@cdktf/provider-aws/lib/data-aws-iam-policy";
import { LambdaFunction } from "@cdktf/provider-aws/lib/lambda-function"
import { LambdaPermission } from "@cdktf/provider-aws/lib/lambda-permission"
import { DataArchiveFile } from "@cdktf/provider-archive/lib/data-archive-file"
import * as path from 'path'
import { ArchiveProvider } from "@cdktf/provider-archive/lib/provider";
import { S3BucketPublicAccessBlock } from "@cdktf/provider-aws/lib/s3-bucket-public-access-block";

// (1) 2 つのバケットの名前を指定
const srcBucketName = "s3-event-test-src"
const dstBucketName = "s3-event-test-dst"

class MyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // (2) provider を指定
    new AwsProvider(this, "AWSProvider", {
      region: "ap-northeast-1"
    })
    new ArchiveProvider(this, "ArchiveProvider")

    // (3) src バケットを作る
    const srcBucket = new S3Bucket(this, "SrcBucket", {
      bucket: srcBucketName
    })
    new S3BucketPublicAccessBlock(this, "SrcBucketPublicAccessBlock", {
      bucket: srcBucket.id,
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true
    })

    // (4) dst バケットを作る
    const dstBucket = new S3Bucket(this, "DstBucket", {
      bucket: dstBucketName
    }) 
    new S3BucketPublicAccessBlock(this, "DstBucketPublicAccessBlock", {
      bucket: dstBucket.id,
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true
    })
   
    // (5) AWS Lambda のサービスが AssumeRole するための IAM ポリシー(信頼関係)を記述
    const assumedPolicy = new DataAwsIamPolicyDocument(this, "DataLambdaAssumedPolicy", {
      statement: [
        {
          effect: "Allow",
          actions: [
            "sts:AssumeRole"
          ],
          principals: [
            {
              type: "Service",
              identifiers: [
                "lambda.amazonaws.com"
              ]
            }
          ]
        }
      ]
    })

    // (6) Lambda 関数に与えられる基本的な権限の IAM ポリシー
    const lambdaBasicExecPolicy = new DataAwsIamPolicy(this, "DataLambdaBasicExecPolicy", {
      name: "AWSLambdaBasicExecutionRole"
    })
    
    // (7) Lambda 関数に与えられる S3 の権限の IAM ポリシーのドキュメント
    // src バケットから GetObject, dst バケットに PutObject を許可
    const lambdaPolicyDocument = new DataAwsIamPolicyDocument(this, "DataLambdaPolicy", {
      statement: [
        {
          effect: "Allow",
          actions: [
            "s3:GetObject"
          ],
          resources: [
            `${srcBucket.arn}/*`
          ]
        },
        {
          effect: "Allow",
          actions: [
            "s3:PutObject"
          ],
          resources: [
            `${dstBucket.arn}/*`
          ]
        }
      ]
    })
 
    // (8) AWS Lambda サービスが AssumeRole する IAM ロール   
    const lambdaRole = new IamRole(this, "LambdaRole", {
      assumeRolePolicy: assumedPolicy.json,
      managedPolicyArns: [
        lambdaBasicExecPolicy.arn
      ],
      inlinePolicy: [
        {
          name: "LambdaS3Policy",
          policy: lambdaPolicyDocument.json
        }
      ]
    })

    // (9) Lambda 関数を zip にアーカイブ
    const lambdaArchive = new DataArchiveFile(this, "LambdaArchive", {
      type: "zip",
      outputPath: "/tmp/myLambdaFunc.zip",
      sourceDir: path.join(__dirname, '../sdk_s3/target/lambda/sdk_s3')
    })
    
    // (10) Lambda 関数
    const lambdaFunc = new LambdaFunction(this, "LambdaFunc", {
      functionName: "MyLambdaFunc",
      role: lambdaRole.arn,
      filename: lambdaArchive.outputPath,
      sourceCodeHash: lambdaArchive.outputBase64Sha256,
      architectures: ["arm64"],
      handler: "bootstrap",
      runtime: "provided.al2",
      environment: {
        variables: {
            DEST_BUCKET: dstBucket.bucket
        }
      }
    })
    
    // (11) S3 サービスが Lambda 関数を実行する許可権限を与える
    new LambdaPermission(this, "LambdaPermission", {
      principal: "s3.amazonaws.com",
      functionName: lambdaFunc.functionName,
      action: "lambda:InvokeFunction",
      sourceArn: srcBucket.arn
    })
    
    // (12) S3 にオブジェクトが作成されたら Lambda 関数を実行するイベントを設定
    new S3BucketNotification(this, "Notification", {
      bucket: srcBucket.id,
      lambdaFunction: [
        {
          lambdaFunctionArn: lambdaFunc.arn,
          events: ["s3:ObjectCreated:*"]
        }
      ]
    })
    
  }
}

const app = new App();
new MyStack(app, "cdktf_lambda_s3_native");
app.synth();

作成したコードを使って cdktf plan を実行すると、terraform plan でおなじみの画面が現れます。

$ cdktf plan
cdktf_lambda_s3_native  Initializing the backend...
cdktf_lambda_s3_native  Initializing provider plugins...
                        - Reusing previous version of hashicorp/aws from the dependency lock file
cdktf_lambda_s3_native  - Reusing previous version of hashicorp/archive from the dependency lock file
cdktf_lambda_s3_native  - Using previously-installed hashicorp/aws v4.46.0
cdktf_lambda_s3_native  - Using previously-installed hashicorp/archive v2.2.0

                        Terraform has been successfully initialized!
cdktf_lambda_s3_native
                        You may now begin working with Terraform. Try running "terraform plan" to see
                        any changes that are required for your infrastructure. All Terraform commands
                        should now work.

                        If you ever set or change modules or backend configuration for Terraform,
                        rerun this command to reinitialize your working directory. If you forget, other
                        commands will detect it and remind you to do so if necessary.
(中略)
cdktf_lambda_s3_native  Terraform used the selected providers to generate the following execution
                        plan. Resource actions are indicated with the following symbols:
                          ~ update in-place

                        Terraform will perform the following actions:
cdktf_lambda_s3_native    # aws_lambda_function.LambdaFunc (LambdaFunc) will be updated in-place
                          ~ resource "aws_lambda_function" "LambdaFunc" {
                                id                             = "MyLambdaFunc"
                              ~ last_modified                  = "2022-12-14T02:23:12.369+0000" -> (known after apply)
                              ~ source_code_hash               = "dXDOQSCW70jaEQiXXMrFI5QFRtJni39n7AagvNUnJ/Y=" -> "kplFsNc1fQDaQ6HkhNM4lNspEWxHWfUTmYqHQvYMGSs="
                                tags                           = {}
                                # (19 unchanged attributes hidden)

                                # (3 unchanged blocks hidden)
                            }

                        Plan: 0 to add, 1 to change, 0 to destroy.

cdktf_lambda_s3_native
                        ─────────────────────────────────────────────────────────────────────────────

                        Saved the plan to: plan

                        To perform exactly these actions, run the following command to apply:
                            terraform apply "plan"

cdktf plan を実行することで、terraform initterraform plan が行われていることが分かります。
また、cdktf.out/stacks/[プロジェクト名]/cdk.tf.json に JSON 形式で書かれた Terraform のファイルが生成されています。

cdk.tf.json
{
  "data": {
    "archive_file": {
      "LambdaArchive": {
        "//": {
          "metadata": {
            "path": "cdktf_lambda_s3_native/LambdaArchive",
            "uniqueId": "LambdaArchive"
          }
        },
        "output_path": "/tmp/myLambdaFunc.zip",
        "source_dir": "/work/sdk_s3/target/lambda/sdk_s3",
        "type": "zip"
      }
    },
    (中略)
  },  
  "resource": {
    "aws_iam_policy": {
      "LambdaPolicy": {
        "//": {
          "metadata": {
            "path": "cdktf_lambda_s3_native/LambdaPolicy",
            "uniqueId": "LambdaPolicy"
          }
        },
        "policy": "${data.aws_iam_policy_document.DataLambdaPolicy.json}"
      }
    },
    (中略)
  }
}

CDKTF を使うと、Terraform 向けの独自言語 (HCL)ではなく、使い慣れた TypeScipt や Python などで Terraform におけるリソースの記述ができることがメリットです。また、TypeScript のような静的型付け言語を使えば、型チェックやエディタの補完機能の恩恵を受けられます。
さらに、HCL ではループの記述(for, for_each など)がトリッキーで、これらを駆使することで反復処理があるコードは書けるものの、書いた本人以外からは可読性が低いものになってしまいます(ログラスの技術顧問で Terraform の経験が豊富な野村友規さんからは、「Terraform では for_each などは原則として使わず、DRY (Don't Repeat Yourself) なコードを目指さない方がよい」というアドバイスを頂きました)。反復処理が必要な場合には、CDKTF でプログラミング言語のループ処理を使うことで、HCL で記述するよりは可読性が高いコードが書けそうです。

しかし、HCL による手軽な記述も捨てがたいと思っています。しばらくは、これまで通り HCL で Terraform のコードを記述しつつ、CDKTF で記述すると便利な場面があるかを見定めていきたいと思っています。

AWS Adapter

上のように、Terraform のコードを TypeScript などで記述できるのが CDKTF であり、使用するコンストラクタは AWS CDK のものと全く別物でした。
AWS CDK をそれなりに長く使ってきた私は、AWS CDK のコンストラクタを使って Terraform のコードが書ければいいのになぁ、と思ってしまいました。
調べてみたら、AWS Adapter というのを発見! Technical Preview の段階ですが、ちょっと使ってみました。

まず、上で Terraform で書いたコードを、AWS CDK (TypeScript) で L2 コンストラクタを使いながら素直に書き換えると、次のようになりました。これも、一部を除いて Terraform で記述したリソースとほぼ対応します。

AWS CDK で記述したリソース
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3n from "aws-cdk-lib/aws-s3-notifications";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";

// (1) 2 つのバケットの名前を指定
const srcBucketName = "s3-event-test-src-cdk";
const dstBucketName = "s3-event-test-dst-cdk";

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

    // (3) src バケットを作る
    const srcBucket = new s3.Bucket(this, "SrcBucket", {
      bucketName: srcBucketName,
      blockPublicAccess: {
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
    });

    // (4) dst バケットを作る
    const dstBucket = new s3.Bucket(this, "DstBucket", {
      bucketName: dstBucketName,
      blockPublicAccess: {
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
    });

    // (5) AWS Lambda のサービスが AssumeRole するための IAM ポリシー(信頼関係)を記述
    // AWS CDK では、iam.Role の assumedBy に ServicePrincipal を指定することで、
    // AssumeRole するための IAM ポリシー(信頼関係)は作成されるので、記述なし。

    // (6) Lambda 関数に与えられる基本的な権限の IAM ポリシー
    const lambdaBasicExecPolicy = iam.ManagedPolicy.fromAwsManagedPolicyName(
      "service-role/AWSLambdaBasicExecutionRole"
    );

    // (7) Lambda 関数に与えられる S3 の権限の IAM ポリシーのドキュメント
    // src バケットから GetObject, dst バケットに PutObject を許可
    const lambdaPolicyDocument = new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:GetObject"],
          resources: [`${srcBucket.bucketArn}/*`],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:PutObject"],
          resources: [`${dstBucket.bucketArn}/*`],
        }),
      ],
    });

    // (8) AWS Lambda サービスが AssumeRole する IAM ロール
    const lambdaRole = new iam.Role(this, "LambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [lambdaBasicExecPolicy],
      inlinePolicies: {
        LambdaS3Policy: lambdaPolicyDocument,
      },
    });

    // (9) Lambda 関数を zip にアーカイブ
    const lambdaArchive = lambda.Code.fromAsset(
      "../sdk_s3/target/lambda/sdk_s3"
    );

    // (10) Lambda 関数
    const lambdaFunc = new lambda.Function(this, "LambdaS3", {
      functionName: "MyLambdaFuncCDK",
      runtime: lambda.Runtime.PROVIDED_AL2,
      handler: "bootstrap",
      code: lambdaArchive,
      architecture: lambda.Architecture.ARM_64,
      environment: {
        DEST_BUCKET: dstBucket.bucketName,
      },
      role: lambdaRole,
    });

    // (11) S3 サービスが Lambda 関数を実行する許可権限を与える
    lambdaFunc.addPermission("LambdaPermission", {
      principal: new iam.ServicePrincipal("s3.amazonaws.com"),
      action: "lambda:InvokeFunction",
      sourceArn: srcBucket.bucketArn,
    });

    // (12) S3 にオブジェクトが作成されたら Lambda 関数を実行するイベントを設定
    srcBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED,
      new s3n.LambdaDestination(lambdaFunc)
    );
  }
}

次にこのファイルに微修正を加え、CDKTF と AWS Adapter を使うようにします。
実施した修正は次の点です。

  • class は cdk.Stack からの継承ではなく、TerraformStack からの継承に変更(super も含め、引数の数も異なります)
  • Terraform の AWS Provider と AWS Adapter のインスタンス作成を追加
  • AWS CDK のコンストラクタの最初の引数 thisawsAdapter に変更する。
  • このクラスを呼び出す処理を追加(AWS CDK では別のファイルに記述されているのの)

差分は次の通りになりました。

@@ -1,20 +1,27 @@
-import * as cdk from "aws-cdk-lib";
 import { Construct } from "constructs";
+import { App, TerraformStack } from "cdktf";
 import * as s3 from "aws-cdk-lib/aws-s3";
 import * as s3n from "aws-cdk-lib/aws-s3-notifications";
 import * as lambda from "aws-cdk-lib/aws-lambda";
 import * as iam from "aws-cdk-lib/aws-iam";
+import { AwsTerraformAdapter } from "@cdktf/aws-cdk";
+import { AwsProvider } from "@cdktf/provider-aws/lib/provider";

 // (1) 2 つのバケットの名前を指定
 const srcBucketName = "s3-event-test-src-cdk";
 const dstBucketName = "s3-event-test-dst-cdk";

-export class CdkLambdaS3Stack extends cdk.Stack {
-  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
-    super(scope, id, props);
-
+class MyStack extends TerraformStack {
+  constructor(scope: Construct, id: string) {
+    super(scope, id);
+
+    // (2) provider を指定
+    new AwsProvider(this, "aws", { region: "ap-northeast-1" });
+    // (NEW) adapter を作成
+    const awsAdapter = new AwsTerraformAdapter(this, "adapter");
+
     // (3) src バケットを作る
-    const srcBucket = new s3.Bucket(this, "SrcBucket", {
+    const srcBucket = new s3.Bucket(awsAdapter, "SrcBucket", {
       bucketName: srcBucketName,
       blockPublicAccess: {
         blockPublicAcls: true,
@@ -25,7 +32,7 @@
     });

     // (4) dst バケットを作る
-    const dstBucket = new s3.Bucket(this, "DstBucket", {
+    const dstBucket = new s3.Bucket(awsAdapter, "DstBucket", {
       bucketName: dstBucketName,
       blockPublicAccess: {
         blockPublicAcls: true,
@@ -62,7 +69,7 @@
     });

     // (8) AWS Lambda サービスが AssumeRole する IAM ロール
-    const lambdaRole = new iam.Role(this, "LambdaRole", {
+    const lambdaRole = new iam.Role(awsAdapter, "LambdaRole", {
       assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
       managedPolicies: [lambdaBasicExecPolicy],
       inlinePolicies: {
@@ -76,7 +83,7 @@
     );

     // (10) Lambda 関数
-    const lambdaFunc = new lambda.Function(this, "LambdaS3", {
+    const lambdaFunc = new lambda.Function(awsAdapter, "LambdaS3", {
       functionName: "MyLambdaFuncCDK",
       runtime: lambda.Runtime.PROVIDED_AL2,
       handler: "bootstrap",
@@ -102,3 +109,7 @@
     );
   }
 }
+
+const app = new App();
+new MyStack(app, "cdktf_lambda_s3");
+app.synth();

ほんのわずかな修正です。
このコードを使って、Terraform のコードを生成するプロセスである cdktf plan を実行してみました。
しかし、エラーを出力して失敗してしまいます。

[ERROR] default - Error: Unsupported resource Type Custom::S3BucketNotifications. There is no custom mapping registered for Custom::S3BucketNotifications and the AWS CloudControl API does not seem to support it yet.

実は、AWS CDK で S3 のイベントを Lambda 関数に渡す設定を行う addEventNotification は、CloudFormation の Custom リソースで記述されています。
すでに説明したように、AWS CDK はリソースが記述されたコードを CloudFormation のテンプレートに変換します。しかし、CloudFormation の標準リソースでは対応していないリソース操作が一部分のサービスではあり、そのような操作はリソースの作成、更新、削除をする際に Lambda 関数を実行することができる Custom リソースによって AWS CDK では実現されています。しかし、このような Custom リソースの機能が Terraform にはないため、Terraform のコードに変換する CDKTF では AWS Adapter を使っても AWS CDK のコードから Terraform のコードの生成に失敗したわけです。

なお、AWS Adapter のドキュメントや上のエラーメッセージにあるように、AWS Adapter で対応している AWS リソースは AWS Cloud Control API対応しているリソースになっており、Custom リソースは対応しておりません。

諦めきれない私は、Custom リソースの使用を避ければ良いはずと考え、イベント通知の設定が必要な src バケットの記述に CloudFormation の AWS::S3::Bucketに対応する L1 コンストラクタ CfnS3Bucketを使うことにしました。CfnS3Bucket を使うとバケットの作成とともに通知イベントの設定ができるのですが(NotificationConfiguration に Lambda 関数やイベントの種類を指定)、その記述の際には Lambda 関数の ARN が決まっている必要があるので、src バケットの CfnS3Bucket による記述を Lambda 関数の記述の後ろに移動しました。
このように修正した CDKTF 向けのコードは次のようになりました。

src バケットを CfnS3Bucket を使って記述するように修正した CDKTF (TypeScript) のコード
main.ts
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import { AwsTerraformAdapter } from "@cdktf/aws-cdk";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";

// (1) 2 つのバケットの名前を指定
const srcBucketName = "s3-event-test-src-cdktf-adapter";
const dstBucketName = "s3-event-test-dst-cdktf-adapter";

class MyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // (2) provider を指定
    new AwsProvider(this, "aws", { region: "ap-northeast-1" });
    // (NEW) adapter を作成
    const awsAdapter = new AwsTerraformAdapter(this, "adapter");

    /* 
      srcBucket は Notification と同時に記述する必要があるため、
      Notification のイベントを送る Lambda 関数を記述したあとに作成する。
    */

    // (4) dst バケットを作る
    const dstBucket = new s3.Bucket(awsAdapter, "DstBucket", {
      bucketName: dstBucketName,
      blockPublicAccess: {
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
    });

    // (5) AWS Lambda のサービスが AssumeRole するための IAM ポリシー(信頼関係)を記述
    // AWS CDK では、iam.Role の assumedBy に ServicePrincipal を指定することで、
    // AssumeRole するための IAM ポリシー(信頼関係)は作成されるので、記述なし。

    // (6) Lambda 関数に与えられる基本的な権限の IAM ポリシー
    const lambdaBasicExecPolicy = iam.ManagedPolicy.fromAwsManagedPolicyName(
      "service-role/AWSLambdaBasicExecutionRole"
    );

    // (7) Lambda 関数に与えられる S3 の権限の IAM ポリシーのドキュメント
    // src バケットから GetObject, dst バケットに PutObject を許可
    const lambdaPolicyDocument = new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:GetObject"],
          resources: [
            // srcBucket がまだ記述されていないので、srcBucketName を用いる
            `arn:aws:s3:::${srcBucketName}/*`,
          ],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["s3:PutObject"],
          resources: [`${dstBucket.bucketArn}/*`],
        }),
      ],
    });

    // (8) AWS Lambda サービスが AssumeRole する IAM ロール
    const lambdaRole = new iam.Role(awsAdapter, "LambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [lambdaBasicExecPolicy],
      inlinePolicies: {
        LambdaS3Policy: lambdaPolicyDocument,
      },
    });

    // (9) Lambda 関数を zip にアーカイブ
    const lambdaArchive = lambda.Code.fromAsset(
      "../rusoto_s3/target/lambda/rusoto_s3"
    );

    // (10) Lambda 関数
    const lambdaFunc = new lambda.Function(awsAdapter, "LambdaS3", {
      functionName: "MyLambdaFuncCDKTFAdapter",
      runtime: lambda.Runtime.PROVIDED_AL2,
      handler: "bootstrap",
      code: lambdaArchive,
      architecture: lambda.Architecture.ARM_64,
      environment: {
        DEST_BUCKET: dstBucket.bucketName,
      },
      role: lambdaRole,
    });

    // (3) src バケットを作る
    // (12) S3 にオブジェクトが作成されたら Lambda 関数を実行するイベントを設定
    // ここで srcBucket を作成する。Notification を指定するため、L2 コンストラクタ s3.Bucket ではなく
    // CloudFormation の AWS::S3::Bucket のリソースに対応するL1 コンストラクタ s3.CfnBucket を用いる。
    const srcBucket = new s3.CfnBucket(awsAdapter, "SrcBucket", {
      bucketName: srcBucketName,
      publicAccessBlockConfiguration: {
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
      notificationConfiguration: {
        lambdaConfigurations: [
          {
            function: lambdaFunc.functionArn,
            event: "s3:ObjectCreated:*",
          },
        ],
      },
    });


    // (11) S3 サービスが Lambda 関数を実行する許可権限を与える
    lambdaFunc.addPermission("LambdaPermission", {
      principal: new iam.ServicePrincipal("s3.amazonaws.com"),
      action: "lambda:InvokeFunction",
      sourceArn: srcBucket.attrArn,
    });
  }
}

const app = new App();
new MyStack(app, "cdktf_lambda_s3");
app.synth();

このコードを使うと cdktf plan は成功して、cdktf.out/stacks/[プロジェクト名]/cdk.tf.json にJSON 形式の Terraform のコードが出力されます。

{
  "//": {
    (略)
  },
  "data": {
    (略)
  },
  "provider": {
    (略)
  },
  "resource": {
    "aws_cloudcontrolapi_resource": {
      "adapter_DstBucket3E241BF2_6686AD11": {
        "//": {
          "metadata": {
            "path": "cdktf_lambda_s3/adapter/DstBucket3E241BF2",
            "uniqueId": "adapter_DstBucket3E241BF2_6686AD11"
          }
        },
        "desired_state": "${jsonencode({BucketName = \"s3-event-test-dst-cdktf-adapter\", PublicAccessBlockConfiguration = {BlockPublicAcls = true, BlockPublicPolicy = true, IgnorePublicAcls = true, RestrictPublicBuckets = true}})}",
        "type_name": "AWS::S3::Bucket"
      },
      "adapter_LambdaS365175FCC_A7CC17E3": {
        "//": {
          "metadata": {
            "path": "cdktf_lambda_s3/adapter/LambdaS365175FCC",
            "uniqueId": "adapter_LambdaS365175FCC_A7CC17E3"
          }
        },
        "depends_on": [
          "time_sleep.adapter_LambdaRole3A44B857_sleep_LambdaRole3A44B857_24187AC5"
        ],
        "desired_state": "${jsonencode({Code = {S3Bucket = replace(replace(\"c\", \"$${0}\", \"d\"), \"/\\\\$\\\\{!(\\\\w+)\\\\}/\", \"$${$1}\"), S3Key = \"fe929d811d117813697e942e211a82f236d7899ba720739e9f14aa21b3d72ea0.zip\"}, Role = aws_iam_role.adapter_LambdaRole3A44B857_2853A987.arn, Architectures = [\"arm64\"], Environment = {Variables = {DEST_BUCKET =
 jsondecode(aws_cloudcontrolapi_resource.adapter_DstBucket3E241BF2_6686AD11.properties)[\"Ref\"]}}, FunctionName = \"MyLambdaFuncCDKTFAdapter\", Handler = \"bootstrap\", Runtime =
\"provided.al2\"})}",
        "type_name": "AWS::Lambda::Function"
      },
    (略)
    }
  }
}

これを見ると、Cloud Control API のリソースの中で、AWS CDK のコンストラクタで記述されたリソースを記述していることがわかります。AWS Adapter が AWS Cloud Control API で対応しているリソースのみに使えることの理由が改めて確認できました。

このように、AWS CDK で記述したコードがそのまま CDKTF で使えて、Terraform で管理できるようになるのは面白いのですが、すべてのリソースで変換がそのままのコードからできるわけではなく、現時点では実用的に使える勇気はまだ出ないなぁ、というのが正直なところです。

最後に

AWS SDK for Rust, CDK for Terraform on AWS (CDKTF) を試してみた記録を紹介しました。本文でも述べたように、Rust と AWS Lambda の相性は抜群であり、Rust で Lambda 関数が簡単に書けてしまいます。アプリのプログラミング言語を移行するのは非常に大変ですが、インフラ管理で使う Lambda 関数を書くというところから Rust を導入していく、という選択肢は現実的にあるのではないかと思っています。AWS SDK for Rust はまだ Developer Preview のフェーズではありますが、今後の動向に注目していきたいと考えています。

AWS のプロダクトである CloudFormation や AWS CDK と、サードパーティーのプロダクトである Terraform は、これまではそれぞれが自分の道を行くイメージを持っていましたが、CDK を通じて融合されたことに正直驚きを感じました。現在のところ、HCL の Terraform のコードを今すぐに CDKTF に移行する強いモチベーションはありませんが、プログラミング言語で Terraform のリソースを記述できるプロダクトがあることを念頭において、その便利な使い道がないかを探っていきたいと思っています。

明日は、しおりん(@jamgodtree)がチームリーダーの役割について語ります。
引き続き株式会社ログラス Productチーム Advent Calendar 2022をお楽しみください。

脚注
  1. AWS Lambda 料金 ↩︎

  2. Shinjuku.rs #17を開催しました - LT3 | AWS LambdaでのRust利用 ↩︎

  3. AWS での構築ツール ↩︎

  4. イベントメッセージの構造 ↩︎

  5. AWS CDKを使用してAmazon ECS の開始方法 ↩︎

  6. 「実践Terraform」著者の野村友規氏が、ログラスの技術顧問に就任
    ↩︎

株式会社ログラス テックブログ

Discussion