K8s 基礎 #4 Deployment と ReplicaSet — 宣言的デプロイとローリングアップデート

読了 14分

#3 kubectl と最初の Pod の最後で確認した通り、Pod は直接立てると消えるだけです。この記事ではその空白を自動で埋める最初のコントローラ Deployment と、その下の ReplicaSet を扱います。replicas: 3 を宣言して Pod を維持する方法、Pod 1 つを消したときに自動復旧する仕組み、イメージタグを変えたときにローリングアップデートとロールバックがどう動くかまでを 1 サイクルで整理します。

このシリーズは K8s 基礎 7 編です。

この記事の終わりには Pod を人が一つ一つ立てずにコントローラに任せる最初のマニフェスト が完成します。ここから先が、事実上、運用で人が書くマニフェストの基本形です。

Deployment、ReplicaSet、Pod — 3 層の関係 #

この記事の主役は 3 つのリソースですが、人が直接書くのは一番上の 1 層だけです。頭の中の絵はこう押さえておくと楽です。

3 層の構造
   ┌──────────────────────┐
   │     Deployment       │  ← 人が書くマニフェスト
   │  (web)               │
   └──────────┬───────────┘
              │ 作る/管理する
   ┌──────────────────────┐
   │     ReplicaSet       │  ← Deployment が自動で作る
   │  (web-abc123)        │
   └──────────┬───────────┘
              │ 作る/維持する
   ┌──────────┬──────────┬──────────┐
   │   Pod    │   Pod    │   Pod    │  ← 実際のワークロード
   │ web-...  │ web-...  │ web-...  │
   └──────────┴──────────┴──────────┘

各層の責任を 1 行ずつ:

  • Deployment — 人が書くマニフェスト。「この Pod テンプレートで N 個立てておき、新バージョンへどう切り替えるか (ローリングアップデート)」までを書く。事実上、運用で一番よく触るリソース。
  • ReplicaSet — Deployment が自動で作り出す中間オブジェクト。責任は 1 つ — 「この Pod テンプレートで N 個を常に維持する」。人が直接 ReplicaSet マニフェストを書くことはほぼ無い。
  • Pod — 実際のワークロード。ReplicaSet が作り出し、死ねば ReplicaSet がまた作る。#3 で手で立てたあの Pod ですが、これからは誰かが消しても自動でまた立ちます。

なぜ 2 層なのか #

最初に見ると Deployment 1 層で十分そうに見えます。ReplicaSet はなぜ別に存在するのか。理由は新バージョンのデプロイにあります。

新バージョンをデプロイするとき Deployment は 新しい ReplicaSet をもう 1 つ 作ります。その新しい ReplicaSet の replicas を 1, 2, 3 と段階的に上げながら、古い ReplicaSet の replicas を 3, 2, 1 と段階的に下げていきます。その間、両方の ReplicaSet の Pod が一緒に立っている短い区間ができます — これがローリングアップデートの本体です。終わると古い ReplicaSet は 0 個に空になりつつもオブジェクトとして残り、ロールバックが必要なときにそちらを再び N 個に増やす形で戻します。

要するに Deployment は「バージョン間の遷移を扱う層」ReplicaSet は「1 バージョンを N 個で維持する層」 です。両者が分かれているおかげで、同じクラスタの中に旧バージョンと新バージョンが一瞬共存できます。

最初の Deployment マニフェスト #

同じ nginx:1.27 を、今度は Pod ではなく Deployment として書きます。ファイル名は web.yaml にして、次のように書きます。

web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27
          ports:
            - containerPort: 80

#3 で見た Pod マニフェストと比べて新しく入ってくる部分が 3 つあります。

  • apiVersion: apps/v1 — Pod は v1 でしたが、Deployment は apps/v1 API グループに入っています。コントローラ系リソース (Deployment、StatefulSet、DaemonSet、ReplicaSet) は皆同じグループです。
  • spec.replicas: 3 — この Pod テンプレートが常に 3 個立っているべき、という宣言。
  • spec.selector.matchLabels + spec.template — Deployment が自分の管理する Pod を見つけるラベル条件と、その Pod がどんな形であるべきかのテンプレート。template 以下の形は #3 で見た Pod の metadata + spec と完全に同じです。

