PowerShellでElasticsearchへデータ投入(と検索で内容確認)


Excelでデータ集計すんのめどい。
特に過去データ増えてくると重いしめどい。
データベース、そうだElasticsearch使おう!
Windows環境だからPowerShellで投げられるといいな。

やること

  • インデックス作成
  • マッピング定義
  • データ投入
  • 確認

バージョン等

Elasticsearch7.8.1
ver6~8でタイプ指定(_docとかのやつ)が順次廃止になり、6以前と7以降で一部スクリプトの書き方が異なる。
localhost動作にて。

取り込み元のログサンプル

log2020-07.csv
2020/7/10 15:13:36,satou,192.168.10.105,6590776
2020/7/13 14:37:5,katou,192.168.10.122,5061220
2020/7/27 18:19:43,takahashi,192.168.10.101,7460879

項目は左から日付時刻、利用者、クライアントIP、ファイルサイズ。実際のシステムではこの後ファイル名、拡張子、操作カテゴリ等色々な利用者データが続く。こんなログをよく集計してる。

サンプルは実際のログと比べてだいぶ単純化していて、例えばヘッダー行やダブルクォーテーションくくりは無いから除去加工は要らないし、データ内に記号や特殊文字は入ってこない前提で入力チェック不要だし、定型でない異常ログのある可能性は知らん顔。
その辺チェックしだすとスクリプトは無限にでかくなり続ける。他人に使わせるならともかく自分の処理用なのでそのへんは穏やかに。どこまで拾うかは利用頻度とエラー頻度と重要性のバランス也。

インデックス作成とマッピング定義

インデックス作成

インデックス作成.ps1
$Param_CreateIndex = @{
    Method = "Put"
    ContentType = "application/json"
    Uri = "http://localhost:9200/system_download-2020.07"
}
Invoke-RestMethod @Param_CreateIndex

インデックス名は前方一致検索できることを考慮して付ける。Logstashに自動で付けさせるとxxxxx-yyyy.MMみたいになるので、これに合わせると世間と話が合いやすい。

作成したインデックスを確認

