【AWS】Lambda-lithで作るWebアプリ【備忘録】

## はじめに

この記事は、社内向けWebアプリを AWS Lambda を用いたサーバーレス構成で構築した際につまったポイントをまとめた備忘録です。  
一つの Lambda 関数に API 処理をすべて集約する、いわゆる「Lambda-lith」と呼ばれるアプローチを採用しています。

## 背景

現在、社員が毎日一度利用する社内システムがあります。これを AWS へ移行することになりました。  
他システムとの連携の都合上、社内サーバで稼働している既存のデータベースは引き続き利用する必要があります。

新システムは社員のみが利用するものであり、大規模な構成は不要です。  
そのため、できるだけ小さく、シンプルに始められる構成を検討する中で、Lambda を使ったサーバレス構築に着目しました。

Lambda を使ったサーバレス構築について調べていくと、「コールドスタート」「アンチパターン」といった不安を感じさせるキーワードが目につき、一見するとあまり推奨されない構成なのかな？という印象も受けました。が、VPC Lambda の改善などにより、そうした懸念も解消されつつあるようです。  
また、アプリケーション全体を1つの Lambda にまとめてデプロイする構成は、「Lambda」と「Monolith」を組み合わせた Lambdalith という造語が作られるほど注目されており、小規模な社内システムとの相性も良いと判断しました。

## 作ったもの

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4054627/e4f272f4-b0e7-4296-84a6-f8bb2e35b8e2.png" width="500" alt="アーキテクチャ図" />

**フロントエンド**  
CloudFront + S3 + Angular

**バックエンド**  
CloudFront + Lambda + FastAPI(+Mangum)  
Lambda を VPC 内に配置し、社内サーバーに設置している API を呼び出して既存データのやり取りを行います。  
Lambda へのデプロイはコンテナイメージから行いました。

**DB**  
RDS + PostgreSQL  

**社内**  
既存システムで使っていた DB と API を配置しています。
Lambda の Elastic IP を社内ネットワークのファイアウォールで許可することで、通信経路を確保します。

## つまったポイント

### SPAをCloudFrontで配信する

SPA (Single Page Application) を CloudFront 経由で配信する際、`/login` のようにファイル名が明示されていないパスでも、常に `index.html` を返す必要があります。  
たとえば `https://example.cloudfront.net/login` にアクセスされた場合、CloudFront がオリジン (S3) に対して `/index.html` をリクエストするようにしたいです。
この要件を満たすために、今回は CloudFront Functions を利用して実装しました。

作成した CloudFront Functions は、ビヘイビア単位で設定が可能なため、API 用のビヘイビアには影響しません。

https://qiita.com/seapolis/items/0afd49d24b12749d93ab

↑の記事を参考に、以下のような関数を作成しました。
`request.uri` にファイル名が含まれない場合は常に `/index.html` を返すようにしています。

```js
function handler(event) {
    var request = event.request;
    var currentUri = request.uri;

    var doReplace = request.method === "GET" && currentUri.indexOf(".") === -1;
    if(doReplace) {
        var indexPath = `/index.html`;
        request.uri = indexPath;
    }
    return request;
}
```

作成した関数をフロントのビヘイビアのビューワーリクエストに設定します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4054627/16e20008-93b2-4bc1-bc82-f51d4421fe4b.png" width="500" alt="ビヘイビアの関数の関連付け" />

この4つの設定はビヘイビアがリクエストを受け取ってから、レスポンスを返すまでのどのタイミングで関数が実行されるかを示しています。

<img src="https://d2908q01vomqb2.cloudfront.net/b3f0c7f6bb763af1be91d9e74eabfeb199dc1f1f/2018/06/07/cloudfront-events-that-trigger-lambda-functions.png" width="500" alt="CloudFrontのイベントトリガー" />

https://aws.amazon.com/jp/blogs/news/lambdaedge-design-best-practices/

ビューワーリクエストの場合、CloudFront がリクエストを受け取ってから、キャッシュがあるかどうかを確認する前に関数が実行されます。

似たような動きをする機能で、CloudFront の「デフォルトルートオブジェクト」や、S3 静的ホスティングの「インデックスドキュメント」設定などがありましたが、これらはルート (`/`) へのアクセスにしか対応できず、SPA で必要となる任意のパス（例：`/login` や `/dashboard`）には対応できませんでした。

### Lambdaの関数URLの実行をCloudFrontからのみ許可する

Lambda を使ったサーバレス構成では、API Gateway + Lambda の組み合わせがよく利用されてきました。

<img src="https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images/APIG_tut_resources.png" width="500" alt="API Gateway Tutorial Resources" />

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/services-apigateway-tutorial.html

しかし、2022年4月に Lambda 関数 URL が登場し、さらに 2024年4月には CloudFront の OAC（Origin Access Control） が Lambda 関数 URL をサポートしたことで、「Lambda 関数を CloudFront 経由でのみアクセス可能にする」構成が実現可能になりました。  
API Gateway を使わずとも WAF を利用できるようになったのも嬉しいポイントです。

