本記事には広告(アフィリエイトリンク)が含まれます。

Kubernetes実践編 #05

5.1 はじめに

5.1.1 前回の振り返り — 基盤が整った状態

前回(第4回)で、アプリケーションを載せるための「箱」を整備しました。現在のクラスタの状態を確認しておきましょう。

[クラスタ基盤]
  kindクラスタ(CP 1 + Worker 3)— 全ノードReady
  Calico CNI — 稼働中
  Metrics Server — 稼働中
  Gateway APIコントローラー(Envoy Gateway)— 稼働中

[Namespace + リソース管理]
  app — ResourceQuota + LimitRange 適用済み
  db — ResourceQuota + LimitRange 適用済み
  monitoring — ResourceQuota + LimitRange 適用済み

[RBAC]
  developer / operator ServiceAccount + Role + RoleBinding — 各Namespace適用済み

[アプリケーション]
  なし(白紙状態)

Namespace、ResourceQuota、LimitRange、RBACが設計書通りに構成されています。しかし、アプリケーションはまだ何もデプロイされていません。基盤は整ったが、中身は空——これが今回の出発点です。

5.1.2 本回の問題提起 — 「どの順番で適用するのか」

第3回(詳細設計)で、TaskBoardの全マニフェストを作成しました。Deployment、StatefulSet、Service、HPA、PDB、Secret、ConfigMap、Job、CronJob、DaemonSet——合計十数個のマニフェストが手元にあります。

では、これらをどの順番でクラスタに適用するのか。

応用編では、学習テーマに沿って1つずつリソースを追加していきました。「第3回でStatefulSetを追加」「第4回でJobとCronJobを追加」という具合に、毎回の学習目的が適用順序を決めていました。

実践編の構築は違います。設計書が先にあり、依存関係に基づいた計画的な順序でマニフェストを適用します。「SecretがStatefulSetより先に必要」「データベースがアプリケーションより先に必要」——こうした依存関係を整理し、各レイヤーで検証してから次に進む。これが本番の構築プロセスです。

5.1.3 本回のゴールと成果物

本回のゴールは、設計書のマニフェストを依存順序に従ってクラスタに適用し、全コンポーネントが内部的に正常稼働している状態を作ることです。

成果物は以下の2つです。

  • 内部稼働中のTaskBoard: Secret/ConfigMap、MySQL(StatefulSet)、TaskBoard API(Deployment)、Nginx(Deployment)、DB初期化Job、DBバックアップCronJob、ログ収集DaemonSet、HPA、PDBが全て動作している状態
  • 構築手順書: 本回で実行した手順そのものが、依存関係を考慮した構築手順書の実例になります

Gateway APIリソース(Gateway、HTTPRoute)とNetworkPolicyは本回では適用しません。まず全コンポーネントが内部で正常に動作することを確認し、外部公開と通信制御は第6回で追加します。この段階的なアプローチの理由は5.2節で説明します。

5.2 VMの構築手順とK8sのデプロイ順序

5.2.1 VMの世界での構築手順 — DB→AP→Webの依存順序

VMwareの世界でWebシステムを構築する際の手順書を思い出してください。3層構成のシステムであれば、構築順序は決まっていました。

  • 第1段階: データベースサーバー — MySQLをインストールし、スキーマを作成し、接続テストを行う。「DBが動いている」ことを確認してから次へ
  • 第2段階: アプリケーションサーバー — Payara(あるいはTomcat)をインストールし、WARをデプロイし、DBへの接続を確認する。「APがDBに接続できる」ことを確認してから次へ
  • 第3段階: Webサーバー — Nginxをインストールし、リバースプロキシを設定し、APへの転送を確認する
  • 第4段階: ファイアウォール設定 — 全サーバー間の通信が確認できてから、ファイアウォールルールを適用する

各段階の間には必ず「完了確認」がありました。DBサーバーの構築手順書には「mysql -u root -p でログインできること」という確認項目があり、APサーバーの手順書には「ヘルスチェックURLにアクセスして200が返ること」という確認項目がありました。確認せずに次に進むと、問題の切り分けが困難になります。

K8sでの構築も同じです。依存関係を考慮した順序で適用し、各レイヤーで検証してから次に進む。ファイアウォール(NetworkPolicy)は最後に設定する。このリズムはVMの構築手順書と変わりません。

5.2.2 K8sのマニフェスト適用順序 — 依存関係グラフ

第4回の4.2.3節で設計した全体の構築順序のうち、本回(7〜15番)の適用順序を依存関係に基づいて整理します。

Step 1: Secret / ConfigMap
  └→ 他のリソースから参照される「前提条件」

Step 2: MySQL StatefulSet + Headless Service
  └→ アプリケーションが依存するデータストア
  └→ SecretをenvFromで参照 → Step 1に依存

Step 3: DB初期化Job
  └→ MySQLが起動していないと実行できない → Step 2に依存
  └→ SecretをenvFrom/secretKeyRefで参照 → Step 1に依存

Step 4: TaskBoard API Deployment + Service / Nginx Deployment + Service
  └→ DBが稼働してから初めてデプロイ可能(APIはDB接続が必要)
  └→ SecretをsecretKeyRefで参照 → Step 1に依存
  └→ ConfigMapをvolumeMountで参照 → Step 1に依存

Step 5: 補助ワークロード(CronJob / DaemonSet)
  └→ CronJobはDBに接続 → Step 2に依存
  └→ DaemonSetは独立だが、コアが動いてから追加が安全

Step 6: スケーリング・可用性(HPA / PDB)
  └→ 対象のDeploymentが存在していること → Step 4に依存
  └→ HPAはMetrics Serverからメトリクスを取得

Step 7: 内部疎通テスト
  └→ 全コンポーネントが稼働していること

この順序を崩すと何が起きるか、いくつかの例を挙げます。

  • SecretよりStatefulSetを先に適用した場合: MySQLのPodがSecretを参照できず、CreateContainerConfigErrorで起動に失敗します
  • MySQLよりTaskBoard APIを先にデプロイした場合: APIがDB接続に失敗し、readinessProbe(DB接続確認を含む)が通らず、ServiceのEndpointに登録されません
  • DeploymentよりHPAを先に適用した場合: ScaleTargetRefが見つからず、HPAがUnable to find targetのエラーを出し続けます

なお、本回ではNetworkPolicyとGateway APIリソース(Gateway、HTTPRoute)を適用しません。これには明確な理由があります。VMの構築でも「まずサーバー間通信を確認してからファイアウォールを設定する」のが一般的です。NetworkPolicyが未適用の状態で全Pod間の疎通を確認しておけば、第6回でNetworkPolicyを適用した後に通信が遮断された場合、「NetworkPolicyが原因」と即座に切り分けられます。段階的に構築し、各段階で動作を確認する——これがトラブル切り分けを容易にする基本原則です。

5.2.3 適用前の安全確認 — dry-run と diff

応用編ではkubectl apply -fを直接実行していました。実践編では適用前に2つの安全確認を行います。

kubectl apply –dry-run=server は、マニフェストをAPIサーバーに送信してバリデーションを実行しますが、実際にはリソースを作成しません。--dry-run=clientがクライアント側でYAMLの文法チェックのみ行うのに対し、--dry-run=serverはAPIサーバーのwebhookやadmission controllerも実行されるため、より正確な事前検証ができます。

