AWS CDKでAmazon Elasticsearch Serviceドメインとそれに接続するREST APIを作ってみた

Elastic Stack (Elasticsearch) Advent Calendar 2019の12月3日の記事です。 今回は、CDKでREST APIを構築して、API Gateway -> Lambda関数を通して、Elasticsearchへ接続できるようにします。
2019.12.03

はじめに

Elastic Stack (Elasticsearch) Advent Calendar 2019の12月3日の記事です。

以前、AWS CDKでAmazon Elasticsearch Serviceのクラスタを作ってみました。

AWS CDKでAmazon Elasticsearch Serviceのドメイン(クラスタ)を作ってみた

しかし、これだけだとクラスタ構築後にローカルPCからは接続できるんですが、AWS Lambda関数からは接続できません。 今回は、CDKでREST APIを構築して、API Gateway -> Lambda関数を通して、Elasticsearchへ接続できるようにします。

今回のソースコードはこちらで公開しています。 https://github.com/shoito/aws-cdk-es-with-api

バージョン情報

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.1
BuildVersion:	19B88

$ cdk --version
1.18.0 (build bc924bc)

$ node --version
v10.15.3

プロジェクトの設定

プロジェクトのファイルは以下のようになります。

.
├── cdk.json
├── bin/ - CDKのエントリポイント
├── lib/ - CDKのスタック定義(api, elasticsaerch)
├── src/ - APIの裏側のLambda関数の定義
├── package-lock.json
└── package.json

次にcdk.jsonですが、コンテキスト変数を用いて、Elasticsearch Serviceのノード数などのパラメータを定義しています。 後ほど紹介するlib/elasticsearch.tsのコード内から、こちらで定義されている値を利用します。 devやprodなど、ステージ毎に、Elasticsearch Serviceをカスタマイズしています。

cdk.json

