🔍

OpenSearch Serverlessを検証した所感とCloudWatchダッシュボードの作成をしてみた

2023/04/24に公開

こんにちは!
any株式会社でエンジニアをしている @huuya です!

弊社が運営しているナレッジ経営クラウドのQastでは検索基盤にOpenSearch Serviceを利用しております。
先日OpenSearch Serverlessが一般公開されたので導入検討のため実際に触って検証を行ってみたので所感をまとめます。

特に気になるところ

検証を行ったコレクションタイプは「検索」になります。
「時系列」のコレクションタイプは検証しておりません。

OpenSearch Serverlessを検証する前の調査を以前行ったので是非こちらも御覧ください🤗

https://zenn.dev/huuya/articles/2d92b8572e608a

indexingの負荷が下がった

従来のOpenSearch Serviceではマスターノードとデータノードという構成でクラスターを組めていましたが、
OpenSearch Serverlessではsearchとindexingのインスタンスが内部的に完全に分離されたことで、
従来のOpenSearch Serviceよりは遥かに耐久性の高い検索基盤を作れそうです。

特に以下2点についてはOpenSearch Serverlessが持っているポテンシャルだと思います!

  • indexingに負荷がかかっている状況でも検索への影響が無くなる
  • 検索に負荷がかかっている状況でもindexingへの影響が無くなる

また、これまでは少々の負荷でも429エラーやタイムアウトエラーを吐いていたOpenSearchが、
indexingの検証をした限りでは429エラーやタイムアウトエラーを殆ど吐かなくなっていました😳
※ 検証データや方法によって環境差はあると思います。

実際にopensearch-benchmarkを使ってベンチマークを取って定量面で比較をしようとしたのですが、
ローカルにインストールされたOpenSearchにしか対応していなかったので断念しました😢

スパイク負荷でのOCUのオートスケールは遅れが出る

突発的且つ大きな負荷がかかる場合は若干(数十秒〜数分)オートスケールが遅れている気がしましたが、
同程度の負荷を掛け続けている最中にはそのような遅れが発生していないように思えました。
メトリクスを参照してもどれくらいのスピード感でオートスケールがされたのか分からないため、
あくまで体感としてそれくらいと捉えて頂ければと思います🙏

Serverlessでは未対応の機能がある

サポートされていない機能がまだまだある」に記載しておりますが、
ある程度システム側で改修を行ったり運用方法の見直しが必要そうだったので要検討という所感です。

まずはCloudWatchのダッシュボードの作成をしてみた

コレクションの詳細ページのモニタリングタブに相当するCloudWatchのダッシュボードも作成してみました。

カスタムで以下のメトリクスも追加しています。

  • 検索とindexingのOCU数
  • ホットストレージ内の合計データ数 (GiB)
  • 削除されたドキュメント数(カウント)

OpenSearch Serverlessでモニタリング可能な指標の詳細は公式ドキュメントを参照ください。
https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/monitoring-cloudwatch.html

上記で作成したダッシュボードのサンプルは以下のコマンドで作成できます。

aws cloudwatch put-dashboard --dashboard-name test-opensearch-serverless --dashboard-body file://test_opensearch_serverless_dashboard.json

CLIオプションの --dashboard-body については下記になります。

--dashboard-body