1 つのルール — selector と template のラベルが一致しなければならない #

最初にマニフェストを書くとき一番よく出る失敗がここです。spec.selector.matchLabelsspec.template.metadata.labels は互いに一致しなければなりません。 一致しないと K8s がマニフェストを拒否します。単なる推奨規約ではなく apiserver が持っている検証ルールです。

上のマニフェストで両方を app: web に揃えたのはそのためです。もし selector を app: web のままにして template のラベルだけ app: nginx に変えたら、kubectl apply が次のようなエラーを吐きます。

ラベルがズレたとき
The Deployment "web" is invalid: spec.template.metadata.labels:
  Invalid value: map[string]string{"app":"nginx"}:
  `selector` does not match template `labels`

頭の中の単純なモデルは — selector は「自分が管理する Pod をどう見分けるか」、template は「自分が作る Pod のラベル」 です。なので両者が一致して初めて、自分が作ったものを自分でまた認識する、という、ほぼトートロジーに近いルールです。

適用してみる #

web.yaml をクラスタに反映します。

Deployment を作る
kubectl apply -f web.yaml
出力例
deployment.apps/web created

3 種類のリソースを一度に見ます。kubectl get はカンマで複数のリソース種類を 1 度に受け取れます。

3 層を一度に
kubectl get deploy,rs,pods
出力例
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/web   3/3     3            3           20s

NAME                            DESIRED   CURRENT   READY   AGE
replicaset.apps/web-abc123      3         3         3       20s

NAME                  READY   STATUS    RESTARTS   AGE
pod/web-abc123-aa11   1/1     Running   0          20s
pod/web-abc123-bb22   1/1     Running   0          20s
pod/web-abc123-cc33   1/1     Running   0          20s

読み方:

  • Deployment 行READY 3/3 は desired 3 個がすべて ready、UP-TO-DATE 3 は現在のテンプレートで更新された Pod 数、AVAILABLE 3 は minReadySeconds まで生きていてトラフィックを受けられる Pod 数。
  • ReplicaSet 行 — 名前が web-abc123 の形です。後ろの abc123 は K8s がテンプレートのハッシュから自動生成した値です。DESIRED 3 / CURRENT 3 / READY 3 が肝心の列です。
  • Pod 行 — 名前が web-abc123-aa11 のような 2 段の任意値を付けています。前の部分 (web-abc123) が ReplicaSet 名と一致しているのが見えます。誰が自分を作ったかが名前にそのまま現れています。

名前のパターンを 1 行で押さえると — <deployment>-<replicaset-hash>-<pod-suffix>。この形はシリーズ最後までよく出会います。

Pod を殺してみる — self-healing #

このマニフェストの最初の効果を確認する番です。#3 では Pod を消すとただ消えました。今回はどう違うのかを確認します。

Pod 1 つを強制削除
kubectl delete pod web-abc123-aa11
出力例
pod "web-abc123-aa11" deleted

すぐに Pod 一覧をまた取ってみます。

再確認
kubectl get pods
出力例
NAME                  READY   STATUS    RESTARTS   AGE
web-abc123-bb22       1/1     Running   0          2m
web-abc123-cc33       1/1     Running   0          2m
web-abc123-dd44       1/1     Running   0          5s

3 個がそのまま立っています。ただし 1 行をよく見ると違いが分かります — bb22cc33AGE 2m ですが、新しく見える dd44AGE 5sたった今新しく立ち上げられた Pod です。名前の後ろの任意値が変わったことも新 Pod の手がかりです。

これが #1 で図で見た reconcile loop が働いている姿です。ReplicaSet は「Pod 3 個があるべき」を持っており、人が 1 つ消した瞬間に desired (3) と actual (2) がズレました。controller-manager 内の ReplicaSet コントローラがその差を検知し、Pod をもう 1 つ作るよう apiserver に依頼します。scheduler がノードを決め、kubelet がコンテナを立て、また 3 個に合わせる。人は別に何もしていません。

