「注文サービスをサーバーレスで作ってみた」JAWS DAYS 2020登壇資料 #jawsug #jawsdays #jawsdays2020

本記事ではAWS上の分散トランザクションを構築する方法を紹介させて頂きたいと思います。あと、そのトランザクションの結果をストリーミングさせ、クライアントにデータが自動で連携される仕組みについても紹介させて頂きます。最後に、私が作ってみたサービスのフルコードのGithubレポジトリーを共有致します。
2020.03.27

本記事は、オンラインで開催された「JAWS DAYS 2020」の登壇記事となります。

はじめに

こんにちは、コンサル部のテウです。

去年の年末年始休暇中、マイクロサービスのいろんな実装パターンについて勉強をしましたところ、分散トランザクションのことにすごく興味が出来ました。そもそもトランザクションの意味だけが分かっていたレベルだったのですが、もっと詳しく理解したいという気持ちで、実際にどういう風にトランザクションが使えるかを調べてみました。その結果、私が思っていたより多くのシステムでトランザクションを活かした開発をしていることが分かりました。いや、もっとストレートに言うと、ほぼ全てのサービスの一部の機能では、トランザクションを適用すべきなのでは?と感じました。

あと、モダンアプリケーション(Modern Application)の実装パターンとして、よく登場するマイクロサービス。マイクロサービスでトランザクションを実装するには、ORMやDBライブラリーを使うだけで解決されるDBレベルのトランザクションとは違って、サービスレベルのトランザクションについて考えないとならないことが分かりました。

ですが、正直、こう考えてみても実際に手を動かして作ってみないと、はっきり分からないですよね。なので、ECサイトのコアコンポーネントである注文サービスの中身を想像しながら、スケーラブルな、かつ、マイクロサービスのコンテキストを考慮した、実際に動く注文サービスを作ってみました。

ということで、本記事ではAWS上の分散トランザクションを構築する方法を紹介させて頂きたいと思います。あと、そのトランザクションの結果をストリーミングさせ、クライアントにデータが自動で連携される仕組みについても紹介させて頂きます。最後に、私が作ってみたサービスのフルコードのGithubレポジトリーを共有致します。

それでは、始めます!

トランザクションとは?

トランザクションのことがすぐ理解できる例を紹介します。

transaction-explained

トムは年末年始休暇を計画しています。トムは今年こそは海外旅行をしたいと思いました。 海外旅行をするために準備しないといけないことを調べてみたところ、以下の全ての項目を用意していないとダメだと思いました。

海外旅行のために必ず事前準備しておくこと

  • パスポート
  • 必要に応じたVISA申請
  • 飛行機の予約
  • 宿泊予約

もちろん現実の場合はこれ以外にもいろいろたくさんあると思いますが、一応今回の例として、トムは上記のものだけを対象にしたとします。全てのものが上手く用意できたら楽しい旅になると思いますが、もし、この中で一つでも用意できなかったとしたらどうなるのか、思ってみませんか?

もしパスボート無しで空港に行ったら?旅行はおろか、出国審査もできないですね。VISAが必要な国に行くくせにVISA申請ができなかったら?その国に到着しても入国できませんよね。飛行機の予約が事前にできていなかったら、飛行機は当然乗れません。

このように旅行をしたいと自分で決めても、実際に旅行するために用意すべきなものが全て揃っていない場合は、旅行承認のステータスがまだOKにならないのです。上記のパスポート、VISA、飛行機、宿泊の4種類のタスクの結果がOKになった場合のみ、旅行承認ステータスがOKとなり、それ以外の全てのケースでは旅行承認ステータスがNGとなることが分かります。

上記の例をプログラムで考えてみても考え方は一緒です。

一連の流れとして以下の流れが考えられます。(ちなみにこの図は AWS Step Functions で簡単なスクリプトだけで作成可能です)

transaction-explained

こういった流れを図で描いておくと、開発系の皆さんの馴染みのあるフローチャートになりました。上記の場合は、道中に中止されることなく、必ず Start から End まで実行されることが大事です。道中に勝手に中止されると、飛行機の予約はできたのに、宿泊の予約ができなかったりする大変なことになります。(現実的に考えると、パスポートはいつでも使えるため、パスポートを受けてから止まってしまっても問題ないはずですが、ただ単純な例として考えてください)

