注目の記事

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

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

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

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

前提

  • Web アプリケーションです。
  • 管理画面用の内部 Web API、外部のサービスと連携するための外部 Web API があります。
  • 処理としてはリソースの CRUD がメインです。
  • 管理画面は SPA で、バックエンドの Web API にリクエストします。
  • 開発メンバーは 4 人ほどで、フロントエンドエンジニア、バックエンドエンジニアといった区分けはしていませんでした。
  • 機能ごとにメンバー全員がバックエンドからフロントエンドまでを一気通貫で実装していく方法で開発しました。技術を見る範囲は広くなってしまうのですが、そのかわり各機能を一人のメンバーが独立して実装でき、スピード感を持って開発できました。
  • AWS のインフラについてもサーバーレスサービスをメインで使っているので、インフラエンジニアというロールは存在せず、あくまでもアプリケーション開発をするエンジニアがインフラも担当しています。
  • 運用負荷の軽減やコスト最適化のため EC2 RDS などの VPC が絡むサービスは極力使わない構成にしています。

プログラミング言語

TypeScript

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

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

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

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

バックエンド

AWS Lambda

バックエンドのコードはすべて Lambda で統一しています。Lambda は1つの処理に対して1つの関数を用意して、API の数だけ Lambda 関数が存在するような構成です。他にも S3 の Put をトリガーにした関数や、EventBridge を使ったイベント駆動な関数、Step Functions の各処理にも使用しています。関数は別だとしても全体で見ると 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 変換もできます。

DynamoDB を使う場合は、データの集計や分析が行えるように、必要なデータは 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 Step Functions

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

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

Auth0

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

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

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

フロントエンド

React

フロントエンドは React の SPA で実装しました。SSR(サーバーサイドレンダリング)する要件がなかっため Next.js は使わずにシンプルに Create React App を使用しました。ルーティングに React Router、フォームに React Hook Form を使用しています。ステート管理には、React 標準の useStateuseContext を使うようにして 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 など日々の開発フローについてざっくりと紹介したいと思います。

ブランチ戦略

GitHub を使い、4つのブランチで日々の開発をしました。

main

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

develop

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

feature

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

hotfix

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

CI/CD 構成

バックエンド用の CI/CD とフロントエンド用の CI/CD があります。

バックエンド CI/CD

AWS の CodePipeline、CodeBuild を使用しました。CI については CircleCI や GitHub Actions など使い慣れたものを使えば良いと思います。パイプラインについては同じ 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 を使う以上ある程度妥協する必要があると考えています。セカンダリインデックスを使っても AND 条件での検索は難しいですし、かといって Scan & Filter を使うとキャパシティユニットをムダに消費してしまうなどの問題があります。 柔軟に検索する必要がある場合は、Algolia や 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 を取得できます。

まとめ

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

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