注目の記事

最近の業務での AWS サーバーレス開発を振り返ってみた

フルサーバーレスで Web アプリケーションを開発してきた振り返りの記事です。
2022.05.16

CX 事業本部 MAD 事業部の佐藤です。 今まで AWS Lambda を使用した Web アプリケーションの開発プロジェクトで、バックエンド・フロントエンド・インフラを一貫して開発をしてきました。 改めて日々どのように開発をしていたのかと使った技術スタック、各サービスをどのように使用したかを整理したいと思い記事にしました。今後、サーバーレス開発を行う際の技術選定の参考にしていただければ幸いです。

前提

  • AWS を使った Web アプリケーションを前提としています。

  • バックエンドの Web API があり、管理画面用の内部 API と外部のサービスと連携するための外部 API があります。

  • API の処理としてはリソースの CRUD がメインです。

  • 管理画面は SPA を使っていて、バックエンドの Web API にリクエストします。

  • サーバーレスでは処理が分散しているので、必然的にマイクロサービス構成になります。

  • プロジェクトメンバーは、3,4 人ほどでフロントエンドエンジニア、バックエンドエンジニアといった区分けはしていません。機能ごとに全員がバックエンドからフロントエンドまで一気通貫で実装していく方法で開発をしてきました。技術を見る範囲は広くなってしまうのですが、そのかわり各機能を一人のメンバーが独立して実装でき、スピード感を持って開発できたと思います。

  • AWS インフラについても、サーバーレスサービスをメインで使っているので、インフラエンジニアは存在しなく、あくまでもアプリケーション開発をするエンジニアがインフラ部分も担当しています。

  • 運用負荷の軽減やコスト最適化のため、EC2 や RDS などの VPC が絡むサービスはまったく出てこない構成になっています。

プログラミング言語

TypeScript

バックエンド・フロントエンド・IaC まで言語はすべて TypeScript で統一しました。使い慣れたメンバーが多いのと型の恩恵が大きいです。

フロントエンドとバックエンドのコードを TypeScript で統一するのはよくある構成だと思いますが、AWS CDK の登場でインフラの定義までもが TypeScript に統一が可能になりました。

バックエンドについては、Web アプリケーションフレームワークは使用していません。フレームワークの責務は Amazon API Gateway が担ってくれていたためです。

最近では、 App Runner が GA されたので、今後はそっちでコンテナー化して使うことは多くなりそうですが、こと Lambda の開発においてはあまりフレームワークは使われていない印象です。今後の案件で、サーバーレスで開発するとしたら、AWS App Runner を技術選定の最初の候補にしそうです。

バックエンド

AWS Lambda

バックエンドのコードはすべて Lambda で統一しています。Lambda は1つの処理に対して1つの関数を用意して、API の数だけ Lambda 関数が存在するような構成です。他にも S3 の Put をトリガーにした関数や、EventBridge を使ったイベント駆動な関数、StepFunctions の各処理にも使用しています。個々の関数は別だとしても全体として見ると 1 つのアプリケーションなので、アプリケーション設計が必要です。Clean Architecture のような構成で、Lambda のハンドラーがあり、内部ではドメイン層・ユースケース層・インフラ層といった形で実装してテストが書きやすい構成をとっていました。この辺の詳しい実装の話は以下の記事がわかりやすいのでサーバーレス開発をする際には参考にしてください。

Amazon API Gateway

Lambda や各サービス(Kinesis, SQS など)の前段に置いて、Web API として公開するために使いました。

主に、管理画面からリクエストされる内部向けの API と、各サービスと連携するために使用する外部向けの API が存在しています。

内部向けの API については、JWT(JSON Web Token)を使用した認証があります。JWT については後述する Auth0 を使って、フロントエンドのログイン後に取得したトークンを API の HTTP ヘッダーに付与して認証しています。

外部向けの API については、エンドユーザーが使用するような公開 API なので認証はありません。

各サービスと連携する API については、Auth0 の M2M(Machine to Machine)トークンを使用して認証しています。

JWT については、Lambda Authorizer を使って検証しています。検証に成功したら、IAM ポリシーを返却して各 AWS サービスにアクセスできるような構成です。

AWS AppSync