ここで、元のトランザクションの話に戻って、トランザクションを一言で表現すると、

全てが成功するか、全てが失敗するかの二つのステータスしかない一連のタスクをトランザクションと呼ぶ。

と定義することが可能です。

分散トランザクションとは?

上記の例をモノリシックアーキテクチャーで実装する時は、こういったトランザクションはDBレベルのトランザクションで解決できるはずです。このトランザクション機能のサポートが強いリレーショナルデータベース(MySQLやPostgreSQL)等が多くのシステム開発に採用された理由でもありますね。

ですが、マイクロサービス時代になってマイクロサービスアーキテクチャーでこのようなトランザクションの仕組みを考慮したら、とても難しい問題が出てきます。DBに依存して解決していたトランザクション処理ができなく、かつ、サービス毎にDBが完全に分離されていて、リレーショナルデータベース以外にもNoSQLや外部のサードパーティサービスと通信しながらトランザクション処理を行うことになるからです。

つまり、「分散トランザクション」の仕組みが必ず必要になったということです。

分散トランザクションの仕組みとして、様々なアプローチがあると思いますが、本記事では Sagas を発展させた Distributed Sagas パターンを紹介させて頂きます。Sagasパターンや2PC(2 Phase Commit)等の詳しい説明は以下の記事が良いと思います。

Distributed Sagasとは?

Distributed Sagas は Caitie McCaffreyさんが考案した分散トランザクションの方法です。主にサードパーティシステムをトランザクションの参加者として考慮しているアルゴリズムですが、マイクロサービスはそもそも他のサービスは全てサードパーティみたいな観点で考えるため、マイクロサービスを実装する際でも、とても参考になる方法です。

一応英語で喋っていますが、図を活用している分かりやすい説明なので、Distributed Sagasについて気になった方は、以下のYoutubeレクチャーもみて頂ければと思います。Caitieさんがとても良いレクチャーをしてくれます。

Distributed Sagas を一言に言うと、

一応、(それぞれの)タスクを処理する。もし他のサービスのためトランザクションが失敗したら、(それぞれのサービス毎に)先処理したタスクを無効にするタスクを(必ず)行う

という仕組みとなります。ここで「先処理したタスクを無効にするタスク」を「補償(compensation)タスク」と言います。

より詳しい説明は後で改めて説明させて頂きますので、最後まで読んで頂きたいと思います。

Step Functions と Distributed Sagas

ここからが本番ですね!:D

Distributed Sagas を実装するには、トランザクションのそれぞれのタスクをコントロールする Saga Execution Coordinator(SEC)の信頼性やスケーラビリティを確保することが一番大事かと思いますし、一番高い難易度の作業の一つかと思います。そもそもSECに問題が発生したら、この仕組みは意味が無いからです。

ここで!!AWS Step Functions が、フルマネージドサービスとして活用できるのではないかと思った海外の方々がいるそうです。

(おそらく)上記の流れでAWS側でも以下のレポジトリーを公開しています。

AWS Step Functions とは、フルマネージドサーバーレスサービスとして、ワークフロー管理ツールとなります。公式ページは以下のリンクを参照してください。

使ってみた感想で、AWS Step Functions は凄く良いサービスなのですが、多くの方々がまだ使ってみたことがないサービスの一つではないかと個人的に感じています。本記事では、この AWS Step Functions が、分散トランザクションのための Distributed Sagas を実装するに最適なサービスであること、特に例として、ECサイトの注文サービスにも使えるそうなサービスであることを紹介させて頂きます。

参考) Apache Airflow は AWS Step Functions とよく比べられたりするのですが、AWS Step Funcitons と比べて、学習コストを含めた初期導入コストが高いところが気になりました。(ECSとKubernetesの差みたいな。。) AWS Step Functions は初期導入コストがほぼ発生しないため、すぐ使えるサービスということがその特徴であり、メリットだと思います。

AWS AppSyncとリアルタイムデータ通信

