コンテンツにスキップ

Docker と Kubernetes:コンテナはなぜ軽く、クラスタはなぜ自己修復するのか

前提:バージョン管理システム Git:Merkle DAG としての履歴と共同開発フロー

生 Markdown
  • コンテナは小さな仮想マシンではありません。カーネルの名前空間が「何が見えるか」を、cgroup が「どれだけ使えるか」を制限しただけの、ホスト上のただのプロセスです。カーネルはホストに 1 つしかありません。
  • 「私の環境では動く」問題の正体は、プログラムの真の依存が requirements.txt よりずっと大きく、OS のライブラリ・ファイル配置・環境変数まで含むのに、それが暗黙のまま放置されていることです。Dockerfile はこの暗黙の依存を、Git で差分の追えるコードに変えます。
  • イメージは不変なレイヤの列です。この構造だけから、ビルドキャッシュが効くのは命令列の前置部分に限ること(命題 4.2)と、後のレイヤでファイルを消してもイメージは縮まないこと(命題 4.5)が導かれます。Dockerfile の書き方の作法は、ほぼこの 2 つの帰結です。
  • Kubernetes の中核は「宣言的 API + 調整ループ」です。利用者は望ましい状態を書き、コントローラが現在の状態との差分を埋め続けます。この設計が有限回で収束すること、そして通知を取りこぼしても壊れないことを 命題 6.2 で示します。
  • 冗長化の効果は計算できます。独立故障なら可用性は 1pN1 - p^{N} ですが、同じノードに全レプリカを置くと、レプリカを何個増やしてもノードの可用性で頭打ちになります(定理 7.1系 7.3)。
  • 無停止更新の速さも設計値で決まります。ローリングアップデートの所要時間には Nt/(u+s)Nt/(u+s) という下界があり(命題 7.7)、「安全側に倒したら 20 分かかった」という事故はこの式で事前に防げます。

1. 動機:「私の環境では動く」は何の問題なのか

Section titled “1. 動機:「私の環境では動く」は何の問題なのか”

新しくチームに入った人が、リポジトリを git clone して、README のとおりにセットアップして、動かない。半日かけて調べると、原因は「開発者 A の macOS には Homebrew 経由で入っていた libjpeg が、新人の環境には無かった」だった——ソフトウェア開発の現場で、この種の時間はいくらでも溶けます。本番サーバでだけ落ちる、CI でだけ落ちる、というのも同じ病気の別の症状です。

素朴に考えると不思議です。ソースコードは Git で完全に共有されていて、1 バイトも違いません。それなのに挙動が違うのは、プログラムが依存しているものがソースコードだけではないからです。依存しているものを列挙してみると、思ったより多いことがわかります。

依存の層具体例従来どう共有していたか
言語のパッケージrequirements.txtpackage-lock.jsonGit で共有できている
OS の共有ライブラリlibssllibjpegglibc のバージョン手順書に「入れてください」と書く
OS の設定ロケール、タイムゾーン、ファイル記述子の上限誰も書いていない
ファイル配置設定ファイルの絶対パス、証明書の置き場所人によって違う
実行時の環境変数DATABASE_URLPATH.env を Slack で送る
カーネル・アーキテクチャLinux 5.x か 6.x か、amd64 か arm64 か意識すらしていない

Git で共有できているのは最上段だけで、下の 5 段は「手順書」という、実行されない自然言語のプログラムで共有されていました。手順書は実行されないので、テストできません。テストできないものは腐ります。

この問題への回答は、歴史的におおむね次の順に洗練されてきました。

  1. 手順書(Wiki や Word)。実行されないので必ず現実とずれます。
  2. 構成管理ツール(CFEngine、Puppet、Chef、Ansible)。セットアップ手順をコードにしました。大きな前進ですが、対象は「すでに何かが入っている既存のサーバ」であり、以前の状態の影響を受けます。同じ Playbook を流しても、10 回運用した本番機と新品では結果が違いえます。
  3. 仮想マシン(VMware、VirtualBox、Vagrant)。OS ごと丸ごと配ることで再現性は一気に上がりました。代わりに、イメージは数 GB、起動は数十秒、1 台のノートで同時に動かせるのはせいぜい数個、という重さを背負いました。
  4. コンテナ。仮想マシンと同じ「環境ごと配る」を、OS を丸ごと複製せずに実現しました。

ここで歴史の細かい話を 1 つ。コンテナの要素技術は Docker の発明ではありません。プロセスに見えるファイルシステムの根を差し替える chroot は 1979 年の Version 7 Unix にあり、FreeBSD jail は 2000 年、Solaris Zones は 2005 年、Linux の cgroups は Google が「process containers」として開発したものが 2008 年のカーネル 2.6.24 に入りました。素材は 2008 年には出そろっていたのです。

2013 年に登場した Docker が変えたのは、隔離の仕組みではなく、イメージという配布可能な成果物の形式と、それを 1 ファイルのレシピから再現的に作る手順でした。「レシピ(Dockerfile)を Git に置き、成果物(イメージ)をレジストリで配り、どこで動かしても同じ」という一連の流れが揃ったことで、環境が初めてバージョン管理の対象になりました。技術の価値が、部品ではなく部品の組み合わせ方にあった例だと言えます。

以下、まず「コンテナとは正確には何か」を定義し(§3)、そこから Dockerfile の作法を定理として導き(§4、§5)、次に 1 台では足りなくなった世界で Kubernetes が何をしているのかを見て(§6)、最後に冗長化・自動増減・無停止更新の効果を数値で評価します(§7)。

2. 準備:実行環境を 4 つ組として捉える

Section titled “2. 準備:実行環境を 4 つ組として捉える”

議論を正確にするため、「プログラムが動く環境」を次の 4 つ組として捉えます。

E=(K, R, V, Q)E = (K,\ R,\ V,\ Q)
  • KKカーネル。システムコールの実装と ABI、CPU アーキテクチャ。
  • RRルートファイルシステム/usr/lib にどの共有ライブラリがあるか、/etc に何が書かれているか。
  • VV可視範囲。そのプロセスから見えるプロセス一覧、ネットワークインタフェース、マウント、ホスト名、ユーザ ID の対応。
  • QQ使用可能な資源量。CPU 時間、メモリ、PID 数、I/O 帯域。

「私の環境では動く」は、EE のうち RRVVQQ が人によって違うことから生じます。たとえば動的リンクされた実行ファイルは、起動時に RR の中から共有ライブラリを探します。

Terminal window
$ ldd ./myapp
linux-vdso.so.1 (0x00007ffd8b7f0000)
libjpeg.so.62 => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2e4c000000)

not found の 1 行が、半日を溶かす原因です。

3. コンテナの実体:名前空間と cgroup

Section titled “3. コンテナの実体:名前空間と cgroup”

定義 3.1コンテナ

Linux におけるコンテナとは、次の 3 つを与えられた 1 つ以上のプロセスの集まりです。

  1. 名前空間 (namespace):プロセス ID、マウント、ネットワーク、ホスト名、プロセス間通信、ユーザ ID などの資源の見え方が、ホストや他のコンテナと分離されていること。§2 の記号では VV の制限にあたります。
  2. cgroup (control group):CPU、メモリ、PID 数などの使用量に上限が課されていること。QQ の制限にあたります。
  3. ルートファイルシステムpivot_root などにより、コンテナ用に用意されたファイルシステムを根として見ていること。RR の差し替えにあたります。

カーネル KK はホストのものをそのまま使います。したがってコンテナ内のプロセスは、ホストから見れば ps に現れるただのプロセスです。

名前空間は種類ごとに独立していて、必要なものだけを分離できます。主なものを挙げます。

名前空間分離される対象分離しないとどうなるか
mountマウント点の集合別コンテナのファイルが見える
PIDプロセス ID 空間他コンテナのプロセスを kill できる
networkインタフェース、ルーティング、ポート同じ 80 番ポートを 2 つ使えない
IPC共有メモリ、セマフォ名前の衝突が起きる
UTSホスト名、ドメイン名すべて同じホスト名になる
userユーザ ID・グループ ID の対応コンテナ内の root がホストの root になる
cgroupcgroup 階層の見え方自分の資源制限の外側が見える

