Amazon OpenSearch Service(旧AWS ElasticSearch)の手動スナップショットをコンパネポチポチで取れるようにする

自分用のメモをブログ用に再編しただけなので、特にアドベントカレンダーは関係ない。一応今年も一人アドベントカレンダー2021 Advent Calendar 2021 - Adventarに登録だけしようか思っていたけど、悩んでいたら枠が全部埋まったので出せる要素がない。

本題

AWS ElasticSearchことAWS OpenSearch Service(以下ES)のスナップショットは自動で取られる分も手動で取る分もコンパネではほぼ制御できない。前者は現在1時間おきに取る以外の選択肢がなく、後者はそういうインターフェイスを提供していない(おそらく、Kibanaを使えということだろうが...)。

じゃあ公式ドキュメントにはどう書いてあるかというと、スナップショットを取るようESに直接HTTPSリクエストを送る、という形になる。しかもリクエストはAWS4Authを通して認証する必要があって、これはcurlではできないのでそれができるライブラリを使え、ということになっている: [Python] Boto3以外でV4署名リクエストを行う | DevelopersIO

書くコード自体はたいしたことないけど、スナップショットリポジトリを作成するのに、「S3関連の操作を許可するRoleをPassRoleして付与できるRole」の必要がある。そのためにIAM Userを発行してローカルでコードを動かすならRoleを作成してAWS内で動く方がいいなー、ということで、雑にLambda SAMを作ってなんとかした。

できたもの : mi-24v/aws-es-snapshot-helper: Lambda簡易ESクライアントによるAWS ESの手動スナップショットヘルパ

IAM ロールの PassRole と AssumeRole をもう二度と忘れないために絵を描いてみた | DevelopersIO

Lambdaの作成

以降は先述したリポジトリの中身を想像しながら書いてるので、わけがわからない場合はソース読んで貰ったほうが早い。

AWS4Authの使い方を見ているとIAM UserのAccess KeyとSecret Access Keyが必要になる。これはLambdaだとLambdaのExecution Roleから付与される一時キーから取得できて、実行時に勝手に環境変数(AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_SESSION_TOKEN)に入る。

この辺はBoto3(AWS SDK for Python)の利用する認証情報 - Qiitaのように、boto3が勝手にやっていることでもあるので、boto3.Session().get_credentials()で出てくるっぽい(試していない)。

credentialsの取得ができたらAWS4Authの準備ができるので、公式ドキュメント(再掲)のコードを適当にコピーして整形する。

def lambda_handler(event, context):
    host = event["domain_url"]  # include https:// and trailing /
    repository_name = event["repo_name"]
    access_key = os.environ["AWS_ACCESS_KEY_ID"]
    secret_key = os.environ["AWS_SECRET_ACCESS_KEY"]
    region = os.environ["AWS_REGION"]
    service = 'es'
    session_token = os.environ["AWS_SESSION_TOKEN"]
    awsauth = AWS4Auth(access_key, secret_key, region, service, session_token=session_token)
    # requestsでよしなにする(以下省略)
    # action = event["action"] # のようにして、処理を分岐

ESへ実際にリクエストを投げるのは、requestsを使わずに本家ElasticSearchの公式クライアントelastic/elasticsearch-pyを使う手もあるけど、スナップショット周りができるかどうかは知らない。

AWS Lambda (Python 3.8)から Amazon Elasticsearchを使う(LambdaはSAMで) – 或る阿呆の記

なお、今回はコンパネポチポチで操作するのが目標なので、ESドメインURL等はLambdaコンソールの「Test」欄から投げることを想定して、イベントに必要なパラメータは揃ってるものとして扱った。必要なイベントは次の感じのJSON。