普段トランザクションを実装する機能は、ビジネスのコアな部分を担当している可能性が高いので、このトランザクションの結果をすぐ知りたいケースも多いかと思います。今回、私が紹介させて頂くDEMOサービスでは、注文トランザクションが成功されたら、管理者側のクライアント画面にその結果をリアルタイムでお知らせしてくれる仕組みも含めています。

そのリアルタイム通信のため、私が使ったサービスは AWS AppSync です。

AWS AppSync はフルマネージドGraphQLサービスであり、サーバーレスサービスとして提供されています。GraphQL が大好きな私は、個人でいろいろ試してみましたが、凄く良いサービスにも関わらず、まだ公開事例や新規開発に採用されるケースが比較的に少ないと感じました。そもそも AppSync は GraphQL を理解している方向けのサービスなのではないかとよく思われますが、GraphQLについて深く理解せずに、単にリアルタイム通信のため考慮しても良いサービスなので、本記事にて紹介させて頂きます。

リアルタイム通信のための AWS AppSync 活用戦略については、私が発表した資料があるのでご覧頂ければと思います。興味ある方は是非ご覧頂ければ嬉しいです。

【登壇資料】サーバーレス開発をより豊富にするAppSyncのUse caseのご紹介 #akiba.aws

あと、AWS AppSync の基礎から説明した記事はこちらです。

AWS再入門ブログリレーAppSync編

DEMO アーキテクチャー紹介

先ずはこのDEMO用動画を視聴頂き、解説を致します。

動画最初の画面で、左がユーザ向けアプリ、右がECサイト等の管理者側の画面の想定です。

demo-architecture

  • アプリは Ionic フレームワークを使って、内部で Angular を使っています。
  • 決済は Stripe を使っています。
  • 黄色部分は注文サービスに関するアーキテクチャー、青い部分はリアルタイムデータ通信のためのアーキテクチャーとなります。
  • Step Functions の Lambda より AppSync を呼び出す際は、実際には SQS 経由で連携されます。
  • AWS Step Functions のワークフローは以下のフローチャートをご覧ください。

demo-step-functions-flowchart

アーキテクチャー解説

demo-flow