PID 名前空間の効果はわかりやすい例です。コンテナ内で ps を打つと、自分のプロセスが PID 1 として先頭に現れます。ホストで同じプロセスを見ると PID は 24187 かもしれません。同じプロセスが、見る場所によって違う番号を持つ——これが「分離」の中身であり、仮想化と呼ぶにはずいぶん素朴な仕掛けです。

cgroup のほうは上限の話です。たとえば cgroup v2 でメモリ上限を 512 MiB に設定すると、そのグループのプロセス群の使用量が上限に達したときに、カーネルは回収を試み、それでも足りなければグループ内のプロセスを OOM killer で停止します。Kubernetes の Pod が突然 OOMKilled になるのは、この仕組みが働いた結果です。

仮想マシンアプリライブラリゲスト OSカーネルアプリライブラリゲスト OSカーネルアプリライブラリゲスト OSカーネルハイパーバイザ / ホスト OS物理ハードウェアカーネルは仮想マシンの数だけ動くコンテナアプリライブラリアプリライブラリアプリライブラリコンテナランタイムホスト OS のカーネル(1 つだけ)物理ハードウェアカーネルはホストに 1 つだけ
仮想マシンとコンテナ:カーネルがいくつ動いているか

図の左右の違いは、アクセント色で示したカーネルの個数に集約されます。仮想マシンでは、仮想マシンを 1 つ増やすたびにカーネルが 1 つ増え、そのカーネルのためのメモリと起動時間が要ります。コンテナではカーネルは増えません。この差が、実務上の性質の差として現れます。

仮想マシンコンテナ
カーネルゲストごとに 1 つホストに 1 つ(共有)
起動時間数十秒(OS 起動を含む)数十ミリ秒〜数百ミリ秒
イメージサイズ数 GB数十 MB〜数百 MB
1 ホストでの実行数数個〜十数個数十〜数百個
隔離の強さハイパーバイザによる強い隔離カーネルの機能による隔離
別 OS を動かすできる(Linux 上で Windows)できない(同じカーネルのみ)

「起動が速い」と「同時にたくさん動く」は、後で見るオートスケールとローリングアップデートの前提条件になります。数十秒かけて起動する単位では、負荷の急増に追従できませんし、1 日に何十回もデプロイできません。

注意 3.2

カーネルを共有するということは、カーネルの脆弱性はそのまま隔離の突破口になるということです。コンテナはマルチテナント環境における第一の防壁として設計されたものではありません。実務上は少なくとも次を守ってください。

  • コンテナ内で root を使わない(Dockerfile で USER を指定する。§4 の例を参照)。
  • ユーザ名前空間を有効にして、コンテナ内の uid 0 をホストの非特権 uid に写す。
  • --privileged や不要な capability、ホストのファイルシステムのマウントを避ける。
  • 信頼できないコードを動かすなら、gVisor や Kata Containers、Firecracker のような、仮想化の壁を挟む実行環境を検討する。

なお「Docker」はもはや唯一の実装ではありません。イメージとランタイムの仕様は OCI (Open Container Initiative) として標準化されており、Kubernetes は v1.24 以降、Docker Engine を直接呼ばず、CRI を実装した containerd や CRI-O を通してコンテナを動かします。Dockerfile で作ったイメージがそのまま動くのは、この標準化のおかげです。

4. イメージとレイヤ:不変性がもたらす 2 つの帰結

Section titled “4. イメージとレイヤ:不変性がもたらす 2 つの帰結”

定義 4.1イメージとレイヤ

イメージとは、順序付きのレイヤの列 L1,L2,,LnL_1, L_2, \ldots, L_n と、起動時の設定(環境変数、作業ディレクトリ、既定のコマンドなど)を記述したメタデータの組です。各レイヤは、直前までのファイルシステムに対する差分(ファイルの追加・変更・削除)を記録した、内容によって一意に名前が付いた不変のデータです。

コンテナを起動するとき、ランタイムは L1L_1 から LnL_n までを重ね合わせ(union filesystem)、その上に書き込み可能な薄い層を 1 枚載せます。LiL_i でファイルを削除しても、Li1L_{i-1} 以前の実データは消えず、「このパスは削除済み」という印(whiteout)が LiL_i に記録されるだけです。

レイヤが不変で内容アドレス指定であること——Git がオブジェクトを内容のハッシュで名付けるのと同じ考え方です(内容アドレス格納庫(定義 2.1)[バージョン管理システム Git])——は、「同じレイヤは 1 度だけ転送・保存すればよい」という嬉しい性質を生みます。10 個のイメージが同じベースイメージを使っていれば、ベース部分の実体はホスト上に 1 つです。一方で、不変性は次の 2 つの制約も生みます。どちらも Dockerfile の書き方に直結するので、定理として書きます。

4.1. キャッシュが効くのは前置部分だけ

Section titled “4.1. キャッシュが効くのは前置部分だけ”

Dockerfile の命令列を I1,,InI_1, \ldots, I_n とします。ビルド時、各命令に対してキャッシュ鍵が

k0=(ベースイメージのダイジェスト),ki=H(ki1, σ(Ii))k_0 = (\text{ベースイメージのダイジェスト}), \qquad k_i = H\bigl(k_{i-1},\ \sigma(I_i)\bigr)

と計算されます。ここで HH は暗号学的ハッシュ関数、σ(Ii)\sigma(I_i) は命令の正規化表現で、RUN についてはコマンド文字列そのものCOPYADD についてはコマンド文字列とコピー対象ファイルの内容のハッシュを含みます。

命題 4.2ビルドキャッシュの前置性

上の鍵計算を用い、「ii 番目の命令がキャッシュヒットするのは、i1i-1 番目までがすべてヒットし、かつ kik_i に対応するレイヤがローカルに存在するときに限る」という規則でビルドするとします。HH は衝突しないものとします。

このとき、キャッシュヒットする命令の集合はある mm に対して {1,2,,m}\{1, 2, \ldots, m\} という前置集合になります。さらに、前回のビルドから σ(Ij)\sigma(I_j) が変化した最小の添字を jj とすると mj1m \le j - 1 であり、Ij,Ij+1,,InI_j, I_{j+1}, \ldots, I_n はすべて再実行されます。

証明(命題 4.2)

前半は規則そのものから従います。ii 番目がヒットするなら、規則の前半より i1i-1 番目もヒットしています。これを繰り返せば 1,,i11, \ldots, i-1 番目がすべてヒットします。したがってヒットする添字の集合は「小さい添字について閉じている」ので、前置集合です。mm をヒットする最大の添字(1 つもヒットしなければ m=0m=0)とすればよいことになります。

後半を示します。i<ji < j では σ(Ii)\sigma(I_i) が変化していないので、k0k_0 が同じである限り、鍵の漸化式から帰納的に kik_i も前回と同じ値です。よってこれらの鍵に対応するレイヤはローカルに残っており、ヒットしえます。

一方 iji \ge j では、kj=H(kj1,σ(Ij))k_j = H(k_{j-1}, \sigma(I_j)) の第 2 引数が変わっています。HH が衝突しないという仮定から kjk_j は前回と異なる値になります。すると kj+1=H(kj,σ(Ij+1))k_{j+1} = H(k_j, \sigma(I_{j+1})) も第 1 引数が変わるので前回と異なり、以下同様に kiki前回k_i \ne k_i^{\text{前回}}iji \ge j のすべてで成り立ちます。前回まで一度も生成されたことのない鍵に対応するレイヤは存在しないので、jj 番目以降はキャッシュミスです。ゆえに mj1m \le j-1 であり、前半の前置性と合わせて IjI_j 以降はすべて再実行されます。

