SaaS Microservices Workshop - Multi-tenancy-meets microservice の紹介

この記事はエムティーアイ - Qiita Advent Calendar 2024 - Qiitaの12/19分の記事です。

こんにちは、システムアーキテクト部兼執行役員の西川です。

※この記事は思ったよりも長くなってしまったので目次をつけました。適宜参照してください。



Introduction

エムティーアイはガラケー時代からB2C向けサービスを中核としてきた会社なのですが、 www.mti.co.jp www.mti.co.jp

近年ではB2B(G)向けのサービスを展開させていただいています。 www.mti.co.jp www.mti.co.jp

B2B(G)向けとなると、弊社のサービスを複数の企業様(自治体様)にご利用いただくわけなのですが、そのサービスモデルとしては「マルチテナント型のSaaS」という形態となっています。

さて、私はつい先日 re:Invent 2024に参加してきた のですが、その中で SAS403: SaaS microservices deep dive: Multi-tenancy-meets microservice というWorkshop(インストラクションに沿って実際にAWSを操作して体験するタイプのセッション)をやってきたのですが、こちらが非常に良かったので紹介させていただきたいと思います。

ちなみに私は海外カンファレンスでのワークショップ体験というのは初めてだったのですが、やってみると意外となんとかなるもので、機会があればぜひ読者の皆様もチャレンジしていただけるといい経験になると思います。

Workshopの概要

対象者

SaaS Builders, Software Developers, Architects となっています。 Level 400ですので、入門レベルではなく、そこそこにソースコードの読み書きとAWSの操作、およびEKSやDynamoDBなどの概要を知っている前提になっています。

題材となるシステム

このワークショップでは、SaaSベースのマイクロサービスアプリケーション を題材として使用します。このアプリケーションは、複数の顧客(テナント)に対応するためのマルチテナントアーキテクチャで構築されています。

機能概要

下図の通りCreateOrderCreateProductの2つだけです。

その裏にプライベートなマイクロサービスがあります。

  • Fulfillment ... Orderサービスから呼ばれ注文確定処理をする。そしてメッセージバスに注文が確定されたイベントを発行
  • Invoice ... 注文確定イベントを受け取ると非同期に請求書作成

どうでもいいですが、Tenant Userの髪型がボボボーボ・ボーボボみたいですね。

システム構成

中核は3つのAZにまたがったEKSです。ALBではなくNLBによってバランシングが行われているところが少し特徴的です。

大まかな流れ

Lab1~6までで構成されており、想定時間 2-3時間となっていますが絶対無理です。。。実際ワークショップの現場でもとりあえずLab2までで終わっていました。

Lab 内容
Lab1 - まずCDKを実行して、EKS, Service, DynamoDBなど一式を作成
- ソースコードをいじって、テナントコンテキスト(後述)を引き回すようにする
- DBはテナントIDに応じてパーティションを分ける
Lab2 - あえてProductサービスのソースコードに間違って他のテナントの情報にアクセスできてしまうバグを混入する
-> コードレベルでの制御はミスが起きやすいことを痛感
- コードレベルではなくサイドカーコンテナで横断的にテナント分離を実現する
Lab3 - Orderサービス -> Fulfillmentサービスの呼び出しを実装
- そのときにテナントコンテキストを引き回す
- Fulfillmentサービスが発行するイベントメッセージにテナントコンテキストを付加
- Invoiceサービスはイベントメッセージに付加されたテナントコンテキストをハンドリングする
Lab4 - テナントのティア(価格プランのこと)ごとに待遇を変える。具体的にはディスパッチされるクラスターが変わる
- 今回の想定では次の3つのティアがある → Basic/Premium/Advanced
- ルーティングの手段としてIstioを利用する
Lab5 - リクエストの権限管理、認可処理のために Amazon Verified Permissionsを組み込む
- 権限の種類は4種類 → ViewProduct, CreateProduct, ViewOrder, and CreateOrder.
Lab6 - これまでにデプロイした4つのマイクロサービスにテナント対応のメトリクスを導入し、テナントごとの利用状況を分析できるようにする

