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

> 「私の環境では動く」問題を出発点に、名前空間と cgroup によるコンテナの実体、イメージのレイヤ構造とキャッシュの前置性、Kubernetes の宣言的 API と調整ループを、可用性・オートスケール・無停止更新の定量的な評価まで含めて積み上げる。
> https://rikai.mugen-giken.com/computer-science/software-engineering/containers-and-kubernetes

## 0. この記事の要点

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

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

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

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

| 依存の層 | 具体例 | 従来どう共有していたか |
|---|---|---|
| 言語のパッケージ | `requirements.txt`、`package-lock.json` | Git で共有できている |
| OS の共有ライブラリ | `libssl`、`libjpeg`、`glibc` のバージョン | 手順書に「入れてください」と書く |
| OS の設定 | ロケール、タイムゾーン、ファイル記述子の上限 | 誰も書いていない |
| ファイル配置 | 設定ファイルの絶対パス、証明書の置き場所 | 人によって違う |
| 実行時の環境変数 | `DATABASE_URL`、`PATH` | `.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 つ組として捉える

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

$$
E = (K,\ R,\ V,\ Q)
$$

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

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

```bash
$ 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 行が、半日を溶かす原因です。

<Aside type="note">
仮想マシンは $K$ も含めて丸ごと複製します。コンテナが複製するのは $R$ だけで、$V$ と $Q$ を制限し、$K$ は**ホストと共有します**。この 1 行が、コンテナの軽さと、後で述べる隔離の限界の両方を説明します。
</Aside>

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

<Definition id="def-container" title="コンテナ">
Linux におけるコンテナとは、次の 3 つを与えられた 1 つ以上のプロセスの集まりです。

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

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

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

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

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

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

<Figure caption="仮想マシンとコンテナ：カーネルがいくつ動いているか">
<svg viewBox="0 0 720 300" width="100%" role="img" aria-label="仮想マシンとコンテナのソフトウェアスタックの比較図">
  <text x="180" y="20" text-anchor="middle" dominant-baseline="central" font-size="15" font-weight="bold" fill="currentColor">仮想マシン</text>
  <rect x="22" y="44" width="96" height="130" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="30" y="52" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="70" y="67" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="30" y="86" width="80" height="28" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="70" y="100" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="30" y="118" width="80" height="48" rx="3" fill="none" stroke="var(--sl-color-accent)" stroke-width="2"/>
  <text x="70" y="134" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ゲスト OS</text>
  <text x="70" y="150" text-anchor="middle" dominant-baseline="central" font-size="11" fill="var(--sl-color-accent)">カーネル</text>
  <rect x="132" y="44" width="96" height="130" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="140" y="52" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="180" y="67" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="140" y="86" width="80" height="28" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="180" y="100" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="140" y="118" width="80" height="48" rx="3" fill="none" stroke="var(--sl-color-accent)" stroke-width="2"/>
  <text x="180" y="134" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ゲスト OS</text>
  <text x="180" y="150" text-anchor="middle" dominant-baseline="central" font-size="11" fill="var(--sl-color-accent)">カーネル</text>
  <rect x="242" y="44" width="96" height="130" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="250" y="52" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="290" y="67" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="250" y="86" width="80" height="28" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="290" y="100" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="250" y="118" width="80" height="48" rx="3" fill="none" stroke="var(--sl-color-accent)" stroke-width="2"/>
  <text x="290" y="134" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ゲスト OS</text>
  <text x="290" y="150" text-anchor="middle" dominant-baseline="central" font-size="11" fill="var(--sl-color-accent)">カーネル</text>
  <rect x="10" y="182" width="340" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <text x="180" y="200" text-anchor="middle" dominant-baseline="central" font-size="13" fill="currentColor">ハイパーバイザ / ホスト OS</text>
  <rect x="10" y="224" width="340" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <text x="180" y="242" text-anchor="middle" dominant-baseline="central" font-size="13" fill="currentColor">物理ハードウェア</text>
  <text x="180" y="282" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">カーネルは仮想マシンの数だけ動く</text>
  <text x="540" y="20" text-anchor="middle" dominant-baseline="central" font-size="15" font-weight="bold" fill="currentColor">コンテナ</text>
  <rect x="382" y="44" width="96" height="88" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="390" y="52" width="80" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="430" y="68" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="390" y="90" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="430" y="105" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="492" y="44" width="96" height="88" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="500" y="52" width="80" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="540" y="68" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="500" y="90" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="540" y="105" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="602" y="44" width="96" height="88" rx="5" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <rect x="610" y="52" width="80" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="650" y="68" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">アプリ</text>
  <rect x="610" y="90" width="80" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="1"/>
  <text x="650" y="105" text-anchor="middle" dominant-baseline="central" font-size="11" fill="currentColor">ライブラリ</text>
  <rect x="370" y="140" width="340" height="32" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <text x="540" y="156" text-anchor="middle" dominant-baseline="central" font-size="13" fill="currentColor">コンテナランタイム</text>
  <rect x="370" y="178" width="340" height="40" rx="4" fill="none" stroke="var(--sl-color-accent)" stroke-width="2"/>
  <text x="540" y="198" text-anchor="middle" dominant-baseline="central" font-size="13" fill="var(--sl-color-accent)">ホスト OS のカーネル（1 つだけ）</text>
  <rect x="370" y="224" width="340" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>
  <text x="540" y="242" text-anchor="middle" dominant-baseline="central" font-size="13" fill="currentColor">物理ハードウェア</text>
  <text x="540" y="282" text-anchor="middle" dominant-baseline="central" font-size="12" fill="currentColor">カーネルはホストに 1 つだけ</text>
