サーバーレス失敗談

肥前洋佑氏:それでは始めさせていただきます。HiCustomerの肥前と申します。

休みの日はこんな格好で山を走っています。

実は今日、次のスピーカーの方とネタが完全にかぶっているということがございまして……。

(会場笑)

司会者:知り合いじゃないんでしょ?

肥前:知り合いではないです(笑)。完全にたまたまですね。はい。

今日は、2017年12月に創業したHiCustomerという会社、いわゆるシードフェーズのスタートアップ企業の話です。

プロダクトを開発してリリースしてから約1年ぐらいで、サーバーレスの構成で開発をしていて、知見というか失敗談のようなものがけっこうたまってきました。なので、このあたりでみなさんに共有できたらなと思って、今日お話させていただきます。

最初に、簡単にプロダクトとチーム、ご紹介させてください。

HiCustomerは、ユーザーを「ファン」に変えるカスタマーサクセス管理プラットフォームというものを作ってます。これは何かというと、我々のお客さんは基本的にはB to Bで、SaaS、サブスクリプションのプロダクトを持っている会社さんです。

SaaSの利用状況を可視化・分析してあげて見せるような、一種の監視サービスとも取れるプロダクトを作っています。

我々のユーザーとしては、SaaS企業のカスタマーサクセス担当の方に使っていただいて、お客さんが日々どういうふうに自分たちのプロダクトを使っているか、あるいは使ってないかみたいなところを確認してもらうダッシュボードやプロダクトを提供しています。

チームはこのような構成でやっています。まだ正社員6名という規模で、そのうちの4名がエンジニア。あと、複業のメンバーにフロントエンドとかデザインを手伝ってもらって、こんなチームでやっています。

開発手法ですね。言語でいうとGoとTypeScriptを使っていて、LambdaでGoを動かして、フロントエンドでTypeScriptを使っている感じです。

アーキテクチャでいうと、いわゆるサーバーレス構成、メインのサービスはAPI Gatewayとか、Lambda、DynamoDBあたりをメインで使っています。ダッシュボードはSPAで、VueとVuexを使って作っています。

プロジェクト管理の話をすると、スクラムでやってます。よく使うツールでいうと、ZenHubとか、あとはNotionあたりでドキュメント管理をしながら開発をしています。

アプリケーション構成

実際のアプリケーションの構成はこんな感じになっています。

流れとしては、SaaSからログデータをもらってきます。いわゆる「収集基盤」の「Public API」というんですけど、ログを受け取ってデータストアにためていきます。

その過程で集計の処理とか、あとはスコアリング、「カスタマーがいまどういうヘルススコアか?」というスコアリングの計算をしています。あとは、スコアが変わったら通知をしてあげたりとか、データストアにたまっているデータを可視化するためのダッシュボードがあるので、これを内部から読み出したりします。これは「Private API」と呼んでいますが、こういったものがあります。

特徴としては、イベントドリブンなシステムです。データの投入を起点に、スコア計算とか、あとは通知の処理が連鎖的に走っていくっていう特徴です。

あとは、B to Bのプロダクトにしては、わりと書き込み、トラフィックが多いプロダクトかなと思っています。「Public API」と呼んでいるものがあって、ダッシュボードでは一般的な話かもしれないんですが、Private APIのほうはけっこう複雑なクエリが発生します。そのような特徴を持ったプロダクトです。

構成はこんなふうになっています。Public APIの部分は一般的な構成ですね。

API Gatewayと、その裏側でLambdaが動いていて、ログをDynamoDBに突っ込むという感じです。

今日のお話のメインのところですが、データストアまわり、基本的にはDynamoDBを使っていて、一部、検索のインデックスとかでElasticsearchを使っている構成です。

あと、DynamoDBにはDynamoDB Streamという仕組みがあります。DynamoDBに入力があったときに、その差分を入力値としてLambdaを実行する機能があって、そのあたりを使ってスコアの計算とか、あとは集計の処理を実装してるような感じですね。あと通知も、この仕組みを使ってやっています。

Public APIと同じようにPrivate APIのほうも、API GatewayとLambdaの組み合わせでやっていて、それを呼び出すSPAのVueのアプリがあって、これが全体の構成になっています。

DynamoDBの不適切なテーブル分割が負債化

DynamoDBのお話で、「不適切にテーブルが分割されていると、負債化しますよ」という話が1つ目です。

先に結論を言ってしまうと、「DynamoDBのテーブルは、可能な限り少なくすべきですよね」という話と、「RDBのようにとりあえず正規化するのって、あんまり良くないよね」っていう話です。

