PR

Kubernetes応用編 第04回

Kubernetes応用編 第04回
Deployment以外のワークロード — DaemonSet / Job / CronJob

4.1 はじめに

前回(第3回)では、StatefulSetを使ってTaskBoardにMySQLデータベースを追加しました。DeploymentでDBを動かしてデータを失う体験をした後、StatefulSetで正しく構築し直し、データが永続化されることを確認しました。さらにTaskBoard APIをMySQL接続版(v2.0.0)に更新し、「アプリ更新 → コンテナイメージ再ビルド → デプロイ」のワークフローも体験しています。

現時点のTaskBoardの構成を確認しておきましょう。

第3回終了時の構成:

[app Namespace]
  ├── Nginx (Deployment, 2レプリカ) + Service (ClusterIP)
  └── TaskBoard API (Deployment, 2レプリカ, MySQL接続版 v2.0.0) + Service (NodePort:30080)
      └── → mysql-0.mysql-headless.db.svc.cluster.local に接続

[db Namespace]
  ├── MySQL (StatefulSet, 1レプリカ) + Headless Service + PVC(1Gi)
  └── mysql-secret (Secret)

[monitoring Namespace]
  └── (空 — 本回でログ収集DaemonSet追加)

基盤:
  ├── kindクラスタ (CP 1 + Worker 3)
  ├── Metrics Server 稼働中
  ├── 各NamespaceにResourceQuota / LimitRange適用済み
  └── 各NamespaceにRBACを適用済み(developer / operator ServiceAccount)

TaskBoardは順調に成長していますが、ここで3つの課題が浮上します。

1つ目は、DB初期化の問題です。第3回でテーブルはJPAのDDL自動生成で作られましたが、初期データ(マスタデータやテスト用データ)はまだ投入されていません。本番環境では、デプロイのたびに手作業でINSERT文を実行するわけにはいきません。

2つ目は、バックアップの問題です。MySQLにデータが永続化されるようになりましたが、定期的なバックアップの仕組みがありません。PVCのデータが壊れたときに復旧する手段がないのです。

3つ目は、ログ収集の問題です。Podのログはkubectl logsで確認できますが、全Nodeにわたって横断的にログを集める仕組みがありません。

これらの課題に共通するのは、「Deploymentでは解決できない」という点です。Deploymentは「常時稼働するアプリケーションを複数レプリカで動かす」ためのリソースです。一度だけ実行して完了するスクリプト、定期的に繰り返すバッチ処理、全Nodeに1つずつ配置するエージェント――これらはDeploymentの守備範囲外です。

本回のゴール

BeforeAfter
ワークロード = Deployment + StatefulSet。それ以外の動かし方を知らない要件に応じて5種のワークロードリソース(Deployment / StatefulSet / DaemonSet / Job / CronJob)を使い分けられる

今回は3つのワークロードリソースを一気に学びます。まずVMの世界との対比で全体像をつかみ、その後TaskBoardに1つずつ追加していきます。CronJobのセクションでは、失敗時の挙動を体験する「破壊」パートもあります。

4.2 VMの世界との対比 — 3つの運用パターン

Job、CronJob、DaemonSetはそれぞれ異なる運用課題を解決するリソースです。VMの世界で馴染みのある運用パターンと対比すると、その役割がすぐに理解できます。

4.2.1 一度だけ実行するスクリプト → Job

VMの世界では、データ移行やDB初期化のとき、担当者がSSHでサーバーにログインし、シェルスクリプトを手動で実行していました。深夜のメンテナンスウィンドウにSSH接続して./migrate-data.shを叩き、完了を待ち、ログを確認して退勤する。もし途中で失敗したら、もう一度SSHで入り直して再実行する。このパターンに心当たりがある方は多いでしょう。

KubernetesのJobは、この「一度だけ実行して完了するスクリプト」をリソースとして定義します。成功・失敗のステータス管理、再試行回数の制御、タイムアウト設定をマニフェストで宣言的に記述できます。人がSSHで張り付く必要がなくなるのです。

4.2.2 cronで定期実行するバックアップ → CronJob

VMの世界では、定期バックアップを/etc/crontabに登録していました。0 2 * * * /opt/scripts/backup-db.shのような1行を書き、毎日午前2時にバックアップが走る仕組みです。しかし、このcrontabはVM内に閉じています。VMが壊れるとcrontabごと失われ、バックアップの設定を再構築する羽目になります。また、スクリプトが失敗したかどうかの確認も、ログファイルを手動で見に行く必要がありました。

KubernetesのCronJobは、crontabの機能をクラスタレベルのリソースとして提供します。スケジュール設定、実行履歴の保持数、失敗時の再試行ポリシーをマニフェストで管理できます。VMの中に隠れていたcrontabが、kubectl get cronjobsで誰でも確認できる形になるのです。

4.2.3 全VMにインストールする監視エージェント → DaemonSet

VMの世界では、全サーバーにZabbix AgentやFluentdをインストールする作業がありました。10台のサーバーがあれば10台それぞれにSSHでログインし、パッケージをインストールし、設定ファイルを配布し、サービスを起動する。新しいサーバーが追加されるたびに同じ手順を繰り返す。Ansibleなどの構成管理ツールで自動化していた方も多いでしょう。

KubernetesのDaemonSetは、「全Node(またはラベルで絞った特定のNode)に1つずつPodを配置する」ワークロードです。新しいNodeがクラスタに参加すると、DaemonSetが自動的にそのNodeにPodを作成します。Nodeが削除されれば、そのNodeのPodも自動で消えます。「全台に1つずつ」という要件を、手作業なしで維持し続けてくれるのです。