同じことがノード単位でも起きます。ある Pod が立っていたノードが死ぬと、K8s はその Pod たちを別の生きているノードに移してまた立ちます。#1 で見た「ノードが死んでもサービスは生きていなければならない」が、実はこの ReplicaSet コントローラが解いている問題です。

replicas の調整 #

3 個では足りなかったり多すぎたりするとき、個数を調整する道は 2 通りあります。

宣言的 — マニフェストの数字を変えてもう一度 apply。一番きれいな道です。

web.yaml — replicas だけを 5 に
spec:
  replicas: 5
  ...
再適用
kubectl apply -f web.yaml
出力例
deployment.apps/web configured

kubectl get pods で見るとすぐに 5 個に増えています。減らすときも同じ方法です — マニフェストの数字を減らして apply

命令的 — 速いが一時的です。

命令的にスケール
kubectl scale deploy/web --replicas=5
出力例
deployment.apps/web scaled

瞬間的に増減させるには軽いです。ただし欠点が明確です — マニフェストの replicas 値とクラスタの実際の状態がズレます。 マニフェストには依然として replicas: 3 と書かれているのに、クラスタには 5 個立っている状態になります。次に誰かが何気なく kubectl apply -f web.yaml をもう 1 度呼ぶと、5 個がまた 3 個に減ってしまいます。

なので 1 行で整理しておくと — 宣言的マニフェストが常に真実の出処 (source of truth) です。kubectl scale はデバッグ中に素早く触らねばならないときや、マニフェストをすぐ同期するつもりのときだけ使い、正常な流れはマニフェストを直して apply です。この原則が #1 で見た desired state モデル全体の土台です。

ローリングアップデート — 無停止デプロイの既定の動作 #

ここでこのシリーズで初めて 新バージョンをデプロイ します。イメージタグを nginx:1.27 から nginx:1.28 に 1 文字だけ変えます。

web.yaml — イメージタグだけ 1.28 に
      containers:
        - name: web
          image: nginx:1.28
          ports:
            - containerPort: 80
新バージョンの適用
kubectl apply -f web.yaml
出力例
deployment.apps/web configured

表面に出るメッセージは 1 行ですが、内部ではかなり大きなことが起きています。

内部で起きていること #

Deployment コントローラはテンプレートが変わったのを見て、その新しいテンプレートのための 新しい ReplicaSet を 1 つ作ります。それから新 RS の replicas を 0 から 1, 2, 3 と上げ、古い RS の replicas を 3 から 2, 1, 0 へ下げていきます。各段階で新 Pod が Ready 状態に入ってから次の段階へ進みます。

進行中に kubectl get rs で見ると ReplicaSet が 2 行見えます。

ロールアウト中
kubectl get rs
出力例 — 真ん中
NAME             DESIRED   CURRENT   READY   AGE
web-abc123       2         2         2       10m   ← 古い RS (1.27)
web-def456       2         2         1       30s   ← 新 RS (1.28)

古い RS は 3 から 2 に減り、新 RS は 0 から 2 に上がっています。この 1 コマがローリングアップデートの本体です。両 RS の Pod が一瞬一緒に立っています — その間のトラフィックは #5 で扱う Service が均等に分配します。

進行のモニタリング #

ロールアウトの進捗を 1 行で見たいなら、次のコマンドが一番便利です。

ロールアウトの進捗
kubectl rollout status deploy/web
出力例
Waiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 1 old replicas are pending termination...
deployment "web" successfully rolled out

画面に進行段階が 1 行ずつ落ちて、最後に成功が出れば新バージョンのデプロイが終わったということです。終わった後 kubectl get rs をもう一度見ると、古い RS が DESIRED 0 で空ですがオブジェクトとしては残っています。この構造が次節のロールバックを可能にします。

既定 strategy を 1 行 #

