この記事は 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 は、
となるようです。
ちなみに、本記事執筆時点のそれぞれの最安の運用コストも確認してみました。
- 参照元
Elastic Cloud - Elasticsearch Service pricing calculator
Amazon ES - Amazon Elasticsearch Service の料金
どちらも時間単位での課金があり、約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」としました。 特別な設定は必要ありませんが、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も作成されていることがわかります。
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.go に AWS 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.Transport
を elasticsearch.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.Transport
は http.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の情報が取得できていることがわかります。
まとめ
- Elasticsearch に Goでアクセスする際に、ローカルでのテストに go-elasticsearch を使用していましたが、同じように Amazon ES にもアクセスできると便利と考えて、今回の調査を行いました。
- ローカルとLambda でほぼ同一のコードが使用できるので、Elasticsearch での試行錯誤がしやすくなりました。
- HTTPクライアントを独自のものに置き換える方法は他にもいろいろ応用が利きそうです。
- Goを触り始めて数週間ですが、go-elasticsearchに限らず、大抵のHTTPリクエストを行うライブラリはクライアントを独自のものに差し替えられるようになっているそうです。