axum on LambdaでHTTPレスポンスをストリーミング配信(Server-Sent Events)する
はじめに
axumはRust製のWebサーバーフレームワークです。最近「RustによるWebアプリケーション開発 設計からリリース・運用まで」を読み非常に使いやすいフレームワークだと思ったので、趣味のWeb(HTTP)サーバーはこちらを使おうと思いました。
ただALBやECS構成だと、料金的に高いので、サーバーレスで運用しつつHTTPレスポンスストリーミングが利用できる構成を検討してみました。
構成
構成は先日のServerlessDays Tokyo 2024で「AWS Lambda Web Adapterが可能にした新しいサーバーレスの実装パターン」で紹介されていた構成が良いなと思いました。
具体的なメリットは資料にある通りですが、個人的には以下の点が気に入っています。
- Lambda Web Adapterを利用するため、ローカルでそのまま動作する(=Lambdaに特化したコードが必要ないため可搬性がたかい)
- Lambdaの起動時間(900秒)を生かした、長時間のストリーミング
- 前段にCloudFrontを置くことでWAFでLambda Function URLsを保護できる
レスポンスストリーミングには、この構成ではTransfer-Encoding: chunked
とServer-Sent Events
がよくあり、後者はあまり記事がなかったので実際にできるか試してみました。
実装
今回のアプリ実装のリポジトリは以下の通りです。全体像がわかりにくい場合はこちらを参考にしてください。
アプリケーション(axum)実装
今回は3つのエンドポイントを作成します。GETとPOSTでそれぞれSSEの検証します。なぜ両方のメソッドで検証するのかは後述します。
メソッド | パス | HTTPリクエストボディ | 説明 |
---|---|---|---|
GET | /hello | - | シンプルなGETレスポンス |
GET | /sse | - | GETメソッドでSSE。数字をインクリメントするレスポンスを返却(1秒間隔,15回) |
POST | /sse | {"count": 10} | POSTメソッドSSE。数字をインクリメントするレスポンスを返却(1秒間隔,countで指定した数字回) |
アプリケーションコードは以下の通りです。
インフラ実装
axum用のDockerfileの作成 + Lambda Web Adapterの導入
先ほどのaxumをホスティングするDockerfileを書きます。マルチステージビルドで、ビルドし、バイナリを配備するようにします。
この1行で、Lambda Web Adapterの導入は完了です。
axum用のLambda
シュッと使えることを目的にしているので、コンテナレジストリ(ECR)は意識しないようにfromImageAssetsを利用して、ローカルでビルドしたものをホスティングします。
レスポンスストリームに対応するため、2箇所invoke modeの設定を忘れないようにしましょう。!記載の部分です。
AWS_LWA_INVOKE_MODE
はコンテナ側に書いても問題ないです。
CloudFront + OAC + Lambda@Edge
CloudFrontとLambda@Edgeの実装は、こちらの記事と実装が非常に参考になります。
今回は上記の記事同様に、Lambda@Edgeではbodyのハッシュ値だけ計算し、sigv4はOACで署名します。
今回POSTも検証するのはこのLambda@Edgeの実装が正しく動作していることを検証するためです。
検証
スタックをデプロイすると以下のコマンドが出力されます。1つずつ実行します。
$ npx cdk deploy web-app-stack
Bundling asset lambda-edge-stack/LambdaEdgeFunction/Code/Stage...
(中略)
✅ web-app-stack
✨ Deployment time: 27.63s
Outputs:
web-app-stack.ApiCommands = CloudFront URL: https://CLOUD_FRONT_ENDPOINT
curl commands:
curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/hello
curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/sse
curl -v -H "Content-Type: application/json" -d '{"count": 10}' -X POST https://CLOUD_FRONT_ENDPOINT/sse
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:622455551446:stack/web-app-stack/a21a2910-8dc2-11ef-b51b-0e569e4b2f3d
✨ Total time: 29.1s
GET /hello
$ curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/hello
Note: Unnecessary use of -X or --request, GET is already inferred.
* Trying 65.9.37.144:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.144) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: CN=*.cloudfront.net
* start date: Jul 30 00:00:00 2024 GMT
* expire date: Jul 3 23:59:59 2025 GMT
* subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
* issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/hello
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /hello]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
> GET /hello HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
>
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Sat, 19 Oct 2024 02:45:24 GMT
< x-amzn-requestid: 4a5dce5a-7560-49e4-9f19-175d45b7d583
< x-amzn-remapped-content-length: 12
< x-amzn-trace-id: Root=1-67131d44-7ef610622128a07630afc1db;Parent=143aee524dbc825b;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sat, 19 Oct 2024 02:45:24 GMT
< x-cache: Hit from cloudfront
< via: 1.1 113c59bcc7514e6035b0efada4559c76.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: FA_oiqc8rv-_rw-VWOpsN3HBfV_aCDFrzVWGfHA2lG2Zfu8v49Fl7A==
< age: 78258
<
* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact
Hello, axum!
GET /sse
$ curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/sse
Note: Unnecessary use of -X or --request, GET is already inferred.
* Trying 65.9.37.144:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.144) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: CN=*.cloudfront.net
* start date: Jul 30 00:00:00 2024 GMT
* expire date: Jul 3 23:59:59 2025 GMT
* subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
* issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/sse
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /sse]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
> GET /sse HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
>
< HTTP/2 200
< content-type: text/event-stream
< date: Sun, 20 Oct 2024 00:30:22 GMT
< x-amzn-requestid: 64efb055-2c41-48ee-bad6-4434eed1d849
< cache-control: no-cache
< x-amzn-trace-id: Root=1-67144f1e-428762b77f4a9ddd65dcf8e7;Parent=61976a1d9f114323;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sun, 20 Oct 2024 00:30:22 GMT
< x-cache: Miss from cloudfront
< via: 1.1 f46e301bb0f5ba5ccb0896790f796b42.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: q6uB1mNo2yZeXoE1aC38kCFtiJn9XRAOX5YrqBMLvtZMhC7xtazW3A==
<
data: 0
data: 1
data: 2
data: 3
data: 4
data: 5
data: 6
data: 7
data: 8
data: 9
data: 10
data: 11
data: 12
data: 13
data: 14
data: [DONE]
* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact
POST /sse
$ curl -v -H "Content-Type: application/json" -d '{"count": 10}' -X POST https://CLOUD_FRONT_ENDPOINT/sse
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 65.9.37.88:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.88) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: CN=*.cloudfront.net
* start date: Jul 30 00:00:00 2024 GMT
* expire date: Jul 3 23:59:59 2025 GMT
* subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
* issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/sse
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /sse]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
* [HTTP/2] [1] [content-length: 13]
> POST /sse HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 13
>
< HTTP/2 200
< content-type: text/event-stream
< date: Sun, 20 Oct 2024 00:31:12 GMT
< x-amzn-requestid: 1b49bf41-26a2-44da-abf9-22e54348e0cb
< cache-control: no-cache
< x-amzn-trace-id: Root=1-67144f50-134aa8517f485d875f6792c5;Parent=243cb83d67eebac8;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sun, 20 Oct 2024 00:31:12 GMT
< x-cache: Miss from cloudfront
< via: 1.1 1f83e59f609910f3106a87395db1ee4a.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: nSSTekpgusXkOdF6EmgStnD9G4ZmT1msShOPnJlxjQstPcbeYSUenw==
<
data: 0
data: 1
data: 2
data: 3
data: 4
data: 5
data: 6
data: 7
data: 8
data: 9
data: [DONE]
* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact
起動通りの結果が得られました。
さいごに
Lambda Web Adapterは手軽に導入できて便利でした。Honoやexpress等を使って環境変数で起動の判定をすることは可能ですが、両方の起動方法を保守しなくて良くなるため、心理的にコストが下がることを実感しました。
パフォーマンス面はこれから運用してみて、また記事を書こうと思います。