この命題の実務的な意味はただ 1 つです。変化しにくい命令を前に、変化しやすい命令を後ろに置く。ソースコードは毎回変わり、依存パッケージの一覧は月に数回しか変わらないのですから、順序は自ずと決まります。

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "-m", "myapp"]
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "myapp"]

例 4.3命令順序が 1 日 30 分を左右する

上の 2 つの Dockerfile を比べます。pip install に 90 秒、COPY に 1 秒かかり、開発中は 1 日 20 回ビルドし、そのうち 19 回はソースコードだけの変更、1 回だけ requirements.txt も変更するとします。

前者では、COPY . . が 3 番目の命令です。ソースコードを 1 文字直せば σ(I3)\sigma(I_3) が変わるので、命題 4.2 より I3I_3 以降がすべて再実行されます。つまり pip install は毎回走ります。1 日あたり

20×(1+90)=1820 秒30 分20 \times (1 + 90) = 1820\ \text{秒} \approx 30\ \text{分}

です。

後者では、ソースコードの変更が影響するのは COPY . .(5 番目)以降だけです。σ(I3)\sigma(I_3)COPY requirements.txt .)も σ(I4)\sigma(I_4)RUN pip install ...)も変わらないので、I4I_4 までヒットします。1 日あたり

19×1+1×(1+90+1)=111 秒2 分19 \times 1 + 1 \times (1 + 90 + 1) = 111\ \text{秒} \approx 2\ \text{分}

です。差は 1 日 28 分、20 営業日で約 9 時間半になります。Dockerfile の 2 行を入れ替えるだけで得られる差としては大きいでしょう。

注意 4.4

命題 4.2 は、古典的な逐次レイヤキャッシュのモデルに対する主張です。現在の既定のビルダである BuildKit は、命令間の依存関係を有向非巡回グラフとして解析するため、マルチステージビルドの独立なステージは並列にビルドされ、片方のステージの変更が他方のキャッシュを壊しません。ただし1 つのステージの中では前置性がそのまま成り立ちますので、上の順序の作法は変わりません。

もう 1 点、σ(RUN ...)\sigma(\texttt{RUN ...}) がコマンド文字列だけで決まることには副作用があります。RUN apt-get update はコマンド文字列が不変なので、上流のパッケージ索引が更新されても永久にキャッシュヒットし続けます。その状態で RUN apt-get install -y foo を後から追加すると、古い索引を見に行って失敗します。apt-get updateapt-get install を 1 つの RUN にまとめるのは、この罠を避けるためです。

4.2. 消してもイメージは縮まない

Section titled “4.2. 消してもイメージは縮まない”

命題 4.5レイヤ削除の非可換性

イメージの総サイズを、各レイヤに格納された実データの合計 S=i=1nLiS = \sum_{i=1}^{n} |L_i| で測ります。レイヤ LiL_i でサイズ s>0s > 0 のファイル ff が追加され、j>ij > i なるレイヤ LjL_j の命令で ff が削除されたとします。

このとき、削除した場合のイメージサイズ SdelS_{\text{del}} と、削除命令を書かなかった場合のサイズ SkeepS_{\text{keep}} の間には

Sdel=Skeep+w  SkeepS_{\text{del}} = S_{\text{keep}} + w \ \ge\ S_{\text{keep}}

が成り立ちます(w0w \ge 0 は whiteout 記録のサイズ)。とくに、ff をそもそも永続レイヤに作らなかった場合のサイズ Snever=SkeepsS_{\text{never}} = S_{\text{keep}} - s と比べると SdelSneversS_{\text{del}} - S_{\text{never}} \ge s であり、削除命令はイメージを ss バイト分も小さくしません。

証明(命題 4.5)

定義 4.1 より、レイヤは不変です。LjL_j をビルドする時点で LiL_i はすでに確定しており、内容ハッシュで名前が付いています。LiL_i の中身を書き換えれば名前が変わり、それは別のレイヤになってしまうので、後続の命令が LiL_i の内容を変更することは定義上できません。したがって Li|L_i|ffss バイトを含んだまま変わりません。

次に LjL_j を見ます。union filesystem において削除は「上位レイヤに whiteout 記録を置く」ことで表現されます(定義 4.1)。すなわち LjL_j には ff の削除を表す記録が加わり、そのサイズを w0w \ge 0 とすれば Lj|L_j|ww だけ増えます。他のレイヤは変化しません。

以上より Sdel=Skeep+wS_{\text{del}} = S_{\text{keep}} + w です。w0w \ge 0 から SdelSkeepS_{\text{del}} \ge S_{\text{keep}} が従います。最後の主張は Snever=SkeepsS_{\text{never}} = S_{\text{keep}} - s を代入して SdelSnever=s+wsS_{\text{del}} - S_{\text{never}} = s + w \ge s より得られます。

つまり、ビルド用の一時ファイルを「あとで消せばいい」と考えてはいけません。回避策は 2 つあります。

  • 同じ RUN の中で作って消す。1 つの RUN は 1 つのレイヤになるので、レイヤ確定前に消えたファイルは記録されません。
  • マルチステージビルドを使う。ビルド用ステージで生成した中間物を捨て、成果物だけを新しいステージにコピーします。
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

例 4.61.2 GB を 30 MB にする

上のマルチステージ Dockerfile で、各部分のサイズが次のとおりだったとします:golang:1.22 のベースが 800 MB、go mod download が取得する依存が 350 MB、ビルドで生じる中間物が 30 MB、生成される静的リンクのバイナリが 30 MB、gcr.io/distroless/static-debian12 が 2 MB。

もしすべてを 1 つのステージで行い、最後に RUN rm -rf /go/pkg /src と書いたとすると、命題 4.5 よりこの rm はサイズを減らしません。総サイズは

800+350+30+30+w1210 MB800 + 350 + 30 + 30 + w \approx 1210\ \text{MB}

です。

マルチステージにすると、最終イメージは第 2 ステージのベースと、COPY --from=builder で持ち込んだバイナリだけで構成されます。

2+30=32 MB2 + 30 = 32\ \text{MB}

約 38 分の 1 です。ノード 50 台に配布するなら転送量は 60 GB から 1.6 GB になり、オートスケールでノードを増やしたときの起動待ちも短くなります。加えて、最終イメージにコンパイラもシェルもパッケージマネージャも含まれないため、侵入されたときにできることが激減するという安全上の利点もあります。

5. Dockerfile:環境をコードにするということ

Section titled “5. Dockerfile:環境をコードにするということ”

ここまでで、Dockerfile の作法の大半は 命題 4.2命題 4.5 から導けることを見ました。残りは「コードにする」ことの意味に関わる話です。

Dockerfile はソースコードと同じリポジトリに置き、同じレビュー手順を通します。こうすると「本番の環境を誰がいつなぜ変えたか」が Git の履歴として残ります(バージョン管理システム(Git)コミットグラフと祖先関係(定義 4.1)[バージョン管理システム Git] を参照してください)。手順書との決定的な違いは、Dockerfile が実行されることです。実行されるものは、間違っていればビルドが落ちるので、腐りません。

ただし「同じ Dockerfile」が「同じイメージ」を意味するわけではないことには注意が必要です。次の 3 つが再現性を壊します。

  1. 可変タグFROM python:3.12-slim の指すイメージは、上流の更新で中身が変わります。厳密な再現が要るなら FROM python:3.12-slim@sha256:... とダイジェストで固定します。
  2. ネットワーク越しの取得apt-get installpip install は実行時点の上流の状態に依存します。バージョンを明示的に固定する(pip install -r requirements.lock)ほど再現性が上がります。
  3. ビルド時刻に依存する処理。タイムスタンプや乱数を埋め込むと、毎回違うイメージができます。

もう 1 つ、実務で必ず踏む落とし穴を挙げておきます。CMDENTRYPOINT には 2 つの記法があります。

CMD python -m myapp
CMD ["python", "-m", "myapp"]