全体的なDEMOサービスの流れをはっきりするため、上記の図を描いてみました。

  1. ハードコーディングされているサーバーレス関連書籍リストの中で、一つを選びます。
  2. クレジットカード情報を入力します。(Stripeページよりテスト用のカード情報が公開されています
  3. クライアントから事前注文リクエストを送ります。
  4. バックエンド(注文サービス)で PaymentIntent オブジェクトを生成して、レスポンスします。このオブジェクトに client_secret 情報が生成されており、この client_secret を使って、クライアントから Stripe に ConfirmPayment リクエストを送ることが可能になります。
  5. クライアントから ConfirmPayment リクエストを送ります。
  6. 図の6a, 6b, 6cは実行順番とは関係ないです。 6a. 決済結果をクライアントにレスポンスします。 6b. 決済結果をバックエンドに webhook イベントとしてトリガーさせます。Stripe の webhook はライブ決済のみトリガーさせるため、このDEMOでは、クライアントアプリより webhook を真似したリクエストを API Gateway に送る形になります。 6c. クライアントはページを移動します。
  7. トランザクションが終わったかを1秒周期で確認します。(Step Functions は非同期で動作するため、結果を確認するには周期的にチェックするか、それとも CloudWatch Events経由でイベントドリブンなアプローチをするかの選択肢があるのですが、今回のDEMOでは周期的に確認するアプローチでコードを書きました)
  8. トランザクションが終わったらその結果を表示します。

あと、Step Functions の最後に SendOrderConfirmEvent というステップがありますが、このステップでは、トランザクションの結果をストリーミングさせるために AppSync に CreateOrder Mutation をかけます。以下の図は AppSync のよるストリーミング過程を示しました。

demo-flow

個人的にはこの AppSync による リアルタイムデータストリーミングが大好きなんですが、開発者やインフラ担当者の工数がほぼゼロになるためです。 具体的に言うと、「データ形式を定義するだけでスケーラブル、かつ、信頼性のあるリアルタイムデータ通信が可能になる」ということです。サービスごとの SLA により、この "リアルタイム" の定義が異なると思いますが、厳密なリアルタイム要件が必要なサービス以外の多くのサービスの場合、特に、SNS系のサービスの場合は、複雑なシステム設計や実装をしなくても、GraphQL タイプを定義しておくだけで、AWS AppSync よりバックエンド側のリアルタイム機能(Subscription)を勝手に構築してくれます。フロント側では、AppSync クライアントライブラリーを使って、subscriptionを呼び出すだけとなります。

例えば、今回のDEMOの管理者画面の実装に使ったコードは以下の通りです。

const subscribeOrder = gql`
  subscription onCreateOrder {
    onCreateOrder {
      id
      paymentId
      createdAt
      itemId
      title
      subtitle
      price
      expiresAt
    }
  }
`;

let subscription;
let self = this;

(async () => {
  subscription = this.appSyncClient.subscribe({ query: subscribeOrder }).subscribe({
    next: data => {
      const item = data.data.onCreateOrder;
      self.items.push(item);
    },
    error: error => {
      console.warn(error);
    }
  });
})();

はい、バックエンド側では何もしなくて良いです。GraphQLのタイプを定義しておいて、クライアント側では上記のコードをどこかに書いておけばリアルタイム機能が出来上がります。(私が AppSync を大好きな理由の一つですが、これ以外にも AppSync はすごいサービスなので、是非使ってみてください!)

ソースコード

Github レポジトリー

twkiiim/jawsdays2020-demo-serverless-order-service

DEMOは4種類のプロジェクトで構成されています。

  • 管理者画面用のAngularプロジェクト
  • ユーザ向けのIonicプロジェクト(Angular基盤)
  • バックエンド注文サービス
  • バックエンドAppSyncサービス
jawsdays2020-demo-admin/    ## 一般的な Angular プロジェクト構成

jawsdays2020-demo-app/    ## 一般的な Ionic プロジェクト構成

order/     ## 注文サービスプロジェクト
   api/
     startOrder/
     postPayment/
     checkOrderStatus/
   manage/
     create_resoures.py
   model/
     order.py
   util/
   node_modules/
   package.json
   requirements.txt
   serverless.yml    ## Step Functionsの定義はここに
   venv/
   yarn.lock

stream/    ## AppSyncサービスプロジェクト
   schema/
     order.graphql
   resolvers/
     Mutation.createOrder.request.vtl
     Mutation.createOrder.response.vtl
   streamToAppSync.js
   serverless.yml
   package.json
   node_modules/
   yarn.lock

開発環境

本DEMOでは、以下のツールやフレームワークを使っていますので、バージョンの確認や事前インストールが必要です。

$ yarn --version
1.17.3

$ npm --version
6.13.4

$ node --version
v12.16.1

$ serverless --version
Framework Core: 1.57.0
...

$ vertualenv --version
16.6.1

$ aws --version
aws-cli/1.16.294 Python/3.7.4 Darwin/19.2.0 botocore/1.13.30

$ docker --version
Docker version 19.03.5, build 633a0ea

jawsdays2020-demo-adminプロジェクト

$ cd jawsdays2020-demo-admin/
$ ng version
Angular CLI: 9.0.7
Node: 12.16.1
OS: darwin x64

Angular: 9.0.7
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.7
@angular-devkit/build-angular     0.900.7
@angular-devkit/build-optimizer   0.900.7
@angular-devkit/build-webpack     0.900.7
@angular-devkit/core              9.0.7
@angular-devkit/schematics        9.0.7
@ngtools/webpack                  9.0.7
@schematics/angular               9.0.7
@schematics/update                0.900.7
rxjs                              6.5.4
typescript                        3.7.5
webpack                           4.41.2

jawsdays2020-demo-appプロジェクト

$ cd jawsdays2020-demo-app
$ ionic version
5.4.16
$ ng version
Angular CLI: 8.3.25
Node: 12.16.1
OS: darwin x64
Angular: 8.2.14
... common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.25
@angular-devkit/build-angular     0.803.25
@angular-devkit/build-optimizer   0.803.25
@angular-devkit/build-webpack     0.803.25
@angular-devkit/core              8.3.25
@angular-devkit/schematics        8.3.25
@angular/cli                      8.3.25
@ngtools/webpack                  8.3.25
@schematics/angular               8.3.25
@schematics/update                0.803.25
rxjs                              6.5.4
typescript                        3.4.5
webpack                           4.39.2

インストール手順

上記のGithubレポジトリーからプロジェクトを clone します。

$ git clone https://github.com/twkiiim/jawsdays2020-demo-serverless-order-service.git

注文サービス

先ずは virtualenv を作り、pip install します。

$ cd order/
$ virtualenv venv
$ source venv/bin/activate
(venv) $ pip install -r requirements.txt

yarn コマンドで serverless framework plugin をインストールします。

(venv) $ yarn

上記の yarn コマンドにて以下のプラグインがインストールされます。

  • serverless-python-requirements
  • serverless-step-functions

次は、serverless.yml を開いて、provider > profile のところにご自身の aws profile を入力してください。

あとは、manage/ の create_resources.py を実行させます。

(venv) $ cd manage/
(venv) $ python create_resources.py

上記コマンドで、SQS Queue と DynamoDB テーブルが作られます。

最後に sls deploy でリソースをデプロイします。

$ sls deploy -v

AppSync サービス

先ずは yarn にて必要なプラグインをインストールします。

$ cd stream/
$ yarn

上記の yarn コマンドにて以下のライブラリーやプラグインがインストールされます。

  • aws-appsync
  • graphql
  • graphql-tag
  • isomorphic-fetch
  • serverless-appsync-plugin

serverless.yml を開いて、先の注文サービスの時のように provider > profile を変更し、functions > streamToAppSync > events > sqs > arn のところに、以下のコマンドで得られる SQS Queue の ARN を記載します。

$ aws sqs aws sqs list-queues ## https://********.queue.amazonaws.com/************/jawsdays2020_demo_order_queue をコピーします。
$ aws sqs get-queue-attributes --queue-url "上記でとったQueueのURL" --attribute-names QueueArn

AppSyncの情報を得るために、一回このままデプロイします。

$ sls deploy -v

デプロイが完了されたら、AppSync Endpoint(GraphQlApiUrl)、API Key(GraphQlApiKeyDefault) が表示されます。これらをコピーして、streamToAppSync.js の urlapiKey 変数に記載します。

またデプロイします。

$ sls deploy -v

参考) このような作業をやりながら、私も「良くない」デプロイ方法だと感じました。今回に限らず、依存関係のあるサービスを同時にデプロイするとしたら、CDKの方が良いかもしれません。

