K8s 中級 #1 StatefulSet / DaemonSet / Job / CronJob — Deployment ではない他のコントローラ
K8s 中級シリーズの最初の記事です。基礎シリーズで見た Deployment は「同じ Pod を複数立て続ける」という 1 つのパターンに忠実なコントローラです。しかし運用クラスタには Deployment が扱えないワークロードが必ずあります。この記事ではその 4 つの空白を埋めるコントローラ StatefulSet、DaemonSet、Job、CronJob を 1 編にまとめます。それぞれを「なぜ Deployment ではダメか」の問題から始め、マニフェスト 1 枚と運用上の注意点まで 1 サイクル追います。
このシリーズは K8s 中級 7 編です。
- #1 StatefulSet / DaemonSet / Job / CronJob — Deployment ではない他のコントローラ ← この記事
- #2 PV / PVC / StorageClass — 永続データモデル
- #3 Ingress と Ingress Controller — 外部入口
- #4 resources.requests / limits — Pod のリソース要求と上限
- #5 Health check — liveness / readiness / startup probe
- #6 オートスケーリング — HPA / VPA / Cluster Autoscaler
- #7 RBAC / NetworkPolicy / ResourceQuota — セキュリティとリソースポリシー
kubectl apply が意図と異なるエラーを返し、原因をクラスタ側から逆に辿ることになります。マニフェストを適用する前に utilrepo の YAML 検証ツール に貼り付けておくと、構文エラーを行・列番号で示してくれます。utilrepo はブラウザで動作する軽量な Web ユーティリティ集で、秘密情報が外部に出ず --- で連結された複数文書マニフェストやタブ・スペース混在のような頻出する罠もまとめて拾ってくれます。Deployment では表現できないワークロード #
基礎 #4 で押さえた Deployment の頭の中のモデルを 1 行で縮めるとこうなります — 同じ Pod テンプレートで N 個を常に維持し、新バージョンが来たら段階的に置き換える。 このモデルがよく合うワークロードは stateless な Web サーバ、API サーバ、ワーカキューコンシューマのように Pod が互いに区別されなくていい場合 です。web-abc123-aa11 でも web-abc123-bb22 でも同じコードが回り、どの Pod が死んでも別の Pod がその役割を埋めれば終わりです。
このモデルではうまく解けない 4 つのパターンがあります。
- Pod が互いに違うと仮定すべきワークロード — データベースクラスタの primary と replica、Kafka の broker-0 / broker-1 / broker-2 のように各 Pod が自分だけのアイデンティティと自分だけのディスクを持つべき場合。Deployment が作る Pod は名前が任意値でディスクも共有されません。
- ノードごとに正確に 1 つずつ立つべきワークロード — ログ収集、ノードモニタリングエージェント、CNI (コンテナネットワークインターフェース) エージェント。「replicas の個数」ではなく「ノードの個数に自動で合わせる」が必要なのに、Deployment の
replicasフィールドはその意図を表現できません。 - 1 度実行して終わるべきワークロード — DB マイグレーション、一回限りのデータレポート、クラスタセットアップスクリプト。Deployment は Pod が終了するとまた立てようとしますが、こういう仕事は終わるのが正常です。
- 周期的に実行されるべきワークロード — 毎日早朝のバックアップ、毎時定刻の整理作業、毎週のレポート生成。cron のようなスケジューリングがコントローラの次元になければなりません。
この 4 つを K8s がそれぞれ別のコントローラに分離したのが StatefulSet、DaemonSet、Job、CronJob です。1 つずつ見ていきます。
StatefulSet — アイデンティティとディスクが必要なワークロード #
データベースを K8s に立てようとすると Deployment が最初にぶつかる壁は明確です。PostgreSQL primary が死んで新しい Pod が立ち上がったとき、その新しい Pod は 以前の Pod のデータディレクトリをそのまま引き継がねばなりません。 名前が任意値に変わっても困りますし、別の replica たちが primary をどう呼ぶかも安定すべきです。Deployment はこの 3 つのどれも保証しません。
StatefulSet が解いてくれるのは次の 3 つです。
- 安定した Pod 名 — Pod が
<name>-0、<name>-1、<name>-2のようにインデックスの付いた名前を受け取ります。Pod が再起動されても同じインデックスを維持します。web-0が死んでまた立っても再びweb-0です。 - Pod ごとに 1:1 の永続ボリューム —
volumeClaimTemplatesで書いた PVC が各 Pod ごとに自動で作られます。web-0はdata-web-0PVC を、web-1はdata-web-1PVC を持ち、そのマッピングが Pod の生存周期を超えて維持されます。PV / PVC モデル自体は #2 で深く扱います。 - 順次的なライフサイクル — 既定で Pod は 0 番から順に作られ、終了は逆順 (N-1 番から) で進みます。ローリングアップデートも同じ順序に従います。primary が先に立たないと replica がつなげないトポロジに合わせたモデルです。
Headless Service と組をなす #
StatefulSet は通常 headless Service と組で作ります。Pod ごとに安定した DNS 名が必要だからです。
apiVersion: v1
kind: Service
metadata:
name: web
spec:
clusterIP: None
selector:
app: web
ports:
- port: 80
targetPort: 80肝心は clusterIP: None の 1 行です。この Service は自分の仮想 IP を持たず、代わりに Pod ごとに個別の DNS レコードを作ってくれます。 クラスタ内部から次の名前で各 Pod を直接呼べます。
web-0.web.default.svc.cluster.local
web-1.web.default.svc.cluster.local
web-2.web.default.svc.cluster.local<pod>.<headless-service>.<namespace>.svc.cluster.local の形です。一般 ClusterIP Service が「複数の Pod の前面の仮想 IP」なら、headless Service は「各 Pod の安定した名札発行機」と見ればいいです。
StatefulSet マニフェスト #
上の headless Service と一緒に適用される StatefulSet マニフェストです。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: web
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
volumeMounts:
- name: data
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1GiDeployment と違う部分が 3 つあります。
spec.serviceName: web— 上で作った headless Service の名前を指します。StatefulSet が Pod の DNS レコードをどこに登録するかを教えるフィールドです。spec.volumeClaimTemplates— Pod ごとに PVC を自動で作り出すテンプレートです。上のマニフェストはdata-web-0、data-web-1、data-web-2の 3 つの PVC を作り、各 Pod の/usr/share/nginx/htmlにマウントします。この PVC が実際にどんなディスクに繋がるかはStorageClassの動的プロビジョニングが決め、この全ての流れは #2 の本題です。replicasと Pod 名 — Deployment と同じreplicas: 3ですが、作られる Pod 名はweb-0、web-1、web-2で固定です。ReplicaSet 中間オブジェクトもありません。
kubectl get pods,pvc -l app=webNAME READY STATUS RESTARTS AGE
pod/web-0 1/1 Running 0 1m
pod/web-1 1/1 Running 0 50s
pod/web-2 1/1 Running 0 40s
NAME STATUS VOLUME CAPACITY AGE
persistentvolumeclaim/data-web-0 Bound pvc-... 1Gi 1m
persistentvolumeclaim/data-web-1 Bound pvc-... 1Gi 50s
persistentvolumeclaim/data-web-2 Bound pvc-... 1Gi 40sPod が 0、1、2 の順に時間差を置いて立ち、PVC も Pod ごとに別々に作られているのが見えます。
運用上の 1 つの注意 — スケールダウン時に PVC は残る #
StatefulSet を replicas: 3 から replicas: 1 に減らすと、Pod web-1、web-2 は終了しますが PVC data-web-1、data-web-2 はそのまま残ります。 意図された動作です — データを誤って飛ばさないようにする安全装置です。再び replicas: 3 に増やすと新しく立った web-1、web-2 がその PVC を再びマウントして以前のデータをそのまま見ます。
PVC まで整理するには明示的に消す必要があります。
kubectl delete pvc data-web-1 data-web-2この安全装置のおかげで運用事故で StatefulSet の replicas を誤って減らしてもデータは生きています。K8s 1.27 からは spec.persistentVolumeClaimRetentionPolicy でこの動作を変えられますが、データ保存の観点では既定値そのままにする方が安全です。
DaemonSet — ノードごとに正確に 1 つずつ #
運用クラスタには「各ノードの状態をそのノードの中で覗き見るべき」ワークロードがあります。ノードのコンテナログをまとめて中央に送る Fluent Bit、ノードの CPU・メモリ・ディスクを測って Prometheus に公開する Node Exporter、Pod 間のネットワークを構成してくれる CNI エージェント (Calico、Cilium など)。こういうワークロードの共通点は ノードの個数だけ立っているべき という点です。
Deployment の replicas: N ではこの意図を表現できません。ノード数が増えたり減ったりするたびに人が N を手で合わせねばならず、1 ノードに同じ Pod が 2 つ立ったり、あるノードには全く立たない状況も防げません。
DaemonSet が解いてくれることは単純です — クラスタの各ノードに自分の Pod を正確に 1 つずつ立てる。 新しいノードがクラスタに参加すればそのノードにも自動で 1 つ立て、ノードが抜ければそのノードの Pod も一緒に消えます。
DaemonSet マニフェスト #
replicas フィールドが無いのが最大の違いです。
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: monitoring
spec:
selector:
matchLabels:
app: node-exporter
template:
metadata:
labels:
app: node-exporter
spec:
hostNetwork: true
containers:
- name: node-exporter
image: prom/node-exporter:v1.8.2
args:
- --path.rootfs=/host
ports:
- containerPort: 9100
hostPort: 9100
volumeMounts:
- name: rootfs
mountPath: /host
readOnly: true
volumes:
- name: rootfs
hostPath:
path: /Deployment と同じ selector + template 構造ですが replicas がありません。個数はノード数が決めます。hostNetwork: true と hostPath ボリュームは DaemonSet ワークロードでよく見るパターンです — ノードのネットワークインターフェースで直接 Pod を公開したり、ノードのファイルシステムを直接覗き見るべきワークロードが多いからです。
kubectl get ds -n monitoring
kubectl get pods -n monitoring -o wideNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
node-exporter 3 3 3 3 3 <none> 2m
NAME READY STATUS RESTARTS AGE IP NODE
node-exporter-7xk2p 1/1 Running 0 2m 10.0.0.11 node-1
node-exporter-9mn4v 1/1 Running 0 2m 10.0.0.12 node-2
node-exporter-bc8qr 1/1 Running 0 2m 10.0.0.13 node-3DESIRED 3 がノード数に応じて自動で決まった値だという点が肝心です。ノードを 1 台追加すれば DESIRED 4 に変わり新しい Pod がそのノードに自動で立ちます。
一部のノードにだけ立てる — nodeSelector / tolerations #
既定の DaemonSet は すべてのワーカノード に Pod を立てます。ただし運用では一部のノードだけに立てたい場合がよくあります — GPU が付いたノードにだけ GPU モニターを立てたり、コントロールプレーンノードにはワークロードを乗せなかったり。
nodeSelector でノードラベルにマッチするノードだけに限定できます。
spec:
template:
spec:
nodeSelector:
hardware: gpu逆に、taint が付いたノード (例: コントロールプレーン) にも立てるには tolerations を書きます。
spec:
template:
spec:
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule実際にクラスタの kube-system namespace に立っている kube-proxy が DaemonSet です。コントロールプレーンノードを含む全ノードに立つ必要があるので上のような toleration を持っています。kubectl get ds -n kube-system で 1 度確認してみるといいです。
ノードが cordon / drain されると #
運用中ノードを点検するときによく使うコマンドが kubectl cordon と kubectl drain です。cordon は新しい Pod のスケジューリングだけを塞ぎ、drain はノード上の Pod を別ノードに移します。DaemonSet Pod は drain の既定の動作で移されません — ノードごとに 1 つずつ立つのが本分なので別ノードに移す意味がないからです。drain コマンドが DaemonSet Pod のために止まったら --ignore-daemonsets フラグを一緒に与えるのが標準パターンです。
kubectl drain node-1 --ignore-daemonsets --delete-emptydir-dataJob — 1 度実行して終わる仕事 #
DB スキーママイグレーション、一回限りのデータ整合性チェック、新クラスタの初期セットアップスクリプト。こういう仕事は 終われば終わり です。ところが Deployment マニフェストでマイグレーションコンテナを立てるとどうなるでしょうか。コンテナが正常終了 (exit 0) する瞬間 Deployment は「なぜ死んだ?」とまた立てます。マイグレーションが無限に繰り返される事故になります。
Job はこのシナリオのためのコントローラです。Pod が成功裏に終了することを正常と見なす という点で Deployment と正反対のモデルです。
Job マニフェスト #
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
completions: 1
parallelism: 1
backoffLimit: 4
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrator
image: myapp/migrator:1.4.0
command: ["./migrate.sh"]
env:
- name: DB_HOST
value: postgres.default.svc.cluster.localapiVersion が batch/v1 の点が新しいです。Deployment 系列は apps/v1 でしたが Job / CronJob は別のグループです。肝心のフィールドを 1 行ずつ押さえると。
completions: 1— Pod が成功で終了しなければならない回数。上の例は 1 回で終わりです。大きなデータを N 個に分けて処理するときは N にします。parallelism: 1— 同時に立っている Pod の個数。completions: 10、parallelism: 3にすれば 10 個を処理しつつ一度に 3 個ずつ並列で回します。backoffLimit: 4— Pod が失敗したときの再試行回数の上限。既定値は 6 です。この回数を超えると Job 自体がFailedで締まります。activeDeadlineSeconds: 600— Job 全体の時間上限。600 秒以内に終わらないと Pod を強制終了します。無限ループに陥ったマイグレーションを断つ安全装置です。
restartPolicy の制約 #
Pod の restartPolicy は普通 Always、OnFailure、Never の 3 つがありますが、Job の Pod テンプレートでは Always が許可されません。 マニフェストに Always と書くと apiserver が拒否します。
理由は単純です。Always は Pod がどんなふうに終わろうと (成功でも失敗でも) また立てろという意味ですが、Job は 終了を期待するワークロード です。Always を許可すると成功してもまた立てることになり Job の意味が消えます。なので OnFailure (失敗時のみ再試行) や Never (絶対に再試行せず、新しい Pod でまた作る) のどちらかしか使えません。
両者の違いは微妙です — OnFailure は同じ Pod の中でコンテナだけ再起動し、Never はその Pod 自体を失敗とマークして新しい Pod を再び作ります。ログを保存してデバッグしたいなら Never が、速い再試行を望むなら OnFailure が普通の選択です。
Job の動作確認 #
kubectl apply -f db-migration-job.yaml
kubectl get jobs
kubectl get pods --selector=job-name=db-migrationNAME COMPLETIONS DURATION AGE
db-migration 0/1 20s 20s
NAME READY STATUS RESTARTS AGE
db-migration-xkz2p 1/1 Running 0 20sNAME COMPLETIONS DURATION AGE
db-migration 1/1 45s 2m
NAME READY STATUS RESTARTS AGE
db-migration-xkz2p 0/1 Completed 0 2mCOMPLETIONS 1/1 が打たれて Pod が Completed で締まったのが正常終了の形です。ログは kubectl logs db-migration-xkz2p でマイグレーション出力をそのまま受け取れます。Job は kubectl delete job db-migration で明示的に整理しないとクラスタに残ります — 履歴として置いて見たいならそのまま、整理したいなら ttlSecondsAfterFinished を追加して自動で整理させることもできます。
CronJob — 周期実行 #
毎日午前 3 時に DB バックアップ、毎時定刻に一時ファイル整理、毎週月曜朝に統計レポート生成。このパターンが CronJob です。モデルは単純です — cron 表現式に沿って定められた時間ごとに Job オブジェクトを作り出す。 Job の上に cron スケジューラを 1 層重ねた形です。
CronJob マニフェスト #
apiVersion: batch/v1
kind: CronJob
metadata:
name: db-backup
spec:
schedule: "0 3 * * *"
timeZone: "Asia/Seoul"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
startingDeadlineSeconds: 300
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: myapp/backup:2.1.0
command: ["/usr/local/bin/backup.sh"]
env:
- name: S3_BUCKET
value: my-backupsCronJob マニフェストの肝心は 2 層です — 外側の spec のスケジューリングフィールドと、内側の jobTemplate の Job 定義です。内側の jobTemplate は上で見た Job マニフェストの spec と同じ形です。
外側の肝心のフィールドを押さえると。
schedule: "0 3 * * *"— 標準 cron 表現式 5 フィールドです。順に分 時 日 月 曜日。この例は毎日午前 3 時定刻です。*/15 * * * *(15 分ごと)、0 9 * * 1-5(平日午前 9 時) のような一般的な cron 文法をそのまま使います。timeZone: "Asia/Seoul"— 1.27 から安定化されたフィールドです。以前は CronJob の時刻がコントロールプレーンコンポーネントのタイムゾーンに従うので UTC で解釈されるのがよくあり、「なぜ早朝 3 時のバックアップが正午に回るのか」のような事故が頻発しました。このフィールドを明示しておけばその曖昧さが消えます。concurrencyPolicy— 前回の Job がまだ終わっていないのに新しい回の時刻が来たときのポリシー。既定値はAllowです。successfulJobsHistoryLimit/failedJobsHistoryLimit— 成功・失敗した Job オブジェクトをいくつまでクラスタに残しておくか。既定はそれぞれ 3 と 1 です。大きすぎると etcd に Job が積もります。startingDeadlineSeconds: 300— 予定時刻からこの秒数以内に開始されないとその回はスキップします。コントロールプレーンが一瞬止まってから回復したとき、滞った回を一度に全部立ち上げる事故を防ぐ安全装置です。
concurrencyPolicy の 3 種類 #
既定値 Allow をそのままにしておくと運用事故が起きやすいです。3 つの選択肢の動作が明確に違います。
| ポリシー | 動作 |
|---|---|
Allow (既定) | 前回の Job が終わっていなくても新しい回の Job を追加で作る。同時に複数立ちうる。 |
Forbid | 前回が終わっていなければ今回はスキップします。 |
Replace | 前回の Job を殺して新しい回で置き換える。 |
DB バックアップのように同じデータに同時に 2 つが触れてはいけないワークロードは Forbid が正解 です。前回のバックアップが 30 分かかってスケジュールが毎時定刻なら、Allow にしておくと毎時新しいバックアップが追加で立って積もる事故になります。「最新の回だけ生きていればいい」ワークロード (例: キャッシュウォーミング) は Replace が合います。
startingDeadlineSeconds が無いときの危険 #
CronJob の微妙な罠の 1 つが startingDeadlineSeconds です。このフィールドが無かったり大きすぎたりして、コントロールプレーンが長く止まってから回復すると、滞った回を一度に全部立ち上げようとする試み が起きえます。毎分回る CronJob が 1 時間止まってから目覚めると Job 60 個を同時に作る具合です。
運用クラスタの CronJob には startingDeadlineSeconds を合理的な値 (例: 300 秒) でほぼ常に書いておくのが安全です。回がその中で開始できなかったらその回はスキップする方が、目覚めたときに一気に 60 個を回すよりほぼ全ての場合に良いです。
CronJob の動作確認 #
kubectl get cronjob,jobs,podsNAME SCHEDULE TIMEZONE LAST SCHEDULE AGE
cronjob.batch/db-backup 0 3 * * * Asia/Seoul 8h 2d
NAME COMPLETIONS DURATION AGE
job.batch/db-backup-29345400 1/1 14m 8h
job.batch/db-backup-29346840 1/1 13m 20m
NAME READY STATUS RESTARTS AGE
pod/db-backup-29346840-7kxqr 0/1 Completed 0 20m3 層の形が見えます — CronJob 1 個があり、その下に回ごとに Job オブジェクトが作られ、各 Job の下に Pod が一度ずつ立って Completed で締まります。Job が終わっても successfulJobsHistoryLimit の個数分はオブジェクトが残って事後デバッグに使えます。
いつどのコントローラを使うか #
基礎シリーズの Deployment まで含めて 5 つのコントローラを 1 つの表にまとめます。
| コントローラ | 適するワークロード | Pod 識別子 | 終了モデル |
|---|---|---|---|
| Deployment | stateless な Web・API サーバ、ワーカコンシューマ | 任意値 (web-abc-aa11) | 死ねばまた立てる |
| StatefulSet | DB、メッセージキューブローカー、分散キャッシュ | web-0、web-1 (固定) | 死ねば同じインデックスでまた立てる |
| DaemonSet | ノードエージェント、ログ収集、CNI | ノードごとに 1 つ | 死ねばまた立てる |
| Job | DB マイグレーション、一回限りバッチ | 任意値 | 成功で終われば終わり |
| CronJob | 周期バックアップ、整理、レポート | 回ごとに Job | 各回が Job の終了モデル |
頭の中の判断ツリーは単純です。
- Pod 同士が互いに同じでもいいか? — 違うなら StatefulSet、合っているなら次の質問へ。
- ノードごとに正確に 1 つ立つべきか? — そうなら DaemonSet、違うなら次の質問へ。
- 1 度実行して終わるべきか? — そうなら周期実行なら CronJob、一回限りなら Job、違うなら Deployment です。
この 4 つのコントローラを知れば、クラスタのマニフェストディレクトリで kind: が何であってもその意図を 1 行で読めます。
まとめ #
この記事で押さえた流れ:
- Deployment は stateless の仮定の上に立つ — Pod が互いに同じだと見て、死ねばまた立てる単純なモデル。アイデンティティ・ノード単位・一回限り・周期性ワークロードは別のコントローラが必要。
- StatefulSet —
serviceName(headless Service) とvolumeClaimTemplatesが肝心。Pod 名が<name>-0、<name>-1で安定、各 Pod が自分の PVC を持つ。スケールダウン時に PVC は残る。 - DaemonSet —
replicasが無い。ノード数に自動で合わせて 1 つずつ立てる。nodeSelector/tolerationsで一部ノードだけに適用でき、kube-proxyが代表例。 - Job —
apiVersion: batch/v1。completions、parallelism、backoffLimit、activeDeadlineSecondsが動作を決める。restartPolicyはOnFailure/Neverのみ許可。 - CronJob — Job の上に cron スケジューラを 1 層。
schedule5 フィールド、timeZone(1.27+)、concurrencyPolicy(Allow/Forbid/Replace)、startingDeadlineSecondsで滞った回の暴走を防ぐ。 - 5 つのコントローラの判断ツリー — Pod のアイデンティティ・ノード単位の有無・終了期待の有無の 3 質問で分かれる。
次 — PV / PVC / StorageClass #
この記事で StatefulSet の volumeClaimTemplates が PVC を自動で作ると 1 行で押さえて通り過ぎましたが、その PVC が本当にどんなディスクにどう繋がるかは扱っていません。運用クラスタではその 1 行の後ろに PV (PersistentVolume)、PVC (PersistentVolumeClaim)、StorageClass の三角関係があります — Pod の生存周期とディスクの生存周期がどう分離されるか、ディスクが動的にどう作られるか、accessModes (ReadWriteOnce、ReadOnlyMany、ReadWriteMany) の違いは何か、reclaimPolicy が PVC が消えたときディスクをどう処理するか。
#2 PV / PVC / StorageClass — 永続データモデル ではこの 3 オブジェクトの関係を整理し、StatefulSet の volumeClaimTemplates がその上で本当に何を作り出すかを 1 サイクルで追います。