Tak's Notebook

Kaggle, Machine Learning, Engineering

Cargo Lambda で AWS Lambda 用の Rust バイナリファイルをビルドする

背景

個人的に家計管理のために Slack に投稿すると IFTTT 経由でスプレッドシートに転記するような AWS Lambda を作って使っていた。

構成図イメージ

Go 1.x が Deprecation になるのに合わせて Go で書いていたアプリケーションを Rust に移植してみた。 そのときの手順やハマったポイントについて書き残しておく。

ちなみに Python 版の話は以前ブログで書いた。

takaishikawa42.hatenablog.com

方法

Rust で書かれた AWS Lambda 関数を便利にビルド・デプロイできるパッケージが AWS 公式でリリースされている。 基本的に手順は README を読みながら進めれば問題なくビルド・デプロイできる。

github.com

Cargo Lambda 自体のドキュメントは以下にまとめられている。

www.cargo-lambda.info

手順

1. Cargo Lambda をインストールする

brew tap cargo-lambda/cargo-lambda
brew install cargo-lambda

2. Lambda 関数を実装する

Cargo Lambda のサブコマンド new を利用すると雛形が作られるので、それをもとに修正を行う。

cargo lambda new $LAMBDA_PACKAGE_NAME

以下のインタラクティブを経て生成される。

? Is this function an HTTP function? (y/N)  
[type `yes` if the Lambda function is triggered by an API Gateway, Amazon Load Balancer(ALB), or a Lambda URL]

? AWS Event type that this function receives  
  activemq::ActiveMqEvent
  autoscaling::AutoScalingEvent
  chime_bot::ChimeBotEvent
  cloudwatch_events::CloudWatchEvent
  cloudwatch_logs::CloudwatchLogsEvent
  cloudwatch_logs::CloudwatchLogsLogEvent
v codebuild::CodeBuildEvent
[↑↓ to move, tab to auto-complete, enter to submit. Leave it blank if you don't want to use any event from the aws_lambda_events crate]

1つ目の質問を No、2つ目の質問を空白で答えると以下のようなコードが生成される

use lambda_runtime::{run, service_fn, Error, LambdaEvent};

use serde::{Deserialize, Serialize};

/// This is a made-up example. Requests come into the runtime as unicode
/// strings in json format, which can map to any structure that implements `serde::Deserialize`
/// The runtime pays no attention to the contents of the request payload.
#[derive(Deserialize)]
struct Request {
    command: String,
}

/// This is a made-up example of what a response structure may look like.
/// There is no restriction on what it can be. The runtime requires responses
/// to be serialized into json. The runtime pays no attention
/// to the contents of the response payload.
#[derive(Serialize)]
struct Response {
    req_id: String,
    msg: String,
}

/// 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<Request>) -> Result<Response, Error> {
    // Extract some useful info from the request
    let command = event.payload.command;

    // Prepare the response
    let resp = Response {
        req_id: event.context.request_id,
        msg: format!("Command {}.", command),
    };

    // Return `Response` (it will be serialized to JSON automatically by the runtime)
    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
}

生成された function_handler 関数が実行のメインなのでここに実装を行う。 別パッケージで処理を行うハンドラを書いておいて、それを呼び出すだけにした方が実装が簡潔になると思う。

注意点としては、 aws lambda invoke で --payload の key になる値に Request 構造体のフィールド名を一致させる必要がある。

3. Lambda 関数をビルドする

Cargo Lambda のサブコマンド build でビルドできる。

--release オプションでリリースモードでビルドされるところや、--arm64 オプションで arm64 アーキでビルドされる(--target aarch64-unknown-linux-gnu の省略形)ところは通常の cargo と同じ。 ちなみに --output-format でバイナリファイルが Zip された状態で生成することもできる。後述の cargo deploy を使わずにデプロイする場合には指定すると良いかもしれない。

cargo lambda build --release --arm64 --bin $LAMBDA_PACKAGE_NAME

4. Lambda 関数をデプロイする

cargo lambda のサブコマンド deploy でデプロイできる。

環境変数AWS CLI のように一つ一つ指定しなくても --env-file オプションで KEY=VALUE フォーマットで記述された .env ファイルのパスを渡すだけで設定できる。 Lambda の実行ロールは指定しなければ、デフォルトのロールが作成される。また細かい設定(ハンドラ名、Lambda パッケージのパス、アーキテクチャ等)の指定も不要。

cargo lambda deploy $LAMBDA_FUNCTION_NAME --env-file .env

5. Lambda 関数を実行する(テストする)

実行も cargo lambda のサブコマンド invoke で実行できる。

--remote オプションですでに AWS Lambda にデプロイされた関数を実行できる。指定しない場合は(デフォルトでは)ローカルのエミュレータに対してリクエストを送ってしまう*1ので注意する必要がある。 また payload が不要な場合でも version 1.0.0 時点では何らかのデータフラグ(--data-file, --data-ascii, --data-example のいずれか)の指定が必要になっている。