要件VMの世界Kubernetes
一度だけ実行するスクリプトSSHログイン → 手動でスクリプト実行Job
定期実行するバックアップ/etc/crontab に登録CronJob
全サーバーに監視エージェント配置Ansible等で全VMにインストールDaemonSet

それでは、この3つのリソースをTaskBoardに順番に追加していきましょう。

4.3 JobでDB初期化を自動化する

4.3.1 Jobとは何か — 「完了」するワークロード

Deploymentが管理するPodは「常時稼働」が前提です。Podが終了すると、Deploymentはすぐに新しいPodを作り直します。つまり、Deploymentにとって「Podが終了した」は異常事態です。

一方、Jobが管理するPodは「完了」が正常な終了状態です。コンテナのプロセスが終了コード0で終わると、PodのステータスはRunningではなくCompletedになります。Jobはこれを「成功」と判定し、Podを再作成しません。Podは削除されずにCompletedの状態で残り続けるため、後からkubectl logsで実行結果を確認できます。

Jobの主要なパラメータを整理しておきます。

パラメータ説明デフォルト値
backoffLimit失敗時の再試行回数6
activeDeadlineSecondsJobの最大実行時間(秒)。超過するとJobが強制終了されるなし(無制限)
ttlSecondsAfterFinished完了後にJobを自動削除するまでの秒数なし(手動削除まで残る)
completions成功が必要なPodの数1
parallelism同時に実行するPodの数1

今回のDB初期化Jobでは、completions: 1(1回成功すればよい)、backoffLimit: 3(3回まで再試行)で十分です。

4.3.2 TaskBoardのDB初期化Jobを作成する

まず、第3回で構築したTaskBoard環境が正常に稼働していることを確認します。

[Execution User: developer]

# MySQLが稼働していることを確認
kubectl get pods -n db

# TaskBoard APIが稼働していることを確認
kubectl get pods -n app

# API経由でタスク一覧を取得(データが返ればOK)
kubectl exec -n app deploy/taskboard-api -- curl -s http://localhost:8080/api/tasks
NAME      READY   STATUS    RESTARTS   AGE
mysql-0   1/1     Running   0          3h

NAME                              READY   STATUS    RESTARTS   AGE
nginx-5d8f9b7c5-k2m5x            1/1     Running   0          3h
nginx-5d8f9b7c5-r8n3p            1/1     Running   0          3h
taskboard-api-6c4d8e9f2-j4h7k    1/1     Running   0          3h
taskboard-api-6c4d8e9f2-m9w2q    1/1     Running   0          3h

[]

APIからは空の配列[]が返ってきました(第3回のテストデータが残っている場合は既存のタスクが表示されます)。MySQLは稼働中で、テーブルはJPAのDDL自動生成により作成済みです。ここに初期データを投入するJobを作成します。

第3回で少し触れましたが、TaskBoardの設計では「テーブル構造(スキーマ)の管理はJPAが担当」「初期データの投入はJobが担当」と責任を分離しています。これはVM環境での「アプリケーションのDDLスクリプトとデータローディングスクリプトを分ける」設計と同じ考え方です。

DB初期化Jobのマニフェストを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/db-init-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: db-init
  namespace: db
  labels:
    app: taskboard
    component: db-init
spec:
  # 失敗時の再試行回数(デフォルト6は多すぎるため3に制限)
  backoffLimit: 3
  # 完了後300秒(5分)でJob自動削除(ログ確認の時間を確保しつつクリーンアップ)
  ttlSecondsAfterFinished: 300
  template:
    metadata:
      labels:
        app: taskboard
        component: db-init
    spec:
      # Jobのデフォルトは"Always"だが、Jobでは"Never"または"OnFailure"が必須
      restartPolicy: Never
      containers:
        - name: db-init
          image: mysql:8.0
          # mysqlクライアントとしてのみ使用(サーバーは起動しない)
          command:
            - sh
            - -c
            - |
              # MySQLの起動を待機してから初期データを投入
              echo "Waiting for MySQL to be ready..."
              for i in $(seq 1 30); do
                if mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
                   -e "SELECT 1" > /dev/null 2>&1; then
                  echo "MySQL is ready."
                  break
                fi
                echo "Attempt $i: MySQL not ready, retrying in 2s..."
                sleep 2
              done

              # 初期データの投入(既存データがなければINSERT)
              echo "Inserting initial data..."
              mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" <<SQL
              INSERT INTO TASK (TITLE, STATUS)
              SELECT 'プロジェクト計画の作成', 'open'
              FROM dual
              WHERE NOT EXISTS (SELECT 1 FROM TASK WHERE TITLE = 'プロジェクト計画の作成');

              INSERT INTO TASK (TITLE, STATUS)
              SELECT 'サーバー環境の構築', 'open'
              FROM dual
              WHERE NOT EXISTS (SELECT 1 FROM TASK WHERE TITLE = 'サーバー環境の構築');

              INSERT INTO TASK (TITLE, STATUS)
              SELECT 'APIの設計レビュー', 'in_progress'
              FROM dual
              WHERE NOT EXISTS (SELECT 1 FROM TASK WHERE TITLE = 'APIの設計レビュー');

              INSERT INTO TASK (TITLE, STATUS)
              SELECT 'テスト計画の策定', 'open'
              FROM dual
              WHERE NOT EXISTS (SELECT 1 FROM TASK WHERE TITLE = 'テスト計画の策定');

              INSERT INTO TASK (TITLE, STATUS)
              SELECT '本番リリース準備', 'open'
              FROM dual
              WHERE NOT EXISTS (SELECT 1 FROM TASK WHERE TITLE = '本番リリース準備');
              SQL

              echo "Initial data insertion completed."
              # 投入結果を確認
              mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
                -e "SELECT ID, TITLE, STATUS FROM TASK ORDER BY ID;"
          env:
            - name: DB_HOST
              value: "mysql-0.mysql-headless.db.svc.cluster.local"
            - name: DB_NAME
              value: "taskboard"
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_USER
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_PASSWORD
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "200m"
              memory: "256Mi"