https://aws.amazon.com/jp/about-aws/whats-new/2024/04/amazon-cloudfront-oac-lambda-function-url-origins/
> CloudFront OAC を使用して、指定した CF ディストリビューションから Lambda 関数 URL へのアクセスを認証できるようになりました。OAC では AWS Signature Version 4 (SigV4) を使用しているため、意図しないユーザーが関数 URL に直接アクセスするのをブロックできます。

ただし、Lambda 関数 URL は API Gateway に比べて制限が多く、例えば WebSocket には対応していません。詳細は公式ドキュメントを参照してください。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/furls-http-invoke-decision.html

CloudFront から Lambda 関数 URL へアクセスさせるためには、以下の手順が必要です。

1. Lambda 関数を作成  
2. 関数 URL の認証タイプを IAM で設定  
3. CloudFront でオリジンを作成し、Lambda の関数 URL をオリジンドメインとして設定
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4054627/f224c1e7-e035-43af-a39e-61a4537d62df.png" width="500" alt="オリジンの作成" />  
4. オリジンの作成と同時に OAC を作成  
   <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4054627/b5ade446-1b5f-43e8-8fd9-8a1a33094a5e.png" width="500" alt="オリジンアクセスコントロールの作成" />  
5. OAC 作成時に表示されるポリシー付与コマンドを、対象の Lambda 関数に対して適用  
6. CloudFront にビヘイビアを追加し、対象オリジンへのパスパターンを設定

これで基本的な構成は完了ですが、このままでは POST リクエストが通りません。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html
> 重要
>Lambda 関数 URL で PUT メソッドまたは POST メソッドを使用する場合、ユーザーはリクエストを CloudFront に送信するときにリクエスト本文の SHA256 を計算し、本文のペイロードハッシュ値を x-amz-content-sha256 ヘッダーに含める必要があります。Lambda は、署名されていないペイロードをサポートしていません。

これを回避するためには、Lambda@Edge でハッシュを計算し、リクエストヘッダーに `x-amz-content-sha256` を追加する必要があります。  
Lambda@Edge 関数は us-east-1 リージョンでしか作成できないことにも注意が必要です。今回のシステムは ap-northeast-1 リージョンで構築しましたが、Lambda@Edge は us-east-1 で作成しました。

https://serverless.co.jp/blog/nwg365t1vv

↑は SSR の記事ですが、Lambda@Edge の作成部分と、CloudFront の作成部分が大変参考になりました。

### LambdaのパブリックIPを固定したい

今回、Lambda からインターネット経由で社内サーバーにアクセスするため、アクセス元 IP を固定する必要がありました。  
Lambda に固定 IP を割り当てたい場合、一般的な方法としては NAT Gateway を使用する構成があります。

ただし、コストが高いです。

代替手段として、Lambda 関数作成時に自動で作成される ENI に EIP を関連付ける方法があることがわかりました。

https://qiita.com/kotarou1192/items/7c435fbcc1ae3b46f10d

https://zenn.dev/nix/articles/7dd29a1e9edc55

この方法により固定 IP を割り当てることは可能ですが、以下のような制限や注意点があります。

- 接続数が増えると、新しい ENI が自動で作成され、別の IP からの通信となってしまう。  
- Lambda 関数が 30日間アイドル状態の場合、ENI が回収されるため IP の固定が解除される。  
- AWS 公式ドキュメントでも「ENI の永続性に依存しない設計を推奨」している。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-vpc.html#configuration-vpc-enis

>特定のサブネットとセキュリティグループの組み合わせを使用して VPC に関数を初めてアタッチすると、Lambda は Hyperplane ENI を作成します。同じサブネットとセキュリティグループの組み合わせを使用するアカウント内の他の関数も、この ENI を使用できます。Lambda は可能な限り既存の ENI を再利用して、リソースの使用率を最適化し、新しい ENI の作成を最小限に抑えます。各 Hyperplane ENI は最大 65,000 個の接続/ポートをサポートします。接続数がこの制限を超えると、Lambda はネットワークトラフィックと同時実行要件に基づいて ENI の数を自動的にスケーリングします。

>ENI ライフサイクルの管理の一環として、Lambda は ENI を削除して再作成し、ENI 間でネットワークトラフィックを負荷分散したり、ENI ヘルスチェックで見つかった問題に対処したりする場合があります。さらに、Lambda 関数が 30 日間アイドル状態のままである場合、Lambda は未使用の Hyperplane ENI を回収し、関数の状態をアイドルに設定します 次の呼び出しの試行は失敗し、Lambda が Hyperplane ENI の作成または割り当てを完了するまで、関数は再び保留状態になります。設計が ENI の永続性に依存しないことをお勧めします。

今回は「毎日アクセスされる」「リクエスト数がさほど多くない」「社内での運用」といった前提があるため、この方法でも 運用に大きな問題はないと判断しました。

