go-elasticsearch で Amazon Elasticsearch Service へアクセスする

この記事は Elastic 社公式の Go で実装された Elasticsearch クライアント go-elasticsearch を使って AWS Lambda から Amazon Elasticsearch Service(Amazon ES)へアクセスする方法をまとめたものです。

目次

Elasticsearch とは

Elasticsearch は Elastic 社が開発している全文検索エンジンです。 オープンソースで「貯める、検索する、集計する」が一つのプロダクトで出来て基本無料という…色々な事に使えそうなプロダクトですね。

基本的な使い方は以下の記事がとてもわかりやすくまとめられているかと思います。

Elasticsearch だけではなく、Kibana とセットで使うことで Elasticsearch の挙動を簡単に確認することができそうです。

マネージドの Elasticsearch

マネージドの Elasticsearch は現状、Elastic社が提供する Elastic Cloud か、Amazon ES になりそうです。

Elastic社のサービスの比較
AWS Elasticsearch Serviceを比較:AmazonとElasticのサービス

両方の所感をまとめて下さっている方がいらっしゃいました。

簡単にまとめると
Elastic Cloud は、

  • 最新のバージョンが提供され、細かいカスタマイズが可能
  • Elastic社の独自サービスになる

Amazon ES は、

  • AWSの知見が活かせ、インスタンスの作成やアクセス制御が簡易
  • 最新バージョンではない

となるようです。

ちなみに、本記事執筆時点のそれぞれの最安の運用コストも確認してみました。

どちらも時間単位での課金があり、約1ヶ月(30日)あたりの料金は以下のようになるようです。

  • Elastc Cloud

    • $16.2

      Memory 1 GB
      Storage 30 GB
      Hourly rate $0.0225

  • Amazon ES

    • $12.96
      t2.micro.elasticsearch(最安のインスタンス

      1 時間あたり $0.018

    • $25.92
      t2.small.elasticsearch(750時間の無料枠あり)

      1 時間あたり $0.036

今回は AWS Lambda と連携させたいので、Amazon ES を使用します。

AWS Lambda 関数の作成

まずは Lambda 関数を作成します。 ここでは関数名を「Go-AES-Test」としました。

f:id:ken01-linkode:20200405032248p:plain
f:id:ken01-linkode:20200405032356p:plain
特別な設定は必要ありませんが、Amazon ESへのアクセスを許可するために、Lambda 関数のロール名を控えておきます。

Amazon ES のセットアップ

今度は Amazon ES のドメインを作成します。
ドメイン名は「go-aes-test-domain」としました。
先ほど作成した Lambda 関数からアクセスできるように、アクセスポリシーの設定を行います。
また、ローカルの環境からも Elasticsearch へアクセスできるように IP アドレスによるアクセス制限を設定すると良いかもしれません。

JSON 定義のアクセスポリシー で設定すると次のようになります。
※ここで先ほどのLambda関数のロール名が必要になります。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::『AWSアカウントID』:role/service-role/『Lambda関数のロール名』"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:『リージョン』:『AWSアカウントID』:domain/go-aes-test-domain/*"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:『リージョン』:『AWSアカウントID』:domain/go-aes-test-domain/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "『IPアドレス』"
        }
      }
    }
  ]
}

ダッシュボードで確認すると、Kibanaも作成されていることがわかります。

f:id:ken01-linkode:20200405032453p:plain

AWS Lambda 関数の実装

HTTP リクエストの署名

Amazon ES のドメインアクセスポリシーにLambda関数のロールを設定したので、HTTPリクエストをする際に署名をする必要があります。

Amazon ES の開発者ガイドでは各言語による HTTPリクエストの署名処理の実装サンプルが紹介されています。
※Goのサンプルは一番最後に記載されています。

Amazon Elasticsearch Service への HTTP リクエストの署名

署名処理の肝は次のようになるようです。

// Get credentials from environment variables and create the AWS Signature Version 4 signer
credentials := credentials.NewEnvCredentials()
signer := v4.NewSigner(credentials)

環境変数から署名を行う署名者 signer (*v4.Signer) を作成していますね。

// Sign the request, send it, and print the response
signer.Sign(req, body, service, region, time.Now())

ここでリクエスreq (*http.Request) に署名を行っています。

これらをそのまま実装すれば、AWS Lambda から Amazon ES の操作が行えそうですが・・・

go-elasticsearch

2019年頭頃(リポジトリのログを確認すると 2019/2/8)から、Elastic 社公式の Go で実装された Elasticsearch クライアント go-elasticsearch が公開されています。
https://github.com/elastic/go-elasticsearch

公式のクライアントがあるなら、これを使って Amazon ES へアクセスしたいと考えました。

Goプロジェクトの準備

空のディレクトリを用意して、プロジェクトの初期化を行います。

$ go mod init linkode.co.jp/example

プロジェクトに必要なソースやディレクトリを配置していきます。
本記事の例では以下のように配置しました。