</svg>
</Figure>

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

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

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

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

- コンテナ内で 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 で作ったイメージがそのまま動くのは、この標準化のおかげです。
</Remark>

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

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

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

レイヤが不変で内容アドレス指定であること——Git がオブジェクトを内容のハッシュで名付けるのと同じ考え方です（<Ref to="computer-science/software-engineering/version-control-git#def-content-addressable" text="内容アドレス格納庫" />）——は、「同じレイヤは 1 度だけ転送・保存すればよい」という嬉しい性質を生みます。10 個のイメージが同じベースイメージを使っていれば、ベース部分の実体はホスト上に 1 つです。一方で、不変性は次の 2 つの制約も生みます。どちらも Dockerfile の書き方に直結するので、定理として書きます。

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

Dockerfile の命令列を $I_1, \ldots, I_n$ とします。ビルド時、各命令に対してキャッシュ鍵が
$$
k_0 = (\text{ベースイメージのダイジェスト}), \qquad k_i = H\bigl(k_{i-1},\ \sigma(I_i)\bigr)
$$
と計算されます。ここで $H$ は暗号学的ハッシュ関数、$\sigma(I_i)$ は命令の正規化表現で、`RUN` については**コマンド文字列そのもの**、`COPY` や `ADD` についてはコマンド文字列と**コピー対象ファイルの内容のハッシュ**を含みます。

<Proposition id="prop-cache-prefix" title="ビルドキャッシュの前置性">
上の鍵計算を用い、「$i$ 番目の命令がキャッシュヒットするのは、$i-1$ 番目までがすべてヒットし、かつ $k_i$ に対応するレイヤがローカルに存在するときに限る」という規則でビルドするとします。$H$ は衝突しないものとします。

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

<Proof of="prop-cache-prefix">
前半は規則そのものから従います。$i$ 番目がヒットするなら、規則の前半より $i-1$ 番目もヒットしています。これを繰り返せば $1, \ldots, i-1$ 番目がすべてヒットします。したがってヒットする添字の集合は「小さい添字について閉じている」ので、前置集合です。$m$ をヒットする最大の添字（1 つもヒットしなければ $m=0$）とすればよいことになります。

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

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

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

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

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

<Example id="ex-dockerfile-order" title="命令順序が 1 日 30 分を左右する">
上の 2 つの Dockerfile を比べます。`pip install` に 90 秒、`COPY` に 1 秒かかり、開発中は 1 日 20 回ビルドし、そのうち 19 回はソースコードだけの変更、1 回だけ `requirements.txt` も変更するとします。

前者では、`COPY . .` が 3 番目の命令です。ソースコードを 1 文字直せば $\sigma(I_3)$ が変わるので、<Ref to="prop-cache-prefix" /> より $I_3$ 以降がすべて再実行されます。つまり `pip install` は毎回走ります。1 日あたり
$$
20 \times (1 + 90) = 1820\ \text{秒} \approx 30\ \text{分}
$$
です。