上のシェル形式/bin/sh -c "python -m myapp" に展開されるため、PID 1 になるのはシェルです。多くの sh は受け取った SIGTERM を子プロセスに転送しません。したがってコンテナを停止しようとしても、アプリケーションは終了処理を始めず、猶予時間(Kubernetes の既定では 30 秒)が過ぎて SIGKILL で強制終了されます。デプロイのたびに処理中のリクエストが落ちる、という症状はしばしばこれが原因です。下のexec 形式なら、アプリケーション自身が PID 1 になり、シグナルを直接受け取れます。角括弧の有無だけの差ですが、無停止デプロイができるかどうかを分けます。

設定値の扱いも整理しておきます。データベースの接続先やログレベルのような環境ごとに違う値はイメージに焼き込まず、環境変数で渡すのが原則です(いわゆる Twelve-Factor App の第 3 因子)。こうすれば、開発・ステージング・本番でまったく同じイメージを動かせます。テスト済みのイメージそのものを本番に出せることが、コンテナによるデプロイの最大の安心材料です。

6. 1 台では足りない:Kubernetes の設計原理

Section titled “6. 1 台では足りない:Kubernetes の設計原理”

コンテナが 1 つ、ホストが 1 台なら docker run で十分です。しかし実際の運用では、次の要求が同時に発生します。

  • サービスを 3 台に冗長化し、1 台落ちても止まらないようにしたい。
  • 夜間は 3 台、昼のピークは 20 台にしたい。
  • ノードのメモリが空いているところにコンテナを配置したい。
  • 新バージョンを、無停止で、少しずつ入れ替えたい。
  • どのノードで動いているかを知らなくても、名前でアクセスしたい。

これらを人手でやるのは無理です。オーケストレーションとは、この一群の判断を自動化することを指し、Kubernetes はその実装です。Kubernetes は 2014 年に Google が公開し、社内クラスタ管理システム Borg の運用経験を設計に取り込んでいます(Borg については Verma らの論文、設計思想の継承については Burns らの解説が参考文献にあります)。

Kubernetes の設計上の中心概念は 1 つだけです。

定義 6.1宣言的 API と調整ループ

利用者は「どういう手順で操作するか」ではなく「どういう状態であってほしいか」(望ましい状態、desired state)を API に登録します。システムは調整ループ (reconciliation loop) を回し続けます。各回では、

  1. 望ましい状態 dd を読む、
  2. 現在の状態 ss を観測する、
  3. ddss の差を減らす操作を実行する、

を行います。この 3 段は、ss をどう作ったかの履歴に依存せず、その時点の ddss だけから決まります。この性質をレベルトリガ (level-triggered) と呼びます。

対比のため、命令的な運用を考えてみます。「Pod を 1 つ増やせ」という命令を送る方式です。この方式では、命令が途中で失われたら数が足りないままになり、二重に届けば数が多くなります。障害の最中は、どちらも普通に起こります。一方、「Pod は 3 つあってほしい」という状態を登録する方式なら、命令が失われようが二重に届こうが、次のループで観測された現状に基づいて是正されます。

flowchart LR
U["利用者: 望ましい状態を宣言"] --> API["API サーバ / etcd に永続化"]
API --> C["コントローラ: 望ましい状態と現在の状態を比較"]
C -->|差分を埋める操作| K["スケジューラ・kubelet"]
K --> R["現在の状態: 実際に動いている Pod"]
R -->|観測| C
調整ループ:望ましい状態と現在の状態の差を埋め続ける

命題 6.2調整ループの有限収束

対象オブジェクトの有限集合を O\mathcal{O}O=M|\mathcal{O}| = M)とし、望ましい状態 dd と時刻 tt の観測状態 sts_t に対して、食い違っているオブジェクトの個数

D(st)=#{oO:st(o)d(o)}D(s_t) = \#\{o \in \mathcal{O} : s_t(o) \ne d(o)\}

を考えます。次を仮定します。

  • (A1) 各ループの操作は (d,st)(d, s_t) のみから決まる(定義 6.1 のレベルトリガ性)。
  • (A2) D(st)>0D(s_t) > 0 ならば、1 回のループで少なくとも 1 つの食い違いが解消され、かつ一致していたオブジェクトが食い違いに転じることはない。
  • (A3) ループの実行中に外部からの状態変更(ノード故障や dd の書き換え)がない。

このとき D(st+1)D(st)1D(s_{t+1}) \le D(s_t) - 1D(st)>0D(s_t) > 0 である限り成り立ち、高々 D(s0)MD(s_0) \le M 回のループで s=ds = d に到達します。到達後は、(A2) の後半より状態は変化せず、操作は行われません(冪等性)。

さらに (A1) の下では、ループの入力は現在の (d,st)(d, s_t) だけなので、過去のイベント通知を取りこぼしても、あるいは同じ通知を複数回受け取っても、収束の主張は変わりません

証明(命題 6.2)

DD は非負整数値の関数です。(A2) の前半より、D(st)>0D(s_t) > 0 のとき少なくとも 1 つの食い違いが減ります。(A2) の後半より、新たな食い違いは生じません。(A3) より、この間に ddss も外部要因では変わりません。よって D(st+1)D(st)1D(s_{t+1}) \le D(s_t) - 1 です。

D(s0)MD(s_0) \le MDD の定義(食い違うオブジェクトは O\mathcal{O} の部分集合)から従います。非負整数列 D(s0)>D(s1)>D(s_0) > D(s_1) > \cdots は無限に減り続けられないので、高々 D(s0)D(s_0) 回で D(sT)=0D(s_T) = 0、すなわち sT=ds_T = d となります。

sT=ds_T = d に達したあとは D=0D = 0 なので、(A2) が要求する「解消すべき食い違い」がなく、操作は空です。したがって同じループを何回回しても状態は変わりません。これが冪等性です。

最後の主張を確認します。(A1) より、時刻 tt の操作は (d,st)(d, s_t) の関数です。通知の取りこぼしや重複は「いつループが起動されるか」を変えるだけで、起動されたループの入力 (d,st)(d, s_t) を変えません。したがって、ループが今後も有限間隔で起動され続ける限り、上の議論はそのまま適用でき、収束します。

注意 6.3

実運用で崩れやすいのは (A2) と (A3) です。

(A2) が崩れる典型は、起動直後に落ちるコンテナです。コントローラは「Pod が足りない」と判断して作り、Pod は起動に失敗し、DD は減りません。ループは回り続け、CrashLoopBackOff として再試行間隔が指数的に伸びていきます。命題 6.2 が保証するのは「差を埋める操作が実際に効くならば収束する」ことであって、「どんな設定でも動くようになる」ことではありません。

(A3) は、そもそもクラスタでは常に破れています。ノードは落ち、利用者はマニフェストを更新します。したがって現実の主張は「収束して終わる」ではなく、「外乱が止めば収束する」です。ノードが 1 台落ちれば DD が跳ね上がり、ループがまたそれを 0 に向かって押し下げます。自己修復と呼ばれるものの正体は、この押し下げです。

定義 6.4Pod

Pod は Kubernetes におけるデプロイの最小単位で、1 つ以上のコンテナの組です。同じ Pod のコンテナは、ネットワーク名前空間(したがって IP アドレスと localhost)とストレージボリュームを共有し、必ず同じノードに配置されます。Pod は使い捨ての存在で、再起動されると IP アドレスも名前も変わります。

「Pod は使い捨て」という点が重要です。だからこそ、その上に状態を保つ仕組みが要ります。主要なオブジェクトを整理します。

オブジェクト望ましい状態として何を宣言するか
Podこのコンテナ群を 1 組動かす
ReplicaSetこの Pod を NN 個保つ
Deploymentこの Pod を NN 個保ち、更新は指定の戦略で行う
Serviceこのラベルを持つ Pod 群に、固定の名前と仮想 IP でアクセスできるようにする
Ingress外部からの HTTP 経路を Service に振り分ける
ConfigMap / Secret設定値・機密情報を Pod に注入する
StatefulSet各 Pod に安定した名前と永続ボリュームを与えて NN 個保つ
Job / CronJobこの処理を 1 回、あるいは定期的に完了させる
HorizontalPodAutoscaler指標が目標値になるようにレプリカ数を調整する