EOF

マニフェストのポイントを確認しておきます。

  • restartPolicy: Never — JobではAlwaysは使えません。Neverにすると、Podが失敗した場合は新しいPodが作られます(OnFailureにすると同じPod内でコンテナが再起動されます)。今回はNeverを選択し、失敗時に新しいPodが作られることで各試行のログを個別に確認できるようにしています
  • backoffLimit: 3 — MySQLが一時的に接続できない場合に3回まで再試行します。デフォルトの6回は長すぎるため、接続問題が解決しない場合は早めに失敗として報告させます
  • ttlSecondsAfterFinished: 300 — 完了後5分でJobとPodを自動削除します。ログを確認する時間は確保しつつ、古いJobが残り続けるのを防ぎます
  • image: mysql:8.0 — MySQLサーバーのイメージですが、ここではmysqlクライアントとしてのみ使用します。クライアント専用の軽量イメージはありませんが、同じバージョンのイメージを使うことで互換性を確保しています
  • env — DB接続先は第3回で作成したHeadless ServiceのDNS名を、認証情報はmysql-secretから参照しています。ハードコードではなく環境変数経由にすることで、接続先が変わってもマニフェストを修正するだけで対応できます
  • SQL文のWHERE NOT EXISTS — 冪等性(べきとうせい)を確保するために、同じタイトルのタスクが既に存在する場合はINSERTをスキップします。Jobが何らかの理由で再実行されてもデータが重複しません
  • resources — mysqlクライアントとして短時間実行するだけなので、requests 100m/128Mi、limits 200m/256Miの軽量な設定にしています。db NamespaceのLimitRange(min: 100m/128Mi)の範囲内です

4.3.3 Jobを実行して結果を確認する — Completedステータスの意味

Jobを適用して実行します。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/db-init-job.yaml
job.batch/db-init created

Jobの実行状況を監視します。

[Execution User: developer]

kubectl get pods -n db -l component=db-init -w
NAME            READY   STATUS    RESTARTS   AGE
db-init-7k2m5   0/1     Pending   0          0s
db-init-7k2m5   0/1     ContainerCreating   0          1s
db-init-7k2m5   1/1     Running             0          3s
db-init-7k2m5   0/1     Completed           0          8s

Ctrl+Cで監視を停止してください。ステータスがCompletedになりました。Deploymentの場合はRunningが正常ですが、Jobの場合はCompletedが正常です。「実行が完了し、正常終了した」という意味です。

Jobのステータスも確認しましょう。

[Execution User: developer]

kubectl get jobs -n db
NAME      STATUS     COMPLETIONS   DURATION   AGE
db-init   Complete   1/1           8s         30s

COMPLETIONS1/1で、STATUSCompleteです。1つのPodが成功裏に完了したことを示しています。DURATIONは実行にかかった時間です。

Jobのログを確認します。

[Execution User: developer]

kubectl logs -n db job/db-init
Waiting for MySQL to be ready...
MySQL is ready.
Inserting initial data...
Initial data insertion completed.
ID	TITLE	STATUS
1	プロジェクト計画の作成	open
2	サーバー環境の構築	open
3	APIの設計レビュー	in_progress
4	テスト計画の策定	open
5	本番リリース準備	open

5件の初期データが正常に投入されました。kubectl logs job/<Job名>でCompletedのPodのログを確認できるのは便利です。VMの世界ではスクリプトの実行ログをファイルに残す設計が必要でしたが、Jobならkubectl logsで後からいつでも確認できます。

API経由でも初期データが反映されていることを確認しましょう。

[Execution User: developer]

kubectl exec -n app deploy/taskboard-api -- curl -s http://localhost:8080/api/tasks | python3 -m json.tool
[
    {
        "id": 1,
        "title": "プロジェクト計画の作成",
        "status": "open"
    },
    {
        "id": 2,
        "title": "サーバー環境の構築",
        "status": "open"
    },
    {
        "id": 3,
        "title": "APIの設計レビュー",
        "status": "in_progress"
    },
    {
        "id": 4,
        "title": "テスト計画の策定",
        "status": "open"
    },
    {
        "id": 5,
        "title": "本番リリース準備",
        "status": "open"
    }
]

TaskBoard APIからも5件のタスクが取得できました。DB初期化Jobが正しく動作しています。

なお、ttlSecondsAfterFinished: 300を設定しているため、5分後にはこのJobとPodは自動的に削除されます。ログを確認し終わったら放置しておいて問題ありません。すぐに削除したい場合はkubectl delete job db-init -n dbで手動削除もできます。

4.4 CronJobでDBバックアップを定期実行する

4.4.1 CronJobとは何か — JobのスケジュールWrapper

CronJobは、指定したスケジュールに従ってJobを自動生成するリソースです。CronJob自体がPodを作るのではなく、スケジュールされた時刻になるとJobリソースを作成し、そのJobがPodを作って処理を実行します。つまり、CronJobはJobの「スケジューラ」です。