### ElasticIPガチャ

ENIにEIPを振り、いざ Lambda から社内ネットワークにつなごうとすると、つながりません。  
`traceroute` コマンドで調査した結果、通信はインターネットプロバイダの IP アドレスで止まっており、社内ネットワークのファイアーウォールまで届いていないことがわかりました。  
新しいEIPを取得し割り振ったところ問題なく通ったので、原因はIPアドレスにあったようです。  
IP アドレスのブラックリストチェックを行いましたがヒットせず、プロバイダ独自の制限かなにかに引っかかったのかな？と推測しています。

古いですがこちらの記事も参考になりました。

http://blog.flect.co.jp/cloud/2015/11/aws-ec2-eip-c3c5.html

ちなみに、Lambda と社内ネットワーク間の疎通確認のため、 Python の `subprocess` を使っていくつかのコマンドを実行しましたが、`ping` が実行できずに軽くハマりました。  
調べてみると、Lambda からのアウトバウンド通信は TCP/IP と UDP/IP のみサポートされており、ICMP はブロックされているとのことです。

https://aws.amazon.com/jp/lambda/faqs/#product-faqs#lambda-faqs#lambda-functions

>Q: AWS Lambda 関数コードにはどのような制限が適用されますか?
Lambda では、通常の言語およびオペレーティングシステムの処理にできるだけ制限を課さないようにしています。ただし、AWS Lambda によりインバウンドネットワーク接続がブロックされること、アウトバウンド接続では TCP/IP および UDP/IP ソケットのみに対応していること、ptrace (デバッグ) システム呼び出しがブロックされることなど、無効化される処理がいくつかあります。TCP ポート 25 のトラフィックも、スパム対策のため、ブロックされます。

このように制限のある Lambda でいろいろ検証するのは手間がかかるため、特定の EIP を使って通信確認だけ行いたい場合は、一時的に EC2 インスタンスを立てて EIP を付与し、その中で確認作業を行うのが手っ取り早いと思います。

即席EC2の作成は↓の方法をよく利用させていただいています。

https://dev.classmethod.jp/articles/create-ec2-playground/

### Lambdaの同時実行数

Lambda の同時実行数が 10 に制限されていたため、同時に 10 件以上のリクエストが来るとエラーが発生していました。  
Lambda 関数の設定画面から同時実行数の変更を試みましたが、10 より大きな値は設定できませんでした。

調べたところ、AWS のサービスクォータから同時実行数の引き上げ申請が必要であることが分かりました。  
サービスクォータのグローバルなデフォルト値は 1000 ですが、今回の環境ではアカウントレベルで 10 に制限されていたようです。

サービスクォータから引き上げを申請したところ、特に問題なく 1000 に増やすことができました。

### RDS Proxy は必要か？

当初は RDS Proxy の使用を検討しており、使わないといけないものだと思っていました。

というのも、Lambda は 1 リクエストごとに 1 インスタンスが起動し、各インスタンスがそれぞれ個別の DB コネクションを必要とします。  
リクエストが増えるほどコネクション数も増加し、RDS 側に負荷がかかります。  
さらに、Lambda を API ごとに分けて実装している場合、それぞれの関数がアイドル状態でも DB コネクションを保持し、未使用のコネクションが乱立する状態になりかねません。

このような課題を解決する手段として有効なのが RDS Proxy です。  
コネクションの接続管理を効率的に行うことで、リソースの無駄を抑えつつ、安定したパフォーマンスを実現できます。  
また、IAM 認証を用いたセキュリティの強化や、障害発生時の可用性保持機能もあるそうです。

ただし、コストが高いです。

試しに RDS Proxy を使わずに、Lambda から RDS に直接接続して動作を確認してみたところ、意外と問題なく動作しました。  
Lambda の同時実行数を RDS の max_connections 未満に制限することで、リクエスト数が同時実行数を超えない限り問題なく処理可能でした。

https://dev.classmethod.jp/articles/do-you-really-need-rds-proxy

↑の記事でも RDS Proxy のメリットが薄くなるパターンとして、1つの Lambda 関数で複数のルートを処理する構成が紹介されています。

今回はとりあえず採用しないことにしました。

### Lambda Web Adapter をなぜ使わなかったのか？

A. 後から Lambda Web Adapter の存在を知ったからです。今から構築するのであればきっと採用します。

時間があれば Mangum からの移行を試してみようと思います。

## さいごに

はじめてサーバレス構築を試してみたのですが、思ったよりもきれいにまとまって満足しています。  
単純な比較はできませんが、社内の他のEC2を使った構成と比べてコストが抑えられたのもうれしいです。  
今回使わなかった Lambda Web Adapter、NAT Gateway、RDS Proxy の利用や、本番での利用を想定したセキュリティや対障害性の強化、ECS への移行なども面白そうだなと思いました。  
復習をかねて、具体的な構築手順についてもそのうち記事にできればと思います。