💡

Docker開発環境(9): Lambda で動かす Rust バイナリを Docker 化する

2021/08/28に公開

前回の続きです。

https://zenn.dev/anyakichi/articles/8b40fd81546783

前回は EC2 のスポットインスタンス上でインクリメンタル開発を行う際の docker-buildenv の活用例を検討しました。今回は AWS つながりで、Lambda 上で動作する Rust アプリケーションビルド・実行環境への docker-buildenv の活用例を検討していきます。

今回のビルド・実行環境を使うと、Lambda で動かす Rust バイナリが以下のように開発・テストできるようになります。

  • Lambda にアップロードして動かせる Rust バイナリが簡単に作成できる。
  • Lambda で実行する前提のバイナリを、ローカルで簡単に動作確認ができる。
  • Lambda で直接動作させることが可能な Docker イメージを簡単に作成できる。
  • AWS SAM CLI と連携することで、API Gateway を通過する前提の Lambda 関数をローカルで簡単に動作確認ができる。

また、今回は docker-buildenv シリーズ最終回で、少しまとめ的な内容も含まれています。

早速見ていきましょう。

docker-lambda-rust-builder の使い方

例によって完成品は既にあります。

https://github.com/anyakichi/docker-lambda-rust-builder

ベースのイメージは、オフィシャルの Lambda 実行環境である public.ecr.aws/lambda/provided:al2 です。Amazon Linux 2 では amazon-linux-extras から rust を入れることができるので、これを使用して rust をインストールした anyakichi/lambda-rust-builder:al2 と、rustup を使って最新の rust をインストールした anyakichi/lambda-rust-builder:al2-rustup の 2 つのバリエーションが用意してあります。

docker-lambda-rust-builder では extract, setup, build に加えて以下のコマンドが用意されています。

run

aws-lambda-rie を使って構築したバイナリをエミュレーターを使用して動作させます。アプリケーションはポート 8080 で動作するので、

$ din -p 8080:8080 anyakichi/lambda-rust-builder:al2 run -y

のように起動してから、

$ curl -d '{}' http://localhost:8080/2015-03-31/functions/function/invocations

とすることで Lambda 関数の実行ができます。

package

Lambda バイナリをアップロードのために zip でアーカイブします。バイナリが bootstrap という名前で生成されていることを前提に、それを zip で固める、というだけのものです。

deploy

zip 化したバイナリを aws コマンドでアップロードします。aws cli を動作させるための credencial とアップデート対象の関数名が必要なので、

$ din -v ${HOME}/.aws:/home/builder/.aws:ro \
    -e FUNCTION_NAME=lambda-rust-sample \
    anyakichi/lambda-rust-builder:al2 deploy 

のように起動する必要があります。FUNCTION_NAME を指定しなかった場合のデフォルト値としてはリポジトリ名が使われます。

ビルドの効率化

ビルドの際にはホストの .cargo ディレクトリを共有するとビルド時間を短くできることがあります。また、ビルド環境には sccache が含まれており、これを利用することでもビルド時間を短縮できます。din を起動する際に以下のオプションを追加します。

$ din -v ${HOME}/.cargo:/cargo -e CARGO_HOME=/cargo \
    -e RUSTC_WRAPPER=sccache -e SCCACHE_DIR=/cache/sccache \
    anyakichi/lambda-rust-builder:al2

$HOME/.cargo をコンテナの /home/builder/.cargo に直接マップすることもできるのですが、/home/builder 以下は uid 調整のための chown などを一括でかけられてしまうので、read-only でマップできないものは別の場所にマウントする方がおすすめです。

sccache を使うには RUSTC_WRAPPERSCCACHE_DIR の設定だけで十分です。/cache にはホスト環境の ~/.cache/buildenv が存在すればここがマップされています。詳しくは第5回の記事を参照してください。

https://zenn.dev/anyakichi/articles/2e2cfcb9af7728

Amazon Linux 2 と OpenSSL ライブラリの問題

Amazon Linux 2 環境で Rust の openssl ライブラリをビルドしようとすると依存関係の問題でエラーになります。一応 Amazon Linux 2 環境に openssl を入れれば解消できるのですが、バイナリを(Docker イメージとしてデプロイするのではなく)Lambda にアップロードする前提で考えると、ライブラリが openssl ではなく rustls を使うように変更したほうがより本質的です。

どうやれば rustls を使ってくれるかはライブラリによって異なるので一般的な解決方法はないのですが、よく使われそうなところとして rusoto 系ライブラリの場合は features に rustls を足せば、reqwest の場合は features に rustls-tls を足せば rustls を使ってくれます(Cargo.toml で default-features = false とあわせて指定してください)。

lambda-rust-sample

