- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存今ここ
コードを置くことと、利用者に効かせることを分けて考えるコースです。今回は、入れ替えの途中で新旧の版が同じデータを共有している時間を見ます。
まず結論
ローリング更新は、台を少しずつ入れ替えて止めずに出す方法です。止まらない代わりに、入れ替えの間は旧版と新版が同時に動き、同じ表を読み書きします。
止まらないことと、新旧が混ざっても壊れないことは別の性質です。後者は、読む側と受ける側が新旧どちらの形も扱えるように作ったときにだけ成り立ちます。
ローリング更新の途中で、ロードバランサが旧版 v1 と新版 v2 に交互に配っています。v2 は価格を新しい形で同じ表に書きます。新しい形の行が入ったあと一覧を10回取得すると、どうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
止めずに出すと、新旧が同じ表を共有する
第3期「無停止で入れ替える — そして期末の切り分け」で見たとおり、Nginx の設定を反映する途中では、古いワーカーと新しいワーカーが同居しました。そこで同居していたのは設定とワーカーです。アプリの版を入れ替えるときは、これに同じ表の読み書きが加わります。
Kubernetes はコンテナを複数の台で動かす仕組みで、Pod はその実行の単位、Deployment は Pod を望む数だけ保ちながら中身の版を入れ替える設定です。Deployment は、既定で次のように入れ替えます。
By default, it ensures that at least 75% of the desired number of Pods are up (25% max unavailable).
By default, it ensures that at most 125% of the desired number of Pods are up (25% max surge).
台を一度に全部止めないので、要求は途中も処理されます。その代わり既定のままでも、更新の間は旧い Pod と新しい Pod が並んで要求を受けます。両者がつなぐ DB は1つです。
このとき、組み合わせは2つの向きで生まれます。
- 新しい行を、旧いコードが読む。 新版が新しい形で書いた行を、同じ表から旧版が読む。
- 新しいクライアントが、旧いサーバへ送る。 新しい画面が送った新しい形の入力が、旧版に配られる。
- ・止まらないのは、どこかの台が常に動いているから
- ・壊れないかは、それぞれの版がもう一方の書いた行や入力を扱えるかで決まる
本教材の実演の出力(スクリプト非掲載)
Docker で、PostgreSQL 18.6 の1つの表を読み書きする小さな API を2つの版で立て、nginx 1.30.4 に配らせました。交互に配らせるため worker_processes を 1 にしています。表は items(id serial primary key, payload jsonb not null) です。
v1 は価格を 120 のような数値で書き、v2 は {"amount": 1000, "currency": "JPY"} の形で書きます。読む部分はこうです。
def amount(price):if V == "v1":return int(price) # v1 は数値しか知らないreturn price["amount"] if isinstance(price, dict) else int(price) # v2 は両方読める
段階0で v1 だけ、段階1で v1 と v2 を交互、段階2で v2 だけにしました。GET の行は10回の結果を数えたもの、POST の行は1件ずつの応答です。
== 段階0: v1 だけ-- nginx の配り先: rc-l3-v1{"version": "v1", "stored": {"name": "pen", "price": 120}} [201]{"version": "v1", "stored": {"name": "note", "price": 300}} [201]10 {"version": "v1", "items": 2, "total": 420} [200]== 段階1: v1 と v2 を交互(ローリング更新の途中)。新しい画面(新形式)から4件 POST-- nginx の配り先: rc-l3-v1 rc-l3-v2{"version": "v1", "error": "TypeError: int() argument must be a string, a bytes-like object or a real number, not 'dict'"} [500]{"version": "v2", "stored": {"name": "cup", "price": {"amount": 1000, "currency": "JPY"}}} [201]{"version": "v1", "error": "TypeError: int() argument must be a string, a bytes-like object or a real number, not 'dict'"} [500]{"version": "v2", "stored": {"name": "mug", "price": {"amount": 1000, "currency": "JPY"}}} [201]-- GET を10回5 {"version": "v1", "error": "TypeError: int() argument must be a string, a bytes-like object or a real number, not 'dict'"} [500]5 {"version": "v2", "items": 4, "total": 2420} [200]-- 表の中身(同じ表に旧形式と新形式が混ざる)1|{"name": "pen", "price": 120}2|{"name": "note", "price": 300}3|{"name": "cup", "price": {"amount": 1000, "currency": "JPY"}}4|{"name": "mug", "price": {"amount": 1000, "currency": "JPY"}}== 段階2: v2 だけ(入れ替え完了)-- nginx の配り先: rc-l3-v210 {"version": "v2", "items": 4, "total": 2420} [200]
全部 v1 の段階0と、全部 v2 の段階2は10回とも 200 です。落ちたのは途中の段階1だけでした。
- GET は5回が v1 に当たって 500。v2 が書いた新しい形の行を、v1 の
int(price)が読めません。v2 側は同じ表から 200 を返し、合計 2420 は 120 + 300 + 1000 + 1000 です。 - POST も v1 に当たった2件が 500。新しい画面が送った新しい形の入力を、v1 が受けられません。
同じ実行をもう1回走らせ、出力は完全に一致しました。ただし5回という比率は、worker を1にして交互に配らせた結果です。「半分が落ちる」とは一般化できません。 配り方しだいで偏ります。
実演の段階1では、新しい画面から新しい形の価格で4件を登録し、2件が 500 になりました。理由として正しいのはどれですか。
止まらないことと、壊れないことは別
ローリング更新の説明は「サービスを止めずに更新する」で終わることが多く、それ自体は正しい説明です。実演の段階1でも v2 側は 200 を返し続け、サービスは止まっていません。それでも同じ時間に一部の要求が 500 になりました。 止まらないことは、新旧が混ざっても壊れないことを保証しません。
逆向きの誤りもあります。「同居が避けられないなら、互換を保つのは無理だ」。これも違います。v2 は両方の形を読めるので、古い形の行が残っていても落ちませんでした。読む側と受ける側がもう一方の形を扱えれば、同居しても落ちません。 新旧どちらも読める形を先に出し、古い形を消すのは最後にする段取り(expand/contract)は第16期で扱います。
戻す操作も、この時間を消しません。Deployment の文書はこう書きます。
This means that when you roll back to an earlier revision, only the Deployment's Pod template part is rolled back.
戻るのは Pod template です。新版が表に書いた行は Deployment の管理の外にあり、文書も DB には触れていません。
残った旧い画面と、戻した旧版
実演には続きがあります。段階2で入れ替えを終えたあと、更新前に読み込まれた画面が、古い形のまま登録を送ってきた場面を作りました。タブを開いたままの利用者は、入れ替えが終わっても旧い JS を持ち続けることがあります。
-- 残った旧い JS が旧形式で POST(v2 は新形式だけを受け付ける版){"version": "v2", "error": "TypeError: 'int' object is not subscriptable"} [500]== 段階3: 旧形式も受け付ける v2(TOLERANT=1)に替える-- nginx の配り先: rc-l3-v2t{"version": "v2", "stored": {"name": "eraser", "price": {"amount": 80, "currency": "JPY"}}} [201]10 {"version": "v2", "items": 5, "total": 2500} [200]== 段階4: いまの表を v1 に戻す(ロールバック)と-- nginx の配り先: rc-l3-v110 {"version": "v1", "error": "TypeError: int() argument must be a string, a bytes-like object or a real number, not 'dict'"} [500]1|{"name": "pen", "price": 120}2|{"name": "note", "price": 300}3|{"name": "cup", "price": {"amount": 1000, "currency": "JPY"}}4|{"name": "mug", "price": {"amount": 1000, "currency": "JPY"}}5|{"name": "eraser", "price": {"amount": 80, "currency": "JPY"}}
配り先が全部 v2 になっても、古い形の入力は届きます。新しい形しか受けない v2 は 500 を返しました。入力も両方の形で受ける版に替えると、同じ登録が新しい形に直されて 201 になります。
最後に、新しい形の行が残った表のまま v1 に戻すと、GET は10回とも 500 でした。戻ったのはコードで、表は戻っていません。
実演の最後に、新しい形の行が残った表のまま配り先を v1 だけに戻しました。一覧の取得はどうなりましたか。
新しい行を旧いコードが読む(DB)
新版が表に書いた新しい形の行を、まだ動いている旧版が同じ表から読みます。実演では段階1で GET の一部が 500 になり、v1 に戻した段階4では全部が 500 になりました。旧版が新しい形を読めれば落ちません。
旧い JS が新しい API を呼ぶ(クライアント)
入れ替えを終えても、更新前に読み込まれた画面は古い形で送ってきます。実演では新しい形しか受けない v2 が 500 を返し、両方の形を受ける版では 201 になりました。
片方はサーバの中の表、片方は利用者の手元の画面です。落ちた理由と、落ちなくなった理由に共通するものは何でしょうか。
それぞれで「受け取った側」はどちらの版で、何を知らなかったかを見てください。
この章のまとめ
- Deployment の既定は 25% max unavailable、25% max surge。既定のままでも、更新の間は新旧の Pod が並んで要求を受ける。
- 新旧が出会う向きは2つ。新しい行を旧いコードが読む、新しいクライアントが旧いサーバへ送る。
- 実演では、入れ替えの途中だけ一部が 500 になった(10回中5回は、交互に配らせたこの構成での値)。止まらないことと壊れないことは別。
- 同居は避けられないが、互換は無理ではない。受け取る側が両方の形を扱えば落ちない。
- ロールバックで戻るのは Pod template の部分だけ。表に書かれた行は残る。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
Kubernetes の Deployment を既定の設定のまま更新したとき、公式文書に沿うのはどれですか。
用語集(4語)
- Pod / Deployment
- Pod は Kubernetes でコンテナを動かす実行の単位。Deployment は Pod を望む数だけ保ちながら版を入れ替える設定で、Pod template は Deployment が新しく作る Pod のひな形(.spec.template)。
- ローリング更新
- 台を少しずつ新しい版へ入れ替えて、止めずに全体を更新する方法。入れ替えの間は旧版と新版が同時に要求を受ける。
- maxUnavailable / maxSurge
- Deployment の更新中に、使えなくなってよい Pod の数と、望む数を超えて作ってよい Pod の数。既定はどちらも25%。
- PodDisruptionBudget(PDB)
- drain(台の保守のために Pod を退避させる操作)などの自発的な中断で、一度に落ちてよい Pod を抑える仕組み。公式文書は、Deployment のローリング更新そのものは PDB に制限されないと書く。