API Gateway と AppSync を同時に使っているのかと疑問に思った方もいるかもしれません。各サービスや使用している SaaS(Auth0 など)ごとにエンドポイントが存在し、このままフロントエンドで扱おうとすると各エンドポイントを個別で見る必要があり、煩雑になってしまう懸念がありました。 そこで、AppSync の HTTP Resolver という機能を使うことで各サービスのエンドポイントや SaaS の API などを AppSync に集約する構成となりました。

HTTP Resolver とは、公開されている API に対して AppSync からパスを指定してリクエストして、レスポンスを GraphQL スキーマにマッピングできる機能です。簡易的な BFF のような構成を作ることができます。

これにより、フロントエンドは GraphQL スキーマにしたがって実装するだけで良くなり、スキーマさえあらかじめ決めておけば、バックエンド側はそれにそってレスポンスを返却する形で良くなりました。

AppSync のパイプラインリゾルバーと HTTP リゾルバーを組み合わせれば、一度に複数サービスからデータをフェッチ、マージしてレスポンスするなどの処理も記述が可能です。VTL のマッピングテンプレートで行うこともできますが、Lambda を使う事もできます。

Amazon DynamoDB

メインで使用したデータベースです。アプリケーションで扱うデータはすべて DynamoDB に保存しました。主に CRUD のリソースを key-value 形式で保存するのに使用しています。ただ、DynamoDB だと柔軟な検索はできません。なので、ある程度最初の設計段階でどのようなユースケースのデータなのかを洗い出して、キーの設計をしないといけないのが難しいところです。

検索については、ハッシュキー指定、ソートキーによる前方一致検索しかできません。厳密には、Filter を使うことで検索はできますが、 必ず全件スキャンがかかってしまうのでキャパシティユニットの観点からオススメできません。キーは慎重に設計する必要があります。GSI を使うことで、非同期レプリケーションされた別テーブルのようなものを作成して、違うハッシュキー・ソートキーで検索することもできますが、SQL のような AND 条件での検索などは現実的に難しいです。また、GSI も 20 個までの制限もあります。

この辺のベストプラクティスについては、AWS 公式がドキュメントに記載しているので、DynamoDB を使う前には良く読むことをオススメします。

Amazon S3

アプリケーションで扱う静的ファイルデータはすべて S3 に保存しています。静的ファイルの配信、アプリケーションからアップロードするファイルの保存、後述する Athena でクエリするための集計元データの保存に使用しました。

Amazon Kinesis Data Firehose

集計用のログデータを S3 に保存していくのに使用しました。S3 のキーを時系列に保存してくれたり、圧縮も自動的にしてくれます。また、Lambda を使うことでデータを変換してから S3 に保存することもできます。

最近だと動的パーティショニングで、特定のパス名で S3 に保存することも可能になったので、Athena を使ったパーティションなどもやりやすくなったと思います。

元データは JSON で Firehose に送って、Parquet 形式に変換したりもできます。

サーバーレスの場合は、あとでデータの集計や分析がスムーズに行えるように、ストリーミングデータ・ログデータ・集計元データなどはとりあえず S3 に保存していき、必要になったタイミングで変換・クエリを行うのが良いと思いました。

Amazon Athena

Athena は S3 にあるデータを SQL でクエリできるサービスです。GROUP BY と SUM などを使用した集計を S3 のデータを対象にクエリできるので、DynamoDB では難しい集計処理を実現するのに使用しました。

実現方法としては、DynamoDB にデータが保存されるタイミングで、Kinesis Data Firehose 経由で集計対象データを S3 に保存します。S3 には JSON Lines や CSV 形式で保存しておきます。クエリの要件を満たせるのであれば、Parquet や ORC などの列指向フォーマットで保存して置くのが、コスト面、処理速度面で良いと思います。

スキーマがある程度決まっているデータの場合は、事前に S3 の保存したデータに対応する Glue のテーブルスキーマを作成しておき、そこに Athena でクエリをかけることになります。スキーマが動的に変更されるようなデータの場合は、Glue のクローラーを使うのも良いと思います。

Athena を使うことで、DynamoDB を使いつつも集計の要件も満たすことができました。また、Athena にはクエリ結果を CSV などのデータ形式で S3 に保存してくれるので、集計結果を CSV 出力したいなどの要件もこの機能を使うことで満たすことができました。

Athena で対応するデータ形式については、以下のドキュメントで確認できます。

AWS StepFunctions

時間のかかる非同期処理や、Lambda を並列処理して処理を高速化するのに使用しました。StepFunctions は AWS CDK と相性がとても良いと感じています。本来なら yaml を書いてワークフローを記述する必要があるんですが、CDK を使うことで直感的に記述でき、開発効率が上がります。ステートマシンについてはすべて AWS CDK で記述しました。

