K8s 基礎 #4 Deployment と ReplicaSet — 宣言的デプロイとローリングアップデート
#3 kubectl と最初の Pod の最後で確認した通り、Pod は直接立てると消えるだけです。この記事ではその空白を自動で埋める最初のコントローラ Deployment と、その下の ReplicaSet を扱います。replicas: 3 を宣言して Pod を維持する方法、Pod 1 つを消したときに自動復旧する仕組み、イメージタグを変えたときにローリングアップデートとロールバックがどう動くかまでを 1 サイクルで整理します。
このシリーズは K8s 基礎 7 編です。
- #1 Kubernetes とは — なぜコンテナオーケストレーターが必要か
- #2 ローカル環境 — minikube / kind / Docker Desktop k8s
- #3 kubectl と最初の Pod
- #4 Deployment と ReplicaSet — 宣言的デプロイとローリングアップデート ← この記事
- #5 Service — ClusterIP / NodePort / LoadBalancer
- #6 ConfigMap / Secret
- #7 Namespace とラベル
この記事の終わりには Pod を人が一つ一つ立てずにコントローラに任せる最初のマニフェスト が完成します。ここから先が、事実上、運用で人が書くマニフェストの基本形です。
Deployment、ReplicaSet、Pod — 3 層の関係 #
この記事の主役は 3 つのリソースですが、人が直接書くのは一番上の 1 層だけです。頭の中の絵はこう押さえておくと楽です。
┌──────────────────────┐
│ 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 にして、次のように書きます。
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/v1API グループに入っています。コントローラ系リソース (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.matchLabels と spec.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 をクラスタに反映します。
kubectl apply -f web.yamldeployment.apps/web created3 種類のリソースを一度に見ます。kubectl get はカンマで複数のリソース種類を 1 度に受け取れます。
kubectl get deploy,rs,podsNAME 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 を消すとただ消えました。今回はどう違うのかを確認します。
kubectl delete pod web-abc123-aa11pod "web-abc123-aa11" deletedすぐに Pod 一覧をまた取ってみます。
kubectl get podsNAME 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 5s3 個がそのまま立っています。ただし 1 行をよく見ると違いが分かります — bb22 と cc33 は AGE 2m ですが、新しく見える dd44 は AGE 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。一番きれいな道です。
spec:
replicas: 5
...kubectl apply -f web.yamldeployment.apps/web configuredkubectl get pods で見るとすぐに 5 個に増えています。減らすときも同じ方法です — マニフェストの数字を減らして apply。
命令的 — 速いが一時的です。
kubectl scale deploy/web --replicas=5deployment.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 文字だけ変えます。
containers:
- name: web
image: nginx:1.28
ports:
- containerPort: 80kubectl apply -f web.yamldeployment.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 rsNAME 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/webWaiting 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.yamlkubectl 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-zz99describe 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/webdeployment.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 <none>リビジョン一覧が見えます。1 番が最初の nginx:1.27、2 番がさっき上げた nginx:1.28 です。直前のリビジョンに戻すには:
kubectl rollout undo deploy/webdeployment.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-0、web-1のように安定して振られ、それぞれにマニフェストで定義した PVC が 1:1 で繋がります。起動順序も0→1→2で保証されます。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.yamldeployment.apps "web" deletedkubectl get deploy,rs,podsNo resources found in default namespace.名前で直接消す道もあります。
kubectl delete deploy webDeployment だけ消したのに ReplicaSet と Pod まで一緒に消えるのを確認しておいてください。この owner reference モデルはシリーズの後の編でも同じように動きます。
まとめ #
この記事で押さえた流れ:
- Deployment / ReplicaSet / Pod の 3 層 — 人が書くのは Deployment の 1 層だけで、ReplicaSet は自動で作られる中間オブジェクト、Pod は ReplicaSet が作り出す実際のワークロード。
- マニフェストの背骨は
apiVersion: apps/v1/kind: Deployment/metadata/specの 4 フィールド。specの中で新しく出会う部分はreplicas、selector.matchLabels、templateの 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 undo1 行。可能な理由は古い 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 個が、そこで初めて「アドレスのあるサービス」になります。