利用者が Deployment を書くと、Deployment コントローラが ReplicaSet を作り、ReplicaSet コントローラが Pod を作り、スケジューラが Pod にノードを割り当て、そのノードの kubelet がコンテナランタイムを呼んで実際にコンテナを起動します。各段が独立に調整ループを回しているのがポイントで、どの段の担当者も、自分の望ましい状態と現在の状態だけを見ています。

apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
app: web
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
containers:
- name: web
image: registry.example.com/web@sha256:0b1f2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b
ports:
- containerPort: 8080
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
memory: "512Mi"
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 2
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 8080

例 6.5ノードが 1 台落ちたとき何が起きるか

上のマニフェストを適用した直後の様子を追います。replicas: 3 なので ReplicaSet コントローラは Pod を 3 つ作り、topologySpreadConstraints により 3 つは別々のノード(kubernetes.io/hostname が異なるノード)に配置されます。maxSkew: 1 はノード間の Pod 数の差を 1 以内に保つ指定です。

いま、そのうち 1 台のノードのハードウェアが故障したとします。

  1. そのノードの kubelet からの定期報告が途絶えます。既定では、しばらく(数十秒程度)応答がないとノードは NotReady と記録されます。
  2. さらに一定時間が過ぎると、そのノード上の Pod に削除の印が付きます。ここで ReplicaSet の観測状態は「稼働 2 個」になり、D=1D = 1 です(命題 6.2 の記号)。
  3. ReplicaSet コントローラは差を埋めるため Pod を 1 つ作ります。スケジューラが残りのノードのうち、requestscpu: 200mmemory: 256Mi を満たす空きがあり、かつ maxSkew を破らないノードを選びます。
  4. 新しい Pod のコンテナが起動し、/healthz が 2 秒間隔の readiness probe に応答するようになると、Service のエンドポイント一覧に加えられ、トラフィックが流れ始めます。D=0D = 0 に戻ります。

誰も手を動かしていません。人間がやったのは「3 個であってほしい」と書いたことだけです。なお、readinessProbe を書かないと、Service は「コンテナのプロセスが起動した瞬間」から振り分けを始めます。アプリケーションの初期化に 10 秒かかるなら、その 10 秒間はエラーが返ります。無停止であるためには、準備完了を宣言する手段が要ります。

resources について 1 点補足します。requests はスケジューラが配置を決めるための予約量、limits は cgroup に設定される上限です。メモリは超過すると OOMKilled になるため上限を書きますが、CPU の limits は超過時に「殺す」のではなく「絞る(スロットリング)」ため、遅延に敏感なサービスでは意図しないレイテンシ悪化を招くことがあります。ここで CPU の limits を書いていないのはそのためです。

Service は、Pod が使い捨てであることと、他のサービスから安定した名前で呼びたいこととの折り合いを付ける仕組みです。クラスタ内の DNS(ドメイン名の階層と権威サーバ(定義 6.1)[ネットワーク(TCP/IP)])が web.default.svc.cluster.local のような名前を Service の仮想 IP に解決し、各ノードの仕組みがその仮想 IP 宛のパケットを実際の Pod の IP に転送します。この転送がどの層で何をしているのかは ネットワーク(TCP/IP)、とくに 接続を識別する 4 つ組(定義 5.1)[ネットワーク(TCP/IP)] の知識があると格段に見通しよく理解できます。

なお、データベースのような状態を持つソフトウェアを Kubernetes 上で動かすかどうかは、慎重に判断すべき論点です。StatefulSet と永続ボリュームで動かすことは可能ですが、バックアップ、フェイルオーバー、スキーマ変更といった運用の難所は残ります(データベース設計の基礎)。クラウド事業者のマネージドデータベースを使い、Kubernetes にはステートレスなアプリケーションだけを載せる構成は、いまも有力な選択肢です(クラウドコンピューティング(AWS, GCP)サービスモデル(IaaS・PaaS・SaaS)(定義 2.2)[クラウドコンピューティング] の区分でいえば、運用の責任をどこまで事業者に預けるかの選択です)。

7. スケーラビリティと耐障害性を数値で見る

Section titled “7. スケーラビリティと耐障害性を数値で見る”

「冗長化すると強くなる」「自動でスケールする」という説明はよく聞きますが、どれだけ強くなるのかは計算できます。可用性という量そのものの定義と、直列・並列に組んだときの合成則は 直列構成と並列構成の可用性(命題 6.2)[クラウドコンピューティング] にまとめてあります。以下ではそれを Kubernetes のレプリカ配置の話として使います。

定理 7.1独立故障の下での冗長化

あるサービスが NN 個のレプリカで提供されており、各レプリカがある時点で利用不能である確率が pp0<p<10 < p < 1)、各レプリカの故障は互いに独立であるとします。少なくとも 1 個のレプリカが利用可能ならサービスは利用可能であるとすると、サービスの可用性は

A(N)=1pNA(N) = 1 - p^{N}

です。したがって目標可用性 AA^{*}0<A<10 < A^{*} < 1)を満たすのに必要なレプリカ数は

N  ln(1A)lnpN \ \ge\ \frac{\ln(1 - A^{*})}{\ln p}

を満たす最小の整数です。

証明(定理 7.1)

サービスが利用不能であるのは、NN 個のレプリカがすべて利用不能なときに限ります。各レプリカが利用不能である事象を FiF_i とすると、独立性の仮定から

Pr[i=1NFi]=i=1NPr[Fi]=pN\Pr\Bigl[\bigcap_{i=1}^{N} F_i\Bigr] = \prod_{i=1}^{N} \Pr[F_i] = p^{N}

です。よって可用性は A(N)=1pNA(N) = 1 - p^{N} です。

必要なレプリカ数を求めます。1pNA1 - p^{N} \ge A^{*}pN1Ap^{N} \le 1 - A^{*} と同値です。両辺は正なので自然対数を取ると Nlnpln(1A)N \ln p \le \ln(1 - A^{*}) です。0<p<10 < p < 1 より lnp<0\ln p < 0 なので、両辺を lnp\ln p で割ると不等号の向きが変わり、Nln(1A)/lnpN \ge \ln(1-A^{*}) / \ln p を得ます。

例 7.2レプリカを 1 個増やすと停止時間が何分減るか

1 個のレプリカの可用性が 99 %、すなわち p=0.01p = 0.01 とします。30 日を 43,200 分として、停止時間を計算します。

  • N=1N = 1A=0.99A = 0.99、月あたりの停止は 43,200×0.01=43243{,}200 \times 0.01 = 432 分(約 7.2 時間)。
  • N=2N = 2A=1104=0.9999A = 1 - 10^{-4} = 0.9999、停止は 43,200×104=4.3243{,}200 \times 10^{-4} = 4.32 分。
  • N=3N = 3A=1106=0.999999A = 1 - 10^{-6} = 0.999999、停止は 43,200×1062.643{,}200 \times 10^{-6} \approx 2.6 秒。

1 個から 2 個にするだけで、月 7 時間の停止が 4 分になります。逆向きに、目標 A=0.99999A^{*} = 0.99999(ファイブナイン)から必要数を 定理 7.1 で求めると

N  ln(105)ln(102)=11.51294.6052=2.5N \ \ge\ \frac{\ln(10^{-5})}{\ln(10^{-2})} = \frac{-11.5129}{-4.6052} = 2.5

なので N=3N = 3 です。

ここまでは「独立」という仮定に全面的に依存しています。その仮定を外すと何が起きるかを見ます。

系 7.3相関故障による可用性の上限

定理 7.1 の設定に加え、NN 個のレプリカがすべて同じノードに配置されているとします。ノード全体が利用不能になる確率を qq0<q<10 < q < 1)とし、ノードが健全な条件の下では各レプリカが独立に確率 pp で利用不能になるとします。このとき