Ionic プロジェクト

1番目の注文サービス(order/)で生成された、API Gateway のエンドポイントを src/app/service/payment.service.tsに変更してください。 あとは、Stripe キーは公開されたテストキーを使っていますので、そのまま yarn && ionic serve で実行します。

$ cd jawsdays2020-demo-app/
$ yarn
$ ionic serve

自動でブラウザーがオープンされ、Ionic アプリが見えると成功です。

管理者用 Angular プロジェクト

AppSync のアクセス情報だけを更新して実行します。 src/app/appsync.connector.ts ファイルを開いて、APPSYNC_ENDPOINTAWS_REGIONAPI_KEY を記載します。あとは ng serve で実行します。

$ cd jawsdays2020-demo-admin/
$ yarn
$ ng serve

上記の4種類のプロジェクトの手順の通り、是非試してみてください!実際に動いているものをみると楽しくて面白いです!w

終わりに

より多くの方々から AWS Step Functions と AWS AppSync の機能を使って頂ければと思い、本記事を書いております。AWSにはフルマネージドサービスが非常に多くて、使ってみることだけでもとても楽しくて面白いと思いますが、これらの特徴やメリットをよく知っておかないと活用することはかなり厳しくなりますよね。フルマネージドサービスって、ある程度の制約は当たり前だとは思いますが、この制約の範囲を越えないようであればガンガン活用して信頼性の高いサービスを爆速で作って行くのはいかがでしょうか?同じ信頼性や可用性のオンプレアーキテクチャーと比べてみると、アーキテクチャーも非常に簡単になりますし、開発自体もとても楽しくなります。