kubectl diff は、現在のクラスタ状態と適用予定のマニフェストの差分を表示します。新規作成時は全フィールドが差分として表示されますが、既存リソースの更新時に威力を発揮します。「意図しないフィールドが変更されていないか」を確認できます。

この2つのコマンドは、本番環境でマニフェストを適用する前の標準的な確認手順です。本回の各ステップで実際に使用します。

5.3 コンテナイメージを準備する

マニフェストを適用する前に、コンテナイメージを準備します。応用編ではTaskBoard APIのイメージを段階的にビルドしていきました。第1回でインメモリ版(1.0.0)、第3回でMySQL接続版(2.0.0)、第8回でカスタムHealthCheck追加版(3.0.0)と、学習テーマに合わせて段階的にファイルを追加・差し替えていきました。

実践編では、完成版のソースコードを一括で配置してビルドします。応用編は「学習のためのステップバイステップ」、実践編は「本番のように一括構築」——このプロセスの違いを意識してください。

5.3.1 TaskBoard APIのDockerイメージをビルドする

まず、作業ディレクトリを準備し、TaskBoard APIのソースコード一式を配置します。以下のファイルは、応用編で段階的に作成・更新してきたものの完成版です。

ファイル応用編での初出役割
pom.xml応用編 第1回Mavenビルド定義(Jakarta EE 11 / MicroProfile 6.1 依存)
Dockerfile応用編 第1回multi-stage build定義
TaskBoardApplication.java応用編 第1回JAX-RSアプリケーション定義(@ApplicationPath)
TaskResource.java応用編 第1回REST APIエンドポイント(/api/tasks)
Task.java応用編 第1回→第3回でJPAアノテーション追加JPAエンティティ
TaskService.java応用編 第1回→第3回で差し替えMySQL接続版(JPA)
DataSourceConfig.java応用編 第3回@DataSourceDefinitionによるJDBCデータソース定義
persistence.xml応用編 第3回MySQL接続設定(JPA)
HealthCheckLive.java応用編 第8回カスタムLivenessチェック
HealthCheckReady.java応用編 第8回カスタムReadinessチェック(DB接続確認を含む)
beans.xml応用編 第8回CDIビーン発見の有効化(bean-discovery-mode=”all”)

応用編の作業ディレクトリ(~/k8s-applied/taskboard-api/)に完成版のソースコードが残っています。これをそのままコピーして使います。応用編は「学習のためのステップバイステップ」でしたが、実践編では既にあるものを活用するのが本番のアプローチです。

[Execution User: developer]

# 実践編の作業ディレクトリを作成し、応用編のソースコードをコピー
mkdir -p ~/k8s-production/manifests
rm -rf ~/k8s-production/taskboard-api
cp -r ~/k8s-applied/taskboard-api ~/k8s-production/

応用編第8回では学習ステップに合わせてイメージタグを3.0.0としていましたが、実践編では完成版を2.0.0として一括ビルドします。HealthCheckLive.java内のバージョン文字列とpom.xmlのバージョンを修正します。

[Execution User: developer]

# HealthCheckLive.javaのバージョン文字列を 3.0.0 → 2.0.0 に変更
sed -i 's/"version", "3.0.0"/"version", "2.0.0"/' \
  ~/k8s-production/taskboard-api/src/main/java/com/taskboard/HealthCheckLive.java

# pom.xmlのプロジェクトバージョンを 3.0.0 → 2.0.0 に変更
sed -i '0,/<version>3.0.0<\/version>/s/<version>3.0.0<\/version>/<version>2.0.0<\/version>/' \
  ~/k8s-production/taskboard-api/pom.xml

ディレクトリ構成を確認します。

[Execution User: developer]

find ~/k8s-production/taskboard-api -type f | sort
/home/developer/k8s-production/taskboard-api/Dockerfile
/home/developer/k8s-production/taskboard-api/pom.xml
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/DataSourceConfig.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/HealthCheckLive.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/HealthCheckReady.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/Task.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/TaskBoardApplication.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/TaskResource.java
/home/developer/k8s-production/taskboard-api/src/main/java/com/taskboard/TaskService.java
/home/developer/k8s-production/taskboard-api/src/main/resources/META-INF/persistence.xml
/home/developer/k8s-production/taskboard-api/src/main/webapp/WEB-INF/beans.xml

変更が正しく反映されたことを確認しておきます。

[Execution User: developer]

grep 'withData' ~/k8s-production/taskboard-api/src/main/java/com/taskboard/HealthCheckLive.java
                .withData("version", "2.0.0")

応用編では第1回から第8回にかけて段階的に追加してきた11ファイルが、ここでは最初から全て揃っています。Dockerイメージをビルドします。

[Execution User: developer]

cd ~/k8s-production/taskboard-api
docker build --no-cache -t taskboard-api:2.0.0 .

multi-stage buildにより、Mavenビルドステージでソースコードがコンパイルされ、実行ステージでpayara/micro:7.2026.1ベースのイメージが作成されます。ホストにJDKやMavenをインストールする必要はありません。--no-cacheを付けているのは、応用編でビルドした際のDockerキャッシュが残っていても確実に新しいソースが反映されるようにするためです。ビルドの最終行にnaming to docker.io/library/taskboard-api:2.0.0と表示されれば成功です。

イメージが作成されたことを確認します。

[Execution User: developer]

docker images taskboard-api
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
taskboard-api    2.0.0     a1b2c3d4e5f6   10 seconds ago   250MB

5.3.2 Nginxのカスタムイメージを準備する

Nginxは標準のnginx:1.27イメージをそのまま使用します。応用編第7回で非root化した際、カスタムDockerイメージを作成するのではなく、ConfigMapで非root対応版のnginx.confをマウントする方式を採用しました。第3回の詳細設計でも同じ方式を設計しています。

このため、Nginxについては別途のイメージビルドは不要です。ただし、Docker Hubのnginx:1.27はマルチアーキテクチャ(amd64/arm64等)のマニフェストリスト形式で配布されており、この形式のままkind load docker-imageを実行すると、containerdのインポート処理でレイヤー不整合エラーが発生する場合があります。docker buildFROM --platform=linux/amd64を指定し、シングルプラットフォームのイメージとして取得します。

[Execution User: developer]

# kindにロードするため、シングルプラットフォームのイメージとして取得する
# Docker Hubのnginx:1.27はマルチアーキテクチャイメージのため、そのままkind loadすると
# containerdのインポート処理でレイヤー不整合エラーが発生する場合がある
# docker buildでFROM --platformを指定し、確実にamd64のみのイメージを作成する
# --provenance=falseでBuildKitのattestation生成を無効化し、シングルマニフェスト形式にする
docker rmi nginx:1.27 2>/dev/null || true
docker build --no-cache --provenance=false -t nginx:1.27 - <<'EOF'
FROM --platform=linux/amd64 nginx:1.27
EOF

5.3.3 kindクラスタにイメージを投入する