また、最近だと Workflow Studio も出てきて、視覚的にもわかりやすく、直感的に処理を記述できるようになっています。まず最初の叩きを Workflow Studio で作ったあと、AWS CDK でコーディングして IaC 化するという方法も取れそうです。

Auth0

極力、ログイン認証やログイン画面などに工数はかけたくなかったため、Auth0 という SaaS を使用して認証周りを開発しました。Auth0 のユニバーサルログインを使用することで、Auth0 が提供してくれるログイン画面をアプリケーションに簡単に組み込むことができます。

他にも、ユーザー管理、ロール、権限管理などもすべて Auth0 で完結するようにしています。フロントエンドではユーザーに紐づく権限によって機能を表示したり非表示にしたりといった機能を実装したり、API のアクセス制御に使用しています。

外部サービス間やマイクロサービス間の認証には、M2M(Mchine to Machine)認証を使用しました。

フロントエンド

React

フロントエンドは React の SPA で実装しました。SSR(サーバーサイドレンダリング)する要件はなかっため Next.js は使わずにシンプルにCreate React Appを使用しました。ルーティングに React Router、フォームに React Hook Form を使用しています。ステート管理には、React 標準の useState と useContext を使うようにして Redux や他のステート管理ライブラリは使用しませんでした。API レスポンスのキャッシュや状態管理は後述する Apollo Client を使用しました。

Apollo Client

フロントエンドの GraphQL クライアントとして使用しました。バックエンドの各サービスのエンドポイントを統合するために AppSync を使った BFF(Backend for Frontend)の構成を取ったためです。

GraphQL Code Generator を使って GraphQL スキーマから ApolloClient の Custom Hooks を自動生成をして開発の効率化をしていました。

この GraphQL Code Generator が優秀でこれで自動生成された Custom Hook を使うだけでフロントエンドからのフェッチ処理はほぼすべて実現できました。

MUI (Material UI)

フロントエンドの CSS フレームワークとして使用しました。開発チームにはデザイナーや専任のフロントエンジニアがいなかったのと、社内で使う管理画面でしたので、今後の保守も考えなるべく CSS を書かないようにして標準のコンポーネントをそのまま使用するようにしました。レイアウトは、Grid や Box を使って構成し、マージンの調整にちょっと CSS を書くだけで済みました。社内で使用するだけの管理画面などデザインをそこまで凝らなくて良いような画面の場合は MUI のような CSS フレームワークをフル活用するのは良い選択だと思います。

ここまでで、開発に使用した技術スタックをひととおり紹介しました。ここからはブランチや CI/CD など日々の開発フローについてざっくりと紹介したいと思います。

ブランチ戦略

以下の4つのブランチで GitHub を使って日々の開発をしていました。比較的シンプルな構成だと思います。

main

本番環境です。main は常にデプロイ可能な状態を維持します。常に本番環境と一致する状態を目指します。

develop

ステージング環境と同等です。機能開発が終わったのものが含まれています。主に、ステージング環境での動作確認を行います。リリースのタイミングで、main ブランチにプルリクエストを送チームメンバーでレビューして、Approve されたばマージされて本番環境に反映されます。

feature

開発している機能ごとのブランチです。各メンバーはこのブランチで機能別に開発をします。 develop からブランチを切ります。開発が終わったら、develop ブランチにプルリクエストを投げてステージング環境にデプロイされます。

hotfix

本番環境での緊急の不具合の修正に使用します。緊急用なのでmainブランチに直接プルリクエストを送ります。

CI/CD 構成

CI/CD については、バックエンド用の CI/CD とフロントエンド用の CI/CD があります。

バックエンド CI/CD

バックエンドの CI/CD には、AWS の CodePipeline、CodeBuild を使用しました。CI については、CircleCI や GitHub Actions など自分の使い慣れたものを使えば良いと思います。AWS CDK を使っていたこともあり、CDK で CodePipeline、CodeBuild の定義も一緒に記述でき、この構成にマッチするのと使い慣れているため採用しています。

パイプラインについては同じ AWS アカウント内の各環境ごとに用意しています。

開発環境パイプライン

develop ブランチにプルリクエストがマージされると起動し、AWS CDK により、各 Lambda の作成、更新や各 AWS リソースの作成。デプロイされた API にリクエストして行う E2E テストを実行します。