後者では、ソースコードの変更が影響するのは `COPY . .`（5 番目）以降だけです。$\sigma(I_3)$（`COPY requirements.txt .`）も $\sigma(I_4)$（`RUN pip install ...`）も変わらないので、$I_4$ までヒットします。1 日あたり
$$
19 \times 1 + 1 \times (1 + 90 + 1) = 111\ \text{秒} \approx 2\ \text{分}
$$
です。差は 1 日 28 分、20 営業日で約 9 時間半になります。Dockerfile の 2 行を入れ替えるだけで得られる差としては大きいでしょう。
</Example>

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

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

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

<Proposition id="prop-layer-size" title="レイヤ削除の非可換性">
イメージの総サイズを、各レイヤに格納された実データの合計 $S = \sum_{i=1}^{n} |L_i|$ で測ります。レイヤ $L_i$ でサイズ $s > 0$ のファイル $f$ が追加され、$j > i$ なるレイヤ $L_j$ の命令で $f$ が削除されたとします。

このとき、削除した場合のイメージサイズ $S_{\text{del}}$ と、削除命令を書かなかった場合のサイズ $S_{\text{keep}}$ の間には
$$
S_{\text{del}} = S_{\text{keep}} + w \ \ge\ S_{\text{keep}}
$$
が成り立ちます（$w \ge 0$ は whiteout 記録のサイズ）。とくに、$f$ をそもそも永続レイヤに作らなかった場合のサイズ $S_{\text{never}} = S_{\text{keep}} - s$ と比べると $S_{\text{del}} - S_{\text{never}} \ge s$ であり、削除命令はイメージを $s$ バイト分も小さくしません。
</Proposition>

<Proof of="prop-layer-size">
<Ref to="def-image" /> より、レイヤは不変です。$L_j$ をビルドする時点で $L_i$ はすでに確定しており、内容ハッシュで名前が付いています。$L_i$ の中身を書き換えれば名前が変わり、それは別のレイヤになってしまうので、後続の命令が $L_i$ の内容を変更することは定義上できません。したがって $|L_i|$ は $f$ の $s$ バイトを含んだまま変わりません。

次に $L_j$ を見ます。union filesystem において削除は「上位レイヤに whiteout 記録を置く」ことで表現されます（<Ref to="def-image" />）。すなわち $L_j$ には $f$ の削除を表す記録が加わり、そのサイズを $w \ge 0$ とすれば $|L_j|$ は $w$ だけ増えます。他のレイヤは変化しません。

以上より $S_{\text{del}} = S_{\text{keep}} + w$ です。$w \ge 0$ から $S_{\text{del}} \ge S_{\text{keep}}$ が従います。最後の主張は $S_{\text{never}} = S_{\text{keep}} - s$ を代入して $S_{\text{del}} - S_{\text{never}} = s + w \ge s$ より得られます。
</Proof>

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

- **同じ `RUN` の中で作って消す**。1 つの `RUN` は 1 つのレイヤになるので、レイヤ確定前に消えたファイルは記録されません。
- **マルチステージビルド**を使う。ビルド用ステージで生成した中間物を捨て、成果物だけを新しいステージにコピーします。

```dockerfile
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"]
```

<Example id="ex-multistage" title="1.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` と書いたとすると、<Ref to="prop-layer-size" /> よりこの `rm` はサイズを減らしません。総サイズは
$$
800 + 350 + 30 + 30 + w \approx 1210\ \text{MB}
$$
です。

マルチステージにすると、最終イメージは第 2 ステージのベースと、`COPY --from=builder` で持ち込んだバイナリだけで構成されます。
$$
2 + 30 = 32\ \text{MB}
$$
約 38 分の 1 です。ノード 50 台に配布するなら転送量は 60 GB から 1.6 GB になり、オートスケールでノードを増やしたときの起動待ちも短くなります。加えて、最終イメージにコンパイラもシェルもパッケージマネージャも含まれないため、侵入されたときにできることが激減するという安全上の利点もあります。
</Example>

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

ここまでで、Dockerfile の作法の大半は <Ref to="prop-cache-prefix" /> と <Ref to="prop-layer-size" /> から導けることを見ました。残りは「コードにする」ことの意味に関わる話です。

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

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

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

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