ビルドしたTaskBoard APIイメージと、Nginxイメージをkindクラスタに投入します。kindクラスタのNodeはDockerコンテナであり、ホスト上のDockerイメージを直接参照できません。kind load docker-imageでイメージを各Nodeに転送します。

[Execution User: developer]

# TaskBoard APIイメージをkindクラスタに投入
kind load docker-image taskboard-api:2.0.0 --name k8s-applied

# Nginxイメージをkindクラスタに投入
kind load docker-image nginx:1.27 --name k8s-applied
Image: "taskboard-api:2.0.0" with ID "sha256:a1b2c3d4..." not yet present on node "k8s-applied-worker", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:a1b2c3d4..." not yet present on node "k8s-applied-worker2", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:a1b2c3d4..." not yet present on node "k8s-applied-worker3", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:a1b2c3d4..." not yet present on node "k8s-applied-control-plane", loading...
Image: "nginx:1.27" with ID "sha256:e5f6a7b8..." not yet present on node "k8s-applied-worker", loading...
Image: "nginx:1.27" with ID "sha256:e5f6a7b8..." not yet present on node "k8s-applied-worker2", loading...
Image: "nginx:1.27" with ID "sha256:e5f6a7b8..." not yet present on node "k8s-applied-worker3", loading...
Image: "nginx:1.27" with ID "sha256:e5f6a7b8..." not yet present on node "k8s-applied-control-plane", loading...

全4ノード(CP 1 + Worker 3)に両イメージが転送されました。これでコンテナイメージの準備は完了です。

5.4 Step 1 — Secret / ConfigMapを適用する

最初に適用するのは、他のリソースから参照される「前提条件」であるSecretとConfigMapです。VMの構築で言えば、ミドルウェアをインストールする前に設定ファイルとパスワードを準備する段階に相当します。

5.4.1 MySQL Secretを適用する

まず、dry-runで事前検証を行います。実践編での最初のマニフェスト適用なので、--dry-run=serverの使い方を確認しておきましょう。

マニフェストの内容は第3回(詳細設計)の3.6.2節で設計した通りです。db NamespaceとApp Namespaceの両方にSecretを作成します。

第3回(詳細設計)の3.6.2節で設計したSecretをマニフェストファイルとして作成します。

[Execution User: developer]

# db Namespace用Secret(MySQL全認証情報)
cat <<'EOF' > ~/k8s-production/manifests/mysql-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
  namespace: db
  labels:
    app: taskboard
    component: db
type: Opaque
stringData:
  MYSQL_ROOT_PASSWORD: "taskboard-root-pass"
  MYSQL_DATABASE: "taskboard"
  MYSQL_USER: "taskboard"
  MYSQL_PASSWORD: "taskboard-pass"
EOF

# app Namespace用Secret(TaskBoard APIが参照する認証情報のみ)
cat <<'EOF' > ~/k8s-production/manifests/mysql-secret-app.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
  namespace: app
  labels:
    app: taskboard
    component: api
type: Opaque
stringData:
  MYSQL_USER: "taskboard"
  MYSQL_PASSWORD: "taskboard-pass"
EOF

db NamespaceのSecretには4キー(ROOT_PASSWORD、DATABASE、USER、PASSWORD)、app NamespaceのSecretにはTaskBoard APIが参照する2キー(USER、PASSWORD)のみを格納しています。Secretはクロスnamespace参照ができないため、両方のNamespaceに作成する必要があります(応用編第3回で学びました)。

[Execution User: developer]

# dry-run(サーバーサイド検証)
kubectl apply -f ~/k8s-production/manifests/mysql-secret.yaml --dry-run=server
secret/mysql-secret created (server dry run)

(server dry run)と表示されています。APIサーバーのバリデーションを通過しましたが、実際にはリソースは作成されていません。Namespaceやフィールドのスキーマに問題がないことが事前に確認できました。問題がなければ実際に適用します。

[Execution User: developer]

# db Namespace用Secret
kubectl apply -f ~/k8s-production/manifests/mysql-secret.yaml

# app Namespace用Secret(TaskBoard APIが参照する認証情報のみ)
kubectl apply -f ~/k8s-production/manifests/mysql-secret-app.yaml
secret/mysql-secret created
secret/mysql-secret created

5.4.2 Nginx ConfigMapを適用する

応用編第7回で作成した非root対応版のnginx.confをConfigMapとして適用します。listen 8080、PID/一時ファイル/ログを/tmp配下に配置する設定です。

[Execution User: developer]

cat <<'EOF' > ~/k8s-production/manifests/nginx-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
  namespace: app
  labels:
    app: taskboard
    component: frontend
data:
  nginx.conf: |
    worker_processes  auto;
    pid        /tmp/nginx.pid;

    events {
        worker_connections  1024;
    }

    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;

        client_body_temp_path /tmp/client_temp;
        proxy_temp_path       /tmp/proxy_temp;
        fastcgi_temp_path     /tmp/fastcgi_temp;
        uwsgi_temp_path       /tmp/uwsgi_temp;
        scgi_temp_path        /tmp/scgi_temp;

        log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                          '$status $body_bytes_sent "$http_referer" '
                          '"$http_user_agent"';

        access_log  /tmp/access.log  main;
        error_log   /tmp/error.log;

        sendfile        on;
        keepalive_timeout  65;

        server {
            listen       8080;
            server_name  localhost;

            location / {
                root   /usr/share/nginx/html;
                index  index.html index.htm;
            }

            error_page   500 502 503 504  /50x.html;
            location = /50x.html {
                root   /usr/share/nginx/html;
            }
        }
    }
EOF

[Execution User: developer]

# dry-runで事前検証
kubectl apply -f ~/k8s-production/manifests/nginx-configmap.yaml --dry-run=server
configmap/nginx-config created (server dry run)

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/nginx-configmap.yaml
configmap/nginx-config created

5.4.3 適用結果を確認する

Step 1の検証です。Secret/ConfigMapが正しいNamespaceに作成されたことを確認します。

[Execution User: developer]

# db Namespaceの確認
kubectl get secret -n db

# app Namespaceの確認
kubectl get secret,configmap -n app
NAME           TYPE     DATA   AGE
mysql-secret   Opaque   4      15s

NAME           TYPE     DATA   AGE
mysql-secret   Opaque   2      12s

NAME                     DATA   AGE
configmap/nginx-config   1      8s

db NamespaceのSecretは4キー(ROOT_PASSWORD、DATABASE、USER、PASSWORD)、app NamespaceのSecretは2キー(USER、PASSWORD)、ConfigMapは1キー(nginx.conf)です。設計書通りの構成であることを確認できました。Step 1完了です。

5.5 Step 2 — データベース層を構築する

VMの構築順序でも最初に構築するのはDBサーバーです。K8sでも同じく、アプリケーション層が依存するデータベース層を先に構築します。

5.5.1 MySQL StatefulSetをデプロイする

MySQL StatefulSetのマニフェストは第3回で設計したものです。StatefulSetとHeadless Serviceを1つのファイルにまとめて適用します。

まずkubectl diffで差分を確認してみましょう。新規作成時は全フィールドが差分として表示されますが、リソースの内容を適用前に確認する習慣をつけておくことが重要です。

