<-- mermaid -->

IAMロールでAmazon OpenSearch Serviceの手動スナップショットを作成しデータを復元してみよう

こんにちは。株式会社ユーザベース NewsPicksでエンジニアをやっております崔(チェ)です。現在は Data / Algorithm チームで検索エンジンの開発を担当しております。

弊社は、約2ヶ月前に検索システムをElastic社のElasticsearch(以下、ES)からマネージドサービスであるAmazon OpenSearch Service(以下、OpenSearch)に移行しました。前回のブログ記事では、移行と同時に検索システムのセキュリティを向上させた話をしました。主に、きめ細かなアクセスコントロール(Fine-Grained Access Control、以下、FGAC)を有効化しSAML + Oktaでログインできるようにする方法などについて詳しく扱っております。ご興味ある方はぜひご覧ください!

tech.uzabase.com

今回はOpenSearchで手動スナップショットを作成し、それを用いてデータを復元する方法について述べたいと思います。必要な権限なども記載しておりますので、ぜひ読んでいただけると嬉しいです。

はじめに

OpenSearchのスナップショットの作成やデータを復元する方法はいくつかの点を除きESと全体的に流れが一致します。まずはESでどのようにスナップショットを作成し、データを復元するかを見ていきましょう。

ESでいう「スナップショット」とは?

起動中のESのクラスター上で作成されるバックアップのことです。スナップショットは必要に応じその都度作成することができますが、Snapshot lifecycle management(SLM)の設定をすることで定期的に作成してもらうことも可能です。作成されたスナップショットを用い、同一クラスターを一定時点に戻したり(Point-In-Time Recovery)、複数の異なるクラスター間でデータをコピーすることができます。

FYI
Point-In-Time Recovery(PITR)とは最後にスナップショットを作成した時点へ復元する手法です。ESの場合、正確には、スナップショットが開始時刻と完了時刻を持っており、その時間帯の各シャードの状態に戻すような仕組みになっています。

詳細については、ESドキュメントの「Snapshot and restore」ページをご参照ください。

ESでスナップショットを作成する方法

ここでは、Amazon S3(以下、S3)を利用しスナップショットを保存する方法をご紹介します。前提条件として以下が要求されます。

  • S3バケットが作成されている
    • リポジトリ用のバケットにデータを書き込む権限を持っている
      • 例えば、EC2上で稼働している場合、EC2インスタンスに権限を付ける必要があります。
  • repository-s3プラグインがインストールされている
    • 8.x以上のバージョンではデフォルトで含まれておりますが、7.x以下のバージョンでは手動でインストールする必要があります。

1) リポジトリを登録する

スナップショットは、インデックスのバックアップの際、リポジトリにセグメントのコピーを保存します。

FYI
ESのインデックスは複数のシャード(Luceneのインデックスのインスタンス)から構成されます。シャードとは複数のセグメント(Luceneのセグメント)とコミットポイントを束ねたものです。セグメントは転置インデックスであり、ドキュメントはセグメントに格納されます。

したがって、スナップショットを作成するためには予めリポジトリを登録する必要があります。リポジトリとしてAzureS3Google Cloud Storageなどのサービスが利用できます。

KibanaのDev ToolsのコンソールからCreate or update snaphot repository APIを叩き、S3のバケットをリポジトリとして登録します。

PUT _snapshot/{repository_name}
{
    "type": "s3",
    "settings": {
        "bucket": "{bucket_name}",
        "region": "{region}",
        "endpoint": "s3.{region}.amazonaws.com",
        "base_path": "base/path"
    }
}

cat APIsで登録されているリポジトリ一覧が確認できます。

GET _cat/repositories
id                    type
{repository_name}     s3

リポジトリの定義を確認したい場合、Get snapshot repository APIを叩くことで確認できます。

GET _snapshot/{repository_name}
{
  "{repository_name}" : {
    "type" : "s3",
    "settings" : {
      "bucket" : "{bucket_name}",
      "base_path" : "base/path",
      "endpoint" : "s3.{region}.amazonaws.com",
      "region" : "{region}"
    }
  }
}

2) スナップショットを作成する

同じくKibanaからCreate snapshot APIを叩きます。

PUT _snapshot/{repository_name}/{snapshot_name}?wait_for_completion=false

cat APIsやGet snapshot APIを叩けばスナップショットの一覧が確認できます。

cat APIsからは各スナップショットの進行状況が確認できます。作成中ならstatusIN_PROGRESS、無事に完了したらSUCCESSと表示されます。

GET _cat/snapshots/{repository_name}?v

例えば、20230801_snapshot という名のスナップショットを作成したとしたら、下記のように出力されるはずです。

