AWSで使うRust

κeenです。この記事では IdeinでのAWSの利用例の開示の一環として、どのようにRustをAWS上で動かしているかをご紹介します。

Ideinの提供しているサービスActcastではサーバの主たる部分をRustで書いています。 はじめの頃は本当にRustだけだったのですが、各方面に秀でたメンバーが集まった結果、今ではHaskellやTypeScript、一部ですがGoも動いています。

そもそもRustの採用事例が少ないことからRustをAWSで扱う知見はそこまで多くなさそうです。 そこで今回はIdeinでどのようにAWS上でRustを動かしているか、動かすにあたって必要だった知見などを紹介していきます。

全体像

全体の中で、Rustが動いている環境はECSとLambdaです。 ECSで動いているのはActcastのバックエンドAPIで、Actcastのサーバ本体とも言える部分です。

Lambdaの方は全部で4つあります。 起動トリガで分類するとAPI GatewayのAuthorizerとHandlerに1つづつ、CloudWatch EventsのEventとTimerに1つづつです。

ActcastとRustの関係

Actcastでは今のところAWSの構成を全てterraformで管理しているのでECSジョブやLambdaもterraformからデプロイしています。 これについてはサービスリリースを乗り越えたので構成の見直しを予定しています。

ECSはdockerコンテナさえ作ってしまえば大きな懸念なく動くので以下ではLambdaでの動かしかたについて紹介します。

コード

使っているライブラリやコーディング上のテクニックを紹介します。

全般

Lambdaランタイム

LambdaのRustランタイムはありません。 しかしカスタムランタイムを使えばRustを動かすことができます。 カスタムランタイムでRustを動かすためのライブラリも公開されています。 このライブラリ(lambda-runtime)を使えばエントリポイント( main 関数)だけLambda用に作ってあげれば動くのでコードの残りの部分はあまりLambdaについて気にしなくてよくなります。 別の言い方をするとLambda特有の処理は全てエントリポイント部分に集約しています。 つまり、 main 関数の見た目は以下のようになっています。

use tokio::runtime::current_thread::block_on_all;
use app::{api, App};

pub async fn handle(
    app: &App,
    event: api::CustomEvent,
    _ctx: Context,
) -> Result {
    app.do_something_with_event(event, hoge, fuga)
    // do other things...
}

fn main() {
    /* 初期化のコード */;
    let app = App::new(...);
    lambda!(|req, ctx| {
        block_on_all(handle(&app, req, ctx))
    })
}

コードを少し解説すると以下のようになっています。

  1. Lambdaのランタイムは何度か使われるので Appはメインループの外で作って使い回す
  2. lambda! マクロでLambdaのメインループに入る
  3. リクエストとレスポンスはserdeの Deserialize/Serialize を実装した型を書いておけばJSONから変換される
  4. Futureを走らせるために block_on_all を呼んでいる

このうち3と4についてもう少し詳しく触れます。

出入力フォーマット