```dockerfile
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 の設計原理

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

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

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

### 6.1. 命令的から宣言的へ

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

<Definition id="def-declarative" title="宣言的 API と調整ループ">
利用者は「どういう手順で操作するか」ではなく「**どういう状態であってほしいか**」（望ましい状態、desired state）を API に登録します。システムは**調整ループ (reconciliation loop)** を回し続けます。各回では、

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

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

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

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

<Proposition id="prop-reconcile-convergence" title="調整ループの有限収束">
対象オブジェクトの有限集合を $\mathcal{O}$（$|\mathcal{O}| = M$）とし、望ましい状態 $d$ と時刻 $t$ の観測状態 $s_t$ に対して、食い違っているオブジェクトの個数
$$
D(s_t) = \#\{o \in \mathcal{O} : s_t(o) \ne d(o)\}
$$
を考えます。次を仮定します。

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

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

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

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

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

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

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

<Remark id="rem-a2-fails">
実運用で崩れやすいのは (A2) と (A3) です。

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

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

### 6.2. 主要なオブジェクト

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

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

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

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

```yaml
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
```

```yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080
```

<Example id="ex-deployment-manifest" title="ノードが 1 台落ちたとき何が起きるか">
上のマニフェストを適用した直後の様子を追います。`replicas: 3` なので ReplicaSet コントローラは Pod を 3 つ作り、`topologySpreadConstraints` により 3 つは別々のノード（`kubernetes.io/hostname` が異なるノード）に配置されます。`maxSkew: 1` はノード間の Pod 数の差を 1 以内に保つ指定です。

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

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

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

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

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

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

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

「冗長化すると強くなる」「自動でスケールする」という説明はよく聞きますが、どれだけ強くなるのかは計算できます。可用性という量そのものの定義と、直列・並列に組んだときの合成則は <Ref to="computer-science/software-engineering/cloud-computing#prop-availability" text="直列構成と並列構成の可用性" /> にまとめてあります。以下ではそれを Kubernetes のレプリカ配置の話として使います。

### 7.1. 冗長化と可用性

<Theorem id="thm-availability" title="独立故障の下での冗長化">
あるサービスが $N$ 個のレプリカで提供されており、各レプリカがある時点で利用不能である確率が $p$（$0 < p < 1$）、各レプリカの故障は互いに独立であるとします。少なくとも 1 個のレプリカが利用可能ならサービスは利用可能であるとすると、サービスの可用性は
$$
A(N) = 1 - p^{N}
$$
です。したがって目標可用性 $A^{*}$（$0 < A^{*} < 1$）を満たすのに必要なレプリカ数は
$$
N \ \ge\ \frac{\ln(1 - A^{*})}{\ln p}
$$
を満たす最小の整数です。
</Theorem>

<Proof of="thm-availability">
サービスが利用不能であるのは、$N$ 個のレプリカが**すべて**利用不能なときに限ります。各レプリカが利用不能である事象を $F_i$ とすると、独立性の仮定から
$$
\Pr\Bigl[\bigcap_{i=1}^{N} F_i\Bigr] = \prod_{i=1}^{N} \Pr[F_i] = p^{N}
$$
です。よって可用性は $A(N) = 1 - p^{N}$ です。

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

<Example id="ex-availability-numbers" title="レプリカを 1 個増やすと停止時間が何分減るか">
1 個のレプリカの可用性が 99 %、すなわち $p = 0.01$ とします。30 日を 43,200 分として、停止時間を計算します。

- $N = 1$：$A = 0.99$、月あたりの停止は $43{,}200 \times 0.01 = 432$ 分（約 7.2 時間）。
- $N = 2$：$A = 1 - 10^{-4} = 0.9999$、停止は $43{,}200 \times 10^{-4} = 4.32$ 分。
- $N = 3$：$A = 1 - 10^{-6} = 0.999999$、停止は $43{,}200 \times 10^{-6} \approx 2.6$ 秒。

1 個から 2 個にするだけで、月 7 時間の停止が 4 分になります。逆向きに、目標 $A^{*} = 0.99999$（ファイブナイン）から必要数を <Ref to="thm-availability" /> で求めると
$$
N \ \ge\ \frac{\ln(10^{-5})}{\ln(10^{-2})} = \frac{-11.5129}{-4.6052} = 2.5
$$
なので $N = 3$ です。
</Example>

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

<Corollary id="cor-correlated-failure" title="相関故障による可用性の上限">
<Ref to="thm-availability" /> の設定に加え、$N$ 個のレプリカがすべて同じノードに配置されているとします。ノード全体が利用不能になる確率を $q$（$0 < q < 1$）とし、ノードが健全な条件の下では各レプリカが独立に確率 $p$ で利用不能になるとします。このとき
$$
A(N) = 1 - \bigl(q + (1-q)\,p^{N}\bigr) \ \le\ 1 - q
$$
であり、レプリカ数を無限に増やしても可用性は $1 - q$ を超えません。
</Corollary>

<Proof of="cor-correlated-failure">
サービスが利用不能になる事象を、ノード故障の有無で場合分けします。ノードが故障している場合（確率 $q$）、その上のレプリカはすべて利用不能なのでサービスも利用不能です。ノードが健全な場合（確率 $1-q$）、条件付きで各レプリカは独立に確率 $p$ で利用不能なので、<Ref to="thm-availability" /> の証明と同じ計算により、全滅する条件付き確率は $p^{N}$ です。全確率の公式より
$$
\Pr[\text{利用不能}] = q \cdot 1 + (1-q)\, p^{N} = q + (1-q)p^{N}
$$
です。$(1-q)p^{N} \ge 0$ なので $\Pr[\text{利用不能}] \ge q$、すなわち $A(N) \le 1-q$ です。$N \to \infty$ のとき $p^{N} \to 0$（$0<p<1$）なので $A(N) \to 1-q$ となり、この上限は改善できません。
</Proof>

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

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

### 7.2. 水平オートスケール

<Proposition id="prop-hpa-fixed-point" title="HorizontalPodAutoscaler の一段収束">
現在のレプリカ数を $n \ge 1$、各 Pod の指標の平均値を $m > 0$、目標値を $T > 0$ とし、Kubernetes の HorizontalPodAutoscaler と同じ規則
$$
n' = \Bigl\lceil\, n \cdot \frac{m}{T} \,\Bigr\rceil, \qquad \text{ただし } \Bigl|\frac{m}{T} - 1\Bigr| \le \tau \text{ のときは } n' = n
$$
でレプリカ数を更新するとします（$\tau$ は許容幅で、既定は $0.1$）。

いま、系全体の負荷 $\lambda > 0$ が一定で、それが全 Pod に均等に分配され、1 Pod あたりの指標が $m = \lambda / n$ と書けるとします。このとき、$n$ の値によらず 1 回の更新で
$$
n' = \Bigl\lceil \frac{\lambda}{T} \Bigr\rceil =: n^{*}
$$
となります。さらに $n^{*}$ は不動点であり、$\lambda / (n^{*} T) \ge 1 - \tau$ が成り立つ限り、そこから減る方向の変更も起きません。
</Proposition>

<Proof of="prop-hpa-fixed-point">
$m = \lambda/n$ を更新式に代入します。
$$
n \cdot \frac{m}{T} = n \cdot \frac{\lambda/n}{T} = \frac{\lambda}{T}
$$
となり、$n$ が約分されて消えます。したがって $n' = \lceil \lambda/T \rceil = n^{*}$ で、これは更新前のレプリカ数 $n$ に依存しません。

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

<Example id="ex-hpa-calc" title="オートスケールの実際の計算">
目標を「1 Pod あたりの CPU 使用率が `requests` の 60 %」とし、現在 6 個の Pod が平均 85 % で動いているとします。<Ref to="prop-hpa-fixed-point" /> の更新式より
$$
n' = \Bigl\lceil 6 \times \frac{85}{60} \Bigr\rceil = \lceil 8.5 \rceil = 9
$$
です。9 個に増えた後の平均使用率は、負荷総量が変わらなければ
$$
\frac{6 \times 85}{9} \approx 56.7\ \%
$$
となります。比は $56.7/60 = 0.944$ で、$|0.944 - 1| = 0.056 \le 0.1$ なので許容幅に収まり、これ以上の変更は起きません。1 回の調整で落ち着き、増減を繰り返す振動は起きないことが確認できました。

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

<Remark id="rem-hpa-assumption">
<Ref to="prop-hpa-fixed-point" /> の要は $m = \lambda/n$、すなわち「Pod を増やせば 1 台あたりの負荷が反比例して減る」という仮定です（負荷を $n$ 台に均等分散したときに応答時間がどうなるかは <Ref to="computer-science/software-engineering/cloud-computing#cor-shard" text="負荷を n 台に均等分散したときの応答時間" /> を参照してください）。この仮定が崩れる代表例が、下流のデータベースが飽和している場合です。Pod を増やしても待ち時間が短くならないどころか、接続数が増えてデータベースがさらに遅くなり、CPU 使用率が下がらないので HPA はさらに Pod を増やす——という悪循環に入ります。オートスケールは律速段階を解消しません。ボトルネックがどこにあるかを確かめてから設定してください。
</Remark>

### 7.3. 無停止更新にかかる時間

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

<Proposition id="prop-rolling-lower-bound" title="ローリングアップデートの所要時間の下界">
$N$ 個のレプリカを持つ Deployment を、`maxUnavailable` $= u \ge 0$、`maxSurge` $= s \ge 0$（$u + s \ge 1$）でローリングアップデートするとします。次を仮定します。

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

このとき、更新の所要時間 $\Theta$ は
$$
\Theta \ \ge\ \frac{N t}{u + s}
$$
を満たします。
</Proposition>

<Proof of="prop-rolling-lower-bound">
時刻 $x$ における「作成済みだが利用可能でない Pod」の個数を $g(x) = P(x) - R(x)$ とおきます。仮定より $P(x) \le N+s$ かつ $R(x) \ge N-u$ なので、
$$
g(x) \le (N+s) - (N-u) = u + s
$$
が常に成り立ちます。すなわち、準備中の Pod は同時に高々 $u+s$ 個しか存在できません。

一方、更新完了までに $N$ 個の新しい Pod が作られ、そのそれぞれが「作成済みだが利用可能でない」状態に少なくとも $t$ だけ留まります（第 2 の仮定）。したがって、更新期間 $[0, \Theta]$ にわたる $g$ の積分は、新しい Pod の分だけを数えても
$$
\int_{0}^{\Theta} g(x)\, dx \ \ge\ N t
$$
です。他方、$g(x) \le u+s$ から
$$
\int_{0}^{\Theta} g(x)\, dx \ \le\ (u+s)\,\Theta
$$
です。2 つを合わせて $(u+s)\Theta \ge Nt$、すなわち $\Theta \ge Nt/(u+s)$ を得ます。
</Proof>

<Example id="ex-rolling-time" title="安全側の設定が 20 分かかる理由">
$N = 60$、新しい Pod が利用可能になるまで $t = 20$ 秒かかるサービスを考えます。

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

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

容量を落としたくないなら $u = 0$ のまま $s$ を増やす（例：$s = 10$ で $\Theta \ge 120$ 秒）ほうが筋がよく、その代償は更新中に 10 個分の計算資源が余分に要ることです。<Ref to="prop-rolling-lower-bound" /> は下界なので、実際の所要時間はこれ以上になります。設定を決めるときは余裕を見てください。
</Example>

## 8. 演習

<Exercise id="exr-cache-order" difficulty="易">
次の Dockerfile について答えてください。`npm ci` には 90 秒、各 `COPY` には 1 秒かかるものとします。

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

(1) `src/` のファイルを 1 つ編集して再ビルドしたとき、キャッシュがヒットする命令はどれですか。<Ref to="prop-cache-prefix" /> を使って説明してください。

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

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

<Solution>
(1) `COPY . .` は 3 番目の命令で、$\sigma(I_3)$ にはコピー対象ファイルの内容ハッシュが含まれます。`src/` を編集すると $\sigma(I_3)$ が変わるので、<Ref to="prop-cache-prefix" /> の後半より $m \le 2$ となり、$I_3$ 以降（`COPY . .`、`npm ci`、`npm run build`）はすべて再実行されます。ヒットするのは `FROM` と `WORKDIR` に対応する $I_1, I_2$ だけです。

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

```dockerfile
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/` を編集しても $\sigma(I_3)$（`COPY package.json package-lock.json ./`）と $\sigma(I_4)$（`RUN npm ci`）は変わらないので、<Ref to="prop-cache-prefix" /> よりキャッシュは $I_4$ までヒットし、`npm ci` は再実行されません。1 日あたりの `npm ci` の待ち時間は $20 \times 90 = 1800$ 秒から 0 秒になります。

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

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

```dockerfile
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 を書き、そのサイズを求めてください。

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

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

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

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