id                 status start_epoch start_time end_epoch  end_time duration indices successful_shards failed_shards total_shards
20230801_snapshot SUCCESS 1647499823  06:50:23   1647500686 07:04:46    14.3m      60               128             0          128

異なるクラスター間でデータをコピーする方法

仮に、本番環境のデータを開発環境にコピーしたいとしましょう。この際、スナップショットが活用できますので、その方法をご紹介します。以下の条件が満たされている必要があります。

  • 開発環境が別アカウントを使用する場合、本番環境のリポジトリとして登録されているバケットに開発環境からアクセスできるよう、クロスアカウントアクセス許可を与えている
    • バケットからデータを読み込む権限がついている
  • 本番環境でのスナップショット作成が成功している
  • 開発環境に repository-s3 プラグインがインストールされている

1) 元クラスターのスナップショットを読み込むリポジトリを登録する

リポジトリを作る方法は本番環境と同じです。必ず同じバケットを設定してください。

PUT _snapshot/{dev_repository_name}
{
    "type": "s3",
    "settings": {
        "bucket": "{prod_bucket_name}",
        "region": "{region}",
        "endpoint": "s3.{region}.amazonaws.com",
        "base_path": "base/path"
    }
}

本番環境のスナップショット作成が無事完了していれば、開発環境で本番環境のスナップショット一覧が確認できます。

id                 status start_epoch start_time end_epoch  end_time duration indices successful_shards failed_shards total_shards
20230801_snapshot SUCCESS 1647499823  06:50:23   1647500686 07:04:46    14.3m      60               128             0          128

2) 元クラスターのスナップショットでデータを復元する

同じくKibanaからRestore snapshot APIを叩き、特定のスナップショットからデータを復元することができます。

POST _snapshot/{repository_name}/{snapshot_name}/_restore?wait_for_completion=false
{
    "indices": "index1, index2, ..."
}

注意すべきは、同じ名前のインデックスが存在するとデータの復元ができないという点です。rename_patternrename_replacementなどのオプションで既存インデックスの削除を回避することも可能です。
また、include_global_stateオプションをtrueにすることで、ドキュメントに限らずシャード数などのインデックスの設定内容も一緒に復元できます。ちなみに、スナップショット作成時にはデフォルトでinclude_global_statetrueになっているため、気にする必要がありません。

FYI
スナップショットを用いた復元処理は、プライマリシャードが対象なのでクラスターがyellow状態になります。プライマリシャードの復元が完了し複製処理が始まりますが、それが完了してからgreen状態に戻ります。

データを復元する際、内部ではIndices recovery APIが叩かれます。なので復元処理の進行状況が知りたい場合は、Indices recovery APIを叩き確認することができます。

GET {index_name}/_recovery

FYI
復元中のインデックスやデータストリームを削除することで復元処理を途中で止めることができます。復元中のものを削除してもスナップショットのデータには影響がありません。

OpenSearchでスナップショット作成 & データ復元をやってみよう

上述したように、OpenSearchでのスナップショット作成 & データ復元の手順は、ESとだいたい一致します。主な差は、(1) 基本的にすべてのドメインで自動スナップショットを作成する、(2) 手動スナップショットをKibanaからAPIを叩いて作成することができない、という点にあります。

FYI
自動スナップショットは無料です。クラスターがred状態になった場合、クラスターを復元するために使われます。バージョンが5.3以上であれば、1時間ごとにスナップショットを作成し最大336個を14日間保持します。一方、バージョン5.1以下であれば毎日1回スナップショットを作成し、14個までを保持します。

IAMロールによる認証

OpenSearchで手動スナップショットを作成するには、OpenSearchの許可を委任するIAMロールが必要です。このIAMロールは、スナップショットを作成するとき以外にも、それを保存するリポジトリを登録する際に必要です。下にAWS CDKで実装したIAMロールを添付します。

import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";

new iam.Role(this, "TheSnapshotRole", {
    roleName: "TheSnapshotRole",
    assumedBy: new iam.ServicePrincipal("es.amazonaws.com"),
    inlinePolicies: {
        policies: new iam.PolicyDocument({
            statements: [
                new iam.PolicyStatement({
                    actions: ["s3:ListBucket"],
                    resources: [
                        cdk.Stack.of(this).formatArn({
                            service: "s3",
                            region: "",
                            account: "",
                            resource: "{bucket_name}",
                            arnFormat: cdk.ArnFormat.NO_RESOURCE_NAME,
                        })
                    ],
                }),
                new iam.PolicyStatement({
                    actions: [
                        "s3:PutObject",
                        "s3:GetObject",
                        "s3:DeleteObject",
                    ],
                    resources: [
                        cdk.Stack.of(this).formatArn({
                            service: "s3",
                            region: "",
                            account: "",
                            resource: "{bucket_name}",
                            resourceName: "base/path/*",
                            arnFormat: cdk.ArnFormat.NO_RESOURCE_NAME,
                        })
                    ],
                }),
            ]
        })
    }
});

