K8s 中級 #1 StatefulSet / DaemonSet / Job / CronJob — Deployment ではない他のコントローラ

読了 17分

K8s 中級シリーズの最初の記事です。基礎シリーズで見た Deployment は「同じ Pod を複数立て続ける」という 1 つのパターンに忠実なコントローラです。しかし運用クラスタには Deployment が扱えないワークロードが必ずあります。この記事ではその 4 つの空白を埋めるコントローラ StatefulSetDaemonSetJobCronJob を 1 編にまとめます。それぞれを「なぜ Deployment ではダメか」の問題から始め、マニフェスト 1 枚と運用上の注意点まで 1 サイクル追います。

このシリーズは K8s 中級 7 編です。

ヒント
本シリーズの実習記事は YAML マニフェストを手で書いていきます。インデント 1 つ、引用符 1 つがずれただけで 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 がそれぞれ別のコントローラに分離したのが StatefulSetDaemonSetJobCronJob です。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-0data-web-0 PVC を、web-1data-web-1 PVC を持ち、そのマッピングが Pod の生存周期を超えて維持されます。PV / PVC モデル自体は #2 で深く扱います。
  • 順次的なライフサイクル — 既定で Pod は 0 番から順に作られ、終了は逆順 (N-1 番から) で進みます。ローリングアップデートも同じ順序に従います。primary が先に立たないと replica がつなげないトポロジに合わせたモデルです。

Headless Service と組をなす #

StatefulSet は通常 headless Service と組で作ります。Pod ごとに安定した DNS 名が必要だからです。

web-headless.yaml
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 を直接呼べます。

StatefulSet Pod の DNS
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 マニフェストです。

web-statefulset.yaml
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: 1Gi

Deployment と違う部分が 3 つあります。

  • spec.serviceName: web — 上で作った headless Service の名前を指します。StatefulSet が Pod の DNS レコードをどこに登録するかを教えるフィールドです。
  • spec.volumeClaimTemplates — Pod ごとに PVC を自動で作り出すテンプレートです。上のマニフェストは data-web-0data-web-1data-web-2 の 3 つの PVC を作り、各 Pod の /usr/share/nginx/html にマウントします。この PVC が実際にどんなディスクに繋がるかは StorageClass の動的プロビジョニングが決め、この全ての流れは #2 の本題です。
  • replicas と Pod 名 — Deployment と同じ replicas: 3 ですが、作られる Pod 名は web-0web-1web-2 で固定です。ReplicaSet 中間オブジェクトもありません。
StatefulSet 適用後
kubectl get pods,pvc -l app=web
出力例
NAME        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        40s

Pod が 012 の順に時間差を置いて立ち、PVC も Pod ごとに別々に作られているのが見えます。

運用上の 1 つの注意 — スケールダウン時に PVC は残る #

StatefulSet を replicas: 3 から replicas: 1 に減らすと、Pod web-1web-2 は終了しますが PVC data-web-1data-web-2 はそのまま残ります。 意図された動作です — データを誤って飛ばさないようにする安全装置です。再び replicas: 3 に増やすと新しく立った web-1web-2 がその PVC を再びマウントして以前のデータをそのまま見ます。

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 フィールドが無いのが最大の違いです。

node-exporter-daemonset.yaml
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: truehostPath ボリュームは DaemonSet ワークロードでよく見るパターンです — ノードのネットワークインターフェースで直接 Pod を公開したり、ノードのファイルシステムを直接覗き見るべきワークロードが多いからです。

DaemonSet の確認
kubectl get ds -n monitoring
kubectl get pods -n monitoring -o wide
出力例
NAME            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-3

DESIRED 3 がノード数に応じて自動で決まった値だという点が肝心です。ノードを 1 台追加すれば DESIRED 4 に変わり新しい Pod がそのノードに自動で立ちます。

一部のノードにだけ立てる — nodeSelector / tolerations #

既定の DaemonSet は すべてのワーカノード に Pod を立てます。ただし運用では一部のノードだけに立てたい場合がよくあります — GPU が付いたノードにだけ GPU モニターを立てたり、コントロールプレーンノードにはワークロードを乗せなかったり。

nodeSelector でノードラベルにマッチするノードだけに限定できます。

GPU ノードだけに立てる — 抜粋
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 cordonkubectl drain です。cordon は新しい Pod のスケジューリングだけを塞ぎ、drain はノード上の Pod を別ノードに移します。DaemonSet Pod は drain の既定の動作で移されません — ノードごとに 1 つずつ立つのが本分なので別ノードに移す意味がないからです。drain コマンドが DaemonSet Pod のために止まったら --ignore-daemonsets フラグを一緒に与えるのが標準パターンです。

ノード点検 — DaemonSet を無視
kubectl drain node-1 --ignore-daemonsets --delete-emptydir-data

Job — 1 度実行して終わる仕事 #

DB スキーママイグレーション、一回限りのデータ整合性チェック、新クラスタの初期セットアップスクリプト。こういう仕事は 終われば終わり です。ところが Deployment マニフェストでマイグレーションコンテナを立てるとどうなるでしょうか。コンテナが正常終了 (exit 0) する瞬間 Deployment は「なぜ死んだ?」とまた立てます。マイグレーションが無限に繰り返される事故になります。

Job はこのシナリオのためのコントローラです。Pod が成功裏に終了することを正常と見なす という点で Deployment と正反対のモデルです。

Job マニフェスト #

db-migration-job.yaml
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.local