実際に docker-lambda-rust-builder を使ったアプリケーションとして lambda-rust-sample があります。これは受け取ったリクエストをそのまま投げ返すだけの echo 関数を提供します。

https://github.com/anyakichi/lambda-rust-sample

これまでのおさらいも兼ねて、extract から始めてみましょう。Docker イメージは何をダウンロードするべきかを知っています。Docker イメージに入ってから何も考えずに extract とすると必要なソースコードを展開してくれます。

$ mkdir lambda-rust-sample-1 && cd $_
$ din anyakichi/lambda-rust-sample:main
[builder@lambda-rust-sample-1 build]$ extract

さらに、setup から build とすると Lambda バイナリが生成されます。これも docker-buildenv では手順が決まっていることなので、ビルド対象が Yocto や AOSP のファームウェアでも Python や Node.js のアプリケーションでも変わりません。

[builder@lambda-rust-sample-1 build]$ setup
[builder@lambda-rust-sample-1 lambda-rust-sample]$ build

この時点で Lambda バイナリは target/release/bootstrap に生成されています。次に zip でアーカイブしてから実際に Lambda に deploy してみます。ちなみに deploy するためには上述のとおり .aws ディレクトリをコンテナにマウントしておく必要があります。一度コンテナを抜けてオプションを指定して起動し直して setup からやり直してみましょう。Lambda の関数名は my-first-rust-function ということにしましょう。

[builder@lambda-rust-sample-1 lambda-rust-sample]$ exit
$ din -v $HOME/.aws:/home/builder/.aws:ro \
    -e FUNCTION_NAME=my-first-rust-function \
    anyakichi/lambda-rust-sample:main
[builder@lambda-rust-sample-1 build]$ setup
[builder@lambda-rust-sample-1 lambda-rust-sample]$ package
[builder@lambda-rust-sample-1 lambda-rust-sample]$ deploy

ここまでが Lambda にデプロイする際の標準的な手順です。

次に Lamda 関数をローカルで動かしてみましょう。動かすのは run でしたね。ポートの公開とのために一回コンテナを抜けて、今度はダイレクト実行モードで動かしてみましょう(もちろん、コンテナに入ってから setup, run しても構いません)。

[builder@lambda-rust-sample-1 lambda-rust-sample]$ exit
$ din -p 8080:8080 anyakichi/lambda-rust-sample:main run

別のターミナルからアクセスしてみます。

$ curl -d '{"message": "hello world"}' \
    http://localhost:8080/2015-03-31/functions/function/invocations
{"message":"hello world"}

うまく動いています。

anyakichi/lambda-rust-sample:main は直接実行も可能なイメージとして構築されています。din を使わず(ソースコードをコンテナに見せることなく)直接 run することも可能です。

$ docker run --rm -p 8080:8080 anyakichi/lambda-rust-sample:main run -y

また、オフィシャルの public.ecr.aws/lambda/provided:al2 では第1引数にハンドラ名を渡すことでカスタムランタイムの実行が可能になっているので、それを踏襲する起動方法もサポートしています。第一引数に .handler もしくは .lambda_handler で終わる値が指定されていた場合も直接起動ができます。

$ docker run --rm -p 8080:8080 anyakichi/lambda-rust-sample:main app.lambda_handler

後者のハンドラ名を指定する方法のほうが、Lambda 本体で Docker イメージを動かす際にも使えるので推奨ではあります。

さらに、lambda-rust-sample では AWS SAM CLI を使用した実行を行うための template.yaml が付属しています。これは前述の anyakichi/lambda-rust-sample:mainapp.lambda_handler で起動する方法でセットアップされています。

AWS SAM CLI がインストールされたホスト上で、template.yaml が含まれるディレクトリから以下のように実行できます。

$ cd lambda-rust-sample
$ echo '{"message": "hello world"}' | sam local invoke LambdaRustSample -e -

ローカルでのイメージのアップデートは、イメージ名を揃えて以下を実行するだけです。

$ docker build -t anyakichi/lambda-rust-sample:main .

もちろん、template.yaml の ImageUri の値を変えても構いません。

ちなみに、AWS SAM CLI を使う場合は素の Lambda の provided.al2 にバイナリだけ配置するようなコンフィギュレーションも可能です。

template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Sample rust application for Lambda

Resources:
  LambdaRustSample:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: provided.al2
      CodeUri: .
    Metadata:
      BuildMethod: makefile
Makefile
build-LambdaRustApi:
	din anyakichi/lambda-rust-sample:main build -y
        cp target/release/bootstrap $(ARTIFACTS_DIR)

この方法は SAM をメインで使って docker-buildenv は補助ツールとして使っていくような位置づけになるかと思います。

実際に使うときには sam build が事前に必要です。