./
|-- cmd
|   `-- main.go
|-- internal
|   `-- aesclient
|       `-- AmazonESClient.go
|-- go.mod
`-- go.sum

main.goAWS Lambdaハンドラ関数を実装して、
AmazonESClient.go に AmazonES に署名付きのHTTPリクエストを行う、go-elasticsearch のクライアント生成処理を実装する想定です。

AmazonESClient.go の実装

go-elasticsearch の README を見ると

To configure other HTTP settings, pass an http.Transport object in the configuration object.

cfg := elasticsearch.Config{
  Transport: &http.Transport{
    MaxIdleConnsPerHost:   10,
    ResponseHeaderTimeout: time.Second,
    TLSClientConfig: &tls.Config{
      MinVersion: tls.VersionTLS11,
      // ...
    },
    // ...
  },
}

とあるので、署名処理を行う http.Transportelasticsearch.Config へ渡せば AmazonES へアクセスできる go-elasticsearch のクライアントが作成できそうです。

http.Transport のソースコードを確認してみると、次のようにコメントがありました。

// Transport is an implementation of RoundTripper that supports HTTP,
// HTTPS, and HTTP proxies (for either HTTP or HTTPS with CONNECT).

http.Transporthttp.RoundTripper インターフェイスの実装を行っているようです。 http.RoundTripper インターフェイスRoundTrip(*Request) (*Response, error) という一つの関数が定義されているので、この関数で署名処理を実装していけば良さそうです。

はじめに、HTTPクライアントとなる構造体とその初期化処理を作成します。
署名に必要となる要素をフィールドに持たせています。
※Lambda関数の環境変数に、Amazon ES のドメインと使用しているリージョンを設定しています。
ドメインAmazon ES のドメイン名ではなく、エンドポイントURLのドメインになります。

type amazonESTransport struct {
  domain    string
  region    string
  awsSigner *signer.Signer
}
 
func newAmazonESTransport() *amazonESTransport {
  t := new(amazonESTransport)
 
  t.domain = os.Getenv("AES_DOMAIN") // e.g. search-mydomain-xxx...
  t.region = os.Getenv("AES_REGION") // e.g. us-west-1
 
  // 環境変数から認証情報を取得し、AWS署名バージョン4の署名者を作成
  t.awsSigner = signer.NewSigner(credentials.NewEnvCredentials())

  return t
}

次に、RoundTrip を実装します。 Goで提供されるデフォルトのHTTPクライアントのリクエストにはグローバルで宣言されている http.DefaultTransport が使用されるので、以下のように実装する事でデフォルトと同様のHTTPリクエストを行うクライアントとなります。

func (t *amazonESTransport) RoundTrip(req *http.Request) (*http.Response, error) {
  return http.DefaultTransport.RoundTrip(req)
}

こちらを署名処理を行うように拡張したものが次の実装です。

func (t *amazonESTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 
  const service = "es"
 
  if h, ok := req.Header["Authorization"]; ok && len(h) > 0 && strings.HasPrefix(h[0], "AWS4") {
    log.Println("リクエストは署名済みなので署名処理をスキップします.")
    return http.DefaultTransport.RoundTrip(req)
  }
 
  req.URL.Scheme = "https"
 
  now := time.Now()
  req.Header.Set("Date", now.Format(time.RFC3339))
  log.Printf("署名日時: %+v", req)
 
  var err error
  switch req.Body {
  case nil:
    log.Println("Bodyなしの署名を行います.")
    _, err = t.awsSigner.Sign(req, nil, service, t.region, now)
  default:
    d, err := ioutil.ReadAll(req.Body)
    if err == nil {
      req.Body = ioutil.NopCloser(bytes.NewReader(d))
      log.Println("Body付きの署名を行います.")
      _, err = t.awsSigner.Sign(req, bytes.NewReader(d), service, t.region, now)
    }
  }
  if err != nil {
    log.Printf("署名中にエラーが発生しました: '%s'", err)
    return nil, err
  }
 
  resp, err := http.DefaultTransport.RoundTrip(req)
  if err != nil {
    log.Printf("http.DefaultTransport.RoundTrip() に失敗しました.\n\n\tResponse: %+v\n\n\tError: '%s'", resp, err)
    return resp, err
  }
 
  return resp, nil
}

あとはパッケージの外部から、Elasticsearchのクライアントを生成するための関数を用意します。

func NewAmazonESClient() (*elasticsearch.Client, error) {
 
  t := newAmazonESTransport()
 
  cfg := elasticsearch.Config{
    Addresses: []string{"https://" + t.domain + "." + t.region + ".es.amazonaws.com"},
    Transport: t,
  }
  es, err := elasticsearch.NewClient(cfg)
  if err != nil {
    log.Printf("Elasticsearchクライアントの作成に失敗しました: %s", err)
    return nil, err
  }
 
  return es, nil
 
}