MySQL StatefulSetとHeadless Serviceのマニフェストを作成します。第3回で設計した内容です。

[Execution User: developer]

cat <<'EOF' > ~/k8s-production/manifests/mysql-statefulset.yaml
# Headless Service(StatefulSetと同一ファイルにまとめる)
apiVersion: v1
kind: Service
metadata:
  name: mysql-headless
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  clusterIP: None
  selector:
    app: taskboard
    component: db
  ports:
    - port: 3306
      targetPort: 3306
      name: mysql
---
# MySQL StatefulSet
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  serviceName: mysql-headless
  replicas: 1
  selector:
    matchLabels:
      app: taskboard
      component: db
  template:
    metadata:
      labels:
        app: taskboard
        component: db
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          envFrom:
            - secretRef:
                name: mysql-secret
          ports:
            - containerPort: 3306
              name: mysql
          resources:
            requests:
              cpu: "200m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:
            tcpSocket:
              port: 3306
            initialDelaySeconds: 15
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            tcpSocket:
              port: 3306
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
            successThreshold: 1
          securityContext:
            runAsNonRoot: true
            runAsUser: 999
            allowPrivilegeEscalation: false
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql
            - name: tmp
              mountPath: /tmp
            - name: run-mysqld
              mountPath: /var/run/mysqld
      volumes:
        - name: tmp
          emptyDir: {}
        - name: run-mysqld
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: mysql-data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi
EOF

[Execution User: developer]

# 適用前に差分を確認
kubectl diff -f ~/k8s-production/manifests/mysql-statefulset.yaml

新規作成のため、マニフェストの全内容が差分(+行)として表示されます。内容に問題がなければ適用します。

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/mysql-statefulset.yaml
service/mysql-headless created
statefulset.apps/mysql created

5.5.2 kubectl waitでPod起動を待機する

応用編ではPodの起動をkubectl get pods -n dbで手動確認していました。実践編ではkubectl waitを使って起動完了をスクリプト的に待機します。構築手順書に組み込めるコマンドであり、CI/CDパイプラインでも同様に使用されます。

[Execution User: developer]

# mysql-0がReady状態になるまで待機(最大120秒)
kubectl wait --for=condition=ready pod/mysql-0 -n db --timeout=120s
pod/mysql-0 condition met

condition metが表示されれば、MySQLのPodが起動し、readinessProbe(tcpSocket port 3306)が通過しています。MySQLの初回起動にはデータベースの初期化処理が含まれるため、20〜40秒ほどかかることがあります。タイムアウトを120秒に設定しているのはそのためです。

PVCの自動作成も確認しておきます。StatefulSetのvolumeClaimTemplatesにより、PVCが自動的に作成されます。

[Execution User: developer]

kubectl get pvc -n db
NAME                 STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
mysql-data-mysql-0   Bound    pvc-a1b2c3d4-e5f6-7890-abcd-ef1234567890   1Gi        RWO            standard       45s

PVCのステータスがBoundであり、1GiのPersistentVolumeが割り当てられています。

5.5.3 MySQL接続テストで動作を検証する

PodがRunningでも、MySQLプロセスが正常に動作しているとは限りません。実際に接続して確認します。

[Execution User: developer]

# mysql-0に直接接続して動作確認
kubectl exec -it mysql-0 -n db -- mysql -u taskboard -ptaskboard-pass taskboard -e "SELECT 1 AS test;"
mysql: [Warning] Using a password on the command line interface can be insecure.
+------+
| test |
+------+
|    1 |
+------+

SELECT 1が正常に返りました。MySQLが起動し、Secretで定義した認証情報(ユーザー: taskboard、データベース: taskboard)で接続できることを確認できました。Step 2完了です。

5.6 Step 3 — DB初期化Jobを実行する

MySQLが稼働したので、初期データを投入するJobを実行します。テーブル構造(スキーマ)はTaskBoard APIのJPAが初回起動時に自動生成しますが、初期データの投入はJobが担当します。応用編第4回で設計した役割分担です。

ただし、JPAによるテーブル自動生成はTaskBoard APIの初回起動時に行われるため、本来はAPI起動後にJobを実行する順序が正しいように見えます。ここでは「DB初期化Jobは、API起動後にテーブルが存在する状態で実行する」という前提で進めます。実際には、JobのSQLスクリプト内でMySQLの起動待機を行い、テーブルが存在しない場合はJobが再試行される設計になっています(backoffLimit: 3)。

適用順序の観点では、DB初期化Jobの実行タイミングには2つの選択肢があります。

  • 選択肢A: MySQL起動直後にJobを実行する(テーブルがなければJob失敗→API起動後に再実行)
  • 選択肢B: TaskBoard API起動後にJobを実行する(JPAがテーブルを作成した後に初期データ投入)

本回ではStep 4(API起動)の後にDB初期化を実行する方が確実ですが、構築手順書としての明確さを優先し、データベース層の構築としてここでJobをapplyしておきます。Job内の待機ロジックとbackoffLimitにより、テーブルが存在しない場合は再試行されます。TaskBoard APIが起動してJPAがテーブルを作成した後に、Jobが成功する流れになります。

5.6.1 DB初期化Jobを適用する

DB初期化Jobのマニフェストを作成します。応用編第4回で設計した内容と同一です。

[Execution User: developer]

cat <<'OUTER' > ~/k8s-production/manifests/db-init-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: db-init
  namespace: db
  labels:
    app: taskboard
    component: db-init
spec:
  backoffLimit: 3
  ttlSecondsAfterFinished: 300
  template:
    metadata:
      labels:
        app: taskboard
        component: db-init
    spec:
      restartPolicy: Never
      containers:
        - name: db-init
          image: mysql:8.0
          command:
            - sh
            - -c
            - |
              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

              echo "Inserting initial data..."
              mysql --default-character-set=utf8mb4 -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" <<SQL
              CREATE TABLE IF NOT EXISTS tasks (
                id INT AUTO_INCREMENT PRIMARY KEY,
                title VARCHAR(255) NOT NULL,
                status VARCHAR(20) NOT NULL
              ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

              INSERT INTO tasks (title, status)
              SELECT 'プロジェクト計画の作成', 'open'
              FROM dual WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE title = 'プロジェクト計画の作成');
              INSERT INTO tasks (title, status)
              SELECT 'サーバー環境の構築', 'open'
              FROM dual WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE title = 'サーバー環境の構築');
              INSERT INTO tasks (title, status)
              SELECT 'APIの設計レビュー', 'in_progress'
              FROM dual WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE title = 'APIの設計レビュー');
              INSERT INTO tasks (title, status)
              SELECT 'テスト計画の策定', 'open'
              FROM dual WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE title = 'テスト計画の策定');
              INSERT INTO tasks (title, status)
              SELECT '本番リリース準備', 'open'
              FROM dual WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE title = '本番リリース準備');
              SQL

              echo "Initial data insertion completed."

              mysql --default-character-set=utf8mb4 -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME"                 -e "SELECT id, title, status FROM tasks 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"
OUTER

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/db-init-job.yaml --dry-run=server
job.batch/db-init created (server dry run)

[Execution User: developer]

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

5.6.2 Job完了を確認しテーブル存在を検証する