CronJob → (スケジュール通りに) → Job を自動生成 → Pod を作成 → 処理実行 → Completed

スケジュール構文はLinuxのcrontabと同じ5フィールド形式です。

┌───────────── 分 (0 - 59)
│ ┌───────────── 時 (0 - 23)
│ │ ┌───────────── 日 (1 - 31)
│ │ │ ┌───────────── 月 (1 - 12)
│ │ │ │ ┌───────────── 曜日 (0 - 6, 0=日曜)
│ │ │ │ │
* * * * *

例:
  */1 * * * *    → 毎分
  0 2 * * *      → 毎日午前2時
  0 2 * * 0      → 毎週日曜の午前2時
  0 0 1 * *      → 毎月1日の午前0時

CronJob固有の主要パラメータも確認しておきます。

パラメータ説明デフォルト値
schedulecrontab形式のスケジュール(必須)
successfulJobsHistoryLimit成功したJobの保持数3
failedJobsHistoryLimit失敗したJobの保持数1
concurrencyPolicy前回のJobがまだ実行中の場合の挙動(Allow / Forbid / Replace)Allow
startingDeadlineSecondsスケジュール時刻から何秒以内にJobを開始すべきかなし

successfulJobsHistoryLimitfailedJobsHistoryLimitは、CronJobが生成したJobの保持数を制御します。バックアップCronJobが毎分実行されると大量のJobが蓄積しますが、この設定で古いJobを自動的に削除してくれます。

4.4.2 TaskBoardのDBバックアップCronJobを作成する

mysqldumpでTaskBoardデータベースをバックアップするCronJobを作成します。ハンズオンでは動作確認のために毎分(*/1 * * * *)で実行します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/db-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup
  namespace: db
  labels:
    app: taskboard
    component: db-backup
spec:
  # ハンズオン用: 毎分実行(本番では "0 2 * * *" など日次に変更)
  schedule: "*/1 * * * *"
  # 成功Jobの保持数(直近3回分のログを確認可能に)
  successfulJobsHistoryLimit: 3
  # 失敗Jobの保持数(直近1回分の失敗ログを確認可能に)
  failedJobsHistoryLimit: 1
  # 前回のJobが完了していない場合は新しいJobを作らない
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 120
      template:
        metadata:
          labels:
            app: taskboard
            component: db-backup
        spec:
          restartPolicy: Never
          containers:
            - name: db-backup
              image: mysql:8.0
              command:
                - sh
                - -c
                - |
                  TIMESTAMP=$(date +%Y%m%d-%H%M%S)
                  echo "=== Backup started at ${TIMESTAMP} ==="

                  # mysqldumpでバックアップを実行
                  mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" \
                    --single-transaction \
                    "$DB_NAME" > /tmp/backup-${TIMESTAMP}.sql

                  # バックアップファイルのサイズを確認
                  ls -lh /tmp/backup-${TIMESTAMP}.sql

                  # バックアップの中身を先頭20行だけ表示(ログで確認用)
                  echo "=== Backup preview (first 20 lines) ==="
                  head -20 /tmp/backup-${TIMESTAMP}.sql

                  echo "=== Backup completed successfully ==="
              env:
                - name: DB_HOST
                  value: "mysql-0.mysql-headless.db.svc.cluster.local"
                - name: DB_NAME
                  value: "taskboard"
                - name: DB_USER
                  valueFrom:
                    secretKeyRef:
                      name: mysql-secret
                      key: MYSQL_USER
                - name: DB_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: mysql-secret
                      key: MYSQL_PASSWORD
              resources:
                requests:
                  cpu: "100m"
                  memory: "128Mi"
                limits:
                  cpu: "200m"
                  memory: "256Mi"
EOF

マニフェストのポイントを確認します。

  • schedule: "*/1 * * * *" — ハンズオンでは動作確認のため毎分実行にしています。本番環境では"0 2 * * *"(毎日午前2時)のような日次スケジュールに変更してください
  • concurrencyPolicy: Forbid — 前回のバックアップJobがまだ実行中の場合、新しいJobの作成をスキップします。バックアップが重複して実行されるのを防ぎます
  • activeDeadlineSeconds: 120 — 2分以内にバックアップが完了しない場合、Jobを強制終了します。バックアップ処理が何らかの理由でハングアップした場合の安全弁です
  • --single-transaction — mysqldumpのオプションで、バックアップ中にテーブルをロックせずに一貫性のあるスナップショットを取得します。InnoDBテーブル(TaskBoardのデフォルト)で有効です
  • バックアップ先は/tmp(Pod内のローカルファイル)です。本番環境ではS3やNFS等の外部ストレージに保存しますが、今回はCronJobの動作確認が目的のため、ログ出力で結果を確認できれば十分です

4.4.3 定期実行を観察する

CronJobを適用します。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/db-backup-cronjob.yaml
cronjob.batch/db-backup created

CronJobの状態を確認します。

[Execution User: developer]

kubectl get cronjobs -n db
NAME        SCHEDULE      TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
db-backup   */1 * * * *   <none>     False     0        <none>          10s

LAST SCHEDULEがまだ<none>です。次のスケジュール時刻(次の分の00秒)まで待ちましょう。1〜2分待ってから、生成されたJobを確認します。

[Execution User: developer]

# 2分ほど待ってから実行
kubectl get jobs -n db -l component=db-backup
NAME                   STATUS     COMPLETIONS   DURATION   AGE
db-backup-1707700800   Complete   1/1           5s         90s
db-backup-1707700860   Complete   1/1           5s         30s

