Kubernetesカスタムリソースの設計: エラー通知
KubernetesのCustom Resourceでユーザの入力に不備があったり、コントローラの実行時にエラーが発生した場合、それをユーザに知らせるエラー通知が必要です。このポストではCRD設計をする際に利用できるエラー通知の手法とそのPros/Consに関してまとめます。
エラー通知の選択肢
自分なら以下の順番で検討すると思います。
- CRD Validation
- ValidatingAdmissionWebhook/Policy
- Condition (Status)
- Event
- Log
- Metrics
CRD Validation
CRD自体に備わっているOpenAPIのValidation機能を利用します。KubebuilderであればMarkerを使ってValidationのルールを書くことができます。文字列のフォーマットチェックから整数値のフィールドの範囲チェック、リストの最大要素数チェック等々の比較的リッチなプリセットのバリデーションが使えます。プリセットのバリデーションだけでは表現できない場合にはCELを使ったバリデーションがv1.29から使えます。
単純な設定ミスの類はなるべくここで検出したいところです。ここでエラーを検知できればリソースの作成・更新自体を失敗させられます。そのため、ユーザが kubectl apply
などを使っている場合にはコマンドの実行自体が失敗し、同期的に通知ができます。これと後述するValidatingAdmissionWebhook/Policyを使った方法以外は全て非同期に通知をすることになるため、ユーザがエラーに気が付きづらく、プログラムでエラー処理をするのも大変なのでテストも書きづらいです。
CELを使うのはなるべく避けた方がいい
CELを使うことはなるべく避けるべきだと考えます。CELを使うほどの複雑なルールは抜け漏れも発生しやすいです。そのため、CELのバリデーション自体をテストするなど余計なメンテナンスコストがかかります。同じ理由で正規表現を使うのも避けたいところです。プリセットのバリデーションはすでによくテストされているため、このようなコストをかけずに済みます。
やむなくCELを使う場合にはCost Limitに気をつけてください。CELを使ったバリデーションを書く際、KubernetesはExpressionの実行コストを静的に計算しています。実行コストには上限があり、その上限はExpressionごとではなく、そのリソースのバリデーションに使われている全てのExpressionのコストの総和に対してかけられています。
コスト計算の方法はKubernetesのバージョンごとに違いますが、個人的に特に気を付けるべきだと思うのはListやStringのバリデーションです。all
や contains
などは全走査が必要なのでListやStringの要素数/文字数に上限 (Kubebuilderの場合はMaxItems
や MaxLength
) がないとコストが高く出やすくなります (上限がないからといってコストが無限になるわけではない)。ちなみにListの要素に対してバリデーションを書いている場合には all
と実質同じです。公式ドキュメントにこのようなコストが高くなりやすいパターンが書いてあります。
この問題の厄介なところは、CRDの仕様が小さいうちは多少雑なバリデーションを書いていてもコストの総和が小さいので、Cost Limitには引っかからず、仕様が大きくなったタイミングである日突然引っかかることです。しかも上限がないListやStringに対して新たに上限を設けることは破壊的変更にあたるため、場合によっては新たに上限を設けてコストを下げるのは大変です。ですので、なるべくListやStringには最初から上限を設けておきたいところです。後から上限を増やすのはユーザ側を壊さずできるので、最初は厳しめの上限にしておくといいのではないかなと思います。
ValidatingAdmissionWebhook/Policy
CRD Validationだけでは検出できない複雑なケースはValidatingAdmissionWebhookまたはValidatingAdmissionPolicyを使ってチェックすることを検討します。これもCRD Validationと同じくAPIの呼び出しの中でチェックが走るので、同期的なエラー通知ができることが利点です。Webhookはサーバの運用をしなければならないので少々面倒です。Policyはサーバの運用はしなくていいものの、CELを使っているのでCRD Validationで触れたものと似た問題があります。
Status (Condition)
API呼び出し時に検出できないエラーはコントローラ側で実行時に検出し、通知をすることになります。エラーの原因となったリソースのStatusにエラー通知も載せられればエラーを発見しやすそうです。しかし、APIサーバとのやりとりが発生するため、あまり高い頻度でエラー通知をすると同じリソースをWatchしている他のコントローラやAPIサーバに負荷がかかります。Statusの設計全般に言えることですがなるべくAPIサーバとのやりとりは極力減らすべきです。このような性質上、Statusはエラーの内容が時間と共にそこまで変化しない、あるいはユーザの介在無しには解決し得ない恒常的なエラーを通知するのに向いています。
特に一つのリソースを複数のコントローラが消費している場合、各々のコントローラからStatusを書くというパターンは避けた方がいいでしょう。以下のようなものです。
status:
someStatus:
controller-0:
msg: foo
controller-1:
msg: bar
controller-2:
msg: baz
一つのリソースのStatusに複数のコントローラからStatusを書き込むのは一応このようにMapを使って実現可能ですが、そのようなStatusはコントローラの数が増えるとそれだけ書き込みの頻度も上がりやすく、各々のコントローラが受ける変更通知の数も増えるため、あまりスケールする設計とは言えないです。参考までに、Ciliumプロジェクトでは過去にはCiliumNetworkPolicyに各NodeにいるエージェントがそのようなStatusを書き込むフィールドがありましたが、スケーラビリティへの懸念から削除されました。基本的にStatusを書き込むコントローラは1つにしておいた方が無難でしょう。
Statusを使う場合、自分でエラー通知用のフィールドを設計する前にConditionを使うことを検討するといいでしょう。ConditionはDeploymentやPodなどのKubernetesのコアなリソースでも使われている実績のある構造なので、特に理由がなければこれに従っておけば間違いないと思います (ObservedGeneration
などは自分で思いつきづらい)。
Event
KubernetesのEvent APIは特定のリソースに関するイベントを通知することができるAPIです。Statusと似たような使い方ができますが、こちらは時系列を表現でき、さらに元のリソースが削除されてもEventが残ります。ただし、デフォルトでリテンションが設定されており、1hと比較的短いです。さらに注意すべきことはEventはAPIサーバ経由で記録をしており、etcdにデータが保存されていることです。ですので、Eventを大量に発行するとAPIサーバの負荷も上がりますし、etcdの容量も消費します。
ログのように使うことができますが、上記のような性質から出すイベントは厳選した方がいいでしょう。Eventは一過性のエラーが起きたことを記録しておくような用途に使うのがいいのかなと思います。
Discussion