終わらない drain の読み方
一言でいうと
drainはPodを削除するのではなくエビクションAPIを呼び出す処理であり、そのリクエストをPodDisruptionBudgetが審査して、足りなければ429を返します。ブロックされたdrainの原因は、ほとんどの場合、バジェットが0であるか、セレクターが重複しているか、準備ができていないPodがすでにバジェットを壊しているかの、3つのうちのどれかです。
なぜ診断が別に必要なのか
PDBの作り方は難しくありません。ドキュメント1ページで足ります。しかし現場で時間を食うのは、作る側ではなくブロックされたときです。ノード1台を空にしようと実行したコマンドが、30分経っても同じ行を繰り返し出力し続け、その行にはerror when evicting podsしかありません。どのバジェットが止めているのか、いくつ足りないのか、今追い出せるPodがいくつあるのかは、その画面にはありません。
そのため、drainが実際に何をしているのかから知る必要があります。kubectl drainはPodをdeleteしません。Podごとにエビクションサブリソース(/api/v1/namespaces/<ns>/pods/<pod>/eviction)にPOSTを送ります。このリクエストはAPIサーバーの中でPDBの審査を受け、バジェットが足りなければ429 Too Many Requestsで拒否されます。そしてdrainは諦めずに再試行します。ブロックされたdrainが失敗で終わらず、永遠に続く理由が、これです。逆に、kubectl delete podはこの審査をまるごと飛ばします。そのため、急いでいるときに使いたくなりますが、それはバジェットを無視するという宣言です。
どう動くのか
PDBの状態は、数字1つではなく4つです。kubectl get pdb <이름> -o jsonの.statusを見ると、次のようになっています。
| フィールド | 意味 |
|---|---|
expectedPods |
セレクターに該当するPodがいくつと見込まれるか |
currentHealthy |
そのうち、今健全なPodがいくつあるか |
desiredHealthy |
エビクションのあとも残っている必要がある最小数 |
disruptionsAllowed |
今すぐ追い出せる数 |
ここでいう「健全」には、正確な定義があります。公式ドキュメントは、.status.conditionsにtype=Readyかつstatus=Trueの項目があるPodを、健全なPodとして数えると述べています。Runningであれば健全だというわけではありません。
minAvailableとmaxUnavailableは、どちらか一方しか使えません。パーセンテージを使うときの切り上げの方向が重要です。Podが7個でminAvailable: "50%"なら3.5ですが、Kubernetesは切り上げて4個を要求します。安全な側です。ところが、maxUnavailableをパーセンテージで指定すると、追い出せる数のほうが切り上げられます。そのため、公式ドキュメントの表現のとおり、実際の中断が指定したパーセンテージを超えることがあり、レプリカが1つだけのときにmaxUnavailable: 30%は、その1つを追い出せるようにしてしまい、結局100%の中断になります。
2つ目の落とし穴は、セレクターが重複したバジェットです。1つのPodが2つ以上のPDBに該当すると、各バジェットに余裕があっても、エビクションは拒否されます。エビクションサブリソースがその状況をサポートしていないからです。ドキュメントも、セレクターの重複を避けるよう述べており、正当な用例としては、Podを1つのバジェットから別のバジェットへ移す過渡期だけを挙げています。
3つ目がunhealthyPodEvictionPolicyです。1.31で安定版になったフィールドで、デフォルト値はIfHealthyBudgetです。このデフォルト値では、まだ健全でないPodを追い出すには、そのアプリケーションがすでにバジェットを壊していない必要があります。つまり、CrashLoopBackOffに陥ったPodや、Readyを報告できないPodが1つでもあれば、まさにその壊れたPodでさえエビクションできません。drainはその場で永遠に止まります。AlwaysAllowに変えると、実行中だが健全でないPodは、バジェットと無関係に追い出せます。公式ドキュメントが、ノードのdrainをサポートするならこの値を推奨すると述べている理由です。
最後に、バジェットが保護できないものも知っておく必要があります。PDBは自発的な中断だけを防ぎます。ノードが単に死ぬことは防げず、そのためドキュメントは、バジェットがその数を常に保証するわけではないと明記しています。そしてmaxUnavailable: 0や、minAvailableをレプリカ数と同じにすると、自発的なエビクションを0にしたことになるので、そのPodがあるノードはdrainが永遠に終わりません。これはバグではなく、ドキュメントに書かれているとおりの意味です。
現場での姿
最もよく見るのは、レプリカ1つのワークロードにminAvailable: 1を設定してしまうことです。意図は「このサービスは絶対に止まってはいけない」でしたが、結果は、そのワークロードがあるノードを永遠に空にできなくなったことでした。アップグレードの計画がまるごと止まります。
2つ目は、drainがブロックされたあと、ノードを元に戻さないことです。drainは、エビクションを始める前に、ノードを先にスケジュール不可の状態に変えます。エビクションがブロックされてコマンドを中断しても、その印は残ります。誰も気づかないうちに、クラスターの容量がノード1台分減っていて、数週間後にトラフィックが集中したときに、その代償を払うことになります。
3つ目は、バジェットのないワークロードです。こちらは逆方向の事故で、drainが何の抵抗もなく通過し、レプリカが一斉に落ちます。そのため、メンテナンスウィンドウを設計するときに最初に作るべき資料は、「レプリカが2つ以上なのに、どのPDBにもカバーされていないワークロード」の一覧です。
このラボ環境の限界
kwokのPodは本物のコンテナではないので、CrashLoopBackOffを本当に作ることはできません。代わりに、Podのstatus.conditionsを直接書き換えてReadyをFalseにします。PDBが見ているのはまさにそのコンディションなので、中断コントローラーは本当にcurrentHealthyを1つ減らし、エビクションの判定も本当に変わります。確認したところ、デフォルトのポリシーでは429、AlwaysAllowに変えると通過しました。また、エビクションを実際に実行すると、あとのステップで使うPodが消えてしまうので、このラボは、ほとんどの判定を?dryRun=Allで受け取ります。実務でも、メンテナンスウィンドウを確保する前に、同じ方法で事前に問い合わせられます。
次のラボですること
1つのノードにワークロードを集め、許容中断数が0のバジェットを作成したあと、エビクションAPIを直接呼び出して429を受け取ります。続いて、drainが実際に止まるのを確認してノードを元に戻し、バジェットを修正して、同じリクエストが通過するのを確認します。次に、7個のレプリカに50%のバジェットを設定して、切り上げの方向をクラスターが計算した数値で確認し、セレクターが重複したバジェットでデッドロックを作って、そのメッセージを読みます。準備ができていないPodを作成して、デフォルトのポリシーとAlwaysAllowの違いを計測し、最後に、バジェットのないワークロードをクラスター全体から探す監査スクリプトを作成します。
参考ドキュメント:
- https://kubernetes.io/docs/concepts/workloads/pods/disruptions/
- https://kubernetes.io/docs/tasks/run-application/configure-pdb/
- https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/