IAMロールで署名を付ける必要があるため、リクエスト時にはcURLやAWS SDKなどを用います。ここではPythonで認証を行う方法をご紹介します。

FYI
cURLは--aws-sigv4オプションをつけリクエストすることで認証することができます。

Pythonでは、requests-aws4authopensearch-pyで署名を作ることができます。

requests-aws4authを使用する場合

import boto3
from requests_aws4auth import AWS4Auth

session = boto3.Session(profile_name={profile_name})  # 指定しない場合、デフォルトのプロフィールを使用
credentials = session.get_credentials()
region = session.region_name
service = "es"
auth = AWS4Auth(
    credentials.access_key,
    credentials.secret_key,
    region,
    service,
    session_token=credential.token,
)

opensearch-pyを使用する場合

もしリクエストも opensearch-py を使って飛ばすなら、署名も同じパッケージで行うほうが便利かもしれません。

import boto3
from opensearchpy import AWSV4SignerAuth

session = boto3.Session(profile_name={profile_name})  # 指定しない場合、デフォルトのプロフィールを使用
credentials = session.get_credentials()
region = session.region_name
auth = AWSV4SignerAuth(credentials, region)

Pythonスクリプトを書いてみる

リクエストに使うAPIはESと変わりないため、全体的な流れを実装したスクリプトを添付します。requestsopensearch-pyで実装可能です。

requestsを使用する場合

import requests

endpoint = "https://your.opensearch.domain.test"
bucket_name = "test-bucket"
repository_name = "test-repository"
snapshot_name = "test_snapshot"

# リポジトリを登録
snapshot_role = session.client("iam").get_role(RoleName="TheSnapshotRole")
api = f"_snapshot/{repository_name}"
payload = {
    "type": "s3",
    "settings": {
        "bucket": bucket_name,
        "region": region,
        "endpoint": f"s3.{region}.amazonaws.com",
        "base_path": "base/path",
        "role_arn": snapshot_role["Role"]["Arn"],
    },
}
response = requests.put(
    f"{endpoint}/{api}",
    auth=auth,
    json=payload,
    headers={"Content-Type": "application/json"},
)

# スナップショットを作成
api = f"_snapshot/{repository_name}/{snapshot_name}?wait_for_completion=false"
response = requests.put(
    f"{endpoint}/{api}",
    auth=auth,
    json=payload,
    headers={"Content-Type": "application/json"},
)

# データを復元
api = f"_snapshot/{repository_name}/{snapshot_name}/_restore?wait_for_completion=false"
payload = {
    "indices": "index1, index2, ...",
}
response = requests.post(
    f"{endpoint}/{api}",
    auth=auth,
    json=payload,
    headers={"Content-Type": "application/json"},
)

opensearch-pyを使用する場合

from opensearchpy import OpenSearch, RequestsHttpConnection

host = "your.opensearch.domain.test"
bucket_name = "test-bucket"
repository_name = "test-repository"
snapshot_name = "test_snapshot"

# 接続時に署名をつける
client = OpenSearch(
    hosts=[{"host": host, "port": 443}],
    http_auth=auth,
    use_ssl=True,  # https
    verify_certs=True,
    connection_class=RequestsHttpConnection,
)

# リポジトリを登録
snapshot_role = session.client("iam").get_role(RoleName="TheSnapshotRole")
payload = {
    "type": "s3",
    "settings": {
        "bucket": "{bucket_name}",
        "region": region,
        "endpoint": f"s3.{region}.amazonaws.com",
        "base_path": "base/path",
        "role_arn": snapshot_role["Role"]["Arn"],
    },
}
client.snapshot.create_repository(
    repository_name,
    payload,
    params={"timeout": 30},
)

# スナップショットを作成
client.snapshot.create(
    repository_name,
    snapshot_name,
    wait_for_completion=False,
)

# データを復元
payload = {
    "indices": "index1, index2, ...",
}
client.snapshot.restore(
    repository_name,
    snapshot_name,
    payload,
    wait_for_completion=False,
)

FYI
現在 timeout パラメータがうまく使えないエラーが発生しているらしく、create_repositorytimeout=秒を渡すのではなく、params={"timeout": 秒}にして渡す必要があります。
詳しくはこちらをご参照ください: [BUG] Timeout parameter doesn't work when calling client.indices.delete() · Issue #260 · opensearch-project/opensearch-py · GitHub

ハマりポイント

1) FGACを有効化すると色々制限される問題