インデックス一覧表示
(Invoke-WebRequest http://localhost:9200/_cat/indices?v"&"s=index).content
index一覧
health status index                   uuid                   pri rep docs.count docs.deleted store.size pri.store.size
yellow open   system_download-2020.07 GRcV0sFLQqq3AhuUQWBVDg   1   1          0            0       208b           208b

PowerShellで書く場合、途中の"&"はくくらないとエラーになる仕様。
実行すると一覧にインデックスsystem_download-2020.07が表示される。まだ何も入っていないのでdocs.countは0。
手元マシンで動かしてる場合、新規で作ったインデックスステータスはyellowになる。これは1ノード(PC1台)ではレプリカを作れないことから出る表示。気にしなくてもいいし、気になるならレプリカ0でもyellowにならないようにElasticsearch側で設定する。(方法は検索せよ)

マッピング定義のJSON

Elasticsearchはマッピング定義せず投入してもそれなりに解釈してくれて自動で型を付けてくれるが、日付や数字が文字列扱いになってしまい首をひねる事が多い。
ログファイルはまず例外なく日付や数字を含んでいるだろうから、面倒がらずにマッピング定義も自分でやっておくほうがいい。後から調べて直すほうがよっぽど面倒である。
ここではJSONファイルを別に用意し、スクリプトから呼ぶ方法で作る。

mapping.json
{
  "properties": {
    "timestamp": {
      "type": "date",
      "format": "date_hour_minute_second"
    },
    "id": {
      "type": "keyword"
    },
    "clientip": {
      "type": "keyword"
    },
    "size": {
      "type": "long"
    }
  }
}

フィールド名(timestamp,id,clientip,size)はいわゆるケースセンシティブで大文字小文字は区別される。野放図に命名せず何らかのルールを考えておいた方が後で困らない。検索うまくいかず悩み抜いて原因がコレだと死にたくなる。

"type": "date"は日付時刻形式1。フォーマットdate_hour_minute_secondも指定していて、これはよくあるyyyyMMddTHH:mm:ssの形式を意味する。今回のサンプルはこのフォーマットで指定される形と異なる(2020/7/13 14:37:5等)ため、投入時に加工する必要がある。
日付時刻のフォーマット一覧は公式ドキュメントを参照のこと。

"type": "keyword"は文字列を格納する型。アナライザでの解析を行わないでそのまま格納される。ログ内容を投入する際に言語解析は要らないのでこの型にする。

"type": "long"は数値型で64ビット。一段小さいinteger型だと32ビット。intergerでなくlongなのは、integerの範囲を超える2GB以上のサイズ値を扱う可能性があるから。
数値データ型の一覧は公式ドキュメントを参照のこと。

マッピング定義

マッピング(ver6).ps1
# Elasticsearch ver7以降ではエラーになる
$Param_Mapping = @{
    Method = "Put"
    ContentType = "application/json"
    Uri = "http://localhost:9200/system_download-2020.07/_mapping/_doc"
    InFile = "mapping.json"
}
Invoke-RestMethod @Param_Mapping

JSONファイルと同じフォルダで実行…すると何やらエラーになる。エラー内容は「タイプ指定はNG!」といった内容。
これはタイプ指定が廃止になる影響で、APIのその個所が変更されたため。_docを除かなければならない。
ver7以降では下の書き方でマッピング定義できる。

マッピング(ver7).ps1
# Elasticsearch ver7以降はマッピング定義時にタイプ指定しない
$Param_Mapping = @{
    Method = "Put"
    ContentType = "application/json"
    uri = "http://localhost:9200/system_download-2020.07/_mapping/"
    InFile = "mapping.json"
}
Invoke-RestMethod @Param_Mapping

マッピング確認

インデックス一覧表示
(Invoke-WebRequest http://localhost:9200/system_download-2020.07/_mapping?pretty).content

JSON出てくるだけなので結果は省略。

ログ投入

ログファイルを下記スクリプトと同じフォルダに置き、スクリプトを実行する。以下いくつか解説。

ログ投入.ps1
# CSV取り込みパラメータ
$Param_Csv = @{
  LiteralPath = "log2020-07.csv"
  Header = @("timestamp","id","clientip","size")
  Encoding = "Default"
}

# データ取り込んで投入
Import-Csv @Param_Csv |
ForEach-Object{
  # データ投入前の加工
  $_.timestamp = (($_.timestamp).Replace(" ","T")).Replace("/","-")
  $Input_Json = ConvertTo-Json $_

  # データ投入
  $Param_Api = @{
    ContentType = "application/json"
    Method = "Post"
    Uri = "http://localhost:9200/system_download-2020.07/_doc/"
    Body = $Input_Json
  }
  # 実行結果の表示不要なら以下を > $null する
  Invoke-RestMethod @Param_Api
}

Import-Csvコマンドレット

Import-Csv
Get-Contentでファイルを読み込んで自分で分割書いて加工書いて…あたりをよしなにしてくれるコマンドレット。各項目がダブルクォーテーションで囲まれているcsv(Excelが出すやつとか)でも気にせず取り込める。デリミタ指定すればタブ区切りテキスト(tsv)やスペース区切り等でもいける。

Headerパラメータはマッピングと合わせなければならない。ヘッダー行の有るcsvで指定するとファイル内のヘッダー行自身がデータとして取り込まれてしまう。このへんはログファイル毎に対処のこと。

対処箇所が多くて厄介なら、Get-Contentを使い自力で加工するほうが簡単かも。

日付時刻のフォーマット

$_.timestamp = (($_.timestamp).Replace(" ","T")).Replace("/","-")

フォーマットdate_hour_minute_secondで指定されるyyyyMMddTHH:mm:ssの形式への加工。
ゼロパディングの有無には寛容で、例えば2020-7-13T14:37:5でも受け付けてくれる。

APIのURL

Uri = "http://localhost:9200/system_download-2020.07/_doc/"

マッピングの時は_docを取り除いたが、データ投入の場合は従来通り_docを付けていないとエラーになる。仕様で「エンドポイントとして残る」らしい…互換性維持のためかしら。

検索して確認

データ投入できたので、入った内容を実際に確認してみる。
結果は見えさえすればいいので1つだけ表示、日付2020-07-01~2020-07-31のドキュメントを探せ、のJSONを投げる。

検索問い合わせ.ps1
$Json_Datetime = @'
{
  "size": 1,
  "query" : {
    "range": {
      "timestamp":{
        "gte":"2020-07-01",
        "lte":"2020-07-31",
        "format":"date"
      }
    }
  }
}
'@

# Elasticsearch問合せ
$Param_Query = @{
  ContentType = "application/json"
  Method = "Post"
  Uri = "http://localhost:9200/system_download-2020.07/_search/"
  Body = $JSON_Datetime
}
$Resp = Invoke-RestMethod @Param_Query

# Depthデフォルトは3。4階層を超える場合はパラメータ指定する
$Resp | Convertto-Json -Depth 4

下が実行結果。実際の結果はインデントが大きくて見づらいため、減らしてある。

実行結果(JSON)
{
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 100,
            "relation": "eq"
        },
        "max_score": 1.0,
        "hits": [
            {
                "_index": "system_download-2020.07",
                "_type": "_doc",
                "_id": "aQ4GmHMBJrgd0p_bsfQv",
                "_score": 1.0,
                "_source": {
                    "timestamp": "2020-7-27T18:19:43",
                    "id": "takahashi",
                    "clientip": "192.168.10.101",
                    "size": "7460879"
                }
            }
        ]
    }
}