CronJobが自動的にJobを生成していることが確認できます。Job名はCronJob名にタイムスタンプ(UNIXエポック秒)が付加された形式です。COMPLETIONS1/1で各Jobが正常に完了しています。

最新のバックアップJobのログを確認しましょう。

[Execution User: developer]

# 最新のJobのログを確認
kubectl logs -n db job/$(kubectl get jobs -n db -l component=db-backup \
  --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}')
=== Backup started at 20260212-020100 ===
-rw-r--r-- 1 root root 2.1K Feb 12 02:01 /tmp/backup-20260212-020100.sql
=== Backup preview (first 20 lines) ===
-- MySQL dump 10.13  Distrib 8.0.42, for Linux (x86_64)
--
-- Host: mysql-0.mysql-headless.db.svc.cluster.local    Database: taskboard
-- ------------------------------------------------------
-- Server version	8.0.42
...
=== Backup completed successfully ===

mysqldumpが正常に実行され、バックアップファイルが作成されていることが確認できます。

3〜4分経過すると、successfulJobsHistoryLimit: 3の効果を観察できます。

[Execution User: developer]

# 3〜4分待ってから実行
kubectl get jobs -n db -l component=db-backup
NAME                   STATUS     COMPLETIONS   DURATION   AGE
db-backup-1707700920   Complete   1/1           5s         2m30s
db-backup-1707700980   Complete   1/1           5s         90s
db-backup-1707701040   Complete   1/1           5s         30s

Jobが3件だけ残っています。4回以上実行されていますが、successfulJobsHistoryLimit: 3により古いJobは自動的に削除されています。VMの世界でcronのログファイルをローテーションしていたのと同じ考え方です。

動作が確認できたので、ハンズオンでの毎分実行を停止します。CronJobを一時停止するにはsuspendフィールドを使います。

[Execution User: developer]

# CronJobを一時停止(deleteではなくsuspend)
kubectl patch cronjob db-backup -n db -p '{"spec":{"suspend":true}}'
cronjob.batch/db-backup patched
# 一時停止を確認
kubectl get cronjobs -n db
NAME        SCHEDULE      TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
db-backup   */1 * * * *   <none>     True      0        30s             5m

SUSPENDTrueに変わり、新しいJobの生成が停止されました。CronJobリソース自体は残っているため、再開したいときはsuspend: falseに戻すだけです。

本番環境のヒント: ハンズオンでは*/1 * * * *(毎分)で動作確認しましたが、本番では0 2 * * *(毎日午前2時)のような日次スケジュールに変更してください。また、バックアップ先をPod内の/tmpではなくPVCやS3互換のオブジェクトストレージにすることで、Pod終了後もバックアップファイルを保持できます。

4.4.4 失敗時の挙動を体験する — backoffLimitとactiveDeadlineSeconds

ここからは「破壊」パートです。意図的にCronJobを失敗させて、Kubernetesがどう対処するかを体験します。

失敗体験用のCronJobを作成します。接続先ホストを存在しないホスト名に変更し、Jobが必ず失敗するようにします。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/db-backup-cronjob-fail.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup-fail
  namespace: db
  labels:
    app: taskboard
    component: db-backup-fail
spec:
  schedule: "*/1 * * * *"
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 2
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      # 2回まで再試行(3回目の失敗でJobが失敗確定)
      backoffLimit: 2
      # 60秒でタイムアウト(ハングアップ防止)
      activeDeadlineSeconds: 60
      template:
        metadata:
          labels:
            app: taskboard
            component: db-backup-fail
        spec:
          restartPolicy: Never
          containers:
            - name: db-backup
              image: mysql:8.0
              command:
                - sh
                - -c
                - |
                  echo "Attempting backup to non-existent host..."
                  mysqldump -h "mysql-0.non-existent.db.svc.cluster.local" \
                    -u taskboard -ptaskboard-pass \
                    --single-transaction \
                    taskboard
                  echo "This line should not be reached."
              resources:
                requests:
                  cpu: "100m"
                  memory: "128Mi"
                limits:
                  cpu: "200m"
                  memory: "256Mi"
EOF

[Execution User: developer]

kubectl apply -f ~/k8s-applied/db-backup-cronjob-fail.yaml
cronjob.batch/db-backup-fail created

1〜2分待って、失敗の様子を観察します。

[Execution User: developer]

# 1〜2分待ってから実行
kubectl get jobs -n db -l component=db-backup-fail
NAME                        STATUS   COMPLETIONS   DURATION   AGE
db-backup-fail-1707701100   Failed   0/1           60s        90s

STATUSFailedCOMPLETIONS0/1です。Jobが失敗したことが一目でわかります。

Podの状態を見てみましょう。backoffLimit: 2を設定したため、最初の失敗に加えて2回の再試行が行われるはずです。

[Execution User: developer]

kubectl get pods -n db -l component=db-backup-fail
NAME                              READY   STATUS   RESTARTS   AGE
db-backup-fail-1707701100-5k8m2   0/1     Error    0          85s
db-backup-fail-1707701100-9j3n7   0/1     Error    0          70s
db-backup-fail-1707701100-h2p4r   0/1     Error    0          45s

Podが3つあります(初回実行 + 2回の再試行)。すべてErrorステータスです。backoffLimit: 2は「再試行の上限」なので、初回を含めて合計3回実行された後にJobが失敗と判定されました。

失敗したPodのログを確認します。

[Execution User: developer]

kubectl logs -n db $(kubectl get pods -n db -l component=db-backup-fail \
  -o jsonpath='{.items[0].metadata.name}')