$ sam build
$ echo '{"message": "hello world"}' | sam local invoke LambdaRustSample -e -

この方法では din <image> build -y に cargo ディレクトリや sccache を指定することでビルドの高速化もしやすいので、開発しながらの動作確認という意味では効率的かもしれません。今回は Lambda で直接実行可能な Docker イメージという説明の都合と、ソースツリーに Makefile を置かなきゃいけないのがややこしい感じがして避けたかったので、Docker イメージを使う方法を採用しています。

ちなみに、Docker イメージを使う方法でも、構築済みのバイナリをコピーするだけの Dockerfile を用意しておけばスピードアップすることができます。実際に Dockerfile.devel としてバイナリをコピーするだけの Dockerfile が用意してあるので、以下のようにすれば Lambda 関数だけアップデートしたイメージを構築できます。

$ docker build -f Dockerfile.devel -t anyakichi/lambda-rust-sample:main .

AWS SAM CLI で動かせる Docker イメージは、当然実際の Lambda 環境でも動かすことができます。Docker イメージを Lambda 上で使用する手順については意外と複雑なのでここでは紹介しませんが、CMD を app.lambda_handler に変更しておくのさえ忘れなければそのまま動かすことができるはずです。

lambda-rust-api-sample

もうひとつ docker-lambda-rust-builder を使用したアプリケーションとして、lambda-rust-api-sample があります。こちらは API Gateway 経由で実行される想定のアプリケーションで、提供する機能としてはやはり値を echo するだけのものになっています。

https://github.com/anyakichi/lambda-rust-api-sample

lambda-rust-sample と基本的な使用方法は同じです。以下のように AWS SAM CLI で start-api を使用するとローカルで API Gateway と(ほぼ)互換のサーバを動かすことができます。

$ sam local start-api

ポート 3000 で API サーバが起動するので、curl でアクセスしてみます。

$ curl -d '{"message": "hello world"}' http://localhost:3000/
{"message": "hello world"}

メッセージがエコーバックしてくれば OK です。これでローカル環境で API Gateway を必要とするような Lambda 関数のテストもできるようになりました。

template.yaml で定義されているのは API Gateway の REST API 相当で、HTTP API を使ったバージョンが template_v2.yaml に定義されています(これは実際には API Gateway Request version 2 のリクエスト・レスポンスの型になります)。

$ sam local start-api -t template_v2.yaml

しかし、現状これはうまく動きません。 aws-lambda-rust-runtimeaws-sam-cli のどちらかにパッチを当てれば動かせるのですが、どうもプロトコル仕様が厳密に決まっていないようでどちらを直すのが正しいのかすらよくわからない、という状況です。とりあえずテストをするだけなら REST API として動かしていてもあまり困らないと思うので(lamda-http を使っていれば、REST API でも HTTP API でもどちらでも動作するバイナリが作れます)、REST API としてテストして HTTP API でデプロイするのでも良いのではないかと思います(実環境でなら HTTP API でも動作します)。

まとめ

これまで 9 回に渡って docker-buildenv の考え方とその応用について解説してきました。いろいろな応用例は上げてきましたが、結局は markdown で記載されたビルドや実行の手順書が含まれた Docker イメージがあり、この手順書に書かれた内容を直接実行できる、というのがほぼ全てです。

それに付随して

  • ビルド手順を忘れても Docker イメージさえわかればビルドはできる
  • 誰でも同じ手順ですぐにビルド開始できる(デフォルトの手順であれば)
  • CI でも同じ手順でビルドできる
  • 決められた手順以外のコマンドでもコンテナ内から何でも叩ける
  • ビルドマシンの移行が簡単にできる
  • クラウドでも気軽にビルドを始められる

などなど、たくさんのメリットが生まれてきます。そしてこれらの機能を自然にサポートするために、uid/gid の動的変更機能を使ってファイルの所有者が root になってしまう問題を避けているのでした。

このシステムの原型は 2016 年には出来上がっていて、私の所属する会社内では 5 年以上の運用実績があります。今回の連載は基本的にはその中で得られた知見をまとめたものです(myenv と EC2 でのビルド方法については今回新たに整備したものなので、まだ検討の十分でないところがあるかもしれません)。

開発当時と比べれば今では Docker をビルドに使うというのもすっかり普通になりましたが、より便利に開発に使っていくための助けとなれば幸いです。

付録

SAM で HTTP API が動くようにするための、現時点での aws-lambda-rust-runtimeaws-sam-cli に対するパッチです。

どちらか片方の適用のみで十分です。個人的な感触ではどちらかといえば aws-sam-cli が 8 対 2 くらいで悪いと思いますが、aws-sam-cli の方の修正はかなり適当です…。

patch for aws-lambda-rust-runtime