以下のパラメータは環境に応じて適宜修正を行ってください。

  • {{region}}
  • {{ClientId}}
  • {{CollectionId}}
  • {{CollectionName}}
{
  "widgets": [
    {
      "height": 1,
      "width": 24,
      "y": 0,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# OCU"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 1,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IndexingOCU",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "インデックス作成容量 (OCU)"
      }
    },
    {
      "type": "metric",
      "x": 8,
      "y": 1,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "SearchOCU",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "検索容量 (OCU)"
      }
    },
    {
      "height": 1,
      "width": 24,
      "y": 9,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# インデックス作成のパフォーマンス"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 10,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionDataRate",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "インデックス作成データレート (ギガバイト/秒)"
      }
    },
    {
      "type": "metric",
      "x": 8,
      "y": 10,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionRequestSuccess",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "インデックス作成成功率 (カウント)"
      }
    },
    {
      "type": "metric",
      "x": 16,
      "y": 10,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionRequestRate",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Average",
        "period": 60,
        "title": "インデックス作成リクエスト率 (カウント)"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 18,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionRequestLatency",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Average",
        "period": 60,
        "title": "インデックス作成のレイテンシー (ミリ秒)"
      }
    },
    {
      "height": 1,
      "width": 24,
      "y": 26,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# ストレージ"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 27,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "StorageUsedInS3",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "S3 で使用されているストレージ (GiB)"
      }
    },
    {
      "type": "metric",
      "x": 8,
      "y": 27,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "HotStorageUsed",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "ホットストレージ内の合計データ数 (GiB)"
      }
    },
    {
      "height": 1,
      "width": 20,
      "y": 35,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# 検索のパフォーマンス"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 36,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "SearchRequestRate",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "クエリの数 (オペレーション/分)"
      }
    },
    {
      "type": "metric",
      "x": 8,
      "y": 36,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "SearchRequestLatency",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Average",
        "period": 60,
        "title": "検索レイテンシー (ミリ秒)"
      }
    },
    {
      "type": "metric",
      "x": 16,
      "y": 36,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "SearchableDocuments",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "検索可能なドキュメント (カウント)"
      }
    },
    {
      "height": 1,
      "width": 20,
      "y": 44,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# エラー"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 52,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "SearchRequestErrors",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "検索エラーの数"
      }
    },
    {
      "type": "metric",
      "x": 8,
      "y": 52,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionRequestErrors",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "取り込みエラーの数"
      }
    },
    {
      "type": "metric",
      "x": 16,
      "y": 52,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [ "AWS/AOSS", "2xx", "CollectionName", "{{CollectionName}}", "CollectionId", "{{CollectionId}}", "ClientId", "{{ClientId}}" ],
          [ ".", "3xx", ".", ".", ".", ".", ".", "." ],
          [ ".", "4xx", ".", ".", ".", ".", ".", "." ],
          [ ".", "5xx", ".", ".", ".", ".", ".", "." ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "レスポンスコードによる HTTP リクエスト (カウント)"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 60,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "IngestionDocumentErrors",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "取り込みリクエストのエラー"
      }
    },
    {
      "height": 1,
      "width": 20,
      "y": 68,
      "x": 0,
      "type": "text",
      "properties": {
        "markdown": "# その他"
      }
    },
    {
      "type": "metric",
      "x": 0,
      "y": 69,
      "width": 8,
      "height": 8,
      "properties": {
        "metrics": [
          [
            "AWS/AOSS",
            "DeletedDocuments",
            "CollectionName",
            "{{CollectionName}}",
            "CollectionId",
            "{{CollectionId}}",
            "ClientId",
            "{{ClientId}}"
          ]
        ],
        "view": "timeSeries",
        "stacked": false,
        "region": "{{region}}",
        "stat": "Sum",
        "period": 60,
        "title": "削除されたドキュメント数(カウント)"
      }
    }
  ]
}

調査して分かったこと

基本的にはAWS Black Belt公式ドキュメントを隅々まで読んでキャッチアップを行いつつ、
実際に触って検証をしてみました。

未対応のメトリクスがある

従来のOpenSearchServiceで監視出来ていた以下のメトリクスが未対応でした。

  • CPU使用率
  • メモリ使用率
  • JVMメモリプレッシャー
  • IOレベルの読み書きに関連するメトリクス
    • スレッドプールのサイズやキューの数、リジェクト数等

また、対応しているメトリクスについてOCU単位で監視出来るものはありませんでした。

https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/monitoring-cloudwatch.html

