Unyablog.

のにれんのブログ

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.runsubprocess.call では実現できず、すぐに kill されてしまう。

Python 3.9 で確認。

subprocess.run 中に Ctrl-C を押すとどうなるのか

subprocess.run 中に Ctrl-C を押すと、まず subprocess.run 中で使われている Popen.communicate でハンドリングされる。

ここでは、0.25秒(固定値)*1待った後に KeyboardInterrupt を re-raise している。

github.com

その後、subprocess.run でハンドリングされるが、そこで有無を言わさず process.kill() が呼ばれて kill されてしまう。

github.com

  • Popen が re-raise するまでの待ち時間を伸ばせないこと
  • re-raise した場合 subprocess.runprocess.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_whitespaceauto_generate_phrase_queries オプションが使えなくなっていた。使えなくなった経緯と、それに伴う対応のメモ。

split_on_whitespaceauto_generate_phrase_queries とは

どちらも query_string query にあったオプション。

query_string はクエリの処理方法の一種で、与えられたクエリをクエリ言語として一旦パースしてから Search analyzer に投入する。クエリ言語が title:(quick OR brown) といった形式で人間が読み書きしやすいのが特徴で、Heineken でも使っていた。

split_on_whitespaceauto_generate_phrase_queries はそのクエリ言語の処理時のオプションで、 split_on_whitespace (デフォルトで true)は、パース時にスペース区切りを有効にするもの、auto_generate_phrase_queries (デフォルトで false)は split_on_whitespace で得られたそれぞれの文字列に対して自動的に Phrase query を生成するものだった。

stackoverflow.com

kmc test というクエリは、以下のような形で処理されて Analyzer に渡されていた*2

  1. kmc test
  2. kmc AND testsplit_on_whitespace=true
  3. "kmc" AND "test"auto_generate_phrase_queries=true

この結果、各単語に対して PhraseQuery (match_phrase 相当)が生成されていた。

何が嬉しかったのか

Heineken ではユーザーから受け取った検索クエリを直接 query_string のクエリとして投げていたが、それはこの2つのオプションのおかげで成り立っていた。

f:id:nonylene:20211227022943p:plain

auto_generate_phrase_queries

これは Tokenizer として N-gram を使っている場合に必要だった。

N-gram tokenizer を使うと kmc[km, mc] という Token に分解されるが、ここでもし Phrase 検索を使っていない場合は +body:km +body:mc のような BooleanQuery が生成される*3。これだと kmmc という文字さえ文章中に存在すればヒットするため、 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_stringsplit_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 としてはみなされなくなった。

github.com

Whitespaces and empty queriesedit
Whitespace is not considered an operator.

Query string query | Elasticsearch Guide [7.16] | Elastic

すなわち、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.

Query string query | Elasticsearch Guide [7.16] | Elastic

ただ、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 に渡すことにした。

github.com

まとめ

www.youtube.com

N-gram を用いて False positive を減らす検索システムを作りたい場合、Phrase 検索を行う必要がある。今までは Elasticsearch の query_string でそれが実現できていた。しかし、split_on_whitespace の廃止によって query_string に直接渡すような実装は相性が悪くなってしまい、 Heineken では独自のパースを行うことになった。

今までありがとう、そしてさようなら、split_on_whitespaceauto_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日目の記事ということにしています。

adventar.org

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 で構成される。

www.elastic.co

Tokenizer は Char filter で整形された文字列を受け取って、それを Token に分解する。Token filter は分解された Token を編集したり、追加・削除を行う。

N-gramTokenizerToken filter もある。一体どういう違いがあるのか?

挙動の違い

Analyzer API を用いて kmc test という文字列に Bi-gram を適用し、どのように展開されるかを確認してみる。

N-gram tokenizer

Analyzer API 結果詳細(Click to open)

$ 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 の位置情報(positionstart, end_offset)は Token によって異なっており、全て独立した Token として扱われている事がわかる。

N-gram token filter

この記事では、Tokenizer には Standard tokenizer を使う*1