Workshop用のAWS環境

re:Inventで参加したときはWorkshop用のアカウントを貸し出してもらえたのですが、自宅でやろうとする場合には各自のAWSアカウントを利用する必要があります。

Workshop用のアカウントでは必要ツールがプリセットされたCloud9の環境が提供され、とても楽だったのですが、自宅でやる場合はこのあたりの準備から必要です。

そのあたりの説明がこの「Running the workshop on your own」に書いてあったハズなのですが・・・、おっと無効化されてしまっている・・・。

このWorkshopに必要なCDKやアプリケーションコードの一式はGitHubで公開されていますので、こちらをCloneするところから始まるということになります。 github.com

  • CDKは言わずもがなTypeScriptで書かれています
  • アプリケーションコードはPythonで書かれています

このWorkshopのポイント

テナントコンテキスト

マルチテナントのSaaSではユーザー個々人のアイデンティティだけではなく、ユーザーがどのテナントに所属するのかの情報が重要になってきます。一つのSaaSをテナントA、テナントBの二社にご利用いただいている場合、この二社のデータは分離されている必要があり、テナントAのユーザーがテナントBのデータにアクセスできてはなりません。これを実現するために、システムは受け取ったリクエストが「誰からの(どのユーザーからの)」という情報だけではなく、「どのテナントの」という情報を識別する必要があります。テナントコンテキストは「どのテナントの」という情報になります。

参考:

このワークショップでの実現方法

このワークショップではCognitoによって発行されるIdentityトークンにカスタム属性としてtenant_id, tenant_tierが付加され、それをIngress Gatewayが検証するという構成になっています。

  • リクエスト:
    • クライアントからマイクロサービスへ送られるリクエストには、Cognitoによって生成・署名されたBearerトークンが含まれる。
    • トークンにはカスタムクレームを通じてテナントコンテキストが埋め込まれている。
  • Ingress Gatewayでのトークン検証:
    • Istio Ingress Gatewayがトークンを検証。
    • 無効なトークンには403エラー(認可されていない)で応答。
  • 適切なマイクロサービスへのルーティング:
    • 有効なリクエストは、テナントコンテキストに基づいて関連するマイクロサービス(例: OrderやProduct)へルーティングされる。

テナント分離

先に述べたように各テナントのデータの分離を実現する方法です。

DynamoDBのPartition

これは見ていただくと分かる通り、各テーブルのpartitionKeyとしてテナントIDを設定します。

    const productTable = new dynamodb.Table(this, "ProductTable", {
        partitionKey: { name: "tenantId", type: dynamodb.AttributeType.STRING }, // tenant-id partition key
        sortKey: { name: "productId", type: dynamodb.AttributeType.STRING },
        readCapacity: 5,
        writeCapacity: 5,
        billingMode: dynamodb.BillingMode.PROVISIONED,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
        tableName: `SaaSMicroservices-Products-${namespace}`, // namespace appended to the table name
    });

そして最も簡単な方法は下記のようにアプリケーションコード中でクエリを投げる際にテナントIDをクエリ条件に含める方法です。

        resp = product_table.query(
            KeyConditionExpression=Key("productId").eq(product_id) & Key("tenantId").eq(tenant_context.tenant_id)
        )

アプリケーションコードで頑張る方法は一般的ですが、Lab2ではコードのバグにより他のテナント情報が見えてしまうという重大なインシデントの発生をエミュレートして問題提起をしています。これが一箇所だけであれば「そんな間抜けなことを」と思ってしまうバグかもしれませんが、何十ものクエリ実行箇所がある場合にはこの様なバグ混入の可能性はないとも言えません。

そこで、個別処理をするのではなく共通化を試みます。