AWS Step Functions や AWS AppSync も活用できる機能が多いサービスなので、この機会に沢山の方々がキャッチアップして使って頂けることを願いながら、本記事を終わらせて頂きたいと思います。アーキテクチャーや開発について、気になりそうなところを纏めて付録に書いておりますので、もし興味ある方は付録も読んで頂ければ幸いでございます。

以上、コンサル部のテウでした!

付録

トランザクションが失敗した時について

Distributed Sagas パターンは基本的に補償(compensation)という形で、てトランザクションが失敗された時の処理を行います。今回の DEMO の場合は、RequestDeliveryCancelDelivery タスクがランダム関数を使うことで、ランダムで失敗したりすることで本番環境の失敗を真似してみました。(なんかカオスエンジニアリング的なアプローチですねw)

jawsdays2020-demo-compensation

上記のフローチャートはランダムで RequestDelivery がキャンセルされ、補償タスクが実行され、成功させた時のステートマシーンです。ですが、もし何かしらの理由で補償タスクも失敗する可能性もあるのでは?という不安もあると思います。なので、この場合も CancelDelivery タスクで真似したく、ランダムで失敗させてみました。

jawsdays2020-demo-failed-transaction

いろんなアプローチができると思いますが、ここからは、正直、監視という形で解決すべきなのではないかと思いました。なので、私はAWS上で一番基本的なアプローチである、CloudWatch Eventsに Step Functions の失敗アラートを紐付けてみました。

jawsdays2020-demo-cloudwatch-event

CloudWatch Event を使うと、監視設定がこんなに簡単に可能となります。後、ターゲットとしては、一応 SNS の test-topic というトピックに連携させ、実際にそのトピックをサブスクライブしている私のメールに監視結果がくるかを確認してみました。

jawsdays2020-demo-sns-email

分かりづらい形のメールが来ていますが、一応そのデータを分かりやすくするには Lambda 経由でフィルタリングし、Slack に繋がるようにするか等の作業をすれば良いと思います。

DynamoDB の TTL について

AWS AppSync側の DynamoDB テーブルをみると、 expiresAt というフィールドがあります。このフィールドに TimeToLiveSpecification が設定されていますが(serverless.yml)、今回のDEMOの AppSync は単にストリームさせるだけの機能なので、データの保存は要らないですよね。それにも関わらず、私は AppSync の resolver に紐づいてるデータソースとして DynamoDB が良いと思いました。なぜなら、DynamoDB を使うと、resolver 側がすごく簡単に実装できるし、かつ、DynamoDB テーブルに TTL を掛けることだけで、データが 自動で 削除されるからです。私の場合、60秒後に削除できるように設定したのですが(stream/streamToAppSync.js)、もっと短い設定をしても全く問題ないと思います。

AppSync の Query(getOrder) について

AppSync の Schema をみると、今回全く使わず、要らない getOrder というクエリが存在します。これは、GraphQL文法上、Queryは必ず1つ以上必要になりため、定義をするだけにしました。resolver側も一応エラーを避けるために作っておきましたが、必要なものではございません。

Stripe の Webhook テストについて

今回のDEMO構築に一番大変(?)な作業だったのですが(DEMO なので、1時間ぐらいかかっても大変な作業扱いになりますよね。。)、Stripe の Webhook 機能がライブ決済(本当の決済イベント)のみ対応しているため、Stripe の Webhook のデータ形式がどんな感じかを把握することが個人的には分かりづらかったです。(Stripe 初経験ですが、非常に便利ですし、良いサービスだと感じております)

Stripe では Stripe CLI を提供しており、CLI を使って webhook イベントを真似して使うことができます。Strip CLI で以下のコマンドを使って webhook イベントをトリガーさせることが可能でした。

$ stripe listen --forward-to localhost:5000/webhook

このコマンドを実行した後に、以下の Flask コードを動かせて、Lambda 関数の handler に繋がるように設定しました。

import stripeWebhook
import json

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/webhook', methods=['POST'])
def webhook():
    event = { 'body': json.dumps(request.json) }
    result = stripeWebhook.handler(event, None)

    print(result)
    return result

if __name__ == '__main__':
    app.run()

参考資料