curlがしんどくなったのでElasticsearchのCLIクライアントを作った

ゴールデンウィークを使って cles というElasticsearchのCLIクライアントを作成したので紹介したい。最近はGolangとだいぶ和解できてきて、CLIツールならけっこう早く作れそうな気がしてきた。ちなみにElasticsearchへの接続には olivere/elastic を使っている。公式のクライアントライブラリより直感的に扱えるので楽しい。

amd64, arm64に対応したdockerコンテナも用意している。ECR publicもそのうち用意したい。

モチベーション

Elasticsearchを扱う際の悩みとして、ちょっとアクセスしたい時でもcurlでアクセスするのが面倒というのがある。たとえばインデックスの作成はこんな感じになる。

$ curl -XPUT \
  -H 'Content-Type: application/json' \
  'http://address.to.my.elasticsearch/index' \
  -d@path/to/index.settings.json

ここに認証がつくとさらにタイプ数が増える。

$ curl -XPUT \
  -u 'elastic:PASSWORD' \
  -H 'Content-Type: application/json' \
  'http://address.to.my.elasticsearch/index' \
  -d@path/to/index.settings.json

Elasticsearchのドメインを覚えておく必要があるし、APIのエンドポイントも覚えておく必要があるし、bulk API は --data-binary をつけ忘れて毎回ハマりそうになる。

運用フェーズではエンドポイントを手で叩くことはそうないが、開発フェーズやトラブル対応でサクッと確認したい時にタイプ数の多さはかなりしんどい。そんなわけで、直感的に扱えるCLIクライアントを作った。

設定ファイルを使いたかった

エンドポイントと認証情報を覚えるのは非効率的なので、tomlで設定ファイルを書けるようにした。

[[profile]]
name = "default" # プロファイル名
address = ["http://localhost:9200"] # 接続するエンドポイント
username = "elastic"  # 認証を利用する際のユーザ名
password = "PASSWORD" # 認証を利用する際のパスワード
sniff = false # sniffing を使うかどうか

[[profile]]
name = "staging"
address = # ... 実際の値を入れていく

開発環境と本番環境を分けている時とか、クラスタごとに役割が違う場合は重宝するだろう。この機能を実装した時点で満足した。この辺りの実装については mattn/memo を大いに参考にしている。

コンテナなどから使いたい場合は環境変数経由でも接続先を設定できる。とにかく毎回色んな情報をタイプしたくないのだ。

設定ファイルを使えば環境を切り替えるのも容易だ。curlだとエイリアス作成したいだけでもたくさんの入力が必要だが、clesを使えば一瞬だ。

# before
$ curl -XPOST -u 'elastic:PASSWORD1' -H 'Content-Type:application/json' \
  'http://address.staging/_aliases' -d '
{
  "actions": [
    {
      "add": {
        "index": "my-data-stream",
        "alias": "my-alias"
      }
    }
  ]
}
'
$ curl -XPOST -u 'elastic:PASSWORD1' -H 'Content-Type:application/json' \
  'http://address.production/_aliases' -d '
{
  "actions": [
    {
      "add": {
        "index": "my-data-stream",
        "alias": "my-alias"
      }
    }
  ]
}
'
# after
$ cles -p staging indices alias my-data-stream my-alias
$ cles -p production indices alias my-data-stream my-alias

プロファイル指定の書式は aws cli に影響されている。

利用しやすいbulk index

個人的には開発中に一番利用する Bulk API はダントツで Bulk index なのだが、汎用性ゆえにcurlで送信するndjsonはどうもまだるっこしい。