cargo lambda invoke $LAMBDA_FUNCTION_NAME --remote --output-format json --data-ascii "{}"

ハマったポイント

いくつか実装中にハマったポイントがあったので列挙する。

1. OpenSSL 関連のエラー

OpenSSL 関連のエラーメッセージ例

error: failed to run custom build command for `openssl-sys v0.9.97`

Caused by:
  process didn't exit successfully: `$PWD/target/release/build/openssl-sys-b2444d8dd69cea00/build-script-main` (exit status: 101)
  --- stdout
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_LIB_DIR
  OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_INCLUDE_DIR
  OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_DIR
  OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_NO_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_SYSROOT_DIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
  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.

  --- stderr
  thread 'main' panicked at '

  Could not find directory of OpenSSL installation, and this `-sys` crate cannot
  proceed without this knowledge. If OpenSSL is installed and this crate had
  trouble finding it,  you can set the `OPENSSL_DIR` environment variable for the
  compilation process.

  Make sure you also have the development packages of openssl installed.
  For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.

  If you're in a situation where you think the directory *should* be found
  automatically, please open a bug at https://github.com/sfackler/rust-openssl
  and include information about your system as well as this message.

  $HOST = aarch64-apple-darwin
  $TARGET = aarch64-unknown-linux-gnu
  openssl-sys = 0.9.97

  ', $HOME/.cargo/registry/src/index.crates.io-6f17d22bba15001f/openssl-sys-0.9.97/build/find_normal.rs:190:5
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

この問題は Cargo Lambda のドキュメントにも実は書かれていた。 自分のケースだと reqwest クレートが TLS バックエンドとして OpenSSL を使用しているため rustls feature (あるいは native-tls-vendored feature)を有効にして、TLS バックエンドをアプリケーションに含めるようにする必要があるらしい。

Cross Compiling with Cargo Lambda | Cargo Lambda

というのも AWS Lambda は Linux サンドボックス環境で関数が実行され、サンドボックスはOSが動作するために必要なライブラリのみ含んでいるため、 *-sys ライブラリ群はバイナリに完全にリンクされているかネイティブの依存関係を他の方法で提供しない限り動作しない可能性があるためらしい。

そのため自分は Cargo.toml の reqwest クレートの記述を修正することでビルドを通すことができた。

-reqwest = { version = "0.11", features = ["blocking", "json"] }
+reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] }

2. Request 構造体のフィールドの DeserializeError

cargo lambda invoke で payload の key になる値に Request 構造体のフィールド名を一致させる必要がある。

payload の値を受けて serde::Deserialize するため DeserializeError が発生する。以下は Request 構造体に command というフィールドがあるにもかかわらず空の payload を渡した場合に起こるエラー例。

$ cargo lambda invoke $LAMBDA_FUNCTION_NAME --remote --data-ascii "{}"
Error: lambda_runtime::deserializer::DeserializeError

  × failed to deserialize the incoming data into the function's payload type: missing field `command` at line 1 column 2

もし payload が不要な場合には Request 構造体はフィールドを持たない空の構造体にする。

3. Invoke サブコマンドで Connection refused (os error 61) エラー

前述の通り、すでに AWS Lambda にデプロイされた関数を実行したい場合には、--remote オプションの指定が必須。

指定しない場合はローカルのエミュレータに対してリクエストを送ってしまい、エミュレータを立ち上げていない場合 Connection refused (os error 61) エラーが発生してしまう。

$ cargo lambda invoke $LAMBDA_FUNCTION_NAME --output-format json --data-ascii "{}"
Error:   × error sending request to the runtime emulator
  ├─▶ error sending request for url (http://[::1]:9000/2015-03-31/functions/$LAMBDA_FUNCTION_NAME/invocations): error trying to connect: tcp connect error: Connection refused (os error 61)
  ├─▶ error trying to connect: tcp connect error: Connection refused (os error 61)
  ├─▶ tcp connect error: Connection refused (os error 61)
  ╰─▶ Connection refused (os error 61)

結果

無事に Rust 製の Lambda 関数をビルド・デプロイできた。

実行の頻度も時間も重くないので、特段気になるところではないが、一応 Billed Duration も Max Memory Used も大きく減った。

Go の Lambda 関数は 600ms, 35MB 程度だったが、Rust だと 400ms, 18MB 程度になっていた*2

以上。

作業リポジトリ: github.com

*1:ソースコードでの分岐箇所: https://github.com/cargo-lambda/cargo-lambda/blob/a342f0b8a7c34b17cee2f4fed3cc7d3ddbd810ab/crates/cargo-lambda-invoke/src/lib.rs#L156-L160

*2:Go は norpc オプションを有効にしてないなど最適化の余地が残ってるかもしれない