どこへ送るかと、着いた後どう扱うか
一言でいうと
VirtualServiceはどこへ送るかを、DestinationRuleは到着したあとにどう扱うかを決めます。カナリアデプロイが動かない事故の半分は、この分業を片方しか作らないことから生じます。
なぜ必要なのか
デプロイとリリースを分離したいという要求が、出発点です。新しいバージョンをクラスターに上げること(デプロイ)と、ユーザーのトラフィックをそちらへ送ること(リリース)を、別々に制御できれば、問題が起きても、イメージを元に戻す代わりに、重みを0に下げるだけで済みます。
ところが、この2つは性質が異なります。「10%だけv2へ」のような分配はリクエスト単位の判断であり、「v2とはversion=v2ラベルを持つPod」という定義は、宛先単位の事実です。Istioは、これを2つのリソースに分けました。
ここで、試験にそのまま出る非対称性が生まれます。subsetはDestinationRuleにしかなく、weightはVirtualServiceにしかありません。DestinationRuleなしでVirtualServiceからsubset: v2を参照すると、Envoyにそのようなクラスターが作られず、リクエストは503を受け取ります。アクセスログのレスポンスフラグは、UH(no healthy upstream)と記録されます。
どう動くのか
ルーティングのルールは上から下へ評価され、最初にマッチしたものが勝ちます。そのため、具体的なルールを上に、包括的なルールを一番下に置きます。最後にmatchのないルール(catch-all)がないと、どのルールにも当てはまらなかったリクエストは、NR(no route)フラグとともに404または503になります。
マッチ条件の組み合わせのルールも、よく間違えられます。
# AND: 경로가 /admin 이면서 동시에 헤더도 맞아야 매칭
- match:
- uri:
prefix: /admin
headers:
x-role:
exact: admin
# OR: 경로가 /admin 이거나, 헤더가 맞으면 매칭
- match:
- uri:
prefix: /admin
- headers:
x-role:
exact: admin
1つのmatchブロックの中の条件はAND、matchの配列の項目の間はORです。インデント1段の違いで、ポリシーの意味が逆転します。
レジリエンスの設定は、固定の倍数を覚えるよりも、時間の予算とリトライの条件を分けて読む必要があります。attempts: 2は、元のリクエストを含めて2回ではなく、追加のリトライが最大2回、つまりアップストリームへのリクエストが最大3回です。perTryTimeoutは、元のリクエストと各リトライの時間の上限で、timeoutは、リトライと待機まで含めた、リクエスト全体の制限です。試行のたびに、上限の分だけ必ず待つわけではありません。
全体の制限と試行ごとの制限がどちらも1秒でも、速い失敗は別です。retryOn: "503"を明示して、実際のアップストリームがすぐに503を返せば、全体の1秒の中でリトライを実行できます。一方、最初の試行が応答のないまま全体の予算を使い切ると、次の試行に使う時間がありません。したがって、perTryTimeout × (attempts + 1)より全体の制限が小さいという事実だけで、リトライが不可能だと決めつけてはいけません。すべての試行にそれぞれ上限まで時間を与えるための予算なら、その積だけでなく、試行の間のbackoffも考慮する必要があります。
実際の回数は、失敗の条件・発生の時点・backoff・残りの予算によって変わります。Istio 1.31.0での別の実測では、同じ1秒/1秒/追加2回の設定で、速い503はアップストリームに3回、1.5秒かかる応答は1回到達しました。最終的なHTTPコードで回数を推測せず、リクエスト識別子とアップストリームの受信記録を突き合わせてください。プロキシが接続エラーを503に変えた場合は、実際のアップストリームの503とは異なるので、retryOn: "503"だけでは、同じリトライは保証されません。
試行ごとの制限は、正常なレイテンシ分布と、呼び出し元の残りの予算を見て決め、リトライは、業務の重複処理に対する安全性を確認してから有効にします。GETという名前だけで、実装に副作用がないことは保証されず、POSTにもリトライを1回くらいなら安全だというルールはありません。応答を受け取れなくても、サーバーはすでに書き込んでいるかもしれません。合成注文の実測では、成功の応答1つのあとに、書き込みが3回残り、同じリクエストキーをアトミックに重複排除した対照群では、1回だけ残りました。単一プロセスのメモリでの対照群を、運用向けのexactly-onceの保証と解釈しないでください。
公式の定義は、VirtualServiceのHTTPRetryで確認してください。以下のファイル作成ラボは、設定の構造を練習するもので、この説明でのHTTPの観測は、別の実際のサイドカーのVMで行ったものです。
リトライは、チェーン全体の観点で見る必要があります。A → B → Cで、各段がattempts 2を持っていると、Cが受け取るリクエストは、最悪の場合3 x 3 = 9倍になります。4段なら27倍です。死にかけているサービスに対して、リトライがとどめを刺すようなものなので、リトライはチェーンの1つの階層(できれば最も外側)にだけ置きます。
サーキットブレーカーは、2つのリソースではなく、DestinationRule 1か所にまとまっています。connectionPoolは「どれだけためておくか」を、outlierDetectionは「どのインスタンスを外すか」を決めます。インスタンスが3つしかないサービスでmaxEjectionPercent: 100にすると、3つが同時に排除されて、サーキットブレーカーがそのまま全面障害になります。
同じDestinationRuleの中に、localityLbSettingもあります。同じゾーンのエンドポイントを先に使い、そのゾーンが落ちたら次のゾーンに渡す設定です。ゾーンをまたぐトラフィックには、料金とレイテンシが加わるので、規模が大きくなると必ず有効にすることになります。
ここに、試験にも現場にも出てくる落とし穴があります。フェイルオーバーはoutlierDetectionなしでは起こりません。次の優先度に渡す条件が「今の優先度のエンドポイントが不健全である」ことで、その判定を作る仕組みがoutlierDetectionだからです。ないと、Envoyは同じゾーンのエンドポイントが死にかけていることを知る方法がなく、そこへ送り続けます。
症状が紛らわしいのは、平常時にはまったく問題なく動作することです。ゾーン優先のルーティング自体はうまく働き、レイテンシも下がり、料金も下がります。それが、ゾーンが1つ落ちる日にだけ、切り替わりません。ラベル(topology.kubernetes.io/zone)が間違っていれば、そもそもゾーン優先のルーティングが動作しないので、症状が違います。平常時はうまくいき、障害のときだけうまくいかないなら、ほとんどの場合、outlierDetectionが抜けています。
現場での姿
筆者のホームラボで得た教訓を1つ、そのまま紹介します。Ciliumで構築したそのクラスターで、同じIP・同じポートのnginxにrules.http: [{method: GET}]のポリシーをかけたところ、GETは200、POSTは403になりました。L3/L4の層ではメソッドが見えないので、この区別は誰かがHTTPをパースしたということです。Istioでは、その誰かがサイドカーのEnvoyであり、ヘッダーのマッチによるルーティングやメソッドのマッチが可能な理由も、まったく同じです。
興味深いのは、そのときのHubbleのログの形でした。リクエストはDROPPED、レスポンスはFORWARDEDと記録されました。プロキシが作り出した403が、正常な応答として流れ出たからです。Istioでも同様に、接続は成立したのに403が返ってくる状況と、接続自体が失敗する状況では、ログの形が違います。前者はL7の判定、後者はL4の問題です。この区別ができないと、見当違いのリソースを何時間も見続けることになります。
次のラボですること
2つのラボが続きます。1つ目では、ネームスペースとv1/v2のワークロードを実際にデプロイしたあと、DestinationRuleとVirtualServiceのマニフェストをファイルとして書き、重み・ヘッダーのマッチ・URIの書き換え・ルールの順序を手になじませます。2つ目では、リトライとタイムアウトの関係、ヘッダーでゲートした障害注入、サーキットブレーカー、ミラーリングを扱います。