AmazonESClient.go 全体の実装は次のようになりました。

AmazonESClient.go

package aesclient
 
import (
    "bytes"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"
    "time"
 
    "github.com/aws/aws-sdk-go/aws/credentials"
    signer "github.com/aws/aws-sdk-go/aws/signer/v4"
    "github.com/elastic/go-elasticsearch/v7"
)
 
type amazonESTransport struct {
    domain    string // e.g. search-mydomain
    region    string // e.g. us-west-1
    awsSigner *signer.Signer
}
 
func newAmazonESTransport() *amazonESTransport {
    t := new(amazonESTransport)
 
    t.domain = os.Getenv("AES_DOMAIN") // e.g. search-mydomain
    t.region = os.Getenv("AES_REGION") // e.g. us-west-1
 
    // 環境変数から認証情報を取得し、AWS署名バージョン4の署名を作成
    t.awsSigner = signer.NewSigner(credentials.NewEnvCredentials())
 
    return t
}
 
func (t *amazonESTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 
    const service = "es"
 
    if h, ok := req.Header["Authorization"]; ok && len(h) > 0 && strings.HasPrefix(h[0], "AWS4") {
        log.Println("リクエストは署名済みなので署名処理をスキップします.")
        return http.DefaultTransport.RoundTrip(req)
    }
 
    req.URL.Scheme = "https"
 
    now := time.Now()
    req.Header.Set("Date", now.Format(time.RFC3339))
    log.Printf("署名日時: %+v", req)
 
    var err error
    switch req.Body {
    case nil:
        log.Println("Bodyなしの署名を行います.")
        _, err = t.awsSigner.Sign(req, nil, service, t.region, now)
    default:
        d, err := ioutil.ReadAll(req.Body)
        if err == nil {
            req.Body = ioutil.NopCloser(bytes.NewReader(d))
            log.Println("Body付きの署名を行います.")
            _, err = t.awsSigner.Sign(req, bytes.NewReader(d), service, t.region, now)
        }
    }
    if err != nil {
        log.Printf("署名中にエラーが発生しました: '%s'", err)
        return nil, err
    }
 
    resp, err := http.DefaultTransport.RoundTrip(req)
    if err != nil {
        log.Printf("http.DefaultTransport.RoundTrip() に失敗しました.\n\n\tResponse: %+v\n\n\tError: '%s'", resp, err)
        return resp, err
    }
 
    return resp, nil
}
 
// NewAmazonESClient はAWSの署名を行うHTTPクライアントを内包した
// Elasticsearchのクライアントを生成して返却します.
func NewAmazonESClient() (*elasticsearch.Client, error) {
 
    t := newAmazonESTransport()
 
    cfg := elasticsearch.Config{
        Addresses: []string{"https://" + t.domain + "." + t.region + ".es.amazonaws.com"},
        Transport: t,
    }
    es, err := elasticsearch.NewClient(cfg)
    if err != nil {
        log.Printf("Elasticsearchクライアントの作成に失敗しました: %s", err)
        return nil, err
    }
 
    return es, nil
 
}

main.go の実装

AWS Lambda 関数ハンドラーを実装します。

ハンドラーの中身は、go-elasticsearch の Elasticsearch の情報を取得する Usage
es, err := elasticsearch.NewDefaultClient()
es, err := aesclient.NewAmazonESClient() に置き換えているだけです。

package main
 
import (
  "log"
 
  "github.com/aws/aws-lambda-go/lambda"
  "linkode.co.jp/example/internal/aesclient"
)
 
func HandleRequest() {
  es, err := aesclient.NewAmazonESClient()
  if err != nil {
    log.Fatalf("Error creating the client: %s", err)
  }
 
  res, err := es.Info()
  if err != nil {
    log.Fatalf("Error getting response: %s", err)
  }
 
  log.Println(res)
}
 
func main() {
  lambda.Start(HandleRequest)
}

動作確認

あとは、ビルドを行い、zip に固めてアップロードします。

$ go build -ldflags="-w -s" ./cmd/Go-AES-Test/
$ zip ./Go-AES-Test.zip ./Go-AES-Test

Lambda 関数のテストを走らせてみると、無事、Elasticsearchの情報が取得できていることがわかります。

f:id:ken01-linkode:20200405032557p:plain

まとめ

  • Elasticsearch に Goでアクセスする際に、ローカルでのテストに go-elasticsearch を使用していましたが、同じように Amazon ES にもアクセスできると便利と考えて、今回の調査を行いました。
    • ローカルとLambda でほぼ同一のコードが使用できるので、Elasticsearch での試行錯誤がしやすくなりました。
  • HTTPクライアントを独自のものに置き換える方法は他にもいろいろ応用が利きそうです。
    • Goを触り始めて数週間ですが、go-elasticsearchに限らず、大抵のHTTPリクエストを行うライブラリはクライアントを独自のものに差し替えられるようになっているそうです。

参考資料