Jobの完了を待機します。kubectl waitはJobの完了条件にも使用できます。

ここでは、先にTaskBoard APIをデプロイしてJPAにテーブルを作成させ、その後にJobの成功を確認する流れとします。Jobは内部でMySQLの起動を待機し、テーブルが存在するまで再試行するため、APIデプロイとJobの実行は並行して進められます。Jobの完了確認はStep 4の後に行います。

まず現時点でのJobの状態を確認しておきます。

[Execution User: developer]

kubectl get jobs -n db
NAME      STATUS    COMPLETIONS   DURATION   AGE
db-init   Running   0/1           5s         5s

JobがRunning状態です。テーブルはまだ存在しないため(JPAがまだ動いていない)、Job内のSQLが失敗し、再試行を待っている可能性があります。Step 4でTaskBoard APIを起動した後に、改めてJobの完了を確認します。

5.7 Step 4 — アプリケーション層を構築する

データベース層が稼働しているので、アプリケーション層を構築します。TaskBoard API → Nginxの順にデプロイします。TaskBoard APIの起動時にJPAがMySQLにテーブルを自動生成するため、APIを先にデプロイする必要があります。

5.7.1 TaskBoard API Deploymentをデプロイする

TaskBoard API DeploymentとServiceを適用します。マニフェストは第3回で設計したもの(taskboard-api-deployment.yamltaskboard-api-service.yaml)です。

TaskBoard APIのDeploymentとServiceのマニフェストを作成します。第3回の詳細設計で設計したSecurityContext、Probe、resources、RollingUpdate戦略がすべて含まれています。

[Execution User: developer]

cat <<'EOF' > ~/k8s-production/manifests/taskboard-api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskboard-api
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: taskboard
      component: api
  template:
    metadata:
      labels:
        app: taskboard
        component: api
    spec:
      containers:
        - name: taskboard-api
          image: taskboard-api:2.0.0
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          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: "200m"
              memory: "384Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          startupProbe:
            httpGet:
              path: /health/started
              port: 8080
            initialDelaySeconds: 0
            periodSeconds: 2
            timeoutSeconds: 3
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
            successThreshold: 1
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: payara-config
              mountPath: /opt/payara/config
      volumes:
        - name: tmp
          emptyDir: {}
        - name: payara-config
          emptyDir: {}
EOF

cat <<'EOF' > ~/k8s-production/manifests/taskboard-api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: taskboard-api
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  type: ClusterIP
  selector:
    app: taskboard
    component: api
  ports:
    - port: 8080
      targetPort: 8080
      protocol: TCP
EOF

[Execution User: developer]

# dry-runで事前検証
kubectl apply -f ~/k8s-production/manifests/taskboard-api-deployment.yaml --dry-run=server
kubectl apply -f ~/k8s-production/manifests/taskboard-api-service.yaml --dry-run=server
deployment.apps/taskboard-api created (server dry run)
service/taskboard-api created (server dry run)

問題ないので適用します。

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/taskboard-api-deployment.yaml
kubectl apply -f ~/k8s-production/manifests/taskboard-api-service.yaml
deployment.apps/taskboard-api created
service/taskboard-api created

5.7.2 Payara Microの起動をkubectl waitで待機する

Payara Microの起動には15〜20秒かかります。応用編第8回で体験した通り、startupProbeが完了するまでlivenessProbeとreadinessProbeは実行されません。startupProbeの設計はperiodSeconds: 2, failureThreshold: 30(最大60秒待ち)です。

kubectl waitで全Podがready状態になるまで待機します。

[Execution User: developer]

# TaskBoard APIのPodがReady状態になるまで待機(最大90秒)
kubectl wait --for=condition=ready pod -l app=taskboard,component=api -n app --timeout=90s
pod/taskboard-api-6b8c9d7e8f-2x4k9 condition met
pod/taskboard-api-6b8c9d7e8f-m7h3j condition met

2つのPod(replicas: 2)がともにReady状態になりました。タイムアウトを90秒に設定しているのは、Payara Microの起動時間(15〜20秒)+ startupProbeの検知間隔 + α の余裕を持たせるためです。

Deploymentのロールアウト状態も確認します。

[Execution User: developer]

kubectl rollout status deployment/taskboard-api -n app
deployment "taskboard-api" successfully rolled out

kubectl rollout statusはDeploymentのロールアウトが完了したことを確認するコマンドです。kubectl waitが個々のPodの状態を確認するのに対し、rollout statusはDeployment全体の状態(指定したreplicas数のPodがAvailableか)を確認します。

5.7.3 ヘルスチェックエンドポイントで動作を検証する

PodがRunningでも、アプリケーションが正常に動作しているとは限りません。ヘルスチェックエンドポイントにアクセスして確認します。Payara Microのコンテナイメージにはcurlが含まれていないため、kubectl port-forwardでホストからアクセスします。

[Execution User: developer]

# port-forwardでホストからヘルスチェックエンドポイントを確認
kubectl port-forward -n app deploy/taskboard-api 18080:8080 > /dev/null 2>&1 &
sleep 2
curl -s http://localhost:18080/health/ready
kill %1 2>/dev/null
{"status":"UP","checks":[{"name":"db-connection","status":"UP","data":{"database":"taskboard"}}]}

"status":"UP"が返り、db-connectionチェックも"UP"です。TaskBoard APIがMySQLに正常接続できていることが確認できました。応用編第8回で追加したカスタムHealthCheckReady(DB接続確認を含む)が動作しています。

ここでDB初期化Jobの完了も確認しましょう。TaskBoard APIが起動してJPAがテーブルを作成したため、Jobの再試行が成功しているはずです。

[Execution User: developer]

kubectl get jobs -n db
NAME      STATUS     COMPLETIONS   DURATION   AGE
db-init   Complete   1/1           35s        2m

JobのステータスがCompleteになりました。Jobのログを確認して、初期データが正常に投入されたことを検証します。

[Execution User: developer]

kubectl logs job/db-init -n db
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件の初期データが投入されています。テーブルの存在と初期データの投入を確認できました。

5.7.4 Nginx Deploymentをデプロイする

次にNginx DeploymentとServiceを適用します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-production/manifests/nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: taskboard
      component: frontend
  template:
    metadata:
      labels:
        app: taskboard
        component: frontend
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
          livenessProbe:
            httpGet:
              path: /
              port: 8080
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
            successThreshold: 1
          securityContext:
            runAsNonRoot: true
            runAsUser: 101
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf
              readOnly: true
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/nginx
      volumes:
        - name: nginx-config
          configMap:
            name: nginx-config
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}
EOF

cat <<'EOF' > ~/k8s-production/manifests/nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  type: ClusterIP
  selector:
    app: taskboard
    component: frontend
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP
EOF

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/nginx-deployment.yaml
kubectl apply -f ~/k8s-production/manifests/nginx-service.yaml
deployment.apps/nginx created
service/nginx created

Nginxは起動が非常に速い(1秒未満)ため、startupProbeは設定していません。kubectl waitで起動を待機します。

[Execution User: developer]

kubectl wait --for=condition=ready pod -l app=taskboard,component=frontend -n app --timeout=60s
pod/nginx-7d8e9f0a1b-4r6t8 condition met
pod/nginx-7d8e9f0a1b-k2m5n condition met