検索結果$Respのこと

検索結果はまずは$Respに格納。中身はPowerShellのオブジェクトである。
わかってれば必要箇所だけ取り出せばいいのだが、わかってないことにしてひとまず全容を見てみる。階層が深い場合、ガバッとJSONにしてしまうほうが当たりをつけやすい。

_shardはElasticsearch側のステータス。

hits以下が肝心の検索結果。
検索ヒット数が欲しければ$Resp.hits.total.value
検索結果一覧が欲しければ$Resp.hits.hits._source
検索結果は、size:1指定しているため、ヒット数が2以上あってもここには1つしか表示されない。(デフォルト10)
たくさん必要ならsizeをもっと増やす。最大でsize:10000まで受け付け、それ以上必要な場合は別な方法で設定することになる。(省略。公式読むこと)

必要な箇所だけ取り出す

欲しい結果は基本$Resp.hitsの箇所だけなので、そこだけを取り出してあれやこれやする事が多い。

$Respから検索結果部分だけJSON表示
# 必要箇所だけ取り出し
$Resp.hits | Convertto-Json -Depth 3

結果は省略。

結果からさらに特定の結果だけ絞り込みたい…場合は、そもそもJSONのほう工夫してその求める結果が出てくるようにしたい。後加工は秘伝のソースや負債になりがちな臭いがする。
PowerShellのほうが加工しやすいなんて場合は、後加工でやることもある。判断は手間とメンテのバランスで。

書いてて考えた疑問

取り込んだ後のログはどうする?

ログ投入とは直接関係ないけど、取り込んだ後の元ログをどうするかについて。
元のログは消さずに別に保存しておく、てひとが多いようだ。私もそう。ログ自体が変わったとか、欲しい結果が変わって取り込み方も変わったとか、インデックス修正したいとかの場合、既存のインデックスを直すよりも新たに元ログから取り込み直すほうが簡単で早いことがある。
古いインデックスを日付でパージするのも、必要になれば元ログから作り直せると思えば気楽に実施できる。
普通のRDBと違い、Elasticsearchはインデックス(DB)を気軽に作って気軽に変更し気軽に消すくらい気楽に扱うほうがやりやすい。

Elasticsearchは集計だけ担当という実運用レポもよく見かける。元のログDBは永続で、Elasticsearchに同内容を格納してこちらで計算し高速さを生かすらしい。

Logstash使わないの?

Logstashでファイルを先頭から読み込ませる、例えばこんなconfigで取り込める。

logstash.conf
input {
  path => "logfile.log"
  start_position => "beginning"
  sincedb_path => NUL
}
filter {
  # 日付時刻の加工とか型の定義とか
}
output {
  elasticseatch{
    host => "server"
    index => "indexname"
  }
}

インデックス作成もマッピングもよしなに取り計らってくれる。
Logstash configの書き方を覚えれば済むので、扱いたいのはデータのほうなんだスクリプトの書き方にリソース割きたくないんだよって場合はこちらを覚える方が早い。
私はあちこち別の環境へ移って作業する場合も多くて、あっここLogstash使えないじゃん…めどいなあ…どうせ全部Windowsマシンだし最初からPowerShellで書いて済ますべ、となった。

BulkAPI使わないの?

BulkAPI公式ドキュメント
BulkAPIは早い、早いのだけどBulkAPIが必要とする形状にデータを加工してから投げないとならない。これが若干めどい。
扱うログのサイズは大きめ程度で巨大ではなく、さほど時間が効いてこないので、今回はBulkAPIの話は無し。
Logstashと(手間も含めて)どっちが早いんだよ等もあるのでいずれ調べたい。


  1. 正確には、文字列型で格納しフォーマット指定を元に解釈する…らしい。