IAM Roleによる制御

    // REPLACE START: LAB2 (IAM resources)
    const accessRole = new iam.Role(this, "access-role", {
      assumedBy: productServiceAccount.role.grantPrincipal,
    });
    accessRole.assumeRolePolicy?.addStatements(
        new iam.PolicyStatement({
            principals: [productServiceAccount.role.grantPrincipal],
            actions: ["sts:TagSession"],
            conditions: {
                StringLike: {[`aws:RequestTag/TenantID`]: "*"},
            },
        })
    );
    accessRole.attachInlinePolicy(
        new iam.Policy(this, "ProductServiceAccessPolicy", {
            statements: [
                new iam.PolicyStatement({
                    actions: ["dynamodb:Query", "dynamodb:PutItem"],
                    resources: [productTable.tableArn],
                    conditions: {
                        "ForAllValues:StringLike": {
                        "dynamodb:LeadingKeys": [`\${aws:PrincipalTag/TenantID}`],
                        },
                    },
                }),
            ],
        })
    );
    // REPLACE END: LAB2 (IAM resources)

要点は下記の2つです。

  1. 後述のToken Vender Sidecarに一時トークンを発行(STS)してもらって、その一時トークンでDynamoDBにアクセスする -> 一時トークンにテナントID情報がタグとして含まれる
  2. dynamodb:LeadingKeysをconditionに指定して特定のPartitionKeyのみをQuery/Put可能なように制限する

一時トークンを発行する Token Vender Sidecar

サイドカーパターンは、共通処理をソースコードレベルで埋め込むのではなく、コンテナレベルで実現するパターンとしてポピュラーなものです。

図中のTVMというのはToken Vending Machineで、テナントコンテキストを使用してテナントスコープの一時トークンを発行するコンポーネントです。上図はProductサービスの本体がこのTVMに一時トークン発行を依頼しているの図です。

TVMはSTS(AssumeRole)を用いて一時トークンを発行しますが、その際にテナントコンテキストを参照して一時トークンにテナントID情報をタグとして付加します。そしてそのタグ情報がDynamoDBに対するポリシーのconditionとして利用されることで、テナントIDに紐づくパーティションだけが取り扱い可能な範囲になるというカラクリです。

内部サービス間のテナントコンテキストの引き回し

さて、Orderサービスは Productサービスに比べると少々複雑になっています。

  • Fulfillmentサービスを同期的に、HTTPで呼び出す
  • それに連動するようにFulfillmentサービスがInvoiceサービスを非同期に呼び指す

HTTPによる同期呼び出しのケース

これは非常に単純です。Orderサービスが受け取ったIdentityトークンを、FulfillmentサービスへのHTTPリクエストの際にそのままAuthorizattionヘッダに付加すればOKです。

# IMPLEMENT ME: LAB3 (submitFulfillment)
def submitFulfillment(order, authorization, tenant_context, fulfillment_endpoint):
    try:
        url = f"http://{fulfillment_endpoint}/fulfillments/{order.order_id}"
        app.logger.debug(f"Fulfillment request: {url}")
        response = requests.post(
            url=url,
            json=order.__dict__,
            headers={
                "Authorization": authorization,
                # PASTE LINES BELOW: LAB4 (routing)
            },
        )
        response.raise_for_status()
        return None
    except Exception as e:
        app.logger.error(f"Exception raised! {e}")
        return None

非同期呼び出しのケース

こちらもさして難しくはありません。キューにPublishするメッセージにIdentityトークンを付加するだけです。

# IMPLEMENT ME: LAB3 (get_message_detail_with_tenant_context)
def get_message_detail_with_tenant_context(event, authorization):
    tenant_context = get_tenant_context(authorization)
    if tenant_context.tenant_id is None:
        raise Exception("Unable to read \"tenantId\" claim from JWT")
    return json.dumps({
        "event": event,
        "tenantId": tenant_context.tenant_id,
        "tenantTier": tenant_context.tenant_tier,
        "authorization": authorization
    })