前回の記事にも書きましたが、OpenSearchではFGACを有効化することで、セキュリティプラグインが利用可能になります。セキュリティプラグインを使えば、OpenSearch Dashboards(ESでいうKibana、以下、Dashboards)でユーザとロールを作成し、特定のデータに対する特定のアクションができるユーザを限定するなど、細かく権限管理することができます。

これに伴い、スナップショット周りの処理にいくつか制限が加われます

FYI
セキュリティプラグインのOpen Distroはシステムインデックスとして.opendistro_securityを持ちます。ユーザ、ロール、権限などの設定内容をすべてこのインデックスに格納します。インデックスで管理することで、設定を変更するためにすべてのノードから設定ファイルを修正しクラスターを再起動する、といった手間が省けます。

OpenSearchのスナップショットを管理する権限

FGACを有効化しセキュリティプラグインが使えるようになると、Dashboardsに「Security」メニューが現れます。

OpenSearch DashboardsのSecurityメニュー

上の画像に見える「Roles」をクリックし、基本的に提供されるロールを確認することができます。manage_snapshotsロールは基本的に提供されるもので、クラスターレベルのMANAGE_SNAPSHOTSアクショングループを持っています。

FYI
セキュリティメニューで付与できる権限は、アクショングループ個別のもの2種類があります。アクショングループとは個別の権限を束ねたものです。例えばMANAGE_SNAPSHOTSアクショングループは、cluster:admin/snapshot/createcluster:admin/snapshot/restoreなどの個別の権限を含んでいます。

manage_snapshotsロールを用いるには、「Role Mappings」からユーザをロールにマッピングする必要があります。AWSのプロフィールで署名を付けているので、「Users」にはそのプロフィールに紐づいているIAMロールのARNを記入してください。設定を保存すればスナップショット関連の作業ができるようになります。

2) 環境ごとにパッケージIDが異なる問題

ESをEC2上で稼働していた頃は、同義語辞書やユーザ辞書などのファイルのパスを各インデックスのテンプレートに指定することで参照できました。一方、OpenSearchでは、辞書ファイルを「S3バケットに保存 → パッケージで登録 → 割り振られたパッケージIDをテンプレートに記入」し参照します。

つまり、別アカウントに登録されているパッケージはそのIDが異なるため、上でご紹介したリクエストボディーだとうまくデータの復元ができません。例えば、本番環境と開発環境が異なるアカウントで構築されており、パッケージを参照する本番環境のインデックスを開発環境にコピーしようとすると、以下のようなエラーで復元が失敗します。

explanation" : "shard has failed to be restored from the snapshot [{snapshot_info}] because of [failed shard on node [{node_id}]: failed to create index, failure IllegalArgumentException[IOException while reading user_dictionary_path: file not readable]] - manually close or delete the index [{index_name}] in order to retry to restore the snapshot again or use the reroute API to force the allocation of an empty primary shard"

実はRestore snapshot APIにはindex_settingsというオプションがあります。インデックスの復元時に、その設定内容を変えて復元するためのオプションです。以下のようなリクエストを飛ばすことで、シャード割当のエラーを解消することができます。

opensearch = session.client("opensearch")
packages = opensearch.list_packages_for_domain(DomainName="{domain_name}")["DomainPackageDetailsList"]
packages = {package["PackageName"]: package["PackageID"] for package in packages}

payload = {
    "indices": indices,
    "index_settings": {
        "index.analysis.filter.{filter_name}.synonyms_path": f"analyzers/{packages['synonyms']}",
        "index.analysis.tokenizer.{tokenizer_name}.user_dictionary": f"analyzers/{packages['user-dic']}",
    },
}
client.snapshot.restore(
    repository_name,
    snapshot_name,
    payload,
    wait_for_completion=False
)

FYI
パッケージはすべてのバージョンで使用できます。ただ、インデックスで参照されているパッケージの元ファイルを更新 → パッケージを更新し、その内容をドメインに反映させるのは、バージョン7.7以上のドメインを要求します。それ以前のバージョンを使用している場合、パッケージを参照中のインデックスを一度閉じる必要があります。

終わりに

2編に分けてOpenSearchへの移行話を書かせていただきました。スナップショット周りは多くのドキュメントに親切に説明されていますが、着手当時はいろんなドキュメントの波に流され大変混乱しておりました。例えば、「AWSのドキュメントに「ロール」と書かれているのがIAMロールだと勘違いをし、一体manage_snapshotsは何なんだ?!」とか、「パッケージIDが異なるのだが、どうすればいいんだ?」とか。

私がハマっていたところやよく理解できず迷っていたところなどに、同じく困惑されている方がいらっしゃると思います。その時この記事が少しでもお役に立てればと思います。

これからもNewsPicksの検索機能の改善とともに、さらに検索システムの開発力を鍛えてまいりますので、引き続きよろしくお願いいたします。

Page top