Analyzer API 結果詳細(Click to open)

$ 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 の位置情報(positionstart,end_offset)を保持している点。その結果、同じ位置情報を持つ Token が複数生成されている*2

イメージとしてはこんなかんじ。

f:id:nonylene:20211226015323p:plain

ここで、N-gram token filter で展開された位置情報が同じ Token は Synonym (類義語)として解釈される。

www.elastic.co

その結果、N-gram tokenizer と挙動が異なってくる。

挙動の違いによる影響

インデックスで用いた場合

検索結果

N-gram tokenizer は通常の N-gram として想定したような結果になる。

一方、N-gram token filter の場合、先述したように Synonym としてまとめて解釈されてしまい、細かい位置情報が失われるので phrase 検索がまともに動かない*3。通常の検索でも、スコアなどの精度が大幅に悪化しそう*4

Highlight

Highlight は検索でマッチしたときにマッチした場所を示す用のテキストを抽出し、さらに検索語句に一致する Token に <mark> タグをつけてくれる Elasticsearch の機能。

f:id:nonylene:20211225193326p:plain
"のにれん" で検索した結果の Highlight の例。黄色くなっている箇所が mark

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

github.com

検索クエリで用いた場合

query_string クエリで test"test" (Phrased) を入れた場合に、最終的にどのようなクエリが実行されるかを見る*6

  • N-gram tokenizer

    • test: BooleanQuery, body:te body:es body:st
      • Token それぞれがクエリとなる。OR か AND になるかは default_operator の設定による。
    • "test": PhraseQuery, body:\"te es st\"
      • Token に分解された上で、 Phrase 検索が有効になっていることが分かる。test という文字列が存在する場合にのみマッチする。
  • 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 オプションを有効にすると見れる

Docker で jq が使いたくなったら gojq の Image を使えば良さそう

タイトル通りのメモ。

jq を Docker で使いたかったけど、公式で提供されている image が明示されておらず、それっぽいものも Updated 6 years ago となっている。

github.com

野良 Image はあまり使いたくないので、どうしたものか。

gojq を使おう

ここで、jq の Go にポートしたものとして gojq がある。いくつか差異はある ものの、基本的に同じ機能が使える。

gojq は Docker image が公式に公開されているので、これを使うことにした。

$ docker run --rm -i ghcr.io/itchyny/gojq
gojq - Go implementation of jq

Version: 0.12.5 (rev: 727b4b5/go1.17)
...

便利!

GitHub Actions で Docker push をするときに、特定のブランチについて latest タグをつける

DockerHub の autobuild が無料では出来なくなったので GitHub Actions で Docker build / push を行う作業をしていた。

基本的にはスムーズに移行できたが、 latest tag を自動で生成する部分だけちょっと詰まったのでメモ。

問題

Docker Hub の autobuild では Default branch に push すると latest tag が自動で生成されるようになっていたが、 GitHub Actions を docker-publish workflow ベースで構築すると生成されない。

どうするか

↓の issue にある通り。

github.com

詳細

この Workflow では、latest タグがつくかどうかは docker/metadata-action でどのような metadata が吐かれるかによって決まっていて、ここで image の push 先として latest が含まれると後続の Action で latest に push される。

そして、 metadata-action では、 https://github.com/docker/metadata-action#latest-tag にある通り flavorlatest で latest タグを出力するかどうかが決まる。デフォルトでは auto となっており、その場合タグが push された場合にのみつくようになっている。ここに true を明示的に設定すると、常に latest タグがつくようになる。

そのため、特定のブランチに push した場合につくように変更するには、flavorlatest を特定のブランチの場合にのみ true にすれば良い。具体的には、Issue にある通り下のようにする。

flavor: |
  latest=${{ github.ref == 'refs/heads/main' }}

docker-compose を Docker で動かす

docker-compose を Docker 内で実行するメモ。

背景

docker-compose は便利なツールだが、Linux サーバー上で動かすときは入れ方に悩む。

Docker engine は Docker 公式 apt リポジトリから入れることができるけど、docker-compose はレポジトリには存在しない。