ティアによる待遇の違い

デプロイメントモデル

SaaSアプリケーションは、規制、競争、戦略、コスト効率、市場のニーズなどに基づいて、さまざまなアーキテクチャモデルで構築されます。これらのモデルは概ね以下の3つに分類されます。

  1. サイロ (Silo)
    テナントごとに専用リソースを提供するアーキテクチャ。いわゆる占有環境。
  2. プール (Pool)
    複数のテナントがリソースを共有するアーキテクチャ。こちらが一般的かと思われます。
  3. ブリッジ (Bridge)
    一部はサイロ型、一部はプール型で構成された混合モード

参考:

ティアごとの待遇の差

このワークショップでは Basic/Premium/Advanced の三種類のティアを提供する前提になっています。 そして、各ティア毎にデプロイメントモデルが異なるという設計です。

ティア モデルタイプ 特徴
Basicティア プール型モデル 複数テナントがリソースを共有
Premiumティア サイロ型モデル 専用リソースを提供
Advancedティア ブリッジ型モデル サイロ型とプール型の混合

  • BasicティアであるテナントA, Dに関しては全ての処理が一番上の共有プールで処理されます
  • テナントB, E はAdvancedティアで、ProductサービスとOrderサービスは共有プールで処理されるのですが、その他のFulfillment, Invoiceに関してはそれぞれのテナントに専用の環境が割当てられます
  • テナントCはPremiumユーザーなので、全ての処理はC用の専用環境で処理されます

Ingress Gatewayによるルーティング

さて、これをどう実現するのかという話です。

ALBではなくNLBが利用されていたのは、実態のバランシング(ディスパッチ)をこのIstio Ingressにおまかせするためで、Ingressに以下のようなVirtualServiceを構成します。

    const productVirtualService = cluster.addManifest(`ProductVirtualService`, {
      apiVersion: "networking.istio.io/v1alpha3",
      kind: "VirtualService",
      metadata: {
        name: "product-vs",
        namespace: namespace,
        labels: {
          ...multiTenantLabels,
        },
      },
      spec: {
        hosts: ["saas-workshop.example.com"],
        gateways: [istioIngressGateway],
        http: [
          {
            name: namespace.substring(0, 14),
            match: [
              {
                uri: {
                  prefix: "/products",
                },
                headers: {
                  "@request.auth.claims.custom:tenant_tier": {
                    regex: tenantTier,
                  },
                  ...(tenantId && {
                    "@request.auth.claims.custom:tenant_id": {
                      regex: tenantId,
                    },
                  }),
                },
              },
            ],
            route: [
              {
                destination: {
                  host: this.productServiceDNS,
                  port: {
                    number: this.productServicePort,
                  },
                },
              },
            ],
          },
        ],
      },
    });

ルールの動作

  • マッチ条件:URIプレフィックスと、@request.authヘッダーに含まれるテナントコンテキストクレーム(tenant_tierとtenant_id)を基に一致します。
  • サイロ型デプロイメント:テナントごとに分離されたデプロイでは、tenant_idとtenant_tierの両方を基にルールを設定します。
  • プール型デプロイメント:すべてのBasicティアテナントがアクセスできるよう、tenant_tierのみに基づいてルールを設定します。

このあたりのカラクリはCDKのスタック構造を読み解かないと理解しづらいかもしれません。