リクエストとレスポンスはAWSのドキュメントを読んで、自分の欲しいフィールドを扱えるようなデータ型を定義します。例えばAPI GatewayのCustom Authorizerであれば以下のようなデータ型を定義しています。

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEvent {
    #[serde(rename = "type")]
    pub type_: String,
    pub authorization_token: String,
    pub method_arn: String,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomOutput {
    pub principal_id: String,
    pub policy_document: PolicyDocument,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct PolicyDocument {
    pub version: String,
    pub statement: Vec,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct Statement {
    pub action: String,
    pub effect: Effect,
    pub resource: String,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "PascalCase")]
pub enum Effect {
    Allow,
    Deny,
}

少し冗長ですが実行時にエラーが出るよりはマシなので1つ1つ丁寧にデータ型を定義しています。

同期と非同期

我々のコードは全て非同期で書かれていますが(cf プロダクションのRustコードを async / await に移行した話)lambda_runtimeは非同期サポートをしていないので明示的にブロックする必要があります。 非同期サポートが欲しいというissueはあるのですが、進捗芳しくないみたいです。 ここは期待せずにブロックするコードを書きましょう。

コード構成

コード構成はLambda依存の部分とビジネスロジックを完全に切り離しています。 具体的にはCargoのWorkspaceの機能を使ってコアとなるビジネスロジック部分と、それに依存するECSやLambdaのエントリポイント部分のパッケージを分けています。 他にも外部(RDBやその他のAWSリソース)とのやりとりもパッケージを分けていますがこれは普通の書き方ですね。

Rusoto

Lambdaで完結することは少なくて、他のAWSサービス群を利用しています。 RustからAWSサービス群を利用するのにはRusotoを使っています。 過去には機能が足りなくてPull-Requestを送ることもありますが、最近はおおむねそのまま使えています。

ただしasync/await対応(とそれに伴なう依存ライブラリのアップデート)がまだなのでasync/await移行してしまった我々のコードベースからは少し使いづらいものがあります。 これについてはPull-Requestが出ていますがマージされるまでもう少し掛かりそうです。 これらのPRがマージされ次第我々もRusotoを使っている部分を async/await に書き換えていく予定です。 本当はRusotoの依存ライブラリが他の依存ライブラリとコンフリクトしてバージョンの更新がブロックしているなどの問題も発生してりるので、 async / await 対応以外の更新もありますがそれはまた別の話。

DLL

Rusotoを使う際にSSLライブラリが依存に入ります。これについて注意点があります。 詳しくはビルドの節で説明しますがLambda内でopensslを使うのは非常にハードルが高いのです。 なのでLambda内でRusotoを使う際はopensslではなくpure rustのrustlsを使いたいです。 一方でECSなどopensslの使用に差し障りのない場面では枯れたライブラリであるopensslを使いたいです。

そこで、ECSでもLambdaでも使われるコアロジック部分は以下のようにopensslでもrustlsでも動くように作っています。

# Cargo.toml

[dependencies]
hyper = "0.12"
# hyper-rustlsとhyper-tlsをoptionalにしておく
hyper-rustls = { version = "0.16", optional = true }
hyper-tls = { version = "0.3", optional = true }
rusoto_core = { version = "0.40", default_features = false }
rusoto_credential = "0.40"
rusoto_ecr = { version = "0.40", default_features = false }

[features]
# featureで全体のtlsを切り替えられるようにしておく
default = ["native-tls"]
native-tls = ["hyper-tls", "rusoto_core/native-tls", "rusoto_ecr/native-tls"]
rustls = ["hyper-rustls", "rusoto_core/rustls", "rusoto_ecr/rustls"]
// src/lib.rs

// Rustのコード内では有効にされた方を使うようにしておく
#[cfg(feature = "hyper-rustls")]
use hyper_rustls as tls;
#[cfg(feature = "hyper-tls")]
use hyper_tls as tls;

上記のようにコードとしてはopensslまたはrustlsで動くように作っておいて、使うときに選択するようにしています。

同様にLambdaやECSのエントリポイントとなるパッケージでもフィーチャを用いてどちらでも動くようにしています。 そしてビルドするときに --no-default-features --features rustls などのオプションを渡してopensslとrustlsを使い分けています。

本当はビルド時ではなくエントリポイントの Cargo.toml 時点でフィーチャを固定したかったのですがそれは難しいようでした。 hyper-tls を有効にするECSのコードと hyper-rustls を有効にするLambdaのコードが混在する状況でワークスペースのビルド (cargo build 相当)をするとどちらのフィーチャも有効になってしまい、ビルドに失敗します。 もうちょっと言うと cargo checkcargo test もままならなくなってしまうので大変不便です。 一応、個別のパッケージ毎のビルド(cargo build -p package 相当)ではフィーチャが混在しなくなるので成功するものの、エディタ/IDEの設定ではデフォルトで -p オプションが付かないものが多いので何もせずに使える方式にしたいです。 なのでデフォルトを全て hyper-tls に寄せてしまい、Lambdaのコードをビルドするときだけ rustls を使うようにしました。

もう1つ、Lambda内からPostgreSQLにもアクセスしています。PostgreSQLを使うためにlibpqに依存しています。 これについてはビルド時に頑張ってLambda内からも使えるようにしてますので、ビルドの節を参照して下さい。

HTTP

RustでLambdaを使うときの一般論としては前項の通りですが、ALBやAPI GatewayのハンドラとしてのLambdaを使うときはもう少しライブラリのサポートがあります。 具体的にはlambda-httpクレートが用意されています。

Actcastでlambda-httpを使っているのは比較的アクセスの少ないAPIで、メインとなるエンドポイント1つとそれに付随するエンドポイント数個からなります。 小用にいくつもLambdaを作るのも管理が大変ですし、cold start問題もあります。 そこで1つのLambdaで全てのエンドポイントを処理しています。 そのLambdaではルーティングに以下のようにHTTPメソッドとリソースパスを match 式に掛けています。

let (http_method, resource_path) = match request.request_context() {
    RequestContext::ApiGateway {
        http_method,
        resource_path,
        authorizer,
        ..
    } => (http_method, resource_path),
    _ => unreachable!(),
};

match (http_method.as_str(), resource_path.as_str()) {
    ("GET", "/path1") => {
        // ...
    }
    ("GET", "/path1/{some_id}") => {
        let some_id = request.get_id("some_id")?;
        // ...
    }
    ("POST", "/path2/{other_id}/hoge") => {
        let other_id = request.get_id("other_id")?;
        // ...
    }
    ("POST", "/path2/{other_id}/fuga") => {
        let other_id = request.get_id("other_id")?;
        // ...
    }
    _ => unreachable!(),
}

マッチ対象が多くなると破綻しそうですが目で数を数えられるくらいの範囲なら問題ないでしょう。

ビルド

ECS

少しだけECSのビルドに触れておきます。

基本的にはビルドと実行のベースコンテナされ揃えればバイナリをコピーしてあげるだけで動きます。 ほとんど大したことをしないのにDockerfileを管理するのが面倒なので cargo-pack-dockerというツールを作ってCargoだけでパッケージングまで済むようにしています。

Lambda

Lambda内で動かすバイナリは普段と勝手が違うところが多くあります。 例えば何も気にせずにビルドしたバイナリをLambda環境に持っていって動かすとglibcのバージョンの問題で version `GLIBC_2.27' not found とエラーが出たりします。 また、今回の我々のようにlibpqを使いたい場合もLambdaのランタイムにlibpqが存在しないのでどうにかしないといけません。

glibcやlibpqなどのダイナミックリンクライブラリ(DLL)が上手く使えずに起動に失敗してしまう問題にはおおまかに2つの対策があります

  1. DLLを使わない
  2. DLLを頑張って使う

DLLを使わない

glibcが動かない環境でバイナリを動かしたいときのノウハウは古くからあります。 musl libcを使い、それをバイナリに静的にリンクしてしまえば実行時にlibcのDLLに依存しなくなります。 同様にlibpqについても静的リンクしてしまえば問題が発生しなくなります。

そのためにrust_musl_dockerのようにmusl libcや静的リンク可能なlibpqが入ったビルド用コンテナも用意されています。 このノウハウを使えばLambdaに限らず色々な場所で動かせるようになります。

しかし今回はこの方法は採用しませんでした。 一応libcとしてはglibcとmusl libcは互換性はありますが、実装は別物です。 細かい部分の挙動やパフォーマンス特性も違うため、glibc環境で開発しているアプリケーション(や、もっと言うと依存ライブラリも)をmusl libcで動かすのは一定のリスクがあります。 それに、今回はglibcが動かない環境という訳ではなくて、ビルド時に使ったglibcと実行時に利用できるglibcのバージョンが異なるだけです。 glibcのバージョンを揃える方向に舵を切ってみます。

DLLを頑張って使う

RustのLambdaランタイムlambda_runtimeのREADMEをよく見るとLambda向けのビルド方法が書かれています。 そこではsoftprops/lambda-rustというdockerコンテナでビルドしています。

このコンテナを詳細に調べると、Lambdaの実行環境と同じバージョンのAmazon Linuxのイメージを使ってRustをビルドしています。 現時点での実行環境のバージョンはドキュメントによると、2018.03.0.20181129-x86_64-gp2 と、いささか古いものになっています。 因みに、今のところRustの動くカスタムランタイムではAmazon Linux 2は使えないようです。

softprops/lambda-rustがそのままビルドに使えたら良かったのですが、残念ながら今回は使えません。 libpq などのライブラリも一緒に使う術が用意されていないからです。

しかし基本となるアイディアはそのまま流用できそうなので使います。 すなわち、以下のような方法を採ります:

  • Lambdaと互換性のあるAmazon LinuxのDockerイメージを使う
    • DockerイメージにRustのツールチェーンをインストールする
    • Dockerイメージにlibpqなどをインストールする
  • Dockerコンテナ内でRustをビルドし、成果物をZIPにまとめる

Lambdaの実行環境と同じDockerイメージを使いたいのですがどうやらピタリとバージョンの合うDockerイメージが配られているとは限らないようです。 今回は 2018.03.0.20191219.0 を使います。 余談ですがActcastではRaspbianをはじめとしてDebian系のOSを統一して使っています。しかしここだけRedHat系をOSを使っていることになります。

次はlibpqのインストールです。 我々はPostgrSQL 11の機能を使いたいので、libpqもそれに相当するものをインストールしたいです。 しかしAmazon Linuxのイメージが古いためyumではインストールできません。 仕方ないのでPostgreSQL 11のレジストリからlibpqをインストールしています(因みにAmazon Linux2なら yum で入るようです)。

ビルド用のイメージが準備できたとして、次はビルドです。ビルドは概ね以下のようなコマンドで行っています。

$ docker run --rm \
    -e CARGO_TARGET_DIR=/tmp/app/your_app/target/lambda \
    -v $ROOT_DIR/:/tmp/app \
    -v $ROOT_DIR/.cache/cargo/registry:/root/.cargo/registry \
    -v $ROOT_DIR/.cache/cargo/git:/root/.cargo/git \
    -t your/build_image /tmp/app/build-script.sh your_app

ここで、 $ROOT_DIR はワークスペースのルートを指す変数です。 ポイントで解説すると

  1. ビルド用のイメージ(your/build_image)内でビルドする
  2. ビルド用のスクリプト(build-script.sh)を使ってビルドする(後述)
  3. ワークスペース全体を /tmp/app にマウントする
  4. ワークスペースのルートに .cache/ ディレクトリを作っておき、そこにCargoのキャッシュを持たせる
  5. Cargoのターゲットディレクトリは target/ ではなく target/lambda にしておく

となっています。

4.と5.について補足します。 コンテナ内でビルドするときにもホスト同様ビルドキャッシュは持っておいて欲しいです。 しかしホスト環境とコンテナ環境で同じキャッシュを使うと権限の問題が発生します。 コンテナ内はrootユーザで動作しているのでコンテナ内から作ったキャッシュはrootのものになり、ホストに戻ったときに取り回しが面倒になってしまうからです。 そこでコンテナ内で使うキャッシュとホストのキャッシュを分けることで問題を解決します。 それが .cache/ ディレクトリと target/lambda ディレクトリです。 また、 target/lambda はビルド成果物であるZIPファイルの受け渡し場所としての役割もあります。

さて、次はビルドに使うスクリプト(build-script.sh)です。 概ね以下のようなことをしています。

# ビルド
cargo build --release

# バイナリ名はbootstrapにしておく
cp "$CARGO_TARGET_DIR/release/your_app" bootstrap

# bootstrapが動的リンクしているライブラリをlibに入れる。ここでは `pq` 。
mkdir -p lib
ldd bootstrap | grep pq | grep -o '=> [^ ]*' | sed 's/=> //' | xargs -I@ cp @ lib/

# bootstrapとlibをまとめてZIPに固める
zip -X "$CARGO_TARGET_DIR/release/your_app.zip" bootstrap lib/*
rm -rf bootstrap lib

ざっくりいうとビルドしてZIPに固めているだけですが、途中で動的リンクしているライブラリのパスを取得してlibディレクトリに入れています。 これでできあがるZIPファイルは以下のような構造になっています

.
├── bootstrap
└── lib
    └── libpq.so
    // その他ライブラリ

LambdaのドキュメントによるとZIPの中のlibディレクトリにDLLを入れておくと LD_LIBRARY_PATH が通っているので($LAMBDA_TASK_ROOT/lib)、実行時に参照できるようになります。

これであとはLambdaにデプロイすると期待通りに動いてくれます。 我々のシステムでは今のところTerraformからデプロイしているのでTerraformが target/lambda 以下にあるZIPファイルを参照することになります。

上記の手法はlibpqに依存した箇所はないので原理的にはあらゆるDLLを同じ方法で使えるようになるはずです。 しかし何故かopensslだけは上手くいきませんでした(crypto.oがみつからないとかなんとか…)。 未だに原因が不明なのですが、現時点では前述のとおりrustlsを使って問題を回避しています。

テスト

テストについてはlambda_runtimeのドキュメントに載っている通りのコマンドが使えます。

unzip -o \
    target/lambda/release/your_app.zip \
    -d /tmp/lambda && \
  docker run \
    -i -e DOCKER_LAMBDA_USE_STDIN=1 \
    --rm \
    -v /tmp/lambda:/var/task \
    lambci/lambda:provided

これにヒアドキュメントを使って標準入力からJSONを渡し、出力のJSONを得ています。

単体テストはこれで済むのですがインテグレーションテストはもうちょっと複雑なセットアップが必要です。 実を言うとActcastでは今のころローカル環境でのインテグレーションテストをできていません。 Lambdaは総じて多彩なAWSリソースと相互連携しながら動くのでローカルに環境を構築するのが難しいからです。 現在は開発/サンドボックス環境に実際にデプロイして動作確認や自動テストを行っています。

振り返って

RustでLambdaを使いはじめたのはカスタムランタイムとRustのサポートライブラリが発表されて間もない頃でした。 Rustを動かす情報がほとんどない上に私が個人的にLambdaを触ったのがはじめてだったこともあり、手探りで進めていって今の形に落ち着きました。

最初に作ったLambdaはAPI Gatewayの裏で動くHandlerとカスタムAuthorizerでした。 新規開発のAPIということもあり、既存のサーバとはコードベースをほとんど共有せずに作ってLambdaで動かすノウハウを獲得しました。 要するに技術的投資として新しいことを始めたのです。 PostgreSQLに接続しているのもその一環です。 複数のコンポーネントがRDBにアクセスするのはアンチパターンとされていますが、様々な状況判断からこのような選択をとりました。 そのあとに既存のコードを流用しつつ動くLambdaも作っていきました。

これらを振り返ってみます。

RustをLambdaで動かす

おおむね良さそうでした。 最初、正しく動くバイナリをビルドするのに苦戦して時間が掛かりましたが一度作り方を把握しさえしてしまえば大きな懸念なく使えます。

新規コードベースで開発した

どちらかというと良くなさそうでした。 一番最初に既存の複雑性を排して新しい技術に取り組める点は良かったです。 しかしコード(ビジネスロジック)に重複があるなどの問題がありました。 実際、他のメンバーから「最近ここに変更加えたけどLambdaの動作に影響ない?」などの問い合わせが来ることもありました。

それだけでなく、Rustのように強い静的型付言語ではコンパイラがある程度の全体整合性を検査してくれることに強みがあります。 コードベースを分けてしまうとその恩恵に与れなくなってしまいます。 コードの重複の排除だけでなく、一歩進んで積極的な整合性検査のためにもコードベースの統合が必要そうでした。

将来的に現在別のコードベース(別ディレクトリ)になっているものを1つにまとめられたらなと思っています。

まとめ

この記事ではActcastでRustをどのようにAWS上で動かしているかを紹介しました。 これで完璧と言えるものではないですし、エコシステムの今後の発展に期待すべき点もあります。 しかしある程度の規模でRustをAWS上で動かしている例として皆さまの一助になれば幸いです。

さいごに、IdeinではRustでWebサービスを作りたいエンジニアを募集しています!!