[Execution User: developer]

kubectl rollout status deployment/nginx -n app
deployment "nginx" successfully rolled out

Nginxの2つのPodがReady状態になりました。

5.7.5 SecurityContextの適用を確認する

第3回の詳細設計で、TaskBoard APIとNginxにSecurityContext(非root実行、readOnlyRootFilesystem、capabilities全剥奪、seccompProfile: RuntimeDefault)を設計しました。マニフェストを適用した後は、設計通りの設定がPodに反映されていることを確認します。

[Execution User: developer]

# TaskBoard APIのSecurityContextを確認
kubectl get pod -n app -l component=api -o jsonpath='{.items[0].spec.containers[0].securityContext}' | python3 -m json.tool
{
    "allowPrivilegeEscalation": false,
    "capabilities": {
        "drop": [
            "ALL"
        ]
    },
    "readOnlyRootFilesystem": true,
    "runAsNonRoot": true,
    "runAsUser": 1000,
    "seccompProfile": {
        "type": "RuntimeDefault"
    }
}

[Execution User: developer]

# NginxのSecurityContextを確認
kubectl get pod -n app -l component=frontend -o jsonpath='{.items[0].spec.containers[0].securityContext}' | python3 -m json.tool
{
    "allowPrivilegeEscalation": false,
    "capabilities": {
        "drop": [
            "ALL"
        ]
    },
    "readOnlyRootFilesystem": true,
    "runAsNonRoot": true,
    "runAsUser": 101,
    "seccompProfile": {
        "type": "RuntimeDefault"
    }
}

TaskBoard APIはrunAsUser: 1000(payaraユーザー)、NginxはrunAsUser: 101(nginxユーザー)で、ともにrunAsNonRoot: truereadOnlyRootFilesystem: truecapabilities.drop: ALLseccompProfile: RuntimeDefaultが適用されています。応用編第7回で設計し、第3回の詳細設計書に記載したPSS Restricted準拠のSecurityContextが正しくデプロイされていることを確認できました。Step 4完了です。

5.8 Step 5-6 — 補助ワークロードとスケーリングを構築する

コアのアプリケーション層が稼働したので、補助ワークロード(CronJob、DaemonSet)とスケーリング・可用性(HPA、PDB)を追加します。

5.8.1 DBバックアップCronJobを適用する

DBバックアップCronJobのマニフェストは応用編第4回で作成したものです。ハンズオン用にschedule: "*/1 * * * *"(毎分実行)になっています。本番では"0 2 * * *"(毎日午前2時)のような日次スケジュールに変更してください。

[Execution User: developer]

cat <<'OUTER' > ~/k8s-production/manifests/db-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup
  namespace: db
  labels:
    app: taskboard
    component: db-backup
spec:
  schedule: "*/1 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      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 -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD"                     --single-transaction                     "$DB_NAME" > /tmp/backup-${TIMESTAMP}.sql
                  ls -lh /tmp/backup-${TIMESTAMP}.sql
                  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"
OUTER

[Execution User: developer]

kubectl apply -f ~/k8s-production/manifests/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>          5s

CronJobが作成されました。LAST SCHEDULEがまだ<none>なのは、最初のスケジュール実行がまだ発生していないためです。1分後に自動的にJobが作成されます。

5.8.2 ログ収集DaemonSetを適用する

ログ収集DaemonSetは応用編第4回で作成したものです。全Worker NodeにPodを配置します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-production/manifests/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
          command:
            - sh
            - -c
            - |
              echo "Log collector started on $(hostname)"
              echo "Monitoring /var/log/containers/ ..."
              while true; do
                echo "--- $(date '+%Y-%m-%d %H:%M:%S') --- Log collection cycle ---"
                ls /var/log/containers/*.log 2>/dev/null | wc -l | xargs -I{} echo "  Container log files: {}"
                sleep 60
              done
          resources:
            requests:
              cpu: "50m"
              memory: "32Mi"
            limits:
              cpu: "100m"
              memory: "64Mi"
          volumeMounts:
            - name: varlog
              mountPath: /var/log
              readOnly: true
      volumes:
        - name: varlog
          hostPath:
            path: /var/log
EOF

[Execution User: developer]

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

DaemonSetのPodが全Worker Nodeに配置されたことを確認します。

[Execution User: developer]

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

DESIRED: 3READY: 3です。Worker 3台それぞれにログ収集Podが配置されました。Podの配置先ノードも確認しておきます。

[Execution User: developer]

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

3つのWorker Nodeそれぞれに1つずつPodが配置されています。DaemonSetの動作通りです。

5.8.3 HPA / PDBを適用する

HPA(HorizontalPodAutoscaler)とPDB(PodDisruptionBudget)を適用します。マニフェストは第3回で設計したものです。

[Execution User: developer]

# HPA(Nginx用)
cat <<'EOF' > ~/k8s-production/manifests/hpa-nginx.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: nginx-hpa
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: nginx
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60
EOF

# HPA(TaskBoard API用)
cat <<'EOF' > ~/k8s-production/manifests/hpa-taskboard-api.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: taskboard-api-hpa
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: taskboard-api
  minReplicas: 2
  maxReplicas: 4
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
EOF

# PDB(Nginx用)
cat <<'EOF' > ~/k8s-production/manifests/pdb-nginx.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: nginx-pdb
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: taskboard
      component: frontend
EOF

# PDB(TaskBoard API用)
cat <<'EOF' > ~/k8s-production/manifests/pdb-taskboard-api.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: taskboard-api-pdb
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: taskboard
      component: api
EOF

[Execution User: developer]

# HPAを適用
kubectl apply -f ~/k8s-production/manifests/hpa-nginx.yaml
kubectl apply -f ~/k8s-production/manifests/hpa-taskboard-api.yaml

# PDBを適用
kubectl apply -f ~/k8s-production/manifests/pdb-nginx.yaml
kubectl apply -f ~/k8s-production/manifests/pdb-taskboard-api.yaml
horizontalpodautoscaler.autoscaling/nginx-hpa created
horizontalpodautoscaler.autoscaling/taskboard-api-hpa created
poddisruptionbudget.policy/nginx-pdb created
poddisruptionbudget.policy/taskboard-api-pdb created

HPAの状態を確認します。Metrics Serverからメトリクスが取得できていることを確認します。

[Execution User: developer]

kubectl get hpa -n app
NAME                REFERENCE              TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
nginx-hpa           Deployment/nginx       4%/70%    2         6         2          15s
taskboard-api-hpa   Deployment/taskboard-api   8%/70%    2         4         2          15s

TARGETS列にCPU使用率の現在値と閾値が表示されています。<unknown>/70%と表示される場合は、Metrics Serverがまだメトリクスを収集中です。1〜2分待ってから再確認してください。数値(例: 4%/70%)が表示されればメトリクスが正常に取得できています。

PDBの状態も確認します。

[Execution User: developer]

kubectl get pdb -n app
NAME                MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
nginx-pdb           1               N/A               1                     10s
taskboard-api-pdb   1               N/A               1                     10s

MIN AVAILABLE: 1が設定され、ALLOWED DISRUPTIONS: 1です。現在replicas: 2で稼働しているため、1つのPodは退避可能(ALLOWED DISRUPTIONS: 1)であることを意味します。

5.8.4 全リソースの状態を一覧で確認する

Step 1〜6の適用が完了しました。全Namespaceのリソース状態を一覧で確認します。

[Execution User: developer]

# app Namespaceの全リソース
kubectl get all,pdb -n app
NAME                                 READY   STATUS    RESTARTS   AGE
pod/nginx-7d8e9f0a1b-4r6t8          1/1     Running   0          3m
pod/nginx-7d8e9f0a1b-k2m5n          1/1     Running   0          3m
pod/taskboard-api-6b8c9d7e8f-2x4k9  1/1     Running   0          4m
pod/taskboard-api-6b8c9d7e8f-m7h3j  1/1     Running   0          4m

NAME                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/nginx           ClusterIP   10.96.50.100   <none>        80/TCP     3m
service/taskboard-api   ClusterIP   10.96.50.200   <none>        8080/TCP   4m

NAME                            READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/nginx           2/2     2            2           3m
deployment.apps/taskboard-api   2/2     2            2           4m

NAME                            REFERENCE              TARGETS    MINPODS   MAXPODS   REPLICAS   AGE
horizontalpodautoscaler/nginx-hpa           Deployment/nginx       4%/70%     2         6         2          1m
horizontalpodautoscaler/taskboard-api-hpa   Deployment/taskboard-api   8%/70%     2         4         2          1m

NAME                                 MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
poddisruptionbudget/nginx-pdb           1               N/A               1                     1m
poddisruptionbudget/taskboard-api-pdb   1               N/A               1                     1m

[Execution User: developer]

# db Namespaceの全リソース
kubectl get all,pvc -n db
NAME           READY   STATUS      RESTARTS   AGE
pod/db-init-xxxxx   0/1     Completed   0          5m
pod/mysql-0    1/1     Running     0          6m

NAME                     TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
service/mysql-headless   ClusterIP   None         <none>        3306/TCP   6m

NAME                     READY   AGE
statefulset.apps/mysql   1/1     6m

NAME                          SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cronjob.batch/db-backup       */1 * * * *   False     0        30s             3m