データへのアクセスポリシーを細かく設定出来る

以下の操作に対してIAMユーザ、IAMロール単位で権限の設定が出来ます。
また任意のコレクションや任意のindexのみに権限を絞り込むことが可能です。

  • インデックス
    • 読み取り
    • 作成
    • 更新
    • 削除
  • ドキュメント
    • 読み取り
    • 書き込み、更新

以下の操作についてもIAMユーザやIAMロール単位で権限の設定が出来て、
任意のコレクションのみに権限を絞り込むことが可能となっておりました。

  • aliasまたはtemplate
    • 読み取り
    • 作成
    • 更新
    • 削除

https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/serverless-data-access.html

認証方法は従来から変更された


引用元 2023/03/02 AWS Black Belt Online Seminar Amazon OpenSearch Serverless P47

ダッシュボードへのログイン時

SAML連携をしていない場合、IAMユーザ又はIAMロールでの認証が必須になります。
※ 3月28日以降からはログイン時にAWSコンソールでのIAM認証情報を使用して自動ログイン出来るようになっています。

Starting March 28, OpenSearch Serverless will no longer support OpenSearch Dashboards login page with IAM access key id and secret key as inputs. Instead, we are introducing a new feature, fast Dashboard login in the AWS Console that will automatically log you into OpenSearch Dashboards using the IAM credentials of your AWS Console once you click the Dashboards endpoint link.
Note: To login with SAML, you will need to copy the URL and paste in the browser to access IDP select page.

引用元 AWSコンソールのヘッダー通知

APIリクエスト時

IAMユーザ又はIAMロールでの認証が必須になっていました。

各言語ごとのClientライブラリで署名したHTTPリクエストをする方法は公式ドキュメントを参考にしました。
https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/request-signing.html

サポートされていない機能がまだまだある


引用元 2023/03/02 AWS Black Belt Online Seminar Amazon OpenSearch Serverless P64