Attempting backup to non-existent host...
mysqldump: Got error: 2005: Unknown MySQL server host 'mysql-0.non-existent.db.svc.cluster.local' (2) when trying to connect

存在しないホストへの接続がエラーになっています。Jobの詳細情報も確認しておきましょう。

[Execution User: developer]

kubectl describe job -n db $(kubectl get jobs -n db -l component=db-backup-fail \
  -o jsonpath='{.items[0].metadata.name}') | tail -15
Events:
  Type     Reason                Age   From            Message
  ----     ------                ----  ----            -------
  Normal   SuccessfulCreate      90s   job-controller  Created pod: db-backup-fail-1707701100-5k8m2
  Normal   SuccessfulCreate      75s   job-controller  Created pod: db-backup-fail-1707701100-9j3n7
  Normal   SuccessfulCreate      50s   job-controller  Created pod: db-backup-fail-1707701100-h2p4r
  Warning  BackoffLimitExceeded  45s   job-controller  Job has reached the specified backoff limit

BackoffLimitExceededイベントが記録されています。Kubernetesは3回実行して(初回 + 再試行2回)すべて失敗した時点で、これ以上の再試行を打ち切りました。再試行の間には指数バックオフ(10秒、20秒、40秒…と待ち時間が増える)が入るため、一時的な障害であれば自動的に復旧する可能性があります。

VMの世界でcronスクリプトが失敗した場合、メールで通知が飛んで担当者が手動で対処するパターンが一般的でした。KubernetesではbackoffLimitで自動再試行してくれるため、一時的な問題であれば人手を介さずに復旧できます。それでもダメな場合にだけ、人間が介入すればよいのです。

activeDeadlineSeconds: 60の効果も確認しておきましょう。JobのDURATIONが60秒で打ち切られているのは、このパラメータのおかげです。もし接続がタイムアウトせずにハングアップするような状況であっても、60秒で強制終了されます。

確認が終わったら、失敗体験用のCronJobを削除します。

[Execution User: developer]

kubectl delete cronjob db-backup-fail -n db
cronjob.batch "db-backup-fail" deleted

CronJobを削除すると、そのCronJobが生成したJob・Podも一緒に削除されます。

4.5 DaemonSetで全Nodeにログ収集Podを配置する

4.5.1 DaemonSetとは何か — 「全Nodeに1つずつ」のワークロード

Deploymentはreplicasで「Podの数」を指定します。3レプリカなら3つのPodがスケジューラによって適切なNodeに配置されます。特定のNodeに何個配置されるかは、スケジューラの判断に委ねられます。

DaemonSetは違います。「全Nodeに1つずつ」がルールです。Worker Nodeが3台なら3つのPod、5台に増えれば自動的に5つ、1台減れば4つ。Node数の増減にDaemonSetが自動で追従します。replicasフィールドは存在しません。Podの数はNode数によって自動的に決まります。

この特性が活きるのは、各Nodeで必ず動かしたいプロセスがある場合です。ログ収集エージェント(Fluentd / Fluent Bit)、監視エージェント(Prometheus Node Exporter)、ネットワークプラグイン(Calico / Cilium)などがその代表例です。今回はbusyboxを使って簡易的なログ収集を体験しますが、仕組みは本番で使うFluentdと同じです。

なお、DaemonSetはデフォルトではControl Planeノードには配置されません。Control Planeにはnode-role.kubernetes.io/control-planeというTaint(汚れ)が付いており、明示的にTolerationを設定しない限りPodはスケジュールされません。今回のkindクラスタではWorker 3台にのみDaemonSetのPodが配置されます。

4.5.2 ログ収集DaemonSetを作成する

monitoring Namespaceにログ収集DaemonSetを作成します。本番ではFluentdやFluent Bitを使いますが、今回は仕組みの理解を優先してbusyboxで簡易実装します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/log-collector-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: log-collector
  namespace: monitoring
  labels:
    app: taskboard
    component: log-collector