NAME                                 STATUS   VOLUME   CAPACITY   ACCESS MODES   AGE
persistentvolumeclaim/mysql-data-mysql-0   Bound    pvc-...  1Gi        RWO            6m

[Execution User: developer]

# monitoring Namespaceの全リソース
kubectl get all -n monitoring
NAME                      READY   STATUS    RESTARTS   AGE
pod/log-collector-4k8m2   1/1     Running   0          2m
pod/log-collector-7j3n5   1/1     Running   0          2m
pod/log-collector-h2p9r   1/1     Running   0          2m

NAME                           DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   AGE
daemonset.apps/log-collector   3         3         3       3            3           2m

全Namespaceのリソースが設計書通りに配置されていることを確認できました。

5.9 Step 7 — 内部疎通テスト(受け入れテスト)

全コンポーネントが個別に稼働していることは確認できました。最後に、コンポーネント間の疎通を確認する受け入れテストを行います。VMの構築手順書で言えば「サーバー間通信テスト」に相当する段階です。

現時点ではNetworkPolicyが未適用のため、全Pod間で通信可能です。この状態で疎通を確認しておくことで、第6回でNetworkPolicyを適用した後に通信が遮断された場合、「NetworkPolicyが原因」と即座に切り分けられます。

5.9.1 テスト用Podから内部アクセスを確認する

テスト用のPodを起動し、Service経由で各コンポーネントにアクセスします。

[Execution User: developer]

# テスト用Podを起動(wgetが使えるbusyboxイメージ)
kubectl run test-client --image=busybox:1.36 -n app --rm -it --restart=Never -- sh

テスト用Podのシェルに入ったら、以下のコマンドを順に実行します。

# (1) Nginx Serviceへのアクセス確認
wget -qO /tmp/test.html http://nginx.app.svc.cluster.local/ && head -5 /tmp/test.html

# (2) TaskBoard API Serviceへのアクセス確認(タスク一覧の取得)
wget -qO- http://taskboard-api.app.svc.cluster.local:8080/taskboard-api/api/tasks

# (3) TaskBoard APIのヘルスチェック
wget -qO- http://taskboard-api.app.svc.cluster.local:8080/health/ready

テスト用Podのシェルからexitで抜けると、--rmオプションによりPodが自動削除されます。

テスト用Podを起動せずに、busyboxの一発実行でも確認できます。

[Execution User: developer]

# 方法2: busyboxの一発実行で確認(対話シェルを使わない方法)
kubectl run test-wget --image=busybox:1.36 -n app --rm -it --restart=Never \
  -- wget -qO- http://nginx.app.svc.cluster.local/ 2>&1 | head -5

5.9.2 Nginx → API → MySQL の全経路を検証する

サービス間の全経路を検証します。NginxコンテナにもPayara Microコンテナにもwget/curlが含まれていないため、busyboxの一時Podから確認します。

[Execution User: developer]

# (1) Nginx Serviceの応答確認
kubectl run test-nginx --image=busybox:1.36 -n app --rm -it --restart=Never \
  -- sh -c 'wget -qO /tmp/test.html http://nginx.app.svc.cluster.local/ && head -3 /tmp/test.html'
<!DOCTYPE html>
<html>
<head>

Nginxが静的ファイル(デフォルトのindex.html)を返しています。

[Execution User: developer]

# (2) TaskBoard API Serviceの応答確認(タスク一覧)
kubectl run test-api --image=busybox:1.36 -n app --rm -it --restart=Never \
  -- wget -qO- http://taskboard-api.app.svc.cluster.local:8080/taskboard-api/api/tasks