公式のBlackBeltに掲載されている上記の機能以外にも未対応の機能がありました

  • カスタムパッケージ(analyzerでsynonym graphの使用が出来ない)
  • スロークエリをCloudWatch Logsへ出力する(検索、indexing共に未対応: 公式フォーラム
    • エラーログや監査ログも未対応でした

HTTPリクエスト時のペイロードの最大サイズは従来と同じっぽい

従来のOpenSearch Serviceではインスタンスタイプ毎に10MB、若しくは100MBのようになっておりましたが、
OpenSearchServerlessの場合は公式ドキュメントに記載はありませんでした。

詳しい検証を行っていないので確信は持てないですが、
10MB以上のリクエストは受け付けてくれて100MB以上の場合は413 Payload Too Largeエラーが返却されたので、恐らく100MBになっているのだと思います。
(検証時のOCUは検索、indexing共に2つずつ。

コレクション単位で使用しているOCU数が分からない

現時点ではコレクション単位で何個のOCUを使用しているか可視化出来ないようです。
公式ドキュメントにも記載がありますが、
IndexingOCUやSearchOCUのディメンションにClientIdの指定しか出来ないためです。

コレクション単位でOCU数の最大値を設定出来ない

現時点ではコレクション単位でのOCU数の最大値を設定出来ないため、実質リージョン単位で検索とindexingのOCU数の最大値を設定することになります。

opensearch-jsをTypeScriptで利用する場合はv2.2.1以上を使用する必要がある

const { defaultProvider } = require('@aws-sdk/credential-provider-node');
const { Client } = require('@opensearch-project/opensearch');
const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws');

const client = new Client({
  ...AwsSigv4Signer({
      region: 'us-west-2',
      service: 'aoss',
      getCredentials: () => {
        const credentialsProvider = defaultProvider();
        return credentialsProvider();
      },
  }),
  node: ''
});

AWSの公式ドキュメントでは上記のようにクライアントを定義して利用できると記載がありますが、
AwsSigv4Signerservice に対応する型がそもそも定義されていなかったのでv2.2.0では使用出来ない状態にありました。

https://github.com/opensearch-project/opensearch-js/pull/377/

このバグはv2.2.1のリリースで修正されたのでTypeScriptで使用する場合は注意が必要です。

opensearch-jsでのClientを作成する時にAWSの認証情報を引数で指定出来なかったので対応した

本来は環境変数や専用の設定ファイル経由で無く引数でも指定したいですよね。
@aws-sdk/credential-provider-nodeのdefaultProviderはデフォルトで環境変数からの取得を試みて、
それが無ければ ~/.aws/credentials~/.aws/configからの取得を行うような実装になっていました。

ライブラリの認証に利用する情報が環境変数や設定ファイルからの取得となると実質暗黙的な依存関係となってしまうので、出来ればそのような運用を避けたいところです。

そのため、defaultProviderへ引数から認証情報を渡せるように作ってPRを提出してみました。

https://github.com/aws/aws-sdk-js-v3/pull/4649

必要最低限の手段で認証情報を取得したい場合は以下のようにカスタムのProvoiderを実装するのが良いかと思います。

customProvider.ts
import { AwsCredentialIdentity, MemoizedProvider } from "@aws-sdk/types";
import { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
import { fromArgs, FromArgsInit } from "./credentialProviderArgs";

export const customProvider = (init: FromArgsInit = {}): MemoizedProvider<AwsCredentialIdentity> =>
  memoize(
    chain(
      fromArgs(init),
      async () => {
        throw new CredentialsProviderError("Could not load credentials from any providers", false);
      }
    ),
    (credentials) => credentials.expiration !== undefined && credentials.expiration.getTime() - Date.now() < 300000,
    (credentials) => credentials.expiration !== undefined
  );

上記に対応するcredential-providerは以下のような実装になるかと思います。

credentialProviderArgs.ts
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { AwsCredentialIdentityProvider } from "@aws-sdk/types";

export type FromArgsInit = {
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken?: string;
  expiry?: string;
}

export const fromArgs = (init: FromArgsInit): AwsCredentialIdentityProvider => async () => {
  const {accessKeyId, secretAccessKey, sessionToken, expiry} = init;

  if (!accessKeyId || !secretAccessKey) {
    throw new CredentialsProviderError("Unable to find arguments credentials.");
  }

  return {
    accessKeyId,
    secretAccessKey,
    ...(sessionToken && { sessionToken }),
    ...(expiry && { expiration: new Date(expiry) }),
  };
};

このようなカスタムのProviderを実装することで以下のように引数で認証情報を設定出来るようになります。

import { Client } from '@opensearch-project/opensearch';
import { AwsSigv4Signer } from '@opensearch-project/opensearch/aws';
import { customProvider } from './customProvider';

const client = new Client({
  ...AwsSigv4Signer({
      region: 'us-west-2',
      service: 'aoss',
      getCredentials: async () => {
        const credentialsProvider = customProvider({
          accessKeyId,
          secretAccessKey,
          sessionToken,
          expiry: new Date().toISOString(),
        );
        return await credentialsProvider();
      },
  }),
  node: ''
});

まとめ

OpenSearch Serverlessはまだ一般公開されたばかりで、
従来のOpenSearch Serviceでサポートされていた機能が対応していない部分が色々ありますが、
シャード戦略や最大負荷に合わせたインスタンスタイプの設定などの運用が不要となることでかなりのポテンシャルを秘めているかと思います!

また、searchとindexingのノードが内部アーキテクチャ上分離されていることで、
検索に負荷が掛かっている状況でもindexingに影響しないことや
その逆のindexingに負荷がかかっている状況でも検索に影響しない点でも検討の余地が十分にあると感じました👏

引き続きOpenSearch Serverlessを触ってみようと思います!
ありがとうございました!

any株式会社

Discussion