{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

更新対象のインデックス名を少し変えたいような時にもけっこう困るので、コマンド行を必要としない bulk index コマンドを実装した。たとえばニューヨーク市の交通事故オープンデータを投入する時はこんな感じになる。idカラムを良い感じに計算したい場合の処理については未来に考えたい。

$ head -n3 source.ndjson
{"collision_id": "4407480", "crashed_at": "2021-04-14T05:32:00-04:00", "borough": null, "zipcode": null, "location": null, "on_street_name": "BRONX WHITESTONE BRIDGE", "cross_street_name": null, "off_street_name": null, "persons_injured": 0, "persons_killed": 0, "pedestrians_injured": 0, "pedestrians_killed": 0, "cyclist_injured": 0, "cyclist_killed": 0, "motorist_injured": 0, "motorist_killed": 0, "contributing_factors": [{"vehicle_type": "Sedan", "factor": "Following Too Closely"}, {"vehicle_type": "Sedan", "factor": "Unspecified"}]}
{"collision_id": "4407147", "crashed_at": "2021-04-13T21:35:00-04:00", "borough": "BROOKLYN", "zipcode": "11217", "location": "40.68358, -73.97617", "on_street_name": null, "cross_street_name": null, "off_street_name": "620 ATLANTIC AVENUE", "persons_injured": 1, "persons_killed": 0, "pedestrians_injured": 1, "pedestrians_killed": 0, "cyclist_injured": 0, "cyclist_killed": 0, "motorist_injured": 0, "motorist_killed": 0, "contributing_factors": [{"vehicle_type": "Sedan", "factor": "Unspecified"}]}
{"collision_id": "4407665", "crashed_at": "2021-04-15T16:15:00-04:00", "borough": null, "zipcode": null, "location": null, "on_street_name": "HUTCHINSON RIVER PARKWAY", "cross_street_name": null, "off_street_name": null, "persons_injured": 0, "persons_killed": 0, "pedestrians_injured": 0, "pedestrians_killed": 0, "cyclist_injured": 0, "cyclist_killed": 0, "motorist_injured": 0, "motorist_killed": 0, "contributing_factors": [{"vehicle_type": "Station Wagon/Sport Utility Vehicle", "factor": "Pavement Slippery"}]}
$ cles -p staging bulk index --source source.ndjson --id-column collision_id nyc-crashes
# 標準入力からもいけるようにした
$ cles -p staging bulk index -i collision_id nyc-crashes < source.ndjson

自分が作るCLIクライアントは少し実装が面倒になってもなるべく標準入力を使えるようにしている。これがあるのとないのとでは取り回しが全然違ってくるし、シェル芸にも組み込みやすい。といってもgolangだとそんなに大変ではない。

body := c.Path("body")
var bytes, err []byte, error
if len(body) > 0 {
	bytes, err = ioutil.ReadFile(body)
} else {
	bytes, err = ioutil.ReadAll(os.Stdin)
}
if err != nil {
	return err
}

ちょっとハマったところ

Render search template API など、search template まわりのAPIは olivere/elastic では実装されていなかった。そのため olivere/elastic のコードを読み解いて、PerformRequest を直接使うことにした。

var urlValues url.Values
res, err := client.PerformRequest(context.Background(), elastic.PerformRequestOptions{
	Method:      "POST",
	Path:        "/_render/template",
	Params:      urlValues,
	Body:        renderbody,
	ContentType: "application/json",
})
if err != nil {
	return err
}

とはいえ上のような感じでライブラリが対応していないAPIでも公式のクライアントライブラリくらいの手間で書けてしまうので、公式のクライアントライブラリより快適だと思う。

golangとCLIツールの組み合わせは良い

いろいろなところで言われているが、golangとCLIツールの組み合わせは良い。urfave/cliのおかげでサブコマンドを持つプログラムでも楽々書ける。

何より良いのが、ビルドが楽なことだ。各プラットフォーム向けの実行バイナリが簡単に作れる。とはいえここらへんの強みが効く分野では良い言語だと思うのだけど、高階関数を使ってコレクション処理を抽象化できないとか、簡潔さの代償として極端に言語仕様を小さくしているのはJVM好きとしてはしんどく感じる。コードに意図を込められる饒舌さは複雑さでもあるので、golangがそこを捨てているのは理解している。なので自分の中ではメイン言語には絶対ならないだろうが、ちょっとしたものを作る時に選択肢に上がる程度には習熟してきた感がある。

今後やりたいこと

  • bulk index のバッファサイズを10MBにしているが、これは可変にしたい。ただ、入力のパースをどうやるかで悩んでいる。

  • cat系APIでヘッダーを指定可能にする

  • リッチなデバッグログ

この記事が気に入ったらサポートをしてみませんか?