[{"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がMySQLからデータを取得し、JSON形式で返しています。DB初期化Jobで投入した5件のタスクがすべて取得できています。これにより、Nginx → TaskBoard API → MySQLの全経路が内部的に正常に機能していることが確認できました。

5.9.3 ヘルスチェックの正常性を確認する

最後に、各コンポーネントのヘルスチェック状態を確認します。

[Execution User: developer]

# TaskBoard APIのヘルスチェック(全エンドポイント)— port-forward経由で確認
kubectl port-forward -n app deploy/taskboard-api 18080:8080 > /dev/null 2>&1 &
sleep 2
curl -s http://localhost:18080/health
kill %1 2>/dev/null
{"status":"UP","checks":[{"name":"db-connection","status":"UP","data":{"database":"taskboard"}},{"name":"taskboard-api-live","status":"UP","data":{"version":"2.0.0"}}]}

/healthエンドポイントで全HealthCheckの結果を確認できます。db-connection(Readiness)とtaskboard-api-live(Liveness)がともに"UP"です。Livenessチェックにはバージョン情報("version":"2.0.0")も含まれており、デプロイされたイメージの確認にも使えます。

[Execution User: developer]

# Nginx のlivenessProbeと同じエンドポイントを確認(port-forward経由)
kubectl port-forward -n app deploy/nginx 18081:8080 > /dev/null 2>&1 &
sleep 2
curl -s http://localhost:18081/ | head -1
kill %1 2>/dev/null
<!DOCTYPE html>

NginxのlivenessProbe(HTTP GET / :8080)が正常に応答しています。

受け入れテストの結果をまとめます。

テスト項目対象結果
Nginx Service応答nginx.app.svc.cluster.local✔ HTML応答
API Service応答taskboard-api.app.svc.cluster.local:8080/taskboard-api/api/tasks✔ JSON応答(5件)
API HealthChecklocalhost:8080/health/ready✔ UP(DB接続確認含む)
MySQL接続mysql-0.mysql-headless.db.svc.cluster.local:3306✔ SELECT 1成功
DB初期化Job db-init✔ Completed(5件投入)
CronJob作成CronJob db-backup✔ スケジュール登録済み
DaemonSet配置DaemonSet log-collector✔ Worker 3台に配置
HPA メトリクスnginx-hpa / taskboard-api-hpa✔ CPU使用率取得中
PDB設定nginx-pdb / taskboard-api-pdb✔ minAvailable: 1
SecurityContextAPI(uid=1000)/ Nginx(uid=101)✔ PSS Restricted準拠

全項目が正常です。Gateway APIとNetworkPolicyを除く全コンポーネントが内部的に稼働し、疎通していることを確認しました。

5.10 この回のまとめ

5.10.1 成果物の確認 — 内部稼働中のTaskBoard + 構築手順書

本回で達成した状態を整理します。

[app Namespace]
  Nginx Deployment (2 replicas) + Service
  TaskBoard API Deployment (2 replicas) + Service
  HPA (nginx-hpa: min 2 / max 6, taskboard-api-hpa: min 2 / max 4)
  PDB (nginx-pdb: minAvailable 1, taskboard-api-pdb: minAvailable 1)
  Secret (mysql-secret — API用)
  ConfigMap (nginx-config — 非root対応版nginx.conf)

[db Namespace]
  MySQL StatefulSet (1 replica) + Headless Service + PVC (1Gi)
  Secret (mysql-secret — MySQL認証情報)
  CronJob (db-backup — 定期バックアップ)
  Job (db-init — 初期データ投入済み、Completed)

[monitoring Namespace]
  DaemonSet (log-collector — Worker 3台に配置)

[未適用(第6回で適用)]
  Gateway API (Gateway + HTTPRoute)
  NetworkPolicy

本回で実行した手順そのものが、依存関係を考慮した構築手順書です。構築手順書として文書化する際には、以下の要素を含めます。

  • 前提条件: 第4回で構築済みの基盤(Namespace、ResourceQuota、LimitRange、RBAC)
  • 適用順序: Step 1(Secret/ConfigMap)→ Step 2(MySQL)→ Step 3(Job)→ Step 4(API/Nginx)→ Step 5(CronJob/DaemonSet)→ Step 6(HPA/PDB)
  • 各ステップの検証項目: 本回で実施した検証コマンドと期待される出力
  • 受け入れテスト: Step 7の全経路疎通確認

5.10.2 「適用順序」と「レイヤー検証」の重要性

本回を通じて、応用編との構築プロセスの違いが明確になったはずです。

観点応用編の構築実践編の構築(本回)
イメージビルド段階的(第1回→第3回→第8回)完成版を一括ビルド
適用順序学習テーマに従って適用依存関係グラフに基づく計画的順序
検証「動いた」で次に進む各レイヤーで検証してから次に進む
kubectl wait使わない(手動で確認)起動完了を待機してから次のステップへ
dry-run / diff使わない適用前に必ず事前確認する

本番環境での構築では、「適用順序の設計」と「各レイヤーでの検証」が品質を左右します。依存関係を無視した順序で適用すると、エラーの原因が依存先の未準備なのか、マニフェスト自体の問題なのか切り分けが困難になります。レイヤーごとに検証することで、問題の発生箇所が明確になります。

5.10.3 次回予告 — 外部公開と通信制御

本回でTaskBoardは内部的に完全に稼働しています。しかし、外部からアクセスする手段がまだありません。また、現時点では全Pod間で通信可能な状態です。

次回(第6回)では以下を行います。

  • Gateway APIの適用: パスベースルーティング(/ → Nginx、/api → TaskBoard API)で外部公開する
  • NetworkPolicyの適用: 最小権限の通信制御(API→DBのみ許可、フロント→DB直接は遮断)を適用する
  • E2Eテスト: Client → Gateway → Service → Pod → DB の全経路を、外部からのアクセスも含めて検証する

本回の受け入れテストで確認した内部疎通が、NetworkPolicy適用後にどう変化するか。意図した通信のみが許可され、それ以外が遮断されることを検証します。本回の疎通テスト結果が、第6回のNetworkPolicy検証のベースラインになります。

AIコラム — 適用順序の相談

マニフェストの適用順序に迷ったとき、AIに相談すると依存関係の整理を手伝ってくれます。ただし、AIの出力をそのまま信頼するのではなく、自分の環境に合わせた検証が必要です。

💬 あなた → AI(Claude):
TaskBoardのマニフェスト一式をkindクラスタに適用したい。以下のリソースがある。適用順序を設計してほしい。
– MySQL StatefulSet + Headless Service
– TaskBoard API Deployment + Service
– Nginx Deployment + Service
– Secret(MySQL認証情報)
– ConfigMap(nginx.conf)
– DB初期化Job
– DBバックアップCronJob
– ログ収集DaemonSet
– HPA(Nginx用、API用)
– PDB(Nginx用、API用)
– Gateway API(Gateway + HTTPRoute)
– NetworkPolicy

AIは依存関係を分析し、以下のような順序を提案してくれるでしょう。

  • Secret/ConfigMap → StatefulSet → Job → Deployment → CronJob/DaemonSet → HPA/PDB → Gateway API → NetworkPolicy

この提案は概ね正しいですが、確認すべきポイントがあります。

🔍 ここで立ち止まって確認しましょう

Secretが2つのNamespaceに必要なことを考慮しているか? — db NamespaceとApp Namespaceの両方にSecretが必要です。AIがクロスNamespace制約を見落としている場合は補足が必要です
DB初期化Jobの実行タイミングは適切か? — JPA(TaskBoard API)がテーブルを作成する前にJobを実行すると、テーブルが存在せずSQLが失敗します。Jobの再試行ロジック(backoffLimit)が適切に設計されているか確認が必要です
NetworkPolicyを最後にする理由を理解しているか? — 「まず全通信可能な状態で疎通確認→その後NetworkPolicyで制限」の順序を取る理由は、トラブル切り分けの容易さです
各ステップの検証項目を含めているか? — AIは適用順序を提案しますが、「各ステップで何を検証するか」までは提案しないことが多いです。kubectl wait、ヘルスチェック確認、接続テストなどの検証項目は自分で設計する必要があります

AIは依存関係の整理と初期ドラフトの生成に有用ですが、環境固有の制約(Namespace間のSecret参照不可、Payara Microの起動時間、JPAのテーブル自動生成タイミング)は自分の知識で補完する必要があります。本回で実践した「Step 1〜7の適用順序 + 各ステップの検証」は、AIの提案をベースに環境固有の知識で磨き上げた結果です。