A(N)=1(q+(1q)pN)  1qA(N) = 1 - \bigl(q + (1-q)\,p^{N}\bigr) \ \le\ 1 - q

であり、レプリカ数を無限に増やしても可用性は 1q1 - q を超えません。

証明(系 7.3)

サービスが利用不能になる事象を、ノード故障の有無で場合分けします。ノードが故障している場合(確率 qq)、その上のレプリカはすべて利用不能なのでサービスも利用不能です。ノードが健全な場合(確率 1q1-q)、条件付きで各レプリカは独立に確率 pp で利用不能なので、定理 7.1 の証明と同じ計算により、全滅する条件付き確率は pNp^{N} です。全確率の公式より

Pr[利用不能]=q1+(1q)pN=q+(1q)pN\Pr[\text{利用不能}] = q \cdot 1 + (1-q)\, p^{N} = q + (1-q)p^{N}

です。(1q)pN0(1-q)p^{N} \ge 0 なので Pr[利用不能]q\Pr[\text{利用不能}] \ge q、すなわち A(N)1qA(N) \le 1-q です。NN \to \infty のとき pN0p^{N} \to 00<p<10<p<1)なので A(N)1qA(N) \to 1-q となり、この上限は改善できません。

具体的に、ノードの月間故障確率が q=103q = 10^{-3} だとします。例 7.2 で「レプリカ 3 個ならファイブナイン」と計算しましたが、その 3 個が同じノードにいると、可用性は 1103=0.9991 - 10^{-3} = 0.999 が上限です。月あたり 43.2 分の停止で、これはレプリカ数をいくら増やしても変わりません。冗長化の効果は、故障の相関がどこにあるかで決まります。

例 6.5topologySpreadConstraints は、まさにこの相関を断つための指定です。topologyKey をノードから可用性ゾーン(topology.kubernetes.io/zone)に変えれば、ゾーン全体の障害という、より粗い粒度の相関にも備えられます。同時に、ゾーンをまたぐ通信は遅延が増えるという代償があります。何を独立にしたいかを決めてから配置制約を書く、という順序が大切です。

命題 7.4HorizontalPodAutoscaler の一段収束

現在のレプリカ数を n1n \ge 1、各 Pod の指標の平均値を m>0m > 0、目標値を T>0T > 0 とし、Kubernetes の HorizontalPodAutoscaler と同じ規則

n=nmT,ただし mT1τ のときは n=nn' = \Bigl\lceil\, n \cdot \frac{m}{T} \,\Bigr\rceil, \qquad \text{ただし } \Bigl|\frac{m}{T} - 1\Bigr| \le \tau \text{ のときは } n' = n

でレプリカ数を更新するとします(τ\tau は許容幅で、既定は 0.10.1)。

いま、系全体の負荷 λ>0\lambda > 0 が一定で、それが全 Pod に均等に分配され、1 Pod あたりの指標が m=λ/nm = \lambda / n と書けるとします。このとき、nn の値によらず 1 回の更新で

n=λT=:nn' = \Bigl\lceil \frac{\lambda}{T} \Bigr\rceil =: n^{*}

となります。さらに nn^{*} は不動点であり、λ/(nT)1τ\lambda / (n^{*} T) \ge 1 - \tau が成り立つ限り、そこから減る方向の変更も起きません。

証明(命題 7.4)

m=λ/nm = \lambda/n を更新式に代入します。

nmT=nλ/nT=λTn \cdot \frac{m}{T} = n \cdot \frac{\lambda/n}{T} = \frac{\lambda}{T}

となり、nn が約分されて消えます。したがって n=λ/T=nn' = \lceil \lambda/T \rceil = n^{*} で、これは更新前のレプリカ数 nn に依存しません。

nn^{*} が不動点であることを示します。レプリカ数が nn^{*} のとき、指標は m=λ/nm^{*} = \lambda/n^{*} です。天井関数の定義より nλ/Tn^{*} \ge \lambda/T なので m=λ/nTm^{*} = \lambda/n^{*} \le T、すなわち比 r:=m/T1r := m^{*}/T \le 1 です。仮定 λ/(nT)1τ\lambda/(n^{*}T) \ge 1-\taur1τr \ge 1-\tau と同じことなので、1τr11-\tau \le r \le 1、つまり r1τ|r - 1| \le \tau が成り立ちます。これは更新式の許容幅の条件そのものなので、n=nn' = n^{*} となり、変更は起きません。

例 7.5オートスケールの実際の計算

目標を「1 Pod あたりの CPU 使用率が requests の 60 %」とし、現在 6 個の Pod が平均 85 % で動いているとします。命題 7.4 の更新式より

n=6×8560=8.5=9n' = \Bigl\lceil 6 \times \frac{85}{60} \Bigr\rceil = \lceil 8.5 \rceil = 9

です。9 個に増えた後の平均使用率は、負荷総量が変わらなければ

6×85956.7 %\frac{6 \times 85}{9} \approx 56.7\ \%

となります。比は 56.7/60=0.94456.7/60 = 0.944 で、0.9441=0.0560.1|0.944 - 1| = 0.056 \le 0.1 なので許容幅に収まり、これ以上の変更は起きません。1 回の調整で落ち着き、増減を繰り返す振動は起きないことが確認できました。

もし許容幅がなかったらどうなるかも見ておきます。9 個・56.7 % の状態で更新式を適用すると 9×56.7/60=8.5=9\lceil 9 \times 56.7/60 \rceil = \lceil 8.5 \rceil = 9 なので、この例では偶然振動しません。しかし天井関数の切り上げのため、比がわずかに 1 を下回る状況が続くと、増減を繰り返す設定は容易に作れます。許容幅 τ\tau と、縮小方向の安定化時間(既定で 5 分)は、この種の振動を止めるための仕組みです。

注意 7.6

命題 7.4 の要は m=λ/nm = \lambda/n、すなわち「Pod を増やせば 1 台あたりの負荷が反比例して減る」という仮定です(負荷を nn 台に均等分散したときに応答時間がどうなるかは 負荷を n 台に均等分散したときの応答時間(系 5.3)[クラウドコンピューティング] を参照してください)。この仮定が崩れる代表例が、下流のデータベースが飽和している場合です。Pod を増やしても待ち時間が短くならないどころか、接続数が増えてデータベースがさらに遅くなり、CPU 使用率が下がらないので HPA はさらに Pod を増やす——という悪循環に入ります。オートスケールは律速段階を解消しません。ボトルネックがどこにあるかを確かめてから設定してください。

Deployment の RollingUpdate は 2 つの数値で挙動が決まります。NN を望ましいレプリカ数として、maxUnavailable =u= u は「利用可能な Pod は NuN - u 個以上に保つ」、maxSurge =s= s は「Pod の総数は N+sN + s 個以下に保つ」という制約です(既定はどちらも 25 %)。

命題 7.7ローリングアップデートの所要時間の下界

NN 個のレプリカを持つ Deployment を、maxUnavailable =u0= u \ge 0maxSurge =s0= s \ge 0u+s1u + s \ge 1)でローリングアップデートするとします。次を仮定します。

  • 更新中、常に利用可能な Pod 数 RRRNuR \ge N - u、Pod の総数 PPPN+sP \le N + s を満たす。
  • 新しい Pod は、作成されてから利用可能になるまでに少なくとも t>0t > 0 の時間を要する。
  • 更新完了時には NN 個の新しい Pod が利用可能になっている。

このとき、更新の所要時間 Θ\Theta

Θ  Ntu+s\Theta \ \ge\ \frac{N t}{u + s}

を満たします。

証明(命題 7.7)

時刻 xx における「作成済みだが利用可能でない Pod」の個数を g(x)=P(x)R(x)g(x) = P(x) - R(x) とおきます。仮定より P(x)N+sP(x) \le N+s かつ R(x)NuR(x) \ge N-u なので、

g(x)(N+s)(Nu)=u+sg(x) \le (N+s) - (N-u) = u + s