https://github.com/aws-samples/aws-saas-factory-saas-microservices-workshop/blob/main/bin/saas-workshop.ts このファイルでは下記の4つのアプリケーションスタックが作られています

  1. const basicStack = new ApplicationStack(app, "PoolBasicStack", {
  2. const tenantBstack = new ApplicationAdvancedTierStack(app, "tenantBstack", {
  3. const tenantEstack = new ApplicationAdvancedTierStack(app, "tenantEstack", {
  4. const tenantCstack = new ApplicationStack(app, "tenantCstack", {

そして、先に述べたこの条件を思い出してください。

  • BasicティアであるテナントA, Dに関しては全ての処理が一番上の共有プールで処理されます
  • テナントB, EAdvancedティアで、ProductサービスとOrderサービスは共有プールで処理されるのですが、Fulfillment, Invoiceに関してはそれぞれのテナントに専用の環境が割当てられます
  • テナントCはPremiumユーザーなので、全ての処理はC用の専用環境で処理されます

これらの条件が、上記4つのApplicationStackに含まれるProductStackのVirtualService Routing Ruleとして記述されています。 ここはかなりトリッキーで相当に読みほどいていく必要がありますね。

ユーザー権限制御

ユーザーのRoleによってできること/できないことの挙動を実装する方法についてです。 このワークショップでは BuyerSellerという2つのRoleを定義しています。

Role Allowed Actions Description
Seller ViewProduct, CreateProduct 静的ポリシー: 商品の閲覧と作成が可能
Buyer ViewProduct, ViewOrder, CreateOrder 静的ポリシー: 商品と注文の閲覧、注文の作成が可能

Amazon Verified Permissionsを使った実現

私はこのAmazon Verified Permissionsをこのワークショップを通じて初めて知りました。

Verified Permissions is a scalable, fine-grained permissions management and authorization service that helps you build and modernize applications without having to implement authorization logic within the code of your application.

Verified Permissions は、スケーラブルで詳細な権限管理と認可を提供するサービスであり、アプリケーションのコード内に認可ロジックを実装することなく、アプリケーションの構築やモダナイズを支援します。

もう、「へー」という他ありませんでしたww

動作としてはProduct, OrderサービスそれぞれがIdentityトークンに含まれるRoleカスタム属性としてをVerified Permissionsにぶん投げてActionのAllow/Denyをチェックするというものです。

テナント単位のメトリクス、モニタリング

マルチテナントモデルは複数のテナントがリソースを使うので監視が非常に厄介なことがあります。 例えばNoisy Neighbor問題です。一つのテナントがやたらと多くのリソースを使ってしまい、他の多くのテナントの通常利用が難しくなってしまうケースがあります。この問題に適切に対処するためにはインフラ全体のモニタリングだけでは解像度が足りず、各テナント毎のUsageモニタリングが必要になります。

このワークショップではCloudWatchのメトリクスにカスタムディメンションを仕込んで対応しています。

# IMPLEMENT ME: LAB6 (create_emf_log_with_tenant_context)
async def create_emf_log_with_tenant_context(service_name, tenant_context, metric_name, metric_value):
    logger = create_metrics_logger()
    logger.set_dimensions(
        {"ServiceName": service_name},
        {"ServiceName": service_name, "Tenant": tenant_context.tenant_id},
        {"ServiceName": service_name, "Tier": tenant_context.tenant_tier},
    )
    logger.put_metric(metric_name, metric_value)
    await logger.flush()

まとめ

このワークショップでは、マルチテナント特有の課題があること、そしてそれらの課題を克服するための戦略やパターンについて学びました。特に、テナントコンテキストがマルチテナントの設計において中心的な役割を果たすことを学びました。

主なポイント

  • マルチテナントライブラリの構築

    • カプセル化と再利用によってマルチテナント開発を簡素化する。
  • テナントコンテキスト取得の戦略を定義

    • SaaS Identityを活用してこのプロセスを簡素化。
  • テナント認識ポリシーによるセキュリティの向上

    • セキュリティを強化するためのポリシーを活用。
  • サービスメッシュとイベントバスルーティングの利用

    • 複雑なマルチテナントルーティングの要件を簡素化。
  • テナント認識メトリクスの計測

    • マイクロサービスを計測し、重要なインサイトを得てビジネス判断を支援。
  • Amazon Verified Permissionsの活用

    • マルチテナントSaaSアプリケーションでリクエストを認可する。

以上、思った以上に長文の記事になってしまいました。最後までお読みいただきありがとうございました!