あとは、「更新系と参照系でテーブルを分けておくと、わりと変更に強い仕組みにできるかな」というところが、学びだったかなというところです。

モデルはこんな感じになっています。

とくにこの青い枠があって、くくられているところが主題です。我々のクライアントはこういうモデルに沿って、SaaSのログデータを送ってきてくれます。

「Customer」と呼んでいるのがSaaSの顧客企業のことで、それに紐付く利用者がいて、さらに「どういう種類のイベントがいつ実行されたか?」という情報が、1対多で紐付いているようなモデルです。なおかつ、プロパティは任意に設計できて、それをHiCustomer上に並べることができます。

テーブル設計は、書き込みと読み取りでテーブルをそれぞれ分けています。けっこうこういうイベントソーシングなアプリケーションでは常套手段なのかと思いますが、まずPublic APIのほうからログの書き込みがあって、リクエストと1対1になるような感じで、「イベントテーブル」と呼ばれるテーブルに書き込んでいると。ここはUpdateが発生しないInsertだけというかたちですね。

そこからDynamoDBのStreamを受け取って、Lambdaを実行して、このビューテーブルのほう、集計されたミュータブルなテーブルのほうに書き込んでいきます。Updateはけっこうコストが高いので、ここをスロットリングしながら都合の良いように更新していくわりと安全な仕組みです。

あとは、ビューのほうのテーブル設計を変えたいときも、いったんイベントのテーブルのほうにデータが残っていて、ある程度は変更しやすい仕組みです。

……というところまではいいのですが、現状のテーブル設計はこんなふうになっています。

まず更新系と参照系でテーブルそれぞれ分かれたうえで、さらにそのアイテムの種類がCustomerとかUserごとにテーブルが分かれていて、これが全部ではないんですけど、ここだけで6テーブルありますね。けっこうテーブルが細かく分かれてしまって、問題が起きていますと。

パフォーマンス低下というか、「期待したほどパフォーマンスが出ない」みたいな問題ですね。利用頻度が低いテーブルがどうしても分割すると出てきてしまうので、そうするとコールドスタートが発生しやすくなったりとか、あと、キャパシティユニットの最適化の文脈でも、あまり最適ではないので、このあたりに問題があります。

あとは、データモデルを追加したくなったら、テーブルを追加して、さらにDynamoDB Streamを受け取るLambdaを追加して、さらにそれをつなぐEvent Source Mappingという仕組みがあります。その設定も追加しなきゃいけないんで、「変更のコストが高いですね」というところが問題視されていて、ここを解消していくアプローチを考えていきたいと思います。

最初に紹介したようにまとめていくのが良いと思うのですが、例外もあります。けっこうニッチなケースではあるんですが、ある程度大容量な時系列データはテーブルを分割したほうが効率が良いケースもあります。

公式ドキュメントを見るとこんなことが書かれていて、「複数のテーブルを使用する特定の理由がない限り、優れた設計のアプリケーションで必要なテーブルは1つのみです」と。けっこうビックリなことが書いてあるんですけど、これが去年の5月ぐらいに出てきて、「早く言ってくれよ」って思った方がいるかもしれません……。

(会場笑)

それで、こういう感じで考えてみました。

ログデータ、イベントデータが入るところは、日ごとにテーブルを分けていきます。書き込みというのは、基本的には当日のテーブルになってくるので、ここはホットパーティション化するんですね。

なので、過去のものには基本的にはアクセスがいかないので、これを分けてあげて、パーティション化、キャパシティをゼロにしてあげると最適化できますよと。

参照系のほうは、もう複数のアイテムを1つのテーブルにまとめてしまって、こんな構成にするのが良さそうだよねというのが結論です。

なので、振り返ると、「テーブルを少なくしていったほうがいいですね」「正規化ではなくて、使い方によってテーブルをまとめていきましょう」「参照系・更新系でテーブルを分割するのが良さそうですよ」というのが、1つ目の話でした。

DynamoDB周辺のCFn化に苦戦

次ですね。DynamoDBまわりのデプロイの話なんですが、実はリリース直後というか、ごく最近までCloudFormation化されていなかったところを、後から載せようとしてだいぶ苦労したというお話ですね。

これも結論から話すと、「とくにDynamoDBまわり、データストアまわりは、もうとにかく最初からCloudFormation化したほうが無難だろう」というのが結論です。

無停止でCloudFormation化するのはかなり手間なのと、あとは、Event Source Mappingを使っているチームがいるとしたら、「そこもどういうふうにデプロイの設計をしていくのかというのを、けっこう初期のフェーズで考えたほうが良さそう」というのが学びですね。