diff --git a/lambda-http/src/request.rs b/lambda-http/src/request.rs
index 352ba78..1797d17 100644
--- a/lambda-http/src/request.rs
+++ b/lambda-http/src/request.rs
@@ -37,7 +37,7 @@ pub enum LambdaRequest<'a> {
         query_string_parameters: StrMap,
         #[serde(default)]
         path_parameters: StrMap,
-        #[serde(default)]
+        #[serde(deserialize_with = "nullable_default")]
         stage_variables: StrMap,
         body: Option<Cow<'a, str>>,
         #[serde(default)]
@@ -127,8 +127,10 @@ pub struct ApiGatewayV2RequestContext {
     #[serde(default)]
     pub authorizer: HashMap<String, Value>,
     /// The full domain name used to invoke the API. This should be the same as the incoming Host header.
+    #[serde(default)]
     pub domain_name: String,
     /// The first label of the $context.domainName. This is often used as a caller/customer identifier.
+    #[serde(default)]
     pub domain_prefix: String,
     /// The HTTP method used.
     pub http: Http,
@@ -137,10 +139,13 @@ pub struct ApiGatewayV2RequestContext {
     /// Undocumented, could be resourcePath
     pub route_key: String,
     /// The deployment stage of the API request (for example, Beta or Prod).
+    #[serde(deserialize_with = "nullable_default")]
     pub stage: String,
     /// Undocumented, could be requestTime
+    #[serde(default)]
     pub time: String,
     /// Undocumented, could be requestTimeEpoch
+    #[serde(default)]
     pub time_epoch: usize,
 }

patch for aws-sam-cli

diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py
index 5a6d397d..4cb6c735 100644
--- a/samcli/local/apigw/local_apigw_service.py
+++ b/samcli/local/apigw/local_apigw_service.py
@@ -701,6 +701,7 @@ class LocalApigwService(BaseLocalService):
         """
         # pylint: disable-msg=too-many-locals
         method = flask_request.method
+        host = flask_request.host

         request_data = flask_request.get_data()

@@ -721,7 +722,7 @@ class LocalApigwService(BaseLocalService):
         cookies = LocalApigwService._event_http_cookies(flask_request)
         headers = LocalApigwService._event_http_headers(flask_request, port)
         context_http = ContextHTTP(method=method, path=flask_request.path, source_ip=flask_request.remote_addr)
-        context = RequestContextV2(http=context_http, route_key=route_key, stage=stage_name)
+        context = RequestContextV2(http=context_http, route_key=route_key, stage=stage_name, domain_name=host)
         event = ApiGatewayV2LambdaEvent(
             route_key=route_key,
             raw_path=flask_request.path,
diff --git a/samcli/local/events/api_event.py b/samcli/local/events/api_event.py
index e2897a68..358de48f 100644
--- a/samcli/local/events/api_event.py
+++ b/samcli/local/events/api_event.py
@@ -289,6 +289,9 @@ class RequestContextV2:
         request_id=str(uuid.uuid4()),
         route_key=None,
         stage=None,
+        domain_name=None,
+        request_time_epoch=int(time()),
+        request_time=datetime.utcnow().strftime("%d/%b/%Y:%H:%M:%S +0000"),
     ):
         """
         Constructs a RequestContext Version 2.
@@ -307,6 +310,9 @@ class RequestContextV2:
         self.request_id = request_id
         self.route_key = route_key
         self.stage = stage
+        self.domain_name = domain_name
+        self.request_time_epoch = request_time_epoch
+        self.request_time = request_time

     def to_dict(self):
         """
@@ -326,6 +332,10 @@ class RequestContextV2:
             "requestId": self.request_id,
             "routeKey": self.route_key,
             "stage": self.stage,
+            "domainName": self.domain_name,
+            "domainPrefix": self.domain_name,
+            "timeEpoch": self.request_time_epoch,
+            "time": self.request_time,
         }

         return json_dict
	 @@ -412,8 +422,9 @@ class ApiGatewayV2LambdaEvent:
             "requestContext": request_context_dict,
             "body": self.body,
             "pathParameters": self.path_parameters,
-            "stageVariables": self.stage_variables,
             "isBase64Encoded": self.is_base_64_encoded,
         }
+        if self.stage_variables:
+            json_dict["stageVariables"] = self.stage_variables

         return json_dict
@@ -412,8 +422,9 @@ class ApiGatewayV2LambdaEvent:
             "requestContext": request_context_dict,
             "body": self.body,
             "pathParameters": self.path_parameters,
-            "stageVariables": self.stage_variables,
             "isBase64Encoded": self.is_base_64_encoded,
         }
+        if self.stage_variables:
+            json_dict["stageVariables"] = self.stage_variables

         return json_dict

Discussion