GKE の Preemptible VM を Spot VM にした
プライベートで GKE を Preemptible VM ベースで運用していた。だいぶ昔に Preemptible VM が Spot VM に変わるよという通知が GCP から来ていて 値段変わらないし 放置していたのだが、GKE のバージョンを上げるついでに Spot VM に変更した。
Preemptible VM は何がなんでも 24 時間以内に終了されるという AWS もびっくりの仕様だったが、 Spot VM ではそれがなくなり、 GCP のインフラ内で必要な場合にのみ終了されるようになった。
料金も変わらないのでユーザーにとってはメリットしかない変更のよう*1。
GKE の Node pool を Spot VM にする
Node pool を直接 Spot VM に変更することはコンソールではできなさそうだったので、新しく Spot VM の Node pool を作って、その後 Preemptible VM の Node pool を削除した。
Spot VM の様子
5/4 に Spot VM を立ててから記事執筆時点で一週間弱立っているが、Terminate されることなく生き続けている。どれくらい生きてくれるのか、数ヶ月ぐらい様子を見て記事にできればと思う(希望)。
TypeScript 型わくわく日記
TypeScript でオブジェクトのバリデータを作っている。それぞれの値ごとのバリデータは値の特性ごとに様々な関数がある((value: string) => Error[]
のような形)。
時折 Optional な値があるものの、バリデータ自体は non-null な値のバリデーションに専念してほしくて、Optional / Required な判定は別の関数に分けたい気持ちがあった。
別の関数に分けないと、バリデータの中で一々 Optional かどうかの引数を取って判定したり、下のように各バリデータに対応して Optional な判定を加えた関数を作る必要があって面倒。
const validateOptional = (value: string | null | undefined, allowAsterisk: boolean): Error[] => { if (value == null) { return [] // ok } return validate(value, allowAsterisk) }
これを、
- non-null 用のバリデーション関数を引数とし、null チェックを行う処理を差し込んだ nullable 用の新しいバリデーション関数を返す高階関数を作成する
- 様々なバリデーション関数の引数に対応するため、TypeScript の Variadic Tuple Types を使う
ことによってスッキリできた。
例として、non-null な値しか受け取らないバリデータに対して、値を Optional とするための高階関数を下のように作った。
const optional = <U, T extends unknown[]>(validate: (value: U, ...args: [...T]) => Error[]) => (value: (U | null | undefined), ...args: [...T]) => { if (value == null) { return []; // ok } return validate(value, ...args); }
nullable な value
を受け取って null チェックを行い、null | undefined であれば ok、non-null であれば validate
関数に再度投げている。それ自体は初めの関数と特に変わらない。
見所は Variadic Tuple Types を使っているところで、 validate
関数の1引数目以外をまとめて [...T]
と受け取ることで、1つ目以外の引数(とその型情報)を維持したまま新しい関数を生成できている。
これがなかったら、バリデータ関数のオプションを全て2引数目に Object として押し込む (value: U, options: T) => Error[]
のような形)ようにインターフェースを統一することになっていたと思う。
// 例 const validate = (val: string, allowAsterisk: boolean) => { const errors = []; if (val.length >= 10) { errors.push(new Error("value must be shorter than 10 chars")) } if (!allowAsterisk && val.includes('*')) { errors.push(new Error("value must not contain character *")) } return errors } // validate: (val: string, allowAsterisk: boolean) => Error[] // optional(validate): (val: string | null | undefined, allowAsterisk: boolean) => Error[] console.log( optional(validate)("*", true), // => [value must not contain character *] optional(validate)("*", false), // => [] optional(validate)(undefined, true) // =>[] )
TypeScript たのし~~
AlertManager: Slack の Incoming webhook URL が Kubernetes の Secret で扱いやすくなっていた
Prometheus の AlertManager は Slack などに通知を送る重要なコンポーネントである。
当然 Slack の Webhook URL といった Credential を設定ファイルに書く必要があるのだけど、AlertManager は強い意志で環境変数や複数設定ファイルに対応していないので、Kubernetes の Secret との相性がとても悪かった。
この状況下で Secret を使うには、Secret にまるまる設定ファイルを突っ込むか、envsubst を initContainer に指定して設定ファイルを動的に生成する必要があった。
Secret は sealed-secret で暗号化した状態でレポジトリに置くようにしているので、AlertManager の設定を変えるたびに暗号化をしなければならないのは面倒すぎる。とはいえ、 envsubst をするのは configmap-reload などと相性が悪い。
AlertManager 0.22
平文でレポジトリに置くしか無いのか・・・? と思っていた矢先、去年にリリースされた AlertManager 0.22 で slack_api_url_file
というオプションが生えて、ファイルから Slack Incoming webhook URL を指定できるようになっていることに気づいた。
[ENHANCEMENT] Add support to set the Slack URL from a file. #2534
https://github.com/prometheus/alertmanager/releases/tag/v0.22.0
最高のアップデートじゃん!ということで、Secret に Slack の Webhook URL だけを置くような構成にすることができた。これで柔軟に Config を変更することができる。
Prometheus の Helm Chart だとこんな感じ。
alertmanager: extraSecretMounts: - name: alertmanager-secrets mountPath: /alertmanager-secrets secretName: prometheus-alertmanager-secrets readOnly: true alertmanagerFiles: alertmanager.yml: global: slack_api_url_file: '/alertmanager-secrets/slack-api-url' receivers: - ...
Git でブランチがリモートリポジトリの HEAD から派生しているかどうかを確認する
Git でトピックブランチを切るときに、元ブランチで git pull し忘れて古いコミットから生やしてしまうときがある。
最新の変更を認識できなかったり、後々コンフリクトが起きたりと不便なことがままあるので、 Commit 前に HEAD がリモートリポジトリの HEAD から派生しているかを確認するスクリプトを書いてみた。
Git には Commit 前に pre-commit hook を実行することができる。そこで non-zero code で exit すれば Commit 処理が中止されるので、これで防ぐようにする。
コード
これを .git/hooks/pre-commit
内に置く。
git fetch origin if ! git merge-base --is-ancestor origin/HEAD HEAD; then echo "ERROR: HEAD is not based on upstream HEAD" exit 1 fi
解説
まず git fetch origin
でリモートリポジトリを fetch する。
その後、「ブランチが origin の HEAD から派生しているかどうかを確認する」ために git merge-base
を使う。今回は HEAD
の祖先に origin/HEAD
がいるかどうかを確認しており、祖先にいなければ派生元のコミットが古いということで exit 1
し、祖先にいれば Commit を続行するようにしている。
git merge-base --is-ancestor
が便利、というお話でした。
Python の subprocess.run は KeyboardInterrupt で強制的に kill される
Python から Terraform をインタラクティブな(stdin をそのまま渡す)形で呼ぶために、subprocess.run
を使って subprocess で走らせようとしている。
ここで、 Ctrl-C (KeyboardInterrupt
) のハンドリングがネックになる。 Terraform は Ctrl-C を打つと Graceful な終了を試みることがあるので、Ctrl-C が打たれてもしばらく subprocess を終了しないようにしたい。
try
で囲んで except KeyboardInterrupt
すれば実現できそうだが、残念ながら subprocess.run
や subprocess.call
では実現できず、すぐに kill されてしまう。
Python 3.9 で確認。
subprocess.run 中に Ctrl-C を押すとどうなるのか
subprocess.run
中に Ctrl-C を押すと、まず subprocess.run
中で使われている Popen.communicate
でハンドリングされる。
ここでは、0.25秒(固定値)*1待った後に KeyboardInterrupt
を re-raise している。
その後、subprocess.run
でハンドリングされるが、そこで有無を言わさず process.kill()
が呼ばれて kill されてしまう。
- Popen が re-raise するまでの待ち時間を伸ばせないこと
- re-raise した場合
subprocess.run
がprocess.kill()
を呼んでしまうこと
から、 subprocess.run
中に Ctrl-C で subprocess が終了するのを防ぐ手段はない。
どうするか
subprocess.run
の便利な機能を使えないのが残念だが、Popen を直接使うようにした。
with subprocess.Popen(args) as p: try: return_code = p.wait() except KeyboardInterrupt: # Ctrl-C が打たれたら、プロセスが終わるのを待ってから raise する # 補足: 2回目の p.wait() は try-except で囲んでいないので、もう一度 Ctrl-C が打たれた場合は特にハンドリングされず raise される return_code = p.wait() raise
*1:初回以降は wait しない。詳細: https://github.com/python/cpython/pull/5026
Elasticsearch: さようなら、query_string のスペース区切り
この記事は KMC advent calendar 2021 9日目の記事です。
昨日の記事は Elasticsearch: N-gram tokenizer と N-gram token filter の挙動の違い - Unyablog. でした。今日*1も Elasticsearch の話をします。
本題
サークル内で開発しているドキュメント検索ツール(Heineken)では Elasticsearch を内部で用いている。
この Elasticsearch を 5.x から 7.x に上げるにあたって、全文検索時に使っていた split_on_whitespace
と auto_generate_phrase_queries
オプションが使えなくなっていた。使えなくなった経緯と、それに伴う対応のメモ。
split_on_whitespace
と auto_generate_phrase_queries
とは
どちらも query_string query にあったオプション。
query_string はクエリの処理方法の一種で、与えられたクエリをクエリ言語として一旦パースしてから Search analyzer に投入する。クエリ言語が title:(quick OR brown)
といった形式で人間が読み書きしやすいのが特徴で、Heineken でも使っていた。
split_on_whitespace
、 auto_generate_phrase_queries
はそのクエリ言語の処理時のオプションで、 split_on_whitespace
(デフォルトで true)は、パース時にスペース区切りを有効にするもの、auto_generate_phrase_queries
(デフォルトで false)は split_on_whitespace
で得られたそれぞれの文字列に対して自動的に Phrase query を生成するものだった。
例
kmc test
というクエリは、以下のような形で処理されて Analyzer に渡されていた*2。
kmc test
kmc AND test
(split_on_whitespace=true
)"kmc" AND "test"
(auto_generate_phrase_queries=true
)
この結果、各単語に対して PhraseQuery (match_phrase 相当)が生成されていた。
何が嬉しかったのか
Heineken ではユーザーから受け取った検索クエリを直接 query_string のクエリとして投げていたが、それはこの2つのオプションのおかげで成り立っていた。
auto_generate_phrase_queries
これは Tokenizer として N-gram を使っている場合に必要だった。
N-gram tokenizer を使うと kmc
は [km, mc]
という Token に分解されるが、ここでもし Phrase 検索を使っていない場合は +body:km +body:mc
のような BooleanQuery が生成される*3。これだと km
と mc
という文字さえ文章中に存在すればヒットするため、 mchoge kmpiyo
といった文字列にもヒットしてしまい、 False positive が非常に多くなってしまう。
一方、Phrase 検索を使った場合は body:\"km mc\"
という PhraseQuery が生成されるようになる。これは kmc
という連続した文字列にしかヒットしないため、思ったとおりの結果を出すことができていた。
split_on_whitespace
これは直感的なスペース区切りの検索を実現するのに必要だった。
上述のように、N-gram では Phrase 検索を行うことが必要不可欠なわけだが、その Phrase 検索は単語ごとに行う必要がある。
もし split_on_whitespace
が false だと、 kmc test
と入力した場合に "kmc test"
という Phrase 検索になってしまう。これだと、kmc test
という連続した文字列がないとヒットせず、False negative が多く発生してしまう。この状態でも kmc AND test
のように Operator を明示的に書けば別々の単語として投入してくれるが、いちいちユーザーに Operator を書かせるのは大変。
Elasticsearch での両オプションの廃止経緯
…と Heineken では活用していたこのオプションだが、どちらも 6.x, 7.x で廃止された。対応を検討するために、廃止になった経緯を調べてみた。
まず、もともと(5.0 まで)は query_string に split_on_whitespace
というオプションはなく、デフォルトで空白文字区切りが有効になっていた。ただ、空白文字で区切られてしまうと不都合な点がいくつかあり、空白文字を含んだ形で Analyzer に渡すべき、という話があったらしい。
... the goal was to ultimately stop splitting on whitespace and let the analyzer do the right thing.
Make `split_on_whitespace` default to `false` · Issue #25470 · elastic/elasticsearch · GitHub
その後、Elasticsearch が内部で用いている Lucene でこの挙動を変更できるようになり、それに応じて Elasticsearch でも split_on_whitespace
オプションが 5.1 で導入された。この Issue では "将来的にはデフォルトは false にしたいが今は機能を設定できるようにだけしておく" といった旨のコメントがある。
... we had an agreement that splitOnWhitespace=false should be the default but it would change the expectations for a lot of users so the proposed plan is to expose the feature in 5.x (with default to true) and change the default to false in 6.0.
Expose splitOnWhitespace in `Query String Query` · Issue #20841 · elastic/elasticsearch · GitHub
そして、6.x 以降では両オプションを無視するようにして、7.x では無くすという判断がなされ、実際に Whitespace は Operator としてはみなされなくなった。
Whitespaces and empty queriesedit
Whitespace is not considered an operator.
すなわち、kmc test
で AND 検索できていたものを kmc AND test
と明示的に書かなければいけなくなった。
auto_generate_phrase_queries
に関しては、代替として type
というオプションが増えて、クエリからパースされた各文字列がどのように扱われるかを決めれるようになった。例えば type: phrase
とすると、内部的には各文字列に対して match_phrase
検索が実行される。
phrase
Runs a match_phrase query on each field and uses the _score from the best field. See phrase and phrase_prefix.
ただ、split_on_whitespace
は削除されているため*4、今までのクエリがそのまま使えるわけではない。
Heineken での対応
形態素解析などで Token が意味を残した形で分解される場合は、 Phrase 検索は必須でないため今回の変更はそこまで大きな影響とならないと思われる。
しかし、Heineken は N-gram で Token を生成しているため、今回の変更との相性が悪かった。
前述のように、N-gram では特性上 Phrase 検索できないと False positive が大量に発生してしまうので Phrase 検索は自動でやりたい。そこで type: phrase
を設定しても、スペース区切りが廃止されているため kmc test
は "kmc test"
のようなクエリに解釈されてしまう*5。これでは多くの人にとって意図した検索結果にはならないだろう。
結局 query_string に直接クエリを入れるのを諦め、独自に検索クエリをパースして Elasticsearch に渡すことにした。
まとめ
N-gram を用いて False positive を減らす検索システムを作りたい場合、Phrase 検索を行う必要がある。今までは Elasticsearch の query_string でそれが実現できていた。しかし、split_on_whitespace
の廃止によって query_string に直接渡すような実装は相性が悪くなってしまい、 Heineken では独自のパースを行うことになった。
今までありがとう、そしてさようなら、split_on_whitespace
と auto_generate_phrase_queries
👋👋
*1:12月27日
*2:なお、この記事では default_operator を AND とした挙動を書いている
*3:ここでは body というフィールドに対して検索をかけている想定
*4:keyword だけは同等の機能が使えるようになっている https://github.com/elastic/elasticsearch/issues/30393 が、今回は text なので使えない
*5:この状態で今までのような Phrase 検索を維持するには Analyzer 内で空白区切りで分解した上で分解した Token に対して Phrase 検索を有効化し、そこに N-gram をかけるといったことが必要になるが、少なくとも Elasticsearch に用意されているものではそのようなことはできない(はず)
Elasticsearch: N-gram tokenizer と N-gram token filter の挙動の違い
この記事は KMC advent calendar 8日目の記事ということにしています。
KMC では部内ドキュメント検索システムで Elasticsearch を使用している。最近 Elasticsearch のバージョンを上げる準備をしていて、設定の見直しの中で N-gram token filter を使ってみたら想定と違った挙動をしたのでメモ。
Disclaimer
- 7.16 で確認
- 検索結果に False positive, False negative がないかの視点で見ている。スコアリング関係は見ていない。
- Edge n-gram 、CJK bi-gram については見ていない
- エラサーのプロではないので間違っていたら教えて下さい…
TL;DR
- N-gram tokenizer はイメージ通り、与えられた文字列を N-gram で各 Token に分解する。各 Token は別々のものになる。
- N-gram token filter は与えられた Token を N-gram し、新たな Token を登録する。分解された Token は元の Token の情報を引き継ぐので、Token のバリエーションが増えたように(= Synonym)解釈される。
この違いは、インデックス・検索時のパース処理や Highlight 機能に大きく関わってくる。
詳細
前提知識 (Tokenizer と Token filter について)
Elasticsearch では Analyzer でインデックス時や検索時の文字列の処理方法を決める。Analyzer は Char filter, Tokenizer, Token filter で構成される。
Tokenizer は Char filter で整形された文字列を受け取って、それを Token に分解する。Token filter は分解された Token を編集したり、追加・削除を行う。
N-gram は Tokenizer も Token filter もある。一体どういう違いがあるのか?
挙動の違い
Analyzer API を用いて kmc test
という文字列に Bi-gram を適用し、どのように展開されるかを確認してみる。
N-gram tokenizer
$ curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'{ "tokenizer": { "type": "ngram", "min_gram": 2, "max_gram": 2 }, "text": "kmc test", "explain": true }' { "detail" : { "custom_analyzer" : true, "charfilters" : [ ], "tokenizer" : { "name" : "__anonymous__ngram", "tokens" : [ { "token" : "km", "start_offset" : 0, "end_offset" : 2, "type" : "word", "position" : 0, "bytes" : "[6b 6d]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "mc", "start_offset" : 1, "end_offset" : 3, "type" : "word", "position" : 1, "bytes" : "[6d 63]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "c ", "start_offset" : 2, "end_offset" : 4, "type" : "word", "position" : 2, "bytes" : "[63 20]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : " t", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 3, "bytes" : "[20 74]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "te", "start_offset" : 4, "end_offset" : 6, "type" : "word", "position" : 4, "bytes" : "[74 65]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "es", "start_offset" : 5, "end_offset" : 7, "type" : "word", "position" : 5, "bytes" : "[65 73]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "st", "start_offset" : 6, "end_offset" : 8, "type" : "word", "position" : 6, "bytes" : "[73 74]", "positionLength" : 1, "termFrequency" : 1 } ] }, "tokenfilters" : [ ] } }
["km", "mc", "c ", " t", "te", "es", "st"]
といった Token 列に分解されている。各 Token の位置情報(position
や start, end_offset
)は Token によって異なっており、全て独立した Token として扱われている事がわかる。
N-gram token filter
この記事では、Tokenizer には Standard tokenizer を使う*1。
$ curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'{ "filter": [{ "type": "ngram", "min_gram": 2, "max_gram": 2 }], "tokenizer": "standard", "text": "kmc test", "explain": true }' { "detail" : { "custom_analyzer" : true, "charfilters" : [ ], "tokenizer" : { "name" : "standard", "tokens" : [ { "token" : "kmc", "start_offset" : 0, "end_offset" : 3, "type" : "<ALPHANUM>", "position" : 0, "bytes" : "[6b 6d 63]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "test", "start_offset" : 4, "end_offset" : 8, "type" : "<ALPHANUM>", "position" : 1, "bytes" : "[74 65 73 74]", "positionLength" : 1, "termFrequency" : 1 } ] }, "tokenfilters" : [ { "name" : "__anonymous__ngram", "tokens" : [ { "token" : "km", "start_offset" : 0, "end_offset" : 3, "type" : "<ALPHANUM>", "position" : 0, "bytes" : "[6b 6d]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "mc", "start_offset" : 0, "end_offset" : 3, "type" : "<ALPHANUM>", "position" : 0, "bytes" : "[6d 63]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "te", "start_offset" : 4, "end_offset" : 8, "type" : "<ALPHANUM>", "position" : 1, "bytes" : "[74 65]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "es", "start_offset" : 4, "end_offset" : 8, "type" : "<ALPHANUM>", "position" : 1, "bytes" : "[65 73]", "positionLength" : 1, "termFrequency" : 1 }, { "token" : "st", "start_offset" : 4, "end_offset" : 8, "type" : "<ALPHANUM>", "position" : 1, "bytes" : "[73 74]", "positionLength" : 1, "termFrequency" : 1 } ] } ] } }
まず、Standard tokenizer によって ["kmc", "test"]
といった Token 列に分解される。その後、 N-gram token filter によって各 Token がさらに分解され、最終的に ["km", "mc", "te", "es", "st"]
という Token 列が生成されている。
注目すべきは N-gram token filter は分解前の Token の位置情報(position
や start,end_offset
)を保持している点。その結果、同じ位置情報を持つ Token が複数生成されている*2。
イメージとしてはこんなかんじ。
ここで、N-gram token filter で展開された位置情報が同じ Token は Synonym (類義語)として解釈される。
その結果、N-gram tokenizer と挙動が異なってくる。
挙動の違いによる影響
インデックスで用いた場合
検索結果
N-gram tokenizer は通常の N-gram として想定したような結果になる。
一方、N-gram token filter の場合、先述したように Synonym としてまとめて解釈されてしまい、細かい位置情報が失われるので phrase 検索がまともに動かない*3。通常の検索でも、スコアなどの精度が大幅に悪化しそう*4。
Highlight
Highlight は検索でマッチしたときにマッチした場所を示す用のテキストを抽出し、さらに検索語句に一致する Token に <mark>
タグをつけてくれる Elasticsearch の機能。
N-gram tokenizer であれば実際に一致した箇所のみがハイライトされるが、N-gram token filter の場合は Token filter が Token の位置情報(start, end_offset
)を変更しないので、分解前の Token 全体がハイライトされる。
例えば、highlight this
というテキストに対して gh
で検索したとする。先述の N-gram tokenizer を使うパターンでは gh
のみがハイライトされるが、N-gram token filter を使うパターンでは highlight
がハイライトされて、やや違和感のある形になる*5。
検索クエリで用いた場合
query_string クエリで test
や "test"
(Phrased) を入れた場合に、最終的にどのようなクエリが実行されるかを見る*6。
N-gram tokenizer
test
: BooleanQuery,body:te body:es body:st
- Token それぞれがクエリとなる。OR か AND になるかは
default_operator
の設定による。
- Token それぞれがクエリとなる。OR か AND になるかは
"test"
: PhraseQuery,body:\"te es st\"
- Token に分解された上で、 Phrase 検索が有効になっていることが分かる。
test
という文字列が存在する場合にのみマッチする。
- Token に分解された上で、 Phrase 検索が有効になっていることが分かる。
N-gram token filter
test
: SynonymQuery,Synonym(body:es body:st body:te)
- Token filter によって分解された Token は Synonym とみなされ、一括で検索になっている。es, st , te のうち一つでも文章に存在すればマッチする。
"test"
: SynonymQuery,Synonym(body:es body:st body:te)
- 通常の検索と同じ挙動になっており、Phrase 検索にはなっていない
このように、N-gram Token filter を使うと類義語のようにみなされる結果、SynonymQuery が発行されてしまい、想定と異なる結果(大量の False positive)になってしまう。
まとめ
Elasticsearch の Token filter は元々の Token の位置情報を変更しない、という性質を持つため、N-gram token filter では分解した Token が Synonym として解釈されてしまう。その結果、インデックス時や検索時に意図しない挙動になってしまいやすい。
N-gram tokenizer ではそのようなことがなく、意図した通りの N-gram 処理が行えるのでオススメ。
*1:今回は Tokenizer と Token filter の関係を分かりやすくするために、一旦 Tokenizer でスペース区切りが行われるようにした。Tokenizer が何も区切らずに Token filter に渡しても、この記事で言っている事象が起きることは変わらない。
*2:この挙動は Elasticsearch が内部で用いている Lucene の NGramTokenFilter のドキュメントにも書いてある
*3:検索時の Analyzer として N-gram tokenizer を使うと基本的にヒットしなくなるし、N-gram token filter を使うと後述のように SynonymQuery となって意図しないものが大量にヒットする
*4:詳細は見ていないが、同義語として解釈されるのは明らかに違うので誤ったスコアリングがなされそう
*5:もし Standard tokenizer ではなく何も分割しないような Tokenizer を使うと、全てがハイライトされるでしょう :party_parrot:
*6:profile オプションを有効にすると見れる