{
    "domain_url": "https://vpc-example-123456.ap-northeast-1.es.amazonaws.com/", # ESのドメインURL(末尾に/を入れる)
    "repo_name": "manual_snapshot_repo", # スナップショットレポジトリ名
    "register_repository_role": "arn:aws:iam::123456:role/exampleRole-12345", # PassRole対象になるRoleのARN(レポジトリ登録時のみ)
    "index_name": ".kibana_1", # index単位で処理するときに使うindex名(delete等のときのみ)
    "action": "register_repository" # このLambdaが処理の分岐に使うキー。ESの操作とは直接関係しないのでよしなに実装。
}

時刻のformatstring

pythonのformat stringには日付フォーマットもある。ってだけのメモ。

日付フォーマット(datetime⇔文字列) | Python Snippets

SAM Templateの作成

前述したRoleを含むテンプレートを作っていく。

今回相手にするESインスタンスはVPCアクセスにしているので、LambdaをESと同じ(もしくは、ESインスタンスの属するSubnetに到達可能な)VPCにつなげておく必要がある。VPCとセキュリティグループのARNを控えて代入するか、テンプレートで定義してARNを渡せばOK。

また、LambdaをVPCアクセスにするとENIの操作が必要になるので、前述したRoleにENI操作のIAM権限をつける必要がある。

結局どのIAM権限がいるかを、作ったテンプレートのうち、Roleに関する部分を抜粋して示す。

 SnapshotRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
          - Effect: Allow
            Principal:
              Service: es.amazonaws.com
            Action: "sts:AssumeRole"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonESFullAccess"
      Policies: # 本当は自身のArnをPassRoleするPolicyも必要なのだが自己参照できないので手動で足す
        - PolicyName: "AllowTakeElasticSearchSnapshotPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "s3:ListBucket"
                Resource: !GetAtt SnapshotBucket.Arn
              - Effect: Allow
                Action:
                  - "s3:GetObject"
                  - "s3:PutObject"
                  - "s3:DeleteObject"
                Resource: !Sub "${SnapshotBucket.Arn}/*"
        - PolicyName: "LambdaWithVPCPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: "arn:aws:logs:*:*:*"
              - Effect: Allow
                Action:
                  - "ec2:CreateNetworkInterface"
                  - "ec2:DescribeNetworkInterfaces"
                  - "ec2:DetachNetworkInterface"
                  - "ec2:DeleteNetworkInterface"
                Resource: "*"

注意として、公式ドキュメント(再掲)に乗ってるPassRole(以下の部分)に関しては、Roleの記述を分けるか、手動で足すかしないといけなくて、自身のRole ARNは参照できないっぽい(IDEAのSAM Syntaxチェックがそう怒ってきたから、そうに違いない)。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/TheSnapshotRole"
    }
  ]
}

CloudFormation組み込み関数の記述をなんとなくつかっていたので、その辺のメモも残す。

動かしていく

ローカルでのテストはめんどくさかったのでしていない(ええ)。トリガーもないし、直接デプロイしてコンパネでポチポチ試していった。

で、実際にKibanaでインデックスを確認するには、プライベートVPCを超える何かが必要。一時的な用途なら踏み台で十分なので踏み台を建ててローカルポートフォワーディングで手元のPCでアクセスできるようにした。

今考えるとSSHポートフォワーディングでいけた気がするけど、踏み台からESへはリバースプロキシを建ててアクセスした。一時的な用途だと、NGINXを建てるのはだるくて....と思っていたら、cortesi/devdというコマンドラインリバースプロキシがあるのを見つけた。Go製なのでReleaseバイナリを解凍するだけですぐ使える。本来は開発サーバ用っぽいが、求めていたものと完全に合致していて良かった。

アクセスできるようになったら、curlとかkibanaのコンソールでスナップショットのデータがリストアされているか試していく。

これで確認できたら踏み台は閉じて、Lambdaでイベントをいじってコンパネからスナップショットを取ったり取らなかったりしておわり。

おわりに

AWS関連の操作やmiwkeyの管理はまだまだやることはいっぱいある。けど、時間とやる気と調査が足りねえ...