spec:
  selector:
    matchLabels:
      app: taskboard
      component: log-collector
  template:
    metadata:
      labels:
        app: taskboard
        component: log-collector
    spec:
      containers:
        - name: log-collector
          image: busybox:1.36
          # コンテナログディレクトリをtailで監視(簡易ログ収集を模擬)
          command:
            - sh
            - -c
            - |
              echo "Log collector started on $(hostname)"
              echo "Watching /var/log/containers/ ..."
              # コンテナログの新しい行を継続的に出力
              tail -F /var/log/containers/*.log 2>/dev/null || \
                echo "No log files found, waiting..." && sleep infinity
          volumeMounts:
            # ホストのコンテナログディレクトリを読み取り専用でマウント
            - name: container-logs
              mountPath: /var/log/containers
              readOnly: true
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "100m"
              memory: "128Mi"
      # ホストのログディレクトリをPodに公開
      volumes:
        - name: container-logs
          hostPath:
            path: /var/log/containers
            type: Directory
      # DaemonSetを安定させるための設定
      terminationGracePeriodSeconds: 10
EOF

マニフェストのポイントを確認します。

  • hostPath — ホスト(Node)のファイルシステムをPod内にマウントする仕組みです。/var/log/containersはKubernetesが各Nodeに作成するコンテナログのディレクトリで、そのNode上で動いている全Podのログファイルへのシンボリックリンクが格納されています。これをマウントすることで、各NodeのPodがそのNodeのログにアクセスできます
  • readOnly: true — ログ収集エージェントが誤ってログファイルを書き換えたり削除したりするのを防ぎます
  • type: Directory — マウント対象がディレクトリであることを明示します。パスが存在しない場合はPodの起動がエラーになります
  • tail -F-F(大文字)は-f(小文字)と異なり、ファイルがローテーションされた場合にも追従します。ログ収集ではこちらが適切です
  • resources — busyboxのtailは非常に軽量なので、requests 50m/64Mi、limits 100m/128Miの最小限の設定にしています。monitoring NamespaceのLimitRange(min: 25m/32Mi)の範囲内です。Worker 3台分の合計でも、requests 150m/192Mi、limits 300m/384Mi で、monitoring NamespaceのResourceQuota(requests: 500m/512Mi)内に収まります

4.5.3 Node数と同数のPodが自動配置されることを確認する

DaemonSetを適用します。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/log-collector-daemonset.yaml
daemonset.apps/log-collector created

DaemonSetの状態を確認します。

[Execution User: developer]

kubectl get daemonsets -n monitoring
NAME            DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
log-collector   3         3         3       3            3           <none>          15s

DESIRED3です。kindクラスタのWorker Nodeが3台なので、DaemonSetは3つのPodを作成しました。Control Planeノードにはデフォルトで配置されないため、DESIREDはWorker数と一致します。

各PodがどのNodeに配置されているかを確認しましょう。

[Execution User: developer]

kubectl get pods -n monitoring -o wide
NAME                  READY   STATUS    RESTARTS   AGE   IP            NODE                    NOMINATED NODE   READINESS GATES
log-collector-4k8m2   1/1     Running   0          20s   10.244.1.12   k8s-applied-worker      <none>           <none>
log-collector-7j3n5   1/1     Running   0          20s   10.244.2.15   k8s-applied-worker2     <none>           <none>
log-collector-h2p9r   1/1     Running   0          20s   10.244.3.10   k8s-applied-worker3     <none>           <none>

NODE列を見てください。k8s-applied-workerk8s-applied-worker2k8s-applied-worker3のそれぞれに1つずつPodが配置されています。k8s-applied-control-planeには配置されていません。これがDaemonSetの「全Nodeに1つずつ」の動作です。

ログ収集が動作していることを確認します。

[Execution User: developer]

# worker上のPodのログを確認(最初の10行)
kubectl logs -n monitoring $(kubectl get pods -n monitoring \
  -o jsonpath='{.items[0].metadata.name}') | head -10
Log collector started on k8s-applied-worker
Watching /var/log/containers/ ...
{"log":"...","stream":"stdout","time":"2026-02-12T..."}

各NodeのコンテナログがPodに流れ込んでいます。本番環境では、このPodの代わりにFluentdやFluent Bitを配置し、ログを集約基盤(Elasticsearch、CloudWatch Logs等)に転送します。DaemonSetの仕組みは同じです。

Deploymentとの違いを体感するために、Nodeの一覧も見ておきましょう。

[Execution User: developer]

kubectl get nodes
NAME                         STATUS   ROLES           AGE   VERSION
k8s-applied-control-plane    Ready    control-plane   4h    v1.32.0
k8s-applied-worker           Ready    <none>          4h    v1.32.0
k8s-applied-worker2          Ready    <none>          4h    v1.32.0
k8s-applied-worker3          Ready    <none>          4h    v1.32.0

Worker Node 3台に対して、DaemonSetのPodが3つ。もしWorkerが5台に増えれば、DaemonSetのPodも自動的に5つに増えます。VMの世界でサーバーを追加するたびにAnsibleで監視エージェントをインストールしていた作業が不要になるのです。

4.6 ワークロード選定の総まとめ — 5つの選択肢

ここまでの応用編(第1回〜第4回)で、Kubernetesの5つのワークロードリソースがすべて揃いました。Deployment(入門編)、StatefulSet(第3回)、Job・CronJob・DaemonSet(本回)です。このセクションでは、「どのワークロードを選ぶべきか」を判断するフローチャートを整理します。

4.6.1 ワークロード選定フローチャート

ワークロードを選ぶときの判断プロセスを以下のフローチャートにまとめます。

                     常時稼働させたいか?
                    ┌──── Yes ────┐
                    │              │
              全Nodeに1つずつ?     No → 一度だけ実行?
              ┌─ Yes ─┐            ┌─ Yes ─┐
              │        │            │        │
          DaemonSet   No        Job     No → CronJob
                       │
                状態(データ)を持つか?
                ┌─ Yes ─┐
                │        │
            StatefulSet  No → Deployment

判断の出発点は「常時稼働させたいか」です。この質問でまず大きく2つに分かれます。

常時稼働させたい場合は、次に「全Nodeに1つずつ配置する必要があるか」を判断します。ログ収集や監視エージェントのように全Nodeで動かしたいならDaemonSet、そうでなければ「状態(データ)を持つか」で判断します。データベースやメッセージキューのようにPod名の固定やPVC専有が必要ならStatefulSet、そうでなければDeploymentです。

常時稼働が不要な場合は、「一度だけ実行するか」で判断します。データ移行やDB初期化のように一度完了すれば終わりの処理はJob、定期的に繰り返す処理はCronJobです。

5つのワークロードの特徴を一覧にまとめます。

ワークロード稼働パターンPod数の決め方典型的な用途
Deployment常時稼働replicasで指定Webサーバー、APIサーバー
StatefulSet常時稼働 + 状態保持replicasで指定データベース、メッセージキュー
DaemonSet常時稼働(全Nodeに1つずつ)Node数で自動決定ログ収集、監視エージェント
Job一度だけ実行completionsで指定DB初期化、データ移行
CronJob定期実行スケジュールに従いJobを生成バックアップ、レポート生成

4.6.2 TaskBoardの各コンポーネントで振り返る

TaskBoardの全コンポーネントがどのワークロードを使っているか、その選定理由とともに振り返ります。

コンポーネントワークロード選定理由
Nginx(フロントエンド)Deployment常時稼働、ステートレス、複数レプリカで冗長化
TaskBoard APIDeployment常時稼働、ステートレス(データはMySQLに委譲)、複数レプリカで冗長化
MySQL(データベース)StatefulSet常時稼働、Pod名の固定(mysql-0)、PVCとの1対1紐付けが必要
DB初期化Job一度だけ実行して完了、初期データの投入
DBバックアップCronJob定期実行、日次でmysqldumpを実行
ログ収集DaemonSet全Worker Nodeで動作、各NodeのログをhostPathで収集

TaskBoardの6つのコンポーネントが、5つのワークロードをすべて使い分けています。「Nginx → Deployment」「MySQL → StatefulSet」「バックアップ → CronJob」という選択は、フローチャートに当てはめれば自然に導き出せます。応用編の後半でさらにGateway APIやNetworkPolicyなどを追加しますが、ワークロード選定の判断基準はここで確立したものをそのまま使い続けます。

4.7 この回のまとめ

4.7.1 TaskBoardの現在地

今回の作業で、TaskBoardは以下の状態になりました。

第4回終了時の構成:

[app Namespace]
  ├── Nginx (Deployment, 2レプリカ) + Service (ClusterIP)
  └── TaskBoard API (Deployment, 2レプリカ, MySQL接続版 v2.0.0) + Service (NodePort:30080)
      └── → mysql-0.mysql-headless.db.svc.cluster.local に接続

[db Namespace]
  ├── MySQL (StatefulSet, 1レプリカ) + Headless Service + PVC(1Gi)
  ├── mysql-secret (Secret)
  ├── DB初期化Job(初期データ5件を投入済み — 完了後に自動削除)
  └── DBバックアップCronJob(一時停止中 — 本番では日次に設定)

[monitoring Namespace]
  └── ログ収集DaemonSet(busybox, 全Worker Node 3台に配置)

基盤:
  ├── kindクラスタ (CP 1 + Worker 3)
  ├── Metrics Server 稼働中
  ├── 各NamespaceにResourceQuota / LimitRange適用済み
  └── 各NamespaceにRBAC適用済み(developer / operator ServiceAccount)

3つのNamespaceすべてにワークロードが配置されました。db NamespaceにはDB初期化JobとバックアップCronJobが追加され、monitoring NamespaceにはDaemonSetによるログ収集が稼働しています。

4.7.2 ワークロード選定の判断基準 — いつ使う / いつ使わない

ワークロード使うべきケース使わないケース
JobDB初期化、データ移行、1回限りのバッチ処理、テストデータ投入定期実行が必要(→ CronJob)、常時稼働が必要(→ Deployment等)
CronJob定期バックアップ、レポート生成、ログローテーション、定期データ同期1回だけの実行(→ Job)、秒単位の精度が必要(→ 外部スケジューラ)
DaemonSetログ収集、監視エージェント、ネットワークプラグイン、ストレージドライバ特定のNode数だけに配置したい(→ Deployment + nodeSelector)、一時的な処理(→ Job)

判断に迷ったときは、4.6.1のフローチャートに立ち戻ってください。「常時稼働か?」「全Nodeに1つずつか?」「一度だけか?」の3つの質問で、5つのワークロードのうちどれを使うべきかが決まります。

4.7.3 実践編への橋渡し

今回学んだ3つのワークロードの知識は、実践編で複数の場面で活用されます。

実践編 第2回「基本設計」では、TaskBoardの各コンポーネントに対して「なぜこのワークロードを選んだのか」を設計書として言語化します。今回体験した選定フローチャートが、そのまま設計判断の根拠になります。

実践編 第7回「運用設計」では、CronJobによるバックアップの運用設計(スケジュール、保持期間、リストア手順)を本格的に設計します。今回のbackoffLimitactiveDeadlineSecondsの体験が、障害時のリカバリ設計の基盤になります。

4.7.4 次回予告

次回は第5回「外部トラフィックの入口設計 — Gateway API」です。現在TaskBoardへのアクセスはNodePort経由ですが、これをGateway APIによるL7ルーティングに置き換えます。/へのリクエストはNginx(フロントエンド)へ、/apiへのリクエストはTaskBoard APIへと、パスベースで振り分けるルーティングを構築します。VMの世界でF5 BIG-IPやNSXロードバランサーで行っていたVirtual Server + iRuleの設計が、Kubernetesではどう変わるかを体験しましょう。

AIコラム

💡 AIの活用ヒント

「このバッチ処理はJobにすべきかCronJobにすべきか」「DaemonSetとDeployment + nodeAffinityのどちらが適切か」といったワークロード選定の判断は、AIに要件を伝えて壁打ちすると効率的です。
例:「月次のレポート生成処理がある。実行に30分かかる。失敗したら翌月まで待てないので再実行したい。JobとCronJobのどちらが適切か、パラメータも含めて教えて」

AIはユースケースに応じた推奨パラメータ(backoffLimit、activeDeadlineSeconds、concurrencyPolicy等)も提案してくれます。ただし、最終的にはあなたの環境の制約(クラスタのリソース余裕、バックアップウィンドウの長さ、SLA要件等)を踏まえて判断してください。

kubernetes