apiVersionbatch/v1 の点が新しいです。Deployment 系列は apps/v1 でしたが Job / CronJob は別のグループです。肝心のフィールドを 1 行ずつ押さえると。

  • completions: 1 — Pod が成功で終了しなければならない回数。上の例は 1 回で終わりです。大きなデータを N 個に分けて処理するときは N にします。
  • parallelism: 1 — 同時に立っている Pod の個数。completions: 10parallelism: 3 にすれば 10 個を処理しつつ一度に 3 個ずつ並列で回します。
  • backoffLimit: 4 — Pod が失敗したときの再試行回数の上限。既定値は 6 です。この回数を超えると Job 自体が Failed で締まります。
  • activeDeadlineSeconds: 600 — Job 全体の時間上限。600 秒以内に終わらないと Pod を強制終了します。無限ループに陥ったマイグレーションを断つ安全装置です。

restartPolicy の制約 #

Pod の restartPolicy は普通 AlwaysOnFailureNever の 3 つがありますが、Job の Pod テンプレートでは Always が許可されません。 マニフェストに Always と書くと apiserver が拒否します。

理由は単純です。Always は Pod がどんなふうに終わろうと (成功でも失敗でも) また立てろという意味ですが、Job は 終了を期待するワークロード です。Always を許可すると成功してもまた立てることになり Job の意味が消えます。なので OnFailure (失敗時のみ再試行) や Never (絶対に再試行せず、新しい Pod でまた作る) のどちらかしか使えません。

両者の違いは微妙です — OnFailure は同じ Pod の中でコンテナだけ再起動し、Never はその Pod 自体を失敗とマークして新しい Pod を再び作ります。ログを保存してデバッグしたいなら Never が、速い再試行を望むなら OnFailure が普通の選択です。

Job の動作確認 #

Job の作成と進捗確認
kubectl apply -f db-migration-job.yaml
kubectl get jobs
kubectl get pods --selector=job-name=db-migration
出力例 — 進行中
NAME           COMPLETIONS   DURATION   AGE
db-migration   0/1           20s        20s

NAME                  READY   STATUS    RESTARTS   AGE
db-migration-xkz2p    1/1     Running   0          20s
出力例 — 完了後
NAME           COMPLETIONS   DURATION   AGE
db-migration   1/1           45s        2m

NAME                  READY   STATUS      RESTARTS   AGE
db-migration-xkz2p    0/1     Completed   0          2m

COMPLETIONS 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 マニフェスト #

db-backup-cronjob.yaml
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-backups

CronJob マニフェストの肝心は 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 の動作確認 #

CronJob とその下の Job、Pod
kubectl get cronjob,jobs,pods
出力例 — 1 度回った後
NAME                      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          20m

3 層の形が見えます — CronJob 1 個があり、その下に回ごとに Job オブジェクトが作られ、各 Job の下に Pod が一度ずつ立って Completed で締まります。Job が終わっても successfulJobsHistoryLimit の個数分はオブジェクトが残って事後デバッグに使えます。

いつどのコントローラを使うか #

基礎シリーズの Deployment まで含めて 5 つのコントローラを 1 つの表にまとめます。

コントローラ適するワークロードPod 識別子終了モデル
Deploymentstateless な Web・API サーバ、ワーカコンシューマ任意値 (web-abc-aa11)死ねばまた立てる
StatefulSetDB、メッセージキューブローカー、分散キャッシュweb-0web-1 (固定)死ねば同じインデックスでまた立てる
DaemonSetノードエージェント、ログ収集、CNIノードごとに 1 つ死ねばまた立てる
JobDB マイグレーション、一回限りバッチ任意値成功で終われば終わり
CronJob周期バックアップ、整理、レポート回ごとに Job各回が Job の終了モデル

頭の中の判断ツリーは単純です。

  • Pod 同士が互いに同じでもいいか? — 違うなら StatefulSet、合っているなら次の質問へ。
  • ノードごとに正確に 1 つ立つべきか? — そうなら DaemonSet、違うなら次の質問へ。
  • 1 度実行して終わるべきか? — そうなら周期実行なら CronJob、一回限りなら Job、違うなら Deployment です。

この 4 つのコントローラを知れば、クラスタのマニフェストディレクトリで kind: が何であってもその意図を 1 行で読めます。

まとめ #

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

  • Deployment は stateless の仮定の上に立つ — Pod が互いに同じだと見て、死ねばまた立てる単純なモデル。アイデンティティ・ノード単位・一回限り・周期性ワークロードは別のコントローラが必要。
  • StatefulSetserviceName (headless Service) と volumeClaimTemplates が肝心。Pod 名が <name>-0<name>-1 で安定、各 Pod が自分の PVC を持つ。スケールダウン時に PVC は残る。
  • DaemonSetreplicas が無い。ノード数に自動で合わせて 1 つずつ立てる。nodeSelector / tolerations で一部ノードだけに適用でき、kube-proxy が代表例。
  • JobapiVersion: batch/v1completionsparallelismbackoffLimitactiveDeadlineSeconds が動作を決める。restartPolicyOnFailure / Never のみ許可。
  • CronJob — Job の上に cron スケジューラを 1 層。schedule 5 フィールド、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 (ReadWriteOnceReadOnlyManyReadWriteMany) の違いは何か、reclaimPolicy が PVC が消えたときディスクをどう処理するか。

#2 PV / PVC / StorageClass — 永続データモデル ではこの 3 オブジェクトの関係を整理し、StatefulSet の volumeClaimTemplates がその上で本当に何を作り出すかを 1 サイクルで追います。

X