```dockerfile
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\ \text{MB}
$$

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

<Exercise id="exr-replicas" difficulty="標準">
あるサービスのレプリカ 1 個の可用性が 99 %（$p = 0.01$）で、レプリカを載せるノード 1 台がまるごと落ちる確率は月あたり $q = 0.002$ です。

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

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

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

<Solution>
(1) <Ref to="cor-correlated-failure" /> より $A \le 1 - q = 1 - 0.002 = 0.998$ です。実際の値は
$$
A = 1 - \bigl(0.002 + 0.998 \times 0.01^{4}\bigr) = 1 - (0.002 + 9.98 \times 10^{-9}) \approx 0.998
$$
で、レプリカ側の寄与 $10^{-8}$ 程度はノード故障の $2\times10^{-3}$ に完全に埋もれています。停止時間は $43{,}200 \times 0.002 = 86.4$ 分です。レプリカを 4 個から 40 個にしても、この 86.4 分は変わりません。

(2) 1 個のレプリカが利用可能なのは「ノードが健全」かつ「レプリカ自身が健全」のときなので、その確率は $(1-q)(1-p) = 0.998 \times 0.99 = 0.98802$ です。よって 1 個が利用不能である確率は
$$
p' = 1 - 0.98802 = 0.01198
$$
です。4 台は独立なので <Ref to="thm-availability" /> がそのまま使えて
$$
A(4) = 1 - (0.01198)^{4} = 1 - 2.06 \times 10^{-8} \approx 0.99999998
$$
停止時間は月あたり $43{,}200 \times 2.06\times10^{-8} \approx 0.0009$ 分、つまり約 0.05 秒です。(1) の 86.4 分と比べてください。**同じ 4 個でも、置き場所を変えるだけで 5 桁以上違います。**

(3) <Ref to="thm-availability" /> の式に $p' = 0.01198$、$A^{*} = 0.99999$ を代入します。
$$
N \ \ge\ \frac{\ln(10^{-5})}{\ln(0.01198)} = \frac{-11.5129}{-4.4234} = 2.60
$$
よって $N = 3$ 個です。ただしこれは「3 台のノードが独立」という仮定の下での話で、3 台が同じラックや同じ電源に載っていればその相関が新たな上限を作ります。<Ref to="cor-correlated-failure" /> の $q$ を、考えている障害の粒度ごとに置き直して評価してください。
</Solution>
</Exercise>

<Exercise id="exr-rolling" difficulty="難">
$N = 200$ レプリカのサービスがあります。新しい Pod が利用可能になるまで $t = 45$ 秒かかります。運用上の要件は次の 2 つです。

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

(1) 要件を満たす $u$（`maxUnavailable`）と $s$（`maxSurge`）の条件を、不等式として書いてください。

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

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

<Solution>
(1) 容量の要件は、更新中の利用可能数の下界 $N - u$ が $0.9N$ 以上であることなので
$$
200 - u \ \ge\ 180 \quad\Longleftrightarrow\quad u \le 20
$$
です。時間の要件は <Ref to="prop-rolling-lower-bound" /> の下界が 600 秒以下であることが**必要条件**なので
$$
\frac{Nt}{u+s} = \frac{200 \times 45}{u+s} = \frac{9000}{u+s} \ \le\ 600 \quad\Longleftrightarrow\quad u + s \ \ge\ 15
$$
です。まとめると $u \le 20$ かつ $u + s \ge 15$（および $u, s \ge 0$）となります。

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

ただし <Ref to="prop-rolling-lower-bound" /> は**下界**であり、実際の所要時間は Pod の終了処理やイメージの取得時間の分だけ長くなります。600 秒の要件に対して下界が 450 秒では余裕が 25 % しかないので、実務では $u = 20, s = 10$（下界 300 秒）のように余裕を持たせるほうがよいでしょう。

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

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

## 参考文献

- Kubernetes Documentation, "Concepts" および "Horizontal Pod Autoscaling". [https://kubernetes.io/docs/concepts/](https://kubernetes.io/docs/concepts/) — Pod、Deployment、Service、HPA の一次資料。HPA の更新式と既定の許容幅・安定化時間は [https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) に記載があります。
- Docker Docs, "Building best practices". [https://docs.docker.com/build/building/best-practices/](https://docs.docker.com/build/building/best-practices/) — レイヤ、キャッシュ、マルチステージビルドの公式ガイド。
- Open Container Initiative, *Image Format Specification*. [https://github.com/opencontainers/image-spec](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](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](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: 何をコンテナにしない方がよいか

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

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

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