ステージング環境パイプライン

develop ブランチにマージされると起動します。承認ステージを設けて、承認した場合のみ検証環境に自動でデプロイされます。

本番環境パイプライン

main ブランチにマージされると起動します。承認すれば本番環境に自動でデプロイされます。

フロントエンド CI/CD

フロントエンドのホスティングには、Amplify Console を使用していたので、そのまま CI/CD にも使用しました。こちらも AWS CDK により各環境の Amplify App を用意して、バックエンドのパイプラインと同様に各環境をブランチに紐付け自動でデプロイされるように設定しています。

たとえば、developブランチにマージされれば、バックエンドでは CodePipeline、フロントエンドでは Amplify Console の CI/CD が同時に動くようになっています。

フルサーバーレス開発で苦労したポイント

ここまでで、技術スタックや開発フローを見てきましたが、フルサーバーレスで開発をしてきていくつか苦労したポイントや難しい部分があったので、どのように検討したかや解決してきたかをいくつか紹介したいと思います。サーバーレスの問題というよりは、DynamoDB を使ったことによる苦労したポイントです。

DynamoDB の検索

基本的にはキーを指定して値を取り出すということしかできません。セカンダリインデックスを使ったり、データの保存の仕方を工夫することで、ある程度までは検索の要件を満たすことができると思います。

実際に、アプリケーション開発をしていて、検索周りで苦労することが多かったです。たとえば、あいまい検索をしたい場合などです。 これについては DynamoDB を使う以上、ある程度妥協する必要があると考えています。セカンダリインデックスを使っても AND 条件での検索は難しいですし、かといって Scan & Filter を使うと、キャパシティユニットをムダに消費してしまうなどの問題があります。 柔軟に検索する必要がある場合は、Algoria などの SaaS を使ったり、ElasticSearch などのサービスを使用する必要があると思います。

DynamoDB のベストプラクティスでは、事前にアクセスパターンやユースケースを洗い出してからテーブル設計するということが書かれています。 これは検索が柔軟にできないことと密接に関わっていて、必要最小限の検索で目的のデータにアクセスできるようにテーブルを設計する必要があるということです。 とはいっても、事前にすべてのアクセスパータンを網羅して完璧なテーブル設計をするのは難しいので、検索には検索用サービスを別途用意するのが無難な選択肢だと思います。

DynamoDB の集計

DynamoDB はデータをグループ化した集計はできません。あるデータを GROUP BY して SUM する要件がありましたが、データベースに DynamoDB を使っている都合上 SQL を使うような柔軟な集計はできませんでした。

集計用に RDB を用意して、DynamoDB Streams 経由で保存するような構成も考えました。ですが、ここまでサーバーレスで開発をしてきて、VPC などのネットワークの要件が必要になってしまうのは本末転倒間があるので、なるべくサーバーレスで完結できないかを考えました。

結論としては、DynamoDB の保存をトリガーに、Kinesis Data Firehose 経由で S3 に集計用データ保存し Athena でクエリするという方式にしました。集計に使うログデータは DynamoDB とは別に S3 側に持っておき、Kinesis Data Firehose を使って継続的に保存しておきます。

集計用のデータを準備して、構造を決めて、Glue スキーマを定義します。あとは Athena で SQL を書いてクエリすれば集計したデータが取れるので、この方法で解決できました。また、Athena は実行結果を CSV 等のデータ形式で S3 に保存してくれるので、そのデータを S3 からダウンロードすれば集計済みの CSV を取得することもできます。集計済みのデータを CSV でほしいと言うような要件はよくあるのではないでしょうか。

まとめ

雑多な技術サービス紹介記事のようになってしまいましたが、サーバーレスでがっつり Web アプリケーションを開発してきた経験として、ある程度の規模であれば十分に開発が可能だと考えます。Lambda を使うことで、運用負荷も軽減され、コスト最適化のメリットもあります。スケーラビリティも担保できます。

ただし、やはり開発のしやすさでは、Web アプリケーションフレームワークを使ってコンテナー化して...という方が、Web 上の情報量も多いですし、開発がしやすいのは事実だと思います。実際にデータや処理が分散することによる開発のしづらさというのは感じました。ただ、ユースケースによっては、それを上回るコストメリットやスケーラビリティを得ることができると思うので、今後も全部とは言わなくとも部分的に採用していくと思います。