TypeScriptで書いたスクリプトでAmazon Elasticsearch Serviceに接続する

2021.04.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部のうらわです。

TypeScriptでhttps://github.com/aws/aws-sdk-jsを使用してAmazon Elasticsearch Service(以下、ES)に接続しようとしてつまずきました。本記事はつまずいた内容とその解決策を記載します。

※ どうしてもTypeScriptで書きたいので、そもそもTypeScriptを使わない、という方法は除外します。

環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15

$ node -v
v14.15.4

$ npm -v
6.14.10

$ yarn -v
1.22.10

ESドメインの作成

テスト用のESドメインを作成します。以下のように設定して作成しました。表に記載のない項目はデフォルトのままとしています。

項目 内容
デプロイタイプ 開発およびテスト
Elasticsearchのバージョン 7.10
Elasticsearchドメイン名 aws-sdk-js-test-domain
自動調整 有効化
インスタンスタイプ t2.small.elasticsearch
ネットワーク構成 パブリックアクセス
ドメインアクセスポリシー AssumeRoleで引き受けるIAMロール(対象アカウントでESへのFullアクセス権限がある)のArnのアクセスを許可する

aws-sdk-js(v2)を使うがうまくいかず

以下のAWS公式ドキュメントにあるようなコードでESへの接続を試みました。

しかし、AWS.Signersの型定義が存在しないため、TypeScriptではドキュメントと同様のコードでは実現できませんでした。これはissueもあがっています。

Missing Typescript Definition for HttpClient, as well as documentation · Issue #1278 · aws/aws-sdk-js

aws-sdk-js-v3を使う

上記のissueのコメントにもありますが、aws-sdk-js-v3ではこれらの問題が解決されているとのことです。

さっそくaws-sdk-js-v3を使ってみたのですが、aws-sdk-js(v2)とは大きくSDKの構造が変わっていて、どうやってESに接続するためのコードを書けば良いのか全くわかりませんでした。

aws-sdk-js-v3のissueを探してみたところ、接続するためのコード例を記載してくれているissueがありました。このissueのコード例を利用して接続してみます。

How to send signed AWS Elasticsearch request? · Issue #2099 · aws/aws-sdk-js-v3

まずは各種ライブラリを準備します。

# 準備
$ mkdir es-connect-test && cd es-connect-test
$ npm init -y

# TypeScript
$ yarn add -D typescript ts-node @types/node

# aws-sdk-js-v3関連
$ yarn add -D @aws-sdk/{signature-v4,protocol-http,credential-provider-node,node-http-handler}

続いて接続テストのためのTypeScriptのコードを用意します。上記issueのコード例を参考にしつつ、/_cat/healthでESのヘルスチェックを行うコードに変更しました。

src/index.ts

import { SignatureV4 } from "@aws-sdk/signature-v4";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { Sha256 } from "@aws-crypto/sha256-js";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { NodeHttpHandler } from "@aws-sdk/node-http-handler";
import { IncomingMessage } from "http";

interface CreateSignHttpRequestParams {
  body?: string;
  headers?: Record<string, string>;
  hostname: string;
  method?: string;
  path?: string;
  port?: number;
  protocol?: string;
  query?: Record<string, string>;
  service: string;
}

export async function createSignedHttpRequest({
  headers,
  hostname,
  method = "GET",
  path = "/",
  port = 443,
  protocol = "https:",
  query,
  service,
}: CreateSignHttpRequestParams): Promise<HttpRequest> {
  const httpRequest = new HttpRequest({
    headers,
    hostname,
    method,
    path,
    port,
    protocol,
    query,
  });
  const sigV4Init = {
    credentials: defaultProvider(),
    region: "ap-northeast-1",
    service,
    sha256: Sha256,
  };
  const signer = new SignatureV4(sigV4Init);
  return signer.sign(httpRequest) as Promise<HttpRequest>;
}

const nodeHttpHandler = new NodeHttpHandler();

(async () => {
  const hostname = "<your domain name>.ap-northeast-1.es.amazonaws.com";

  const signedHttpRequest = await createSignedHttpRequest({
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      host: hostname,
    },
    hostname,
    path: "_cat/health",
    service: "es",
  });
  console.log(signedHttpRequest);

  try {
    const res = await nodeHttpHandler.handle(signedHttpRequest);
    const body = await new Promise((resolve, reject) => {
      const incomingMessage = res.response.body as IncomingMessage;
      let body = "";
      incomingMessage.on("data", (chunk) => {
        body += chunk;
      });
      incomingMessage.on("end", () => {
        resolve(body);
      });
      incomingMessage.on("error", (err) => {
        reject(err);
      });
    });
    console.log(body);
  } catch (err) {
    console.error("Error:");
    console.error(err);
  }
})();

対象アカウントでESへのFullAccessの権限があるIAMロールにAssumeRoleしてコードを実行してみます。ちなみに私はremind101/assume-roleというCLIツールを使用して一時的セキュリティ認証情報を環境変数にセットしています。

$ assume-role role名
$ npx ts-node src/index.ts
// 省略
1619766241 07:04:01 accountId:aws-sdk-js-test-domain green 1 1 true 1 1 0 0 0 0 - 100.0%

_cat/healthの結果を確認することができました。

別のライブラリを使う

上記コード例のissueのこのコメントには、aws-sdk-js-v3ではなく@elastic/elasticsearch@acuris/aws-es-connectionという別のライブラリを使用して接続するコード例が記載されています。こちらも試してみます。

追加でライブラリをインストールします。ちなみにこちらの方法ではaws-sdk-js(v2)に依存しています。

$ yarn add -D aws-sdk @elastic/elasticsearch @types/elasticsearch @acuris/aws-es-connection

@acuris/aws-es-connectionのREADMEを参考にしてESへの接続を試すコードを用意します。

src/main.ts

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

const hostname = "<your domain name>.ap-northeast-1.es.amazonaws.com";

(async () => {
  const awsCredentials = await awsGetCredentials();
  const AWSConnection = createAWSConnection(awsCredentials);
  const client = new Client({
    ...AWSConnection,
    node: `https://${hostname}`,
  });
  const res = await client.cat.health();
  console.log(res.body);
})();

aws-sdk-js-v3の時と同様にAssumeRoleして実行してみます。

$ npx ts-node src/main.ts
1619766263 07:03:39 accountId:aws-sdk-js-test-domain green 1 1 true 1 1 0 0 0 0 - 100.0%

こちらもうまくいきました。

おわりに

最初はTypeScriptでもRubyやPythonと同様に@elastic/elasticsearchとSignature V4を作成するライブラリを組み合わせれば簡単に接続できるのかな思っていたのですが、そうではなかったので苦労しました。

ESへの接続において、TypeScriptの場合はaws-sdk-js(v2)だと厳しいのでaws-sdk-js-v3か別のライブラリを使うのが良さそうです。

参考記事

Elasticsearch クラスターにアクセスしようとすると、「User: anonymous is not authorized」というエラーが表示されます