{
  "app": "npx ts-node bin/index.ts",
  "context": {
    "es": {
      "version": "7.1"
    },
    "dev": {
      "es": {
        "domainName": "elasticsearch-dev-domain",
        "instanceType": "t2.small.elasticsearch",
        "instanceCount": 1,
        "volumeSize": 10
      }
    },
    "prod": {
      "es": {
        "domainName": "elasticsearch-prod-domain",
        ...省略
    }
  }
}

クラスタの作成

cdk.jsonで定義したコンテキストに従って、dev環境では、Elasticsearchクラスタはシングルノードで構築し、prod環境では、マルチノード構成で構築します。 Elasticsearchクラスタの構築スタックは lib/elasticsearch.ts で定義しています。

定義の解説はこちらをご参照ください。

AWS CDKでAmazon Elasticsearch Serviceのドメイン(クラスタ)を作ってみた

差異としては、ElasticsearchへLambda関数からアクセスできるようにするためのアクセスポリシーの設定追加(Condition.StringLikeを通じてAWSアカウントのIAMロールで制限)、Elasticsearchへクエリを投げるLambda用に、CloudFormationのOutputを使用し、Elasticsearchのエンドポイントを参照できるようにしている点です。

lib/elasticsearch.ts

accessPolicies: {
        Version: "2012-10-17",
        Statement: [
...
          {
            Effect: "Allow",
            Principal: {
              AWS: ["*"]
            },
            Action: ["es:*"],
            Resource: `arn:aws:es:${cdk.Stack.of(this).region}:${
              cdk.Stack.of(this).account
            }:domain/${esContext.domainName}/*`,
            Condition: {
              StringLike: {
                "aws:PrincipalArn": `arn:aws:iam::${
                  cdk.Stack.of(this).account
                }:*`
              }
            }
          }
        ]
...
    new cdk.CfnOutput(this, "elasticsearchEndpoint", {
      value: domain.attrDomainEndpoint,
      exportName: "elasticsearch-endpoint"
    });
...

API GatewayやLambda関数の定義をしている lib/api.ts では、 cdk.Fn.importValue を通じて、Lambda関数の環境変数にElasticsearchのエンドポイントを渡します。

lib/api.ts

...
    const catLambda = new lambda.Function(this, "cat", {
      runtime: lambda.Runtime.NODEJS_10_X,
      handler: "handlers/index.handler",
      layers: [layer],
      code: lambda.Code.fromAsset("src/lambda", { exclude: ["**/*.ts"] }),
      functionName: "cat",
      environment: {
        ELASTICSEARCH_ENDPOINT: cdk.Fn.importValue("elasticsearch-endpoint")
      }
    });
...

デプロイ

CDKスタックをビルド・デプロイします。 なお、私の環境ではデプロイに10分程度かかります。

$ npm i
$ npm run build
$ npm run deploy

デプロイ確認

AWSマネジメントコンソールからやElasticsearch、Kibanaのエンドポイントなどが作られていることは前回確認しました。 今回は、CDKで構築したREST APIにアクセスしてみます。 ElasticsearchのCat APIのhealthをProxyするAPIになってますので、Elasticsearchクラスタのヘルスステータスが確認できました。 (レスポンスの一部IDなどは書き換えています)

$ curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/cat | jq
{
  "body": [
    {
      "epoch": "1575365795",
      "timestamp": "09:36:35",
      "cluster": "123456:elasticsearch-dev-domain",
      "status": "green",
      "node.total": "1",
      "node.data": "1",
      "discovered_master": "true",
      "shards": "1",
      "pri": "1",
      "relo": "0",
      "init": "0",
      "unassign": "0",
      "pending_tasks": "0",
      "max_task_wait_time": "-",
      "active_shards_percent": "100.0%"
    }
  ],
  "statusCode": 200,
  "headers": {
    "date": "Tue, 03 Dec 2019 09:36:35 GMT",
    "content-type": "application/json; charset=UTF-8",
    "content-length": "312",
    "connection": "keep-alive",
    "access-control-allow-origin": "*"
  },
  "warnings": null,
  "meta": {
    "context": null,
    "request": {
      "params": {
        "method": "GET",
        "path": "/_cat/health",
        "body": null,
        "querystring": "format=json",
        "headers": {
          "User-Agent": "elasticsearch-js/7.1.0 (linux 4.14.138-99.102.amzn2.x86_64-x64; Node.js v10.17.0)"
        },
        "timeout": 30000
      },
      "options": {
        "warnings": null
      },
      "id": 1
    },
    "name": "elasticsearch-js",
    "connection": {
      "url": "https://search-elasticsearch-dev-domain-xxxxxx.ap-northeast-1.es.amazonaws.com/",
      "id": "https://search-elasticsearch-dev-domain-xxxxxx.ap-northeast-1.es.amazonaws.com/",
      "headers": null,
      "deadCount": 0,
      "resurrectTimeout": 0,
      "_openRequests": 0,
      "status": "alive",
      "roles": {
        "master": true,
        "data": true,
        "ingest": true,
        "ml": false
      }
    },
    "attempts": 0,
    "aborted": false
  }
}

削除

確認が終わって不要になったので、cdk destroyコマンドでスタックを削除します。 ここでは、npm run destroyで間接的に上記コマンドを呼んでいます。 Amazon Elasticsearch Serviceドメインの削除は時間がかかるので、気長に待ちましょう。

$ npm run destroy

Lambda関数からElasticsearch Node.js clientを使うために

Amazon Elasticsearch ServiceにLambda関数(Node.js)からリクエストを送るためには、リクエストに署名をする必要があります。 この件は以下の公式ドキュメントに説明があります。

Amazon Elasticsearch Service への HTTP リクエストの署名 https://docs.aws.amazon.com/ja_jp/elasticsearch-service/latest/developerguide/es-request-signing.html#es-request-signing-node

ドメインアクセスポリシーに IAM ユーザーまたはロールが含まれる場合、Elasticsearch API へのリクエストに署名する必要があります。

そのため、 Elasticsearch Node.js client をそのまま使うのが適さない構成もありますが、今回は@acuris/aws-es-connectionライブラリを利用して、リクエストに署名をするようにしました。

src/lambda/es.ts

import {
  createAWSConnection,
  awsCredsifyAll,
  awsGetCredentials
} from "@acuris/aws-es-connection";
import { Client } from "@elastic/elasticsearch";

export const createClient = async (node: string): Promise<Client> => {
  const awsCredentials = await awsGetCredentials();
  const AWSConnection = createAWSConnection(awsCredentials);
  return awsCredsifyAll(
    new Client({
      node: `https://${node}`,
      Connection: AWSConnection
    })
  );
};

上記コードで、Elasticsearch Clientをラップする関数を用意し、Lambda関数内で、以下のようにcat.health APIを利用しています。 Elasticsearch Clientが使えるおかげで、クエリを組み立てる手間が少なくすみます。

src/lambda/index.ts

...
export const handler = async () => {
  const health = await (await createClient(ELASTICSEARCH_ENDPOINT)).cat.health({
    format: "json"
  });
...

さいごに

CDKで、Amazon Elasticsearch ServiceクラスタとそれにアクセスするREST APIを構築してみました。 Elasticsearch Node.js clientを使うためにひと手間必要だったり、Amazon Elasticsearch Service特有のことがありますし、誰かの参考になればと思います。