公式ドキュメント では curl でインストールする方法が書いてある。一度インストールする分にはこれで良いものの、更新を考えるとイマイチ。

また、DebianUbuntu の公式リポジトリには docker-compose パッケージが存在しているけど、新しくなかったりする。

そこで、docker-compose の Docker image を使って動かしてみる。

やり方

公式ドキュメント の "Alternative install options" にはラッパー(run.sh)を使ったやり方が書いてあり、 run.sh/usr/local/bin/docker-compose に置くよう案内している。

github.com

ただ、できれば docker コマンドのみでシンプルにやりたい。

run.sh を使わずにやる

run.sh はそこまで特殊なことはしておらず、

  1. Docker UNIX socket の mount
  2. ホスト側の Working directory の mount
  3. コンテナの Working directory をホスト側と同じに
  4. ホスト側の Home directory の mount
  5. 環境変数の維持

などを行いつつ docker/compose image を起動している(詳細は run.sh のコメント参照)*1

多くの場合*2は、1-3 があれば十分なはず。これをふまえて、docker コマンドで docker-compose を最小限使うには↓のコマンドになる:

$ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:`pwd` -w `pwd` docker/compose:{version} --help
Define and run multi-container applications with Docker.

Usage:
  docker-compose [-f <arg>...] [--profile <name>...] [options] [--] [COMMAND] [ARGS...]
  docker-compose -h|--help

Options:
...

これで、docker-compose をダウンロードしなくても Docker があれば compose を立ち上げることができる。

*1:Volume などで Working directory 外からの参照がいるのであれば自分でフラグを追加してねというスタンス

*2:混み入ったことをせず、環境変数や Home directory 配下の config なども使わない場合

replicaCount: 1 と Drain とダウンタイム

Kubernetes で Node を Drain するとき、replicaCount: 1 な ReplicaSet (Service) はある程度のダウンダイムを許容せざるを得ない。

関連 issue は以下。

github.com

問題

  • ReplicaSet X があり、その replicaCount は 1 にしている
    • また、同じ Selector で Service X を作成している
  • Node A に ReplicaSet X の Pod X1 が 1つある

状況で Node A を Drain すると、

  • Pod X1 が Terminate される
  • ReplicaSet X に設定した replicaCount に従って、新しい Pod X2 が別の Node に立つ

といった挙動となり、Pod X1 が Terminate されてから Pod X2 が Ready になるまでは Service X に属する Ready な Pod がない状態になる。その結果、ダウンタイムが生じる。

Terminate されてから Pod が Scheduling されるまではほとんど同時なので、ダウンタイムは Container のセットアップが終わるまでの時間と大体同じになる。

PDB は無力

このような問題の対策として Pod Disruption Budget (PDB) で minAvailable を設定することが考えられるが、残念ながら replicaCount: 1 の場合は仕様上うまく動かない。

PDB は Drain の抑制を行うものの、レプリカ数を良い感じに調整してくれるものではない。なので、上の条件からさらに PDB で minAvailable を 1 に設定した状態で Node A を Drain すると、

  • Pod X1 は PDB があるので Terminate されない
  • ReplicaSet X から見ると Pod X1 が Healthy な状態で存在しているので、新たな Pod を別 Node にスケジューリングを行うこともしない

ということで単に Pod X1 が消えずに Node A が一生 Drain されないことになる。

どうするか?

Issue にある通り replicaCount が1つである限りどうしようもないので、replicaCount を増やすことになる。

replicaCount を増やす

replicaCount を増やすと 1 つの Node が死んでも別の Pod が生きており、ダウンタイムは生じない。PDB があると複数ノードが同時に Drain されることも抑制されるのでなお良い。

Drain 前に rollup して Drain 後に rolldown する

要するに replicaCount を一時的に増やす。

…このように replicaCount を増やすのが正攻法だと思うが、今回は個人のどうでもいいクラスタなので、コンテナが立つまでの十数秒のダウンタイムは許容することにした。