お話はこの真ん中の部分、「ストリーム処理」と書いてあるあたりの話です。

DynamoDB Streamとは何かというと、データ更新時のChange Setのシーケンスなんですね。Event Source Mappingという仕組みでStreamとLambdaを紐付けるんですけど、その紐付いたLambdaがStreamを定期的にボーリングして、1秒に4回ボーリングして、Change Setを入力してFunctionを実行しますと。

ここがポイントなんですけど、Event Source Mappingが「どこまでシーケンスを実行したか?」というデータ、シーケンス番号を持っていて、そこでEvent Source Mappingが関係をしているアーキテクチャになっています。

当初はこんな感じでデプロイしてました。

DynamoDBは手動で作ってました。……手動っていうか、CLIも含めてなんですけど、CloudFormation化されていないデプロイの仕組みを使ってました。

Lambdaはけっこうナイーブな実装で、Code BuildでIn Placeにいま動いてるLambdaを更新しにいく、みたいなスクリプトを書いて実行してました。

当然、後々になって、スケールしないので「CloudFormation化したい」という話になります。それと同時に「Blue/Greenデプロイをしたいよね」という話にもなってきたので、ここを一緒にやろうかなと思ってました。それとCloudFormation化の作業自体もBlue/Greenでやっていきたいので、そのへんを考えながらやり方を検討しました。

問題が、基本的にはCloudFormationで管理されてないスタックが後から管理下にできなくて、基本的には再構築が必要ですと。「じゃあ、どこまでCloudFormation化するんだっけ?」という意思決定が必要になったんですね。

いまのところは、テーブルについてはCloudFormationの管理外にしました。先ほど紹介したビューテーブルのほう、イベントじゃなくてビューのほうですね、こっちはCloudFormation化してもいいかなとは思いました。ちょっと手が回らなかったような状態ですね。実はProductionだけこの仕組みを提供していて、開発環境では全環境でCloudFormationを使ってるような感じです。

Event Source Mappingは入れるか入れないかけっこう迷って、2パターン検討しました。

1つ目のパターン、Event Source Mappingまで入れてしまうパターンですね。Event Source MappingとLambdaのスタック、そして新しいスタックとしてもう1組作っておいて、切り替えるようにしようというのが1つ目のアイデアですね。

良さそうなんですけど、実は問題があって、さっきご紹介した「どこのシーケンスまでを処理したか?」というのを管理しているのはEvent Source Mappingなんです。「Blueのほうで最後にどのシーケンスを処理したか?」というのをGreenのほうに伝えてあげないといけない。これを自前でやらなきゃいけなくて、だいぶつらそうだったんでここはいったんあきらめました。

最終的にはこんな感じにして、LambdaだけCloudFormation化して、Event Source MappingはCLIで最初に作ったものを使い回すような構成にして付け替えてあります。

ということをすると、手順としては、まずEvent Source MappingをDisableにして、向き先のLambdaを変えてEnableにすると、最後に処理した次のものからGreenのほうで処理を始めてくれるので、処理漏れがなくなるよと。

こういったExactly Onceが必要なStream処理とかはこういった方法が取れるかなと思っています。

ということがドキュメント探してもなかったんですけど、実はmanにけっこう詳しく書いてありました。

「こういうふうにすればポジションを失わずに安全にデプロイができますよ」というところが書かれていました。

というわけで、「とくにDynamoDBまわりはCloudFormation化、最初から必須じゃないかな」という話と、無停止で、……まあ、停止できるサービスだったらいいのかなと思うんですが、我々、ログを受け取って保存するようなタイプのクラウドってなかなか止められないんで、そのあたりを考慮すると、「最初から入れておくのが無難かな」というところですね。あと、「Event Source Mappingも同じように注意が必要」というような話でした。

私のほうからは発表以上です。けっこうこういったネタがテックブログにも書かれているので、もしご興味のある方は読んでみてください。以上です。ありがとうございました。

(会場拍手)

司会者:ありがとうございます。時間があるので、なにか質問を1つぐらい、聞けますよね?

肥前:はい。

司会者:なにか質問ある方います?

質問者:ありがとうございました。DynamoDBの更新系のテーブル、ログのテーブルを日付別に分けてるというところで、キャパシティユニットの調整は、実行日時でLambdaで行っていたりするんでしょうか?

肥前:Lambdaで行って……(というか)実はまだきちんとできていないところなんです。Lambdaで行うか、オンデマンドを使用すると料金がゼロになるはずなので、それが良いかなっていう感じですね。

質問者:ありがとうございます。

司会者:はい、ありがとうございました。