が常に成り立ちます。すなわち、準備中の Pod は同時に高々 u+su+s 個しか存在できません。

一方、更新完了までに NN 個の新しい Pod が作られ、そのそれぞれが「作成済みだが利用可能でない」状態に少なくとも tt だけ留まります(第 2 の仮定)。したがって、更新期間 [0,Θ][0, \Theta] にわたる gg の積分は、新しい Pod の分だけを数えても

0Θg(x)dx  Nt\int_{0}^{\Theta} g(x)\, dx \ \ge\ N t

です。他方、g(x)u+sg(x) \le u+s から

0Θg(x)dx  (u+s)Θ\int_{0}^{\Theta} g(x)\, dx \ \le\ (u+s)\,\Theta

です。2 つを合わせて (u+s)ΘNt(u+s)\Theta \ge Nt、すなわち ΘNt/(u+s)\Theta \ge Nt/(u+s) を得ます。

例 7.8安全側の設定が 20 分かかる理由

N=60N = 60、新しい Pod が利用可能になるまで t=20t = 20 秒かかるサービスを考えます。

  • 既定の u=s=25 %u = s = 25\ \%、すなわち u=s=15u = s = 15 のとき:Θ60×20/30=40\Theta \ge 60 \times 20 / 30 = 40 秒。
  • 「絶対に容量を落としたくない」として u=0u = 0s=1s = 1 にしたとき:Θ60×20/1=1200\Theta \ge 60 \times 20 / 1 = 1200 秒 = 20 分。

同じサービスで 30 倍の差です。u=0,s=1u = 0, s = 1 は「常に 60 個の利用可能な Pod を保ちつつ、1 個ずつ入れ替える」という、直感的には最も安全な設定ですが、更新に 20 分かかると、緊急のロールバックにも 20 分かかります。障害対応のときにこれは重い制約です。

容量を落としたくないなら u=0u = 0 のまま ss を増やす(例:s=10s = 10Θ120\Theta \ge 120 秒)ほうが筋がよく、その代償は更新中に 10 個分の計算資源が余分に要ることです。命題 7.7 は下界なので、実際の所要時間はこれ以上になります。設定を決めるときは余裕を見てください。

演習 8.1

次の Dockerfile について答えてください。npm ci には 90 秒、各 COPY には 1 秒かかるものとします。

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/main.js"]

(1) src/ のファイルを 1 つ編集して再ビルドしたとき、キャッシュがヒットする命令はどれですか。命題 4.2 を使って説明してください。

(2) src/ の編集だけで 1 日 20 回ビルドする開発者にとって、1 日あたりの npm ci の待ち時間を最小にするように Dockerfile を書き換えてください。

(3) 書き換えた Dockerfile でも npm ci が再実行されるのは、どういう変更をしたときですか。

解答

(1) COPY . . は 3 番目の命令で、σ(I3)\sigma(I_3) にはコピー対象ファイルの内容ハッシュが含まれます。src/ を編集すると σ(I3)\sigma(I_3) が変わるので、命題 4.2 の後半より m2m \le 2 となり、I3I_3 以降(COPY . .npm cinpm run build)はすべて再実行されます。ヒットするのは FROMWORKDIR に対応する I1,I2I_1, I_2 だけです。

(2) 依存の定義ファイルだけを先にコピーします。

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]

src/ を編集しても σ(I3)\sigma(I_3)COPY package.json package-lock.json ./)と σ(I4)\sigma(I_4)RUN npm ci)は変わらないので、命題 4.2 よりキャッシュは I4I_4 までヒットし、npm ci は再実行されません。1 日あたりの npm ci の待ち時間は 20×90=180020 \times 90 = 1800 秒から 0 秒になります。

(3) package.json または package-lock.json の内容が変わったときです。このとき σ(I3)\sigma(I_3) が変わるので I3I_3 以降が再実行され、npm ci も走ります。依存を追加・更新したときに再インストールが走るのは正しい挙動で、狙いどおりです。

演習 8.2標準

次の Dockerfile をビルドします。各命令が新たに書き込むデータ量は、ベースイメージ 120 MB、命令 2 が 400 MB、命令 3 が 120 MB、命令 4 で展開されるソースが 350 MB、命令 5 のビルド中間物とバイナリが合わせて 240 MB とします。whiteout 記録のサイズは無視できるものとします。

FROM debian:12
RUN apt-get update && apt-get install -y build-essential
RUN curl -sL https://example.com/src.tar.gz -o /tmp/src.tar.gz
RUN tar xf /tmp/src.tar.gz -C /opt && rm /tmp/src.tar.gz
RUN make -C /opt/src && make -C /opt/src install

(1) 最終イメージのサイズを求めてください。命令 4 の rm はサイズにどう効いていますか。

(2) 完成したバイナリは 40 MB で、実行に必要なのはそれだけだとします。debian:12-slim(80 MB)を実行用ベースとして、最終イメージを最小化する Dockerfile を書き、そのサイズを求めてください。

解答

(1) 命題 4.5 より、命令 4 の rm /tmp/src.tar.gz は、命令 3 のレイヤに書かれた 120 MB を削除しません。命令 3 のレイヤは不変で、内容ハッシュで名前が付いているため、後続の命令から書き換えられないからです。削除は命令 4 のレイヤに whiteout 記録として現れるだけで、そのサイズは無視できるとしました。したがって

120+400+120+350+240=1230 MB120 + 400 + 120 + 350 + 240 = 1230\ \text{MB}

です。rm はイメージサイズを 1 バイトも減らしておらず、減らしたのは「起動したコンテナから見えるファイル」だけです。

(2) マルチステージビルドにします。

FROM debian:12 AS builder
RUN apt-get update && apt-get install -y build-essential curl
RUN curl -sL https://example.com/src.tar.gz -o /tmp/src.tar.gz \
&& tar xf /tmp/src.tar.gz -C /opt \
&& rm /tmp/src.tar.gz \
&& make -C /opt/src \
&& make -C /opt/src install
FROM debian:12-slim
COPY --from=builder /usr/local/bin/myapp /usr/local/bin/myapp
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/myapp"]

最終イメージに含まれるのは第 2 ステージのベースとコピーしたバイナリだけなので

80+40=120 MB80 + 40 = 120\ \text{MB}

です。1230 MB から約 10 分の 1 になりました。第 1 ステージで取得・展開・ビルド・削除を 1 つの RUN にまとめているのは、そのステージのレイヤも小さくして中間キャッシュの容量を節約するためですが、最終イメージのサイズには影響しません(第 1 ステージのレイヤは最終イメージに含まれないからです)。

演習 8.3標準

あるサービスのレプリカ 1 個の可用性が 99 %(p=0.01p = 0.01)で、レプリカを載せるノード 1 台がまるごと落ちる確率は月あたり q=0.002q = 0.002 です。

(1) レプリカ 4 個をすべて 1 台のノードに置いたときの可用性の上限を、系 7.3 を使って求めてください。30 日を 43,200 分として、月あたりの停止時間に直してください。

(2) 4 個を 4 台の別々のノードに分散し、ノード故障もレプリカ故障もすべて独立とみなせるとします。1 個のレプリカが「そのノードが落ちる」か「レプリカ自身が落ちる」かで利用不能になる確率を求め、サービス全体の可用性を求めてください。

(3) (2) の結果から、目標を 99.999 % にするには何個必要ですか。

解答

(1) 系 7.3 より A1q=10.002=0.998A \le 1 - q = 1 - 0.002 = 0.998 です。実際の値は

A=1(0.002+0.998×0.014)=1(0.002+9.98×109)0.998A = 1 - \bigl(0.002 + 0.998 \times 0.01^{4}\bigr) = 1 - (0.002 + 9.98 \times 10^{-9}) \approx 0.998

で、レプリカ側の寄与 10810^{-8} 程度はノード故障の 2×1032\times10^{-3} に完全に埋もれています。停止時間は 43,200×0.002=86.443{,}200 \times 0.002 = 86.4 分です。レプリカを 4 個から 40 個にしても、この 86.4 分は変わりません。

