AWS CDKでAmazon Elasticsearch Serviceドメインとそれに接続するREST APIを作ってみた
はじめに
Elastic Stack (Elasticsearch) Advent Calendar 2019の12月3日の記事です。
以前、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をカスタマイズしています。
{ "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
で定義しています。
定義の解説はこちらをご参照ください。
差異としては、ElasticsearchへLambda関数からアクセスできるようにするためのアクセスポリシーの設定追加(Condition.StringLikeを通じてAWSアカウントのIAMロールで制限)、Elasticsearchへクエリを投げるLambda用に、CloudFormationのOutputを使用し、Elasticsearchのエンドポイントを参照できるようにしている点です。
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のエンドポイントを渡します。
... 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ライブラリを利用して、リクエストに署名をするようにしました。
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が使えるおかげで、クエリを組み立てる手間が少なくすみます。
... 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特有のことがありますし、誰かの参考になればと思います。