上の流れが起きる正確な理由は、Deployment の spec.strategy の既定値が RollingUpdate で、その中の 2 つのパラメータが次のようになっているからです。

  • maxSurge: 25% — desired より一時的に多く立っていてもいい上限。3 個基準で +1 個まで追加許容。
  • maxUnavailable: 25% — desired より一時的に足りなくてもいい上限。3 個基準で -1 個まで不足許容。

別のオプションは Recreate です — 古い Pod を全部殺して新しい Pod を立てる方式。無停止にはなりませんが、両バージョンが同時に立っていてはいけない状態性ワークロード (例: 同じボリュームを占有する DB マイグレーション) でたまに使います。一般的な Web サーバなら既定値の RollingUpdate で十分です。

失敗するとどうなるか #

イメージタグをわざと間違えて書いてみましょう — 例えば nginx:1.99-not-real のように。

存在しないタグで適用
kubectl apply -f web.yaml

kubectl rollout status deploy/web が長い間止まり、kubectl get pods で見ると新しく作られた Pod 1 つが ImagePullBackOff の状態にあります。

出力例
NAME                  READY   STATUS             RESTARTS   AGE
web-abc123-aa11       1/1     Running            0          15m
web-abc123-bb22       1/1     Running            0          15m
web-abc123-cc33       1/1     Running            0          15m
web-ghi789-zz99       0/1     ImagePullBackOff   0          40s

興味深いのは 古い Pod 3 個がそのまま生きている という点です。新 Pod が Ready で入って来られないと Deployment は次の段階に進みません。つまり古い RS を 0 に減らさないわけです。ロールアウトが止まっている間も古いバージョンはトラフィックを正常に受けています。 無停止の核心がここにあります。

このときのデバッグ順は #3 の最後で整理した通りです。

止まったロールアウトを覗く
kubectl describe deploy/web
kubectl describe pod web-ghi789-zz99

describe deploy の Events には ReplicaSet ... has timed out progressing のようなメッセージが、describe pod の Events には Failed to pull image "nginx:1.99-not-real" が書かれています。答えはほぼ常にこの 2 つの出力の中にあります。

ロールバック #

新バージョンが間違って上がったのを発見したら、古いバージョンに戻す道は 1 行で用意されています。

ロールアウト履歴
kubectl rollout history deploy/web
出力例
deployment.apps/web
REVISION  CHANGE-CAUSE
1         <none>
2         <none>

リビジョン一覧が見えます。1 番が最初の nginx:1.27、2 番がさっき上げた nginx:1.28 です。直前のリビジョンに戻すには:

直前のリビジョンに戻す
kubectl rollout undo deploy/web
出力例
deployment.apps/web rolled back

特定のリビジョンを指定したいなら --to-revision フラグを使います。

特定のリビジョンへ
kubectl rollout undo deploy/web --to-revision=1

これが可能な理由は 1 行で整理できます — リビジョンは ReplicaSet の別の姿 です。新バージョンをデプロイするとき古い ReplicaSet は消えずに replicas: 0 で空になったまま残っていたからです。undo はその古い RS をまた N 個に増やす作業です。なのでほぼ即座に古いバージョンがまたトラフィックを受けます。

既定で K8s はリビジョンを 10 個まで保管します。マニフェストの spec.revisionHistoryLimit で増減させられます。長すぎると古い ReplicaSet が登録簿に積もって整理が煩雑になり、短すぎるとずっと前のバージョンに 1 度に戻れないので、運用環境のデプロイ頻度に合わせて決めます — 一般的な Web サービスなら既定値 10 で問題ありません。

Deployment が解いてくれないこと #