(2) 1 個のレプリカが利用可能なのは「ノードが健全」かつ「レプリカ自身が健全」のときなので、その確率は (1q)(1p)=0.998×0.99=0.98802(1-q)(1-p) = 0.998 \times 0.99 = 0.98802 です。よって 1 個が利用不能である確率は

p=10.98802=0.01198p' = 1 - 0.98802 = 0.01198

です。4 台は独立なので 定理 7.1 がそのまま使えて

A(4)=1(0.01198)4=12.06×1080.99999998A(4) = 1 - (0.01198)^{4} = 1 - 2.06 \times 10^{-8} \approx 0.99999998

停止時間は月あたり 43,200×2.06×1080.000943{,}200 \times 2.06\times10^{-8} \approx 0.0009 分、つまり約 0.05 秒です。(1) の 86.4 分と比べてください。同じ 4 個でも、置き場所を変えるだけで 5 桁以上違います。

(3) 定理 7.1 の式に p=0.01198p' = 0.01198A=0.99999A^{*} = 0.99999 を代入します。

N  ln(105)ln(0.01198)=11.51294.4234=2.60N \ \ge\ \frac{\ln(10^{-5})}{\ln(0.01198)} = \frac{-11.5129}{-4.4234} = 2.60

よって N=3N = 3 個です。ただしこれは「3 台のノードが独立」という仮定の下での話で、3 台が同じラックや同じ電源に載っていればその相関が新たな上限を作ります。系 7.3qq を、考えている障害の粒度ごとに置き直して評価してください。

演習 8.4

N=200N = 200 レプリカのサービスがあります。新しい Pod が利用可能になるまで t=45t = 45 秒かかります。運用上の要件は次の 2 つです。

  • 更新中も、利用可能なレプリカを常に NN の 90 % 以上に保つ。
  • 更新(およびロールバック)は 10 分以内に完了させたい。

(1) 要件を満たす uumaxUnavailable)と ssmaxSurge)の条件を、不等式として書いてください。

(2) 更新中の追加計算資源を最小にする (u,s)(u, s) を求めてください。

(3) u=0u = 0 を要求された場合、ss をいくつにすればよいですか。そのとき更新中に余分に必要なノード数は、1 ノードあたり 10 Pod 動くとして何台分ですか。

解答

(1) 容量の要件は、更新中の利用可能数の下界 NuN - u0.9N0.9N 以上であることなので

200u  180u20200 - u \ \ge\ 180 \quad\Longleftrightarrow\quad u \le 20

です。時間の要件は 命題 7.7 の下界が 600 秒以下であることが必要条件なので

Ntu+s=200×45u+s=9000u+s  600u+s  15\frac{Nt}{u+s} = \frac{200 \times 45}{u+s} = \frac{9000}{u+s} \ \le\ 600 \quad\Longleftrightarrow\quad u + s \ \ge\ 15

です。まとめると u20u \le 20 かつ u+s15u + s \ge 15(および u,s0u, s \ge 0)となります。

(2) 更新中に余分に必要な資源は ss 個分の Pod です(総数の上限が N+sN+s なので)。ss を最小にしたいので s=0s = 0 とし、そのとき u15u \ge 15 が必要です。u20u \le 20 と合わせて 15u2015 \le u \le 20 なので、たとえば (u,s)=(20,0)(u, s) = (20, 0) が資源追加ゼロで両要件を満たします。このとき下界は 9000/20=4509000/20 = 450 秒です。

ただし 命題 7.7下界であり、実際の所要時間は Pod の終了処理やイメージの取得時間の分だけ長くなります。600 秒の要件に対して下界が 450 秒では余裕が 25 % しかないので、実務では u=20,s=10u = 20, s = 10(下界 300 秒)のように余裕を持たせるほうがよいでしょう。

(3) u=0u = 0 なら (1) の条件から s15s \ge 15 です。s=15s = 15 とすると下界は 9000/15=6009000/15 = 600 秒でちょうど要件の境界なので、余裕を見て s=30s = 30 とすれば下界は 300 秒になります。

s=30s = 30 のとき、更新中の Pod 総数は最大 200+30=230200 + 30 = 230 個です。余分な 30 個は、1 ノードあたり 10 Pod なら 3 台分のノードに相当します。つまり「容量を一切落とさずに 5 分で更新する」という要件の値段は、更新中の一時的な 3 台分のノードです。クラスタオートスケーラが動く環境なら、この 3 台は更新中だけ増えて後で消えるので、費用は更新時間に比例した分だけで済みます。

  • Kubernetes Documentation, “Concepts” および “Horizontal Pod Autoscaling”. https://kubernetes.io/docs/concepts/ — Pod、Deployment、Service、HPA の一次資料。HPA の更新式と既定の許容幅・安定化時間は https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/ に記載があります。
  • Docker Docs, “Building best practices”. https://docs.docker.com/build/building/best-practices/ — レイヤ、キャッシュ、マルチステージビルドの公式ガイド。
  • Open Container Initiative, Image Format Specification. https://github.com/opencontainers/image-spec — レイヤの積み重ねと whiteout 記録の仕様。§4 のイメージの定義はこれに基づいています。
  • B. Burns, B. Grant, D. Oppenheimer, E. Brewer, J. Wilkes, “Borg, Omega, and Kubernetes”, Communications of the ACM 59(5) (2016), 50–57. https://doi.org/10.1145/2890784 — 宣言的 API と調整ループという設計に至った経緯。
  • A. Verma, L. Pedrosa, M. Korupolu, D. Oppenheimer, E. Tune, J. Wilkes, “Large-scale cluster management at Google with Borg”, Proceedings of EuroSys 2015. https://doi.org/10.1145/2741948.2741964 — Kubernetes の原型となったクラスタ管理システム。
  • B. Burns, J. Beda, K. Hightower, L. Evenson, Kubernetes: Up and Running, 3rd ed., O’Reilly Media, 2022 — 実践的な入門書。Deployment とローリングアップデートの章が §7 の内容に対応します。

Appendix: 何をコンテナにしない方がよいか

Section titled “Appendix: 何をコンテナにしない方がよいか”

コンテナ化が向かない対象があります。 カーネルモジュールや特定のデバイスドライバに強く依存するもの(コンテナはカーネルを共有するため、ホストのカーネル側の準備が別途要ります)、GUI を伴うデスクトップアプリケーション、ライセンスがホストのハードウェア識別子に紐づくソフトウェアなどです。また、1 台のサーバで完結する小規模なシステムに Kubernetes を導入すると、得られる可用性より運用の複雑さのほうが大きくなりがちです。クラスタそのものの保守(バージョン更新、証明書の更新、ネットワークプラグインの管理)は決して軽い仕事ではありません。

導入するなら順序を守ってください。 実務でうまくいく順番は、(1) まず開発環境だけをコンテナ化して「私の環境では動く」を消す、(2) CI をコンテナ上で走らせてビルドの再現性を得る、(3) ステートレスなアプリケーションを本番でコンテナ化する、(4) 台数と更新頻度がオーケストレーションを必要とする水準に達してから Kubernetes を導入する、です。(4) を先にやると、解決すべき問題を持たないまま複雑さだけを抱えることになります。

状態を持つものは最後に検討してください。 データベースやメッセージキューは、バックアップ・レプリケーション・フェイルオーバーの正しさが個別の製品知識に依存します。マネージドサービスで済むなら、まずそれを使うのが妥当な判断だと思います。Kubernetes 上で運用する場合は、StatefulSet と永続ボリュームに加えて、その製品専用のオペレータ(調整ループを製品固有の知識で拡張したコントローラ)の採用を検討することになります。

この記事の誤りを報告する ・運営: 夢現技研合同会社料金プラン利用条件特定商取引法に基づく表記

© 2026 夢現技研合同会社 ・本文の LLM への入力は自由です。コード例は MIT ライセンスです。