ここまでの Deployment が全てのワークロード形を扱えるわけではありません。性質の異なるケースを 1 段落に押さえておきます。

  • 状態 (stateful) ワークロード — データベースのように各インスタンスが自分の名前・自分のディスクを持つ必要があるワークロードは StatefulSet が合います。Pod 名が web-0web-1 のように安定して振られ、それぞれにマニフェストで定義した PVC が 1:1 で繋がります。起動順序も 012 で保証されます。Deployment は Pod の名前・ディスクをすべて任意値として扱うので DB には適しません。
  • ノードごとに 1 つずつ立つべきワークロード — ログ収集 (Fluent Bit、Filebeat)、ノードモニタ (Node Exporter)、CNI エージェントのようなものは DaemonSet が適します。新しいノードがクラスタに加わると自動でそのノードにも 1 つ立ち上がります。
  • 一回限りの作業 — マイグレーション、バックアップ、バッチジョブのような 1 度回って終わる作業は Job (即時実行) または CronJob (スケジュール実行) を使います。Pod の Phase が Succeeded に入るのが自然なワークロードです。

この 3 つは K8s 中級で 1 編ずつ扱います。このシリーズでは一番よく触る Deployment にだけ集中します。ただし頭の中の分類は先に押さえておくと良いです — 状態無し → Deployment、状態あり → StatefulSet、ノードごとに 1 つ → DaemonSet、一回限り → Job

片付け #

今日作ったリソースをきれいに消します。Deployment 1 つを消すと、その下の ReplicaSet と Pod が一緒に整理されます。K8s が親子関係 (owner reference) を通じてガベージコレクションをしてくれる部分です。

マニフェストで片付け
kubectl delete -f web.yaml
出力例
deployment.apps "web" deleted
本当に空か
kubectl get deploy,rs,pods
出力例
No resources found in default namespace.

名前で直接消す道もあります。

名前で片付け
kubectl delete deploy web

Deployment だけ消したのに ReplicaSet と Pod まで一緒に消えるのを確認しておいてください。この owner reference モデルはシリーズの後の編でも同じように動きます。

まとめ #

この記事で押さえた流れ:

  • Deployment / ReplicaSet / Pod の 3 層 — 人が書くのは Deployment の 1 層だけで、ReplicaSet は自動で作られる中間オブジェクト、Pod は ReplicaSet が作り出す実際のワークロード。
  • マニフェストの背骨は apiVersion: apps/v1 / kind: Deployment / metadata / spec の 4 フィールド。spec の中で新しく出会う部分は replicasselector.matchLabelstemplate の 3 つで、selector と template のラベルは必ず一致しなければなりません。
  • Pod 1 つを強制的に消しても ReplicaSet コントローラがすぐ新しい Pod を 1 つ立てて desired (N) と actual を再び合わせる — #1 で見た reconcile loop の最も単純な姿。
  • replicas の調整はマニフェスト修正 + apply が正攻法、kubectl scale は一時的。宣言的マニフェストが常に真実の出処です。
  • ローリングアップデートは新 ReplicaSet を作って古い ReplicaSet を段階的に空にしていく流れ。既定 strategy は RollingUpdate (maxSurge 25%maxUnavailable 25%)、進捗は kubectl rollout status で見る。
  • ロールバックは kubectl rollout undo 1 行。可能な理由は古い ReplicaSet が replicas: 0 で空のまま残っているから。

次 — Service #

ここまで来てもまだ 1 つ解けていません — 外部からクラスタ内の Pod へどうトラフィックを送るのか。 今作った nginx Pod 3 個にはクラスタ内部 IP が付いているだけで、その IP は Pod が死んで新しく立つたびに変わります。ReplicaSet が Pod を自動で生かしてはくれますが、そう生き返った Pod の IP が毎回違うので、クライアントがどこに接続すればいいかが曖昧です。

#5 Service — ClusterIP / NodePort / LoadBalancer では (1) Service が Pod の前面に安定した仮想 IP/DNS 名をどう付けるか(2) クラスタ内部通信用の ClusterIP、ノードポートで外部に開く NodePort、クラウドロードバランサを付ける LoadBalancer の 3 種類の違い(3) この記事で立てた app: web Pod の前面に Service を付けて初めての外部接続を作る流れ までを扱います。この記事の Pod 3 個が、そこで初めて「アドレスのあるサービス」になります。

X