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

Kubernetes実践編 #09

9.1 はじめに

9.1.1 これまでの旅路 — 設計・構築・運用の積み上げ

実践編の第1回から第8回まで、長い道のりを歩いてきました。

第1回で構成図を描き、TaskBoardの全体像を可視化しました。第2回と第3回で基本設計書・詳細設計書を書き、すべてのパラメータに「なぜこの値か」という根拠を与えました。第4回で基盤を整備し、第5回で依存順序に沿った段階デプロイを実行し、第6回でGateway APIとNetworkPolicyによる通信制御を完成させました。第7回で監視・スケーリング・バックアップの運用設計書を仕上げ、第8回でHelmによる変更管理とNodeメンテナンスの手順を確立しました。

設計、構築、運用——インフラのライフサイクルを3つのフェーズにわたって回してきました。あなたの手元には、構成図、基本設計書、詳細設計書、構築手順書、通信制御マトリクス、運用設計書、変更管理手順書が揃っています。TaskBoardはこれらの設計書に基づいて構築され、Helmで管理され、正常に稼働しています。

しかし、ライフサイクルにはもう1つ、避けて通れないフェーズがあります。

9.1.2 本回の問題提起 — 「壊れたとき、あなたは何をする?」

VMの世界で障害対応の経験があるなら、深夜の電話で叩き起こされた記憶があるかもしれません。ESXiホストがダウンした、VMが応答しない、ストレージが見えなくなった——そのとき、何から調べましたか。おそらくvCenterのアラームを確認し、ホストの状態を見て、VMの配置を確認し、ストレージの接続を確認する。レイヤーを一つずつ切り分けて、原因を絞り込んだはずです。

K8sでも障害は起きます。Podがクラッシュする、Nodeが落ちる、通信が遮断される、ストレージがマウントできない。入門編第11回で「トラブルシュートの4種の神器」(get / describe / logs / exec)を学びました。しかし、個別のPodを調べるスキルと、システム全体の障害を体系的に切り分けて復旧するスキルは別物です。

本回では、TaskBoardを意図的に壊します。6つの異なるレイヤーで障害を注入し、検知し、切り分け、復旧し、再発防止策を講じます。これまでの8回分の知識——構成図、設計書、通信制御マトリクス、運用設計書——すべてが障害対応の「武器」として機能します。

9.1.3 本回のゴールと成果物

本回のゴールは、Pod / Node / Network / Storage / 設定の各レイヤーで発生する障害を体系的に切り分け、復旧し、再発防止策を講じ、その過程を障害報告書として文書化できるようになることです。

成果物は2つです。

  • 障害対応手順書: 切り分けフローチャートとシナリオ別対応手順をまとめた、チームで共有できる手順書
  • 障害報告書テンプレート(記入例付き): 障害の概要・タイムライン・原因分析・復旧対応・再発防止策を記録するテンプレート
【フェーズ1:設計】
  ✅ 第1回 ── 構成図
  ✅ 第2回 ── 基本設計
  ✅ 第3回 ── 詳細設計

【フェーズ2:構築】
  ✅ 第4回 ── 環境構築
  ✅ 第5回 ── アプリケーション構築
  ✅ 第6回 ── ネットワーク構築

【フェーズ3:運用】
  ✅ 第7回 ── 運用設計
  ✅ 第8回 ── 日常運用

【フェーズ4:障害対応と発展】
  ☐ 第9回 ── 障害対応        ← 今回 ★クライマックス
  ☐ 第10回 ── 本番への道

9.2 VMの障害対応とK8sの障害対応

9.2.1 VMの世界での障害対応 — ESXi/vCenter/Storageの切り分け

VMware環境で障害が発生したとき、ベテランのインフラエンジニアはレイヤーごとに切り分けます。「VMが応答しない」という連絡を受けたら、まずvCenterのアラームを確認し、次にESXiホストの状態を見て、VMの電源状態を確認し、ネットワーク接続を調べ、データストアのアクセス可否を確認する。このレイヤーの切り分けは、長年の経験で身体に染み込んだ「型」です。

レイヤーVMwareでの確認ポイントツール / 手段
プロセス層VMゲスト内のサービス状態、プロセス存否vCenter VMコンソール、SSH
ホスト層ESXiの状態(Connected / Disconnected)vCenterホスト一覧、ILO/iDRAC
ネットワーク層vSwitch / ポートグループ / NSXルールvCenter NW構成、NSX Manager
ストレージ層データストアの接続状態、VMFS/vSANの健全性vCenterストレージビュー、esxcli
設定層VM設定(vCPU/メモリ/NIC)、ゲストOS設定VM設定画面、構成ファイル

レイヤーを下から(ストレージ/ネットワーク)調べるか、上から(プロセス)調べるかは状況によりますが、「レイヤーごとに切り分ける」という思考フレームは変わりません。

9.2.2 K8sの障害対応 — Pod/Node/NW/Storageの切り分け

K8sでも同じレイヤー構造があります。名前が変わっただけで、思考フレームはそのまま使えます。

レイヤーK8sでの確認ポイントツール / コマンド
Pod層Pod状態(Running / CrashLoopBackOff / OOMKilled / Pending)kubectl get podskubectl describe podkubectl logs
Node層Node状態(Ready / NotReady)、リソース使用率kubectl get nodeskubectl describe nodekubectl top nodes
Network層Service疎通、NetworkPolicyルール、DNS解決kubectl exec(接続テスト)、kubectl describe networkpolicy
Storage層PVC状態(Bound / Pending)、StorageClasskubectl get pvckubectl describe pvc
設定層ConfigMap / Secret内容、環境変数、マニフェスト設定値kubectl describe configmapkubectl get -o yaml

9.2.3 共通する思考フレーム — レイヤーごとの切り分け

VMとK8sの障害対応で共通するのは、「まずレイヤーを特定し、次にそのレイヤー内で原因を絞り込む」という2段階のアプローチです。VMでESXiが落ちていればホスト層の問題、K8sでNodeがNotReadyならNode層の問題。レイヤーさえ特定できれば、調査対象が大幅に絞り込まれます。

もう1つ共通するのは、設計書の存在価値です。VMの構成図があれば「どのホストにどのVMが載っているか」がすぐわかります。K8sの構成図(第1回の成果物)があれば「どのNamespaceにどのPodがあり、どこと通信しているか」がすぐわかります。通信制御マトリクス(第6回の成果物)があれば「この通信は許可されているはずか、遮断されているはずか」が設計書から判断できます。

障害報告書を書く習慣も、VMでもK8sでも変わりません。プラットフォームが変わっても、プロの仕事の仕方は同じです。

9.3 障害対応の基本フレーム

9.3.1 障害対応フロー(検知→切り分け→調査→復旧→恒久対策→報告)

本回の6つの障害シナリオすべてで、以下の共通フローを踏みます。

1. 検知 — 障害の発生を認識する
     kubectl get pods / kubectl top / ヘルスチェック失敗

2. 切り分け — 原因のレイヤーを特定する
     Pod層 / Node層 / Network層 / Storage層 / 設定層

3. 調査 — 具体的な原因を特定する
     kubectl describe / kubectl logs / kubectl get events

4. 復旧 — サービスを回復させる(暫定対処)
     Pod再起動 / マニフェスト修正 / ロールバック

5. 恒久対策 — 再発を防止する
     設計変更 / パラメータ修正 / 監視追加

6. 報告 — 障害報告書を作成する

VMの障害対応でも「検知→一次切り分け→エスカレーション→暫定復旧→恒久対策→報告」という流れは同じです。K8sではkubectlがvCenterコンソールの代わりを果たし、マニフェストの修正がVM設定変更の代わりになります。フローの構造は変わりません。

9.3.2 切り分けフローチャート

障害発生時に「まず何を見るか」を迷わないよう、切り分けのフローチャートを整理します。このフローチャートは、本回の障害対応手順書の核になる部分です。

障害発生
  │
  ├── Podは起動しているか? ── kubectl get pods -n <ns>
  │     │
  │     ├── No(異常状態)
  │     │     ├── CrashLoopBackOff
  │     │     │     └── kubectl logs / kubectl describe pod
  │     │     │           ├── アプリエラー → シナリオ1(アプリクラッシュ)
  │     │     │           ├── OOMKilled   → シナリオ2(メモリ枯渇)
  │     │     │           └── 設定エラー  → シナリオ6(ConfigMapミス)
  │     │     │
  │     │     ├── Pending
  │     │     │     └── kubectl describe pod(Eventsセクション)
  │     │     │           ├── PVC関連エラー → シナリオ5(ストレージ障害)
  │     │     │           └── スケジュール不能 → Node障害 or リソース不足
  │     │     │
  │     │     └── ImagePullBackOff / ErrImageNeverPull
  │     │           └── イメージ名・タグ・imagePullPolicyの確認
  │     │                 ├── ErrImageNeverPull → ローカルにイメージが存在しない
  │     │                 │     (imagePullPolicy: Never の場合。kind環境で発生)
  │     │                 └── ImagePullBackOff → レジストリからのプル失敗
  │     │                       (本番環境で発生。認証・タグ・ネットワークを確認)
  │     │
  │     └── Yes(Running)── Podは動いているが正常か?
  │           │
  │           ├── Readiness失敗 ── kubectl describe pod(Conditions)
  │           │     └── DB接続エラー → シナリオ4(NW障害)or DB自体の障害
  │           │
  │           └── 動作異常(レスポンスエラー等)
  │                 └── シナリオ6(設定ミス)
  │
  ├── Nodeは正常か? ── kubectl get nodes
  │     └── NotReady → シナリオ3(Node障害)
  │
  └── 通信は正常か? ── kubectl exec でテスト用Pod起動 / 接続テスト
        └── 特定経路が遮断 → シナリオ4(NetworkPolicy誤設定)

このフローチャートを手元に置きながら、以降の6シナリオに取り組みます。各シナリオで「フローチャートのどの分岐に該当するか」を意識してください。

9.3.3 4種の神器の復習 — get / describe / logs / exec

入門編第11回で学んだトラブルシュートの4種の神器を、障害対応の文脈で整理し直します。

コマンド役割障害対応での使いどころ
kubectl getリソースの一覧と状態検知: Pod/Node/PVCの状態を素早く把握する。-o wideでNode配置も確認
kubectl describeリソースの詳細とEvents切り分け: Eventsセクションでエラーの経緯を時系列で追跡する
kubectl logsコンテナのログ出力調査: アプリケーションのエラーメッセージから原因を特定する。--previousでクラッシュ前のログも取得
kubectl execコンテナ内でのコマンド実行調査: ネットワーク接続テスト、ファイルシステム確認、環境変数確認

4種の神器に加えて、本回では以下のコマンドも多用します。

コマンド障害対応での使いどころ
kubectl get events --sort-by=.lastTimestampNamespace内のイベントを時系列で確認。複数リソースにまたがる障害の全体像を把握
kubectl top pods / nodesリソース使用量のリアルタイム確認。OOMKilled前のメモリ消費を観察
kubectl get pods -o widePodがどのNodeに配置されているかを確認。Node障害時の影響範囲を特定
docker stop / startkindのNode障害シミュレーション(Worker NodeはDockerコンテナ)

準備が整いました。ここからは、TaskBoardを意図的に壊して復旧する6つのシナリオに入ります。各シナリオの開始前に、TaskBoardが正常稼働していることを確認してください。

[Execution User: developer]
# TaskBoardの正常稼働確認
kubectl get pods -n app
kubectl get pods -n db
kubectl get nodes

# 期待される状態:
#   app Namespace: Nginx Pod x2 (Running), TaskBoard API Pod x2 (Running)
#   db Namespace:  MySQL Pod x1 (Running)
#   全Node: Ready

9.4 シナリオ1 — アプリクラッシュ(CrashLoopBackOff)

9.4.1 障害を注入する

最初のシナリオは、最も遭遇頻度の高い障害——CrashLoopBackOffです。TaskBoard APIのDeploymentを変更し、存在しないイメージタグを指定します。

[Execution User: developer]
# 障害注入: TaskBoard APIのイメージタグを存在しないものに変更
kubectl set image deployment/taskboard-api \
  taskboard-api=taskboard-api:99.99 -n app

障害が注入されました。ここから障害対応フローの「検知」に入ります。

9.4.2 検知と切り分け

まず、Podの状態を確認します。

[Execution User: developer]
# 検知: Podの状態を確認
kubectl get pods -n app

出力例:

NAME                              READY   STATUS              RESTARTS   AGE
nginx-xxxxxxxxxx-xxxxx            1/1     Running             0          1h
nginx-xxxxxxxxxx-yyyyy            1/1     Running             0          1h
taskboard-api-xxxxxxxxxx-xxxxx    1/1     Running             0          1h
taskboard-api-xxxxxxxxxx-yyyyy    1/1     Running             0          1h
taskboard-api-xxxxxxxxxx-zzzzz    0/1     ErrImageNeverPull   0          30s

切り分けフローチャートに従います。Podが起動していない → ErrImageNeverPull → イメージ問題。kind環境ではimagePullPolicy: Neverに設定しているため(レジストリを使わずkind load docker-imageでイメージを投入)、レジストリへのプル失敗(ImagePullBackOff)ではなく、ローカルにイメージが存在しないことを示すErrImageNeverPullが表示されます。本番環境(レジストリを使用)ではImagePullBackOffになります。ローリングアップデート中なので、旧Podは2つともRunningのまま残っています。DeploymentのstrategyでmaxSurge: 1, maxUnavailable: 0と設定しているため、新Podが1つ追加(surge)されますが、新PodがReadyにならない限り旧Podは1つも終了しません。結果としてAPI Podが計3つ表示されます。

[Execution User: developer]
# 切り分け: Eventsで詳細を確認
kubectl describe pod -l component=api -n app | grep -A 10 "Events:"

Eventsセクションに以下のようなメッセージが表示されます。

Events:
  Type     Reason              Age   From               Message
  ----     ------              ----  ----               -------
  Normal   Scheduled           1m    default-scheduler  Successfully assigned app/taskboard-api-xxx to k8s-applied-worker2
  Warning  ErrImageNeverPull   1m    kubelet            Container image "taskboard-api:99.99" is not present with pull policy of Never
  Warning  Failed              1m    kubelet            Error: ErrImageNeverPull

原因が明確です。taskboard-api:99.99というイメージが存在しません。

9.4.3 原因特定と復旧

正しいイメージタグに戻します。

[Execution User: developer]
# 復旧: 正しいイメージタグに戻す
kubectl set image deployment/taskboard-api \
  taskboard-api=taskboard-api:2.1.0 -n app

# ロールアウト状態を監視
kubectl rollout status deployment/taskboard-api -n app
deployment "taskboard-api" successfully rolled out
[Execution User: developer]
# 復旧確認: 全Podが正常稼働していること
kubectl get pods -n app

# ヘルスチェック確認(Payara Microのコンテナにはcurlが含まれないため、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

全PodがRunningに戻り、ヘルスチェックが正常であれば復旧完了です。

9.4.4 再発防止策

このシナリオの直接原因は「存在しないイメージタグの指定」です。根本原因は「イメージタグの管理ルールが不明確であること」です。

再発防止策として以下を検討します。

  • イメージタグの命名規則策定: latestタグの使用を禁止し、セマンティックバージョニング(3.0.03.1.0等)を義務化する
  • 変更管理手順書の遵守: 第8回で作成した変更管理手順書に従い、kubectl diffで変更内容を事前確認する
  • dry-runによる事前検証: kubectl apply --dry-run=serverでマニフェストの妥当性を確認してから適用する

なお、今回はkubectl set imageで直接変更しましたが、Helm管理下のリリースではhelm upgradevalues.yamlを使ってイメージタグを管理します(第8回で構築済み)。Helmの変更履歴(helm history)が残るため、問題発生時にhelm rollbackで即座に前バージョンに復元できます。

9.5 シナリオ2 — メモリ枯渇(OOMKilled)

2つ目のシナリオは、実務で最も調査が厄介な障害の1つ——OOMKilledです。このシナリオは本回で最も丁寧に扱います。理由は、Payara MicroのようなJVMアプリケーションでは、JVMヒープとK8sのresources.limitsの関係を正しく理解していないと原因特定が困難だからです。

9.5.1 障害を注入する

TaskBoard APIのresources.limits.memoryを極端に小さくします。Payara MicroのJVMは起動時に一定量のヒープメモリを確保するため、64Miでは確実にOOMKilledが発生します。

[Execution User: developer]
# 障害注入前の正常状態を記録
kubectl top pods -n app
# TaskBoard APIのメモリ使用量(正常時)を確認しておく
[Execution User: developer]
# 障害注入: limits.memoryを64Miに変更
kubectl patch deployment taskboard-api -n app --type='json' \
  -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/resources/limits/memory", "value": "64Mi"},
       {"op": "replace", "path": "/spec/template/spec/containers/0/resources/requests/memory", "value": "64Mi"}]'

9.5.2 検知と切り分け

新しいPodが作成されますが、すぐにクラッシュします。少し待ってから状態を確認します。

[Execution User: developer]
# 30秒ほど待ってから確認
kubectl get pods -n app -w

出力例:

NAME                              READY   STATUS      RESTARTS      AGE
taskboard-api-xxxxxxxxxx-zzzzz    0/1     OOMKilled   3 (15s ago)   1m

切り分けフローチャートに従います。Podが起動していない → CrashLoopBackOff(OOMKilledによる再起動の繰り返し)。RESTARTSカウントが増加し続けています。

[Execution User: developer]
# 切り分け: OOMKilledの確認
kubectl describe pod -l component=api -n app | grep -A 5 "Last State:"
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      ...
      Finished:     ...

Reason: OOMKilledExit Code: 137——これはLinuxカーネルのOOM Killerがプロセスを強制終了したことを示しています。Exit Code 137はSIGKILL(128 + 9)です。

Payara MicroはJVMベースのアプリケーションサーバーです。JVMの起動時にヒープメモリ(デフォルトではコンテナのメモリ制限の約25%)、Metaspace、ネイティブメモリが確保されます。これらの合計がK8sのlimits.memory(64Mi)を超えた瞬間にOOMKilledが発生します。

第3回(詳細設計)で設計したlimits.memory: 512Miの根拠を振り返ります。

JVMメモリ領域概算使用量備考
ヒープ(-Xmx)約256Miコンテナメモリの50%程度をJVMが自動設定
Metaspace約64Miクラス情報の格納領域
スレッドスタック約50Miスレッド数に依存
ネイティブメモリ約30MiJNI、バッファ等
合計約400Mi512Miのlimitsに対して約80%

64Miではヒープの確保すら不可能で、JVM起動直後にOOMKilledとなります。

9.5.3 原因特定と復旧

resources.limits.memoryを設計値(512Mi)に戻します。

[Execution User: developer]
# 復旧: limitsを設計値に戻す
kubectl patch deployment taskboard-api -n app --type='json' \
  -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/resources/limits/memory", "value": "512Mi"},
       {"op": "replace", "path": "/spec/template/spec/containers/0/resources/requests/memory", "value": "384Mi"}]'

# ロールアウト状態を監視
kubectl rollout status deployment/taskboard-api -n app
[Execution User: developer]
# 復旧確認
kubectl get pods -n app
kubectl top pods -n app

# ヘルスチェック確認(Payara Microのコンテナにはcurlが含まれないため、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

PodがRunningに戻り、kubectl topでメモリ使用量が正常範囲(200〜350Mi程度)に収まっていれば復旧完了です。

9.5.4 再発防止策

OOMKilledの再発防止には、JVMとK8sの関係を理解した上での設計が不可欠です。

  • resources設計の文書化: 第3回の詳細設計書に記載した「JVMヒープ + Metaspace + ネイティブメモリ ≤ limits.memory × 80%」の設計根拠をチームで共有する
  • 監視閾値の設定: 第7回の運用設計書に「メモリ使用量がlimitsの70%を超えたらアラート」のルールを追加する
  • 負荷テストの実施: 実際のリクエストパターンでメモリ使用量のピークを測定し、limitsに十分なマージンがあることを確認する

このシナリオは後ほど9.10節の障害報告書の記入例として使用します。

9.6 シナリオ3 — Node障害

9.6.1 障害を注入する

3つ目のシナリオは、インフラエンジニアにとって最も馴染み深い障害——ホスト(Node)の停止です。VMの世界でESXiホストがダウンした場合にvSphere HAがVMを別ホストに退避させるのと同様に、K8sでもNode障害時にPodが自動で別Nodeに再配置されます。

まず、現在のPod配置を記録します。

[Execution User: developer]
# 障害注入前: Podの配置Nodeを記録
kubectl get pods -o wide -n app
kubectl get pods -o wide -n db

出力例:

# app Namespace
NAME                              READY   STATUS    NODE
nginx-xxxxxxxxxx-xxxxx            1/1     Running   k8s-applied-worker
nginx-xxxxxxxxxx-yyyyy            1/1     Running   k8s-applied-worker2
taskboard-api-xxxxxxxxxx-xxxxx    1/1     Running   k8s-applied-worker2
taskboard-api-xxxxxxxxxx-yyyyy    1/1     Running   k8s-applied-worker3

# db Namespace
NAME       READY   STATUS    NODE
mysql-0    1/1     Running   k8s-applied-worker3

記録しました。ここでk8s-applied-worker2を停止します。k8s-applied-worker2にはNginx Pod 1つとTaskBoard API Pod 1つが配置されています。

kindのNodeをDockerコンテナとして停止・再起動すると、Node内のkube-proxyやCNI(Calico)が正常に復帰するためにLinuxカーネルのinotifyリソースが十分に必要です。不足しているとkube-proxyがtoo many open filesで起動に失敗し、連鎖的にCNIも起動できなくなります。障害注入の前に、ホストのinotify上限を確認・拡張しておきます。

[Execution User: developer]
# inotify上限を確認
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances

# 不足している場合は拡張(kind環境ではNodeがDockerコンテナのためホスト設定が共有される)
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=512
[Execution User: developer]
# 障害注入: Worker Nodeを停止(kindのNodeはDockerコンテナ)
docker stop k8s-applied-worker2

9.6.2 検知と切り分け

[Execution User: developer]
# 検知: Nodeの状態を確認(NotReadyになるまで40秒程度かかる)
# docker stop直後はまだReadyと表示されることがある
sleep 45
kubectl get nodes

出力例:

NAME                  STATUS     ROLES           AGE   VERSION
k8s-applied-control-plane    Ready      control-plane   1d    v1.32.x
k8s-applied-worker           Ready      <none>          1d    v1.32.x
k8s-applied-worker2          NotReady   <none>          1d    v1.32.x
k8s-applied-worker3          Ready      <none>          1d    v1.32.x

k8s-applied-worker2のStatusがNotReadyになっています。docker stop直後に確認すると、ノードコントローラーがまだ異常を検出しておらずReadyのまま表示されることがあります。kubeletからのハートビートが途絶えてから約40秒(node-monitor-grace-periodのデフォルト値)でNotReadyに遷移します。切り分けフローチャートの「Nodeは正常か? → NotReady → シナリオ3」に該当します。

[Execution User: developer]
# 切り分け: 影響を受けるPodを確認
kubectl get pods -o wide -n app
kubectl get pods -o wide -n db

k8s-applied-worker2上のPodは、まだRunningと表示されています。「Nodeが停止したのにRunning?」と驚くかもしれませんが、これは正常な動作です。kubeletが停止しているためPodのステータスを更新できず、最後に報告されたRunningが表示され続けます。VMの世界でESXiホストがダウンしたとき、vCenterがしばらくVMの状態を「パワーオン」と表示し続けるのと同じです。

9.6.3 Pod再配置の観察

K8sのノードコントローラーは、NodeがNotReadyになるとnode.kubernetes.io/not-ready:NoExecuteテイントを自動的に付与します。Podにはこのテイントに対するデフォルトのtoleranceが設定されており、猶予期間は300秒(5分)です。5分経過後にテイントベースのeviction(退去)が発動し、該当Node上のPodがTerminatingに遷移します。ただしkubeletが停止中のため実際の終了処理は実行できず、Terminatingのまま残ります。同時に、Deploymentコントローラーがreplicas数を維持するため、他のNodeに新しいPodを作成します。

[Execution User: developer]
# Pod再配置を継続的に監視(5分ほど待つ)
kubectl get pods -o wide -n app -w

5分程度待つと、k8s-applied-worker2上のPodがTerminatingに遷移し、同時にk8s-applied-workerまたはk8s-applied-worker3に新しいPodが作成されます。TerminatingのPodはkubelet停止中のため削除されずに残りますが、新しいPodがRunningになればサービスは復旧しています。

PDB(PodDisruptionBudget)の効果も確認します。第2回(基本設計)で設計したPDB(minAvailable: 1)により、Node障害時も最低1つのPodがRunning状態を維持しています。これにより、サービスの完全断は発生しません。

[Execution User: developer]
# PDBの状態確認
kubectl get pdb -n app

9.6.4 Node復旧 — 基盤コンポーネントから順に回復させる

Nodeを復旧させます。ただし、docker startでNodeがReadyに戻っても、それだけではPodが正常に稼働するとは限りません。kind環境ではdocker stop/startによるNode再起動で基盤コンポーネント(kube-proxy、CNI)が正常に復帰しないことがあります。復旧は「Node → 基盤コンポーネント → Terminating Pod解消 → アプリケーション」の順に進めます。

Step 1: Nodeの起動とReady確認

[Execution User: developer]
# Node復旧
docker start k8s-applied-worker2

# Node復帰を確認(数十秒待つ)
sleep 30
kubectl get nodes

k8s-applied-worker2のStatusがReadyに戻ります。

Step 2: 基盤コンポーネントの確認

Node自体はReadyでも、そのNode上でPodネットワークを構成するkube-proxyとCNI(Calico)が正常でなければ、Podは起動できません。まず基盤コンポーネントの状態を確認します。

[Execution User: developer]
# kube-proxyの状態を確認
kubectl get pods -n kube-system -o wide | grep k8s-applied-worker2

# Calico CNIの状態を確認
kubectl get pods -n calico-system -o wide | grep k8s-applied-worker2

ここで2つのパターンに分かれます。

パターンA: 基盤コンポーネントが正常(全てRunning + Ready) — Step 3に進んでください。

パターンB: kube-proxyやcalico-nodeが異常(CrashLoopBackOff / Unknown / Init:Error) — 以下の手順で復旧します。kind環境ではDockerコンテナのstop/startにより、kube-proxyがtoo many open filesで起動失敗し、連鎖的にCalico CNIもAPIサーバーに接続できず起動不能になることがあります。

[Execution User: developer]
# kube-proxyのログを確認(too many open filesの場合はinotify上限の問題)
kubectl logs -n kube-system -l k8s-app=kube-proxy \
  --field-selector=spec.nodeName=k8s-applied-worker2 --tail=5

too many open filesが表示された場合、ホストのinotify上限が不足しています。障害注入前の手順で設定済みであれば発生しませんが、発生した場合は以下で対処します。

[Execution User: developer]
# inotify上限を拡張(未設定の場合)
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=512

# kube-proxyを再起動(DaemonSetが新しいPodを作成する)
kubectl delete pods -n kube-system \
  --field-selector=spec.nodeName=k8s-applied-worker2 --force --grace-period=0

# kube-proxyの復旧を確認
sleep 10
kubectl get pods -n kube-system -o wide | grep k8s-applied-worker2

kube-proxyが1/1 Runningになったら、Calico CNIを復旧させます。

[Execution User: developer]
# Calico CNIの異常なPodを強制削除して再作成させる
kubectl delete pods -n calico-system \
  --field-selector=spec.nodeName=k8s-applied-worker2 --force --grace-period=0

# Calico CNIの復旧を確認(calico-nodeが1/1 Runningになるまで待つ)
sleep 30
kubectl get pods -n calico-system -o wide | grep k8s-applied-worker2

calico-node1/1 Runningになれば、このNode上でPodネットワークが構成可能になります。kube-proxyがClusterIPへのルーティングを担い、CalicoがPod間通信とNetworkPolicyを制御する——この2つが揃って初めてPodが正常に動作します。

Step 3: Terminating Podの強制削除

[Execution User: developer]
# Pod状態を確認
kubectl get pods -o wide -n app
kubectl get pods -o wide -n db

k8s-applied-worker2上のPodがTerminatingのまま残っています。NodeはReadyに復帰しましたが、kubeletがPodの終了処理を完了できず、Podオブジェクトが削除されずに残ることがあります。特に注目すべきはmysql-0です。StatefulSetは同名のPodが完全に削除されるまで後続のPodを作成しないため、mysql-0Terminatingのまま残っている限りMySQLは復旧しません。MySQLが停止しているため、新しく別Nodeに再配置されたTaskBoard API PodもCrashLoopBackOff(DB接続失敗)になり、既存のAPI PodもREADY 0/1(readinessProbe失敗)になります。

[Execution User: developer]
# Terminating状態で残っているPodを強制削除(停止していたNode上のPodを対象)
kubectl delete pods -n db \
  --field-selector=spec.nodeName=k8s-applied-worker2 --force --grace-period=0
kubectl delete pods -n app \
  --field-selector=spec.nodeName=k8s-applied-worker2 --force --grace-period=0

--force --grace-period=0はPodオブジェクトをAPIサーバーから即座に削除します。通常のkubectl delete podはgraceful shutdown(デフォルト30秒の猶予)を試みますが、kubeletが終了処理を完了できない状態ではTerminatingのまま残り続けます。--forceはこの猶予を無視してAPIサーバーレベルでPodオブジェクトを削除するため、kubeletの状態に依存しません。--field-selector=spec.nodeName=k8s-applied-worker2で停止していたNode上のPodのみを対象にしています。

Step 4: MySQL → API の依存順で復旧を確認

[Execution User: developer]
# mysql-0がTerminating解消後、StatefulSetが新しいmysql-0を作成するのを待つ
kubectl get pods -n db -w

mysql-0Terminatingが消えると、StatefulSetコントローラーが新しいmysql-0を作成します。MySQLの起動を待ちます。

[Execution User: developer]
# MySQLの起動を待つ
kubectl wait --for=condition=ready pod/mysql-0 -n db --timeout=120s

# API PodもMySQLへの接続が回復し、readinessProbeが通ると自動的にREADY 1/1に戻る
kubectl get pods -n app -w

MySQLがRunning+Readyになると、TaskBoard API PodのreadinessProbe(/health/readyでDB接続を確認)が自動的に回復し、READY 1/1に戻ります。CrashLoopBackOffだったPodも次の再起動で正常起動し、Readyになります。

Step 5: HPAによるreplica数の確認

Node障害中にHPA(Horizontal Pod Autoscaler)がPodの不健全状態を検知し、replicas数を設計値(2)より増やしている場合があります。

[Execution User: developer]
# 現在のreplicas数を確認
kubectl get deployment taskboard-api -n app -o jsonpath='{.spec.replicas}'; echo
kubectl get deployment nginx -n app -o jsonpath='{.spec.replicas}'; echo

設計値(2)より大きい場合は、HPAが自動でスケールダウンするのを待つか、手動で戻します。

[Execution User: developer]
# 設計値より大きい場合のみ実行(設計値は2)
kubectl scale deployment taskboard-api -n app --replicas=2
kubectl scale deployment nginx -n app --replicas=2

Step 6: 復旧確認

[Execution User: developer]
# 復旧確認: 全Node Ready、全Pod Running
kubectl get nodes
kubectl get pods -o wide -n app
kubectl get pods -o wide -n db

全NodeがReady、全PodがRunning + READYであれば復旧完了です。Node復帰後に自動的にPodがk8s-applied-worker2に戻るわけではありません。次にPodの再作成やスケールアウトが発生したとき、スケジューラーがk8s-applied-worker2も配置先の候補として扱います。

9.6.5 再発防止策

このシナリオはNode障害そのものよりも、Node復旧後の連鎖障害が厄介でした。再発防止策として以下を検討します。

  • PDB設計の重要性: PDB(minAvailable: 1)がNode障害時のサービス継続に寄与していることを確認できた。PDBが未設定のワークロードがないか確認する
  • Pod分散配置の検討: topologySpreadConstraintsを設定し、同一Deploymentの全Podが同じNodeに集中しないようにする。1つのNodeに全APIのPodが配置されていた場合、そのNode障害でAPIサービスが全断する
  • inotify上限の恒久設定: /etc/sysctl.conffs.inotify.max_user_watches=524288fs.inotify.max_user_instances=512を追記し、ホスト再起動後も設定が維持されるようにする。kind環境ではNodeがDockerコンテナとしてホストのカーネルパラメータを共有するため、ホスト側の設定が不可欠
  • Node復旧時の基盤確認を手順化: Node復帰後は「kube-proxy → CNI → アプリケーション」の順に正常性を確認する。NodeのStatusがReadyでも、基盤コンポーネントが異常なままPodを起動しようとするとContainerCreatingで停滞する
  • StatefulSetの復旧手順を文書化: StatefulSetのPod(MySQL)は同名Podが完全削除されるまで新Podが作成されない。Node障害後にTerminating Podが残る場合はkubectl delete pod --force --grace-period=0が必要になる。この手順を障害対応手順書に含める
  • 依存関係を意識した復旧順序: MySQL → API → Nginxの順にサービスが回復する。MySQL停止中はAPI PodがCrashLoopBackOffになるのは正常な動作であり、MySQL復旧後に自動回復する。慌ててAPI Podを手動で再起動する必要はない
  • Node監視: kubectl get nodesを定期的に実行する監視を検討する(本番ではNode Exporterやクラウドプロバイダーの監視機能を使用)

9.7 シナリオ4 — NW障害(NetworkPolicy誤設定)

4つ目のシナリオは、切り分けが最も難しい障害——ネットワーク障害です。NetworkPolicyの誤設定によるものですが、症状としては「TaskBoard APIがMySQLに接続できない」という形で現れます。NW障害はログにDB接続エラーが出るため、一見するとDB障害に見えます。この「見た目と原因のズレ」がNW障害の切り分けを難しくしています。

9.7.1 障害を注入する

db NamespaceのNetworkPolicy(allow-api-to-mysql)のラベルセレクタを誤った値に変更します。第6回で構築した正しい設定では、namespaceSelectorlayer: applicationラベルを持つNamespace(app)からのcomponent: apiラベルを持つPodのIngressを許可しています。このlayer: applicationlayer: frontendに変更し、マッチしないようにします。

[Execution User: developer]
# 障害注入: NetworkPolicyのラベルセレクタを誤った値に変更
cat <<'EOF' > /tmp/netpol-db-allow-api-broken.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-to-mysql
  namespace: db
spec:
  podSelector:
    matchLabels:
      app: taskboard
      component: db
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              layer: frontend          # ← 誤り!正しくは layer: application
          podSelector:
            matchLabels:
              app: taskboard
              component: api
      ports:
        - protocol: TCP
          port: 3306
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
EOF

kubectl apply -f /tmp/netpol-db-allow-api-broken.yaml

9.7.2 検知と切り分け — 新しいPodで障害が顕在化する

障害注入後、kubectl get pods -n appを確認すると、API PodはまだREADY 1/1のままです。「NetworkPolicyを変更したのに影響が出ない?」と思うかもしれませんが、これはPayara MicroのJDBCコネクションプールが既存のDB接続を保持し続けているためです。NetworkPolicyは新規のTCPコネクション確立を遮断しますが、既に確立済みのコネクションには即座に影響しません。readinessProbe(/health/ready)もプール内の既存接続でDB疎通確認を行うため、しばらくの間は成功し続けます。

実際のプロジェクトでは、コネクションプールの有効期限切れやPodの再起動をきっかけに障害が顕在化します。ここではrollout restartで新しいPodを作成し、障害を顕在化させます。

[Execution User: developer]
# 既存のコネクションプールをリフレッシュするためにPodを再起動
kubectl rollout restart deployment/taskboard-api -n app

# 新しいPodがDB接続に失敗するのを待つ
sleep 30
kubectl get pods -n app

出力例:

NAME                              READY   STATUS             RESTARTS      AGE
nginx-xxxxxxxxxx-xxxxx            1/1     Running            0             1h
nginx-xxxxxxxxxx-yyyyy            1/1     Running            0             1h
taskboard-api-xxxxxxxxxx-xxxxx    1/1     Running            0             1h
taskboard-api-xxxxxxxxxx-yyyyy    1/1     Running            0             1h
taskboard-api-xxxxxxxxxx-zzzzz    0/1     Running            4 (5s ago)    1m

新しいAPI Pod(zzzzz)がREADY 0/1になっています。startupProbeがタイムアウトし、再起動を繰り返しています。maxSurge: 1, maxUnavailable: 0の設定により、旧Pod 2つはRunningのまま残っています。旧PodはNetworkPolicy変更前に確立した既存DB接続を使い続けているため、まだ正常に見えます。

[Execution User: developer]
# 切り分け: ログでエラー内容を確認
kubectl logs -n app deploy/taskboard-api --tail=20

ログには以下のようなDB接続エラーが記録されています。

SEVERE: Connection refused to host mysql-0.mysql-headless.db.svc.cluster.local:3306
javax.persistence.PersistenceException: Unable to acquire connection from pool
...

ここで、このエラーメッセージだけ見ると「MySQLが落ちている」と判断してしまいがちです。実際にMySQLの状態を確認しましょう。

[Execution User: developer]
# MySQLは正常か?
kubectl get pods -n db
kubectl exec -n db mysql-0 -- bash -c 'mysqladmin ping -u root -p$MYSQL_ROOT_PASSWORD'

MySQLは正常に稼働しています。つまり、DB自体の問題ではなく、APIからDBへの通信経路に問題があります。

テスト用Podで通信テストを実行します。

[Execution User: developer]
# app Namespaceからdb NamespaceのMySQL(TCP 3306)への接続テスト
kubectl run test-netpol --rm -it --restart=Never \
  --image=busybox -n app \
  --labels="app=taskboard,component=api" \
  -- nc -zv -w 3 mysql-headless.db.svc.cluster.local 3306

タイムアウトします。app: taskboard, component: apiラベルを持つPodからMySQLへの通信が遮断されています。

なお、テスト用Podの実行時にPSA(Pod Security Admission)のWarningメッセージが表示されることがあります(would violate PodSecurity "restricted:latest")。app Namespaceにpod-security.kubernetes.io/warn=restrictedを設定しているためですが、warnモードは警告を表示するだけでPodの作成はブロックしません。テスト用Podは一時的な診断ツールなので、このWarningは無視して問題ありません。

ここでAIを使って切り分けの方向性を確認してみましょう。

💬 あなた → AI(Claude):
TaskBoard APIのPodが以下のエラーを出しています。原因を分析してください。

SEVERE: Connection refused to host mysql-0.mysql-headless.db.svc.cluster.local:3306
javax.persistence.PersistenceException: Unable to acquire connection from pool

MySQLのPodは正常にRunningです。app Namespaceのテスト用Podからdb NamespaceのMySQL(TCP 3306)に接続テストしたところ、タイムアウトしました。

🤖 Claude の回答:
MySQL Podが正常稼働しているにもかかわらず接続がタイムアウトするため、ネットワーク層の問題が最も疑わしいです。考えられる原因を優先度順に挙げます。

1. NetworkPolicyの設定ミス — db NamespaceのNetworkPolicyが、app Namespaceからのingressを正しく許可していない可能性があります。kubectl describe networkpolicy -n dbでルールを確認してください。特にnamespaceSelectorpodSelectorのラベルが実際のNamespace/Podのラベルと一致しているかを確認します。

2. DNS解決の問題mysql-0.mysql-headless.db.svc.cluster.localが正しいIPに解決されているか、nslookupで確認してください。

3. Serviceの設定ミス — Headless Serviceのセレクタが正しくMySQLのPodにマッチしているか確認してください。

テスト用Podからのタイムアウトという情報から、NetworkPolicyが最も可能性が高いです。

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

AIの分析は一般論として的確です。「MySQLが正常 + 通信がタイムアウト → NetworkPolicyが疑わしい」という推論は正しい方向を示しています。しかし、AIはあなたの環境の具体的な構成を知りません。ここからは自分の設計書と照合して原因を特定します。

✅ 最近NetworkPolicyを変更していないか? → 変更した(障害注入で変更済み)
✅ 通信制御マトリクス(第6回成果物)と照合したか? → 次の節で照合する
✅ テスト用Podから直接接続テストを実行したか? → 実行済み(タイムアウト確認)

AIは有力な情報源ですが、最終判断は自分の環境知識に基づきます。「自分の設計書にはどう書いてあるか」が、切り分けの最も信頼できる基準です。

9.7.3 通信制御マトリクスとの照合で原因を特定する

第6回で作成した通信制御マトリクスを参照します。「API(app Namespace, component: api)→ MySQL(db Namespace, component: db): TCP 3306 ✅ 許可」と記載されています。設計上は許可されるべき通信が遮断されている——NetworkPolicyの設定が設計書と一致していない可能性が高いです。

[Execution User: developer]
# NetworkPolicyの詳細を確認
kubectl describe networkpolicy allow-api-to-mysql -n db

出力のうち、Allowing ingress trafficセクションに注目します。

Spec:
  PodSelector:     app=taskboard,component=db
  Allowing ingress traffic:
    To Port: 3306/TCP
    From:
      NamespaceSelector: layer=frontend
      PodSelector: app=taskboard,component=api

原因が判明しました。NamespaceSelectorlayer=frontendになっています。正しくはlayer=applicationです。app Namespaceのラベルを確認します。

[Execution User: developer]
# app Namespaceのラベルを確認
kubectl get namespace app --show-labels
NAME   STATUS   AGE   LABELS
app    Active   1d    kubernetes.io/metadata.name=app,layer=application,pod-security.kubernetes.io/warn=restricted,pod-security.kubernetes.io/warn-version=latest,team=taskboard

app Namespaceのラベルはlayer=applicationです。NetworkPolicyのnamespaceSelectorlayer=frontendを要求しているため、マッチするNamespaceが存在せず、通信が遮断されています。

9.7.4 復旧と再発防止策

[Execution User: developer]
# 復旧: 正しいNetworkPolicyを再適用
kubectl apply -f ~/k8s-production/netpol-db-allow-api.yaml

# 復旧確認: 通信テスト
kubectl run test-netpol --rm -it --restart=Never \
  --image=busybox -n app \
  --labels="app=taskboard,component=api" \
  -- nc -zv -w 3 mysql-headless.db.svc.cluster.local 3306

接続が成功するはずです。readinessProbeも自動的に回復し、TaskBoard API PodのREADYが1/1に戻ります。

[Execution User: developer]
# Pod状態の復旧確認(readinessProbeの回復を待つ)
kubectl get pods -n app -w

再発防止策として以下を検討します。

  • NetworkPolicy変更時のテスト手順: 変更前にテスト用Podで通信テストを実施し、変更後に同じテストを実行して結果を比較する。第6回のPhase Cで実施したE2Eテストの手順をそのまま再利用する
  • 通信制御マトリクスとの照合をルール化: NetworkPolicy変更時は必ず通信制御マトリクス(第6回成果物)と照合し、設計書とマニフェストの一致を確認する
  • ラベルの一覧管理: Namespace / Podに付与しているラベルの一覧を設計書に明記し、NetworkPolicyで参照するラベルとの整合性を維持する

9.8 シナリオ5 — ストレージ障害(PVC設定ミス)

9.8.1 障害を注入する

5つ目のシナリオは、ストレージ障害です。MySQL StatefulSetのvolumeClaimTemplatesで参照するStorageClass名を存在しないものに変更します。

StatefulSetのvolumeClaimTemplatesはイミュータブル(変更不可)なフィールドです。直接変更するにはStatefulSetの削除・再作成が必要です。ここではStatefulSetを一度削除し、誤った設定で再作成します。

[Execution User: developer]
# 障害注入前: 現在のPVCの状態を記録
kubectl get pvc -n db

# StatefulSetを削除(PVCは保持される)
kubectl delete statefulset mysql -n db --cascade=orphan

--cascade=orphanを指定することで、StatefulSetは削除されますがPodとPVCはそのまま残ります。

[Execution User: developer]
# 障害注入: 存在しないStorageClassを指定したStatefulSetを作成
cat <<'EOF' > /tmp/mysql-statefulset-broken.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: 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
        storageClassName: non-existent-storage    # ← 存在しないStorageClass
        resources:
          requests:
            storage: 1Gi
EOF

# 既存のmysql-0 Podと既存PVCを削除する
kubectl delete pod mysql-0 -n db
kubectl delete pvc mysql-data-mysql-0 -n db

# 壊れたStatefulSetを適用
kubectl apply -f /tmp/mysql-statefulset-broken.yaml

StatefulSetはvolumeClaimTemplatesから{テンプレート名}-{StatefulSet名}-{序数}というPVC名を算出し、同名のPVCが既に存在すればStorageClassに関係なく再利用します。そのため、既存PVCが残ったままだとStorageClassの誤りが発現しません。ここでは障害を確実に再現するため、既存PVCも削除しています。StatefulSetが新しいPod(mysql-0)を作成する際、同名のPVCが存在しないため新規作成を試みますが、non-existent-storageというStorageClassは存在しないためPVCがPendingのままになります。

9.8.2 検知と切り分け

[Execution User: developer]
# 検知: Podの状態を確認
kubectl get pods -n db

出力例:

NAME      READY   STATUS    RESTARTS   AGE
mysql-0   0/1     Pending   0          30s

切り分けフローチャートに従います。Podが起動していない → Pending → PVCかスケジュール不能。

[Execution User: developer]
# 切り分け: Pod Eventsを確認
kubectl describe pod mysql-0 -n db | grep -A 10 "Events:"
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  30s   default-scheduler  0/4 nodes are available:
    pod has unbound immediate PersistentVolumeClaims. ...
[Execution User: developer]
# PVCの状態を確認
kubectl get pvc -n db
NAME                STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS            AGE
mysql-data-mysql-0  Pending                                      non-existent-storage    30s

PVCがPendingのままです。STORAGECLASS列がnon-existent-storageになっています。

[Execution User: developer]
# 利用可能なStorageClassを確認
kubectl get storageclass

non-existent-storageというStorageClassは存在しないことが確認できます。kind環境ではstandardがデフォルトのStorageClassです。

9.8.3 PVC復旧とデータ保全

復旧手順として、誤ったStatefulSetを削除し、正しいStorageClass名で再作成します。

[Execution User: developer]
# 誤ったStatefulSetとPVCを削除
kubectl delete statefulset mysql -n db
kubectl delete pvc mysql-data-mysql-0 -n db

# 正しいStatefulSetマニフェストで再作成
kubectl apply -f ~/k8s-production/manifests/mysql-statefulset.yaml

# 復旧確認
kubectl get pods -n db -w
kubectl get pvc -n db

PVCがBound状態になり、MySQL PodがRunningになれば復旧完了です。

ただし、PVCを削除・再作成したため、以前のデータは失われています。第7回(運用設計)で設計したCronJobによるバックアップが存在すれば、バックアップからリストアできます。これが、バックアップ設計の重要性を実感する瞬間です。

[Execution User: developer]
# DB初期化: データを直接投入して復元する
# db-init Jobはdb Namespaceのdefault-deny-all NetworkPolicyにより
# DNS/MySQL EgressがブロックされるためJobからの実行は失敗する。
# ここではmysql-0に直接execしてスキーマ作成と初期データ投入を行う。
kubectl exec -n db mysql-0 -- bash -c 'mysql -u root -p$MYSQL_ROOT_PASSWORD taskboard -e "
CREATE TABLE IF NOT EXISTS tasks (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  status VARCHAR(50) NOT NULL DEFAULT '\''TODO'\''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT IGNORE INTO tasks (title, status) VALUES
  ('\''プロジェクト計画の作成'\'', '\''TODO'\''),
  ('\''サーバー環境の構築'\'', '\''IN_PROGRESS'\''),
  ('\''APIの設計レビュー'\'', '\''TODO'\''),
  ('\''テスト計画の策定'\'', '\''TODO'\''),
  ('\''本番リリース準備'\'', '\''TODO'\'');

SELECT id, title, status FROM tasks ORDER BY id;"'

なぜdb-init Jobではなくkubectl execで復元するのか?

第6回で構築したNetworkPolicyにより、db Namespaceにはdefault-deny-all(全通信遮断)が適用されています。MySQLへのIngressはAPIとバックアップCronJobからのみ許可され、db-init Job(component: db-init)にはDNSやMySQLへのEgress許可が設定されていません。第5回でdb-init Jobを実行したのはNetworkPolicy適用前だったため問題になりませんでしたが、障害復旧時にはNetworkPolicyが存在する状態で再実行するため失敗します。kubectl execはPod内部で直接コマンドを実行するためNetworkPolicyの影響を受けません。

[Execution User: developer]
# データ確認(Payara Microのコンテナにはcurlが含まれないため、port-forward経由)
# API PodのDB接続プールをリフレッシュするため、先にrollout restartする
kubectl rollout restart deployment/taskboard-api -n app
kubectl rollout status deployment/taskboard-api -n app --timeout=90s

kubectl port-forward -n app deploy/taskboard-api 18080:8080 > /dev/null 2>&1 &
sleep 2
curl -s http://localhost:18080/taskboard-api/api/tasks
kill %1 2>/dev/null

9.8.4 再発防止策

  • StorageClass名のバリデーション: マニフェスト適用前にkubectl get storageclassで利用可能なStorageClassを確認する
  • dry-runによる事前確認: kubectl apply --dry-run=server -fでマニフェストの妥当性を事前に検証する
  • バックアップの定期確認: CronJobによるバックアップが正常に実行されていることを定期的に確認する。バックアップは「存在するだけ」では不十分で、リストアテストまで含めて初めて信頼できる
  • PVC削除時のデータ消失リスクの周知: PVCの削除はデータの完全消失を意味する。変更管理手順書に「PVC操作は必ずバックアップ確認後に実施」と明記する

9.9 シナリオ6 — 設定ミス(ConfigMap値の誤り)

9.9.1 障害を注入する

最後のシナリオは、設定ミスによる障害です。Nginx ConfigMap(nginx-config)に構文エラーのあるnginx.confを設定し、rollout restartで反映します。第8回で学んだ「ConfigMap変更 → rollout restart」の手順が、誤った設定で実行された場合に何が起きるかを体験します。

[Execution User: developer]
# 障害注入前: 現在のConfigMapの状態を記録(参考用)
# 復元には正規のマニフェストファイル(~/k8s-production/manifests/nginx-configmap.yaml)を使用する
kubectl get configmap nginx-config -n app -o yaml > /tmp/nginx-config-backup.yaml
[Execution User: developer]
# 障害注入: 構文エラーのあるnginx.confを作成
# ep5で適用した正規のnginx.confから、httpブロックの閉じ括弧を削除する
cat <<'EOF' > /tmp/broken-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;

    access_log  /tmp/access.log;
    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;
        }
    }

    # ← httpブロックの閉じ括弧が不足(構文エラー)
EOF

# ConfigMapを上書き
kubectl create configmap nginx-config -n app \
  --from-file=nginx.conf=/tmp/broken-nginx.conf \
  --dry-run=client -o yaml | kubectl apply -f -
[Execution User: developer]
# ConfigMap反映のためrollout restart
kubectl rollout restart deployment/nginx -n app

9.9.2 検知と切り分け

[Execution User: developer]
# 検知: Podの状態を確認
kubectl get pods -n app

出力例:

NAME                              READY   STATUS             RESTARTS      AGE
nginx-xxxxxxxxxx-xxxxx            1/1     Running            0             1h
nginx-xxxxxxxxxx-yyyyy            1/1     Running            0             1h
nginx-xxxxxxxxxx-zzzzz            0/1     CrashLoopBackOff   3 (10s ago)   1m
taskboard-api-xxxxxxxxxx-xxxxx    1/1     Running            0             1h
taskboard-api-xxxxxxxxxx-yyyyy    1/1     Running            0             1h

新しいNginx PodがCrashLoopBackOffになっています。シナリオ1と同じメカニズムで、maxSurge: 1, maxUnavailable: 0の設定により、新PodがReadyにならない限り旧Pod 2つは終了しません。Nginx Podが計3つ表示されます。

[Execution User: developer]
# 切り分け: Nginxのエラーログを確認
kubectl logs -n app -l component=frontend --tail=10
nginx: [emerg] unexpected end of file, expecting "}" in /etc/nginx/nginx.conf:35
nginx: configuration file /etc/nginx/nginx.conf test failed

nginx.confの構文エラーが原因です。Nginxは設定ファイルの構文チェックに失敗するとプロセスを起動できず、コンテナが即座に終了します。

9.9.3 復旧と変更管理手順の徹底

[Execution User: developer]
# 復旧: 正規のConfigMapマニフェストを再適用
# バックアップファイル(kubectl get -o yaml で取得)はresourceVersionを含むため、
# 別のリソース更新と競合してapplyが失敗することがある。
# 構築時に使用した正規のマニフェストファイルから復元するのが確実。
kubectl apply -f ~/k8s-production/manifests/nginx-configmap.yaml

# rollout restartで正しい設定を反映
kubectl rollout restart deployment/nginx -n app

# ロールアウト状態を監視
kubectl rollout status deployment/nginx -n app --timeout=90s
[Execution User: developer]
# 復旧確認
kubectl get pods -n app
kubectl exec -n app deploy/nginx -- nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Nginx Podが全てRunningに戻り、nginx -t(構文チェック)が成功していれば復旧完了です。

9.9.4 再発防止策

  • ConfigMap変更前のバリデーション: Nginxの場合、ConfigMap更新前にテスト用Podでnginx -t相当の構文チェックを実施する。変更管理手順書(第8回成果物)に「設定ファイルのバリデーション手順」を追加する
  • 正規マニフェストファイルの維持: kubectl get -o yamlで取得したバックアップはresourceVersionやHelm管理アノテーションを含むため、復元時にresourceVersion競合やアノテーション不整合で失敗することがある。構築時に使用した正規のマニフェストファイル(~/k8s-production/manifests/配下)をGitで管理し、復元にはこれを使用する
  • 変更管理手順書の遵守: 第8回で作成した手順書の「2.1 ConfigMap変更と反映」に従い、変更 → バリデーション → 反映 → 確認のフローを守る

6つのシナリオすべてを完了しました。TaskBoardは再び正常に稼働しています。

[Execution User: developer]
# 全シナリオ完了後の最終確認
kubectl get pods -n app
kubectl get pods -n db
kubectl get nodes
kubectl get pvc -n db

9.10 障害報告書を書く

9.10.1 障害報告書テンプレート

障害を復旧して終わり、ではプロの仕事ではありません。「何が起き、なぜ起き、どう直し、どう防ぐか」を文書として残すことで、チーム全体の知見になります。VMの世界でも障害報告書を書いていたはずです。K8sでも同じです。

以下のテンプレートをTaskBoardの障害対応手順書に組み込みます。

============================================================
障害報告書
============================================================

1. 障害概要
   発生日時:
   検知方法:
   影響範囲:
   障害レベル: □ 重大(サービス全断)  □ 中程度(一部機能停止)  □ 軽微(性能劣化)

2. タイムライン
   HH:MM  検知
   HH:MM  切り分け開始
   HH:MM  原因特定
   HH:MM  暫定復旧
   HH:MM  完全復旧

3. 原因分析
   直接原因:
   根本原因:

4. 復旧対応
   暫定対処の内容:
   暫定対処の確認結果:

5. 再発防止策
   恒久対策:
   実施期限:
   担当者:

6. 教訓
   今回の障害から学んだこと:

============================================================

9.10.2 シナリオ2(OOMKilled)の記入例

シナリオ2(OOMKilled)を題材に、障害報告書の記入例を示します。OOMKilledを選んだ理由は、原因分析が明確で、再発防止策がresources設計(第3回の成果物)に直結するためです。

============================================================
障害報告書
============================================================

1. 障害概要
   発生日時: 2026-01-15 14:30(JST)
   検知方法: kubectl get pods -n app で RESTARTS カウント増加を確認
   影響範囲: TaskBoard APIサービス停止
             (全API Podが再起動を繰り返し、Serviceのエンドポイントから外れた)
   障害レベル: ■ 重大(サービス全断)

2. タイムライン
   14:30  resources.limits.memory変更を含むマニフェスト適用
   14:32  API Podの再起動を検知(kubectl get pods で RESTARTS 増加)
   14:35  kubectl describe pod で OOMKilled を確認、原因特定
   14:38  limits.memory を設計値(512Mi)に修正、kubectl apply 実行
   14:40  全API PodがRunning + Ready、ヘルスチェック正常。完全復旧

3. 原因分析
   直接原因: TaskBoard APIの resources.limits.memory が 64Mi に設定された
   根本原因: Payara Micro(JVMアプリケーション)のメモリ要件と
             K8s limits の関係に対する理解不足。
             JVMヒープ + Metaspace + ネイティブメモリの合計が
             64Mi を大幅に超過し、OOM Killer が発動した

4. 復旧対応
   暫定対処の内容: resources.limits.memory を設計値(512Mi)、
                   requests.memory を設計値(384Mi)に修正して再デプロイ
   暫定対処の確認結果: 全API PodがRunning、readinessProbe正常、
                       kubectl top pods でメモリ使用量が正常範囲(約300Mi)

5. 再発防止策
   恒久対策:
     (1) 詳細設計書のresources設計セクションに
         「JVMヒープ + Metaspace + ネイティブメモリ ≤ limits × 80%」
         の設計ルールを明記する
     (2) 運用設計書の監視項目に
         「メモリ使用量 > limits × 70% でアラート」を追加する
     (3) resources変更を含むマニフェスト適用前に
         kubectl diff で差分確認を必須とする
   実施期限: 2026-01-22
   担当者: インフラチーム

6. 教訓
   JVMアプリケーションのK8s resources設計では、
   アプリケーション固有のメモリ消費特性を理解した上で
   limitsを設定する必要がある。
   NginxやMySQLとは異なるメモリ消費パターンであり、
   コンポーネントごとの特性に応じた設計が不可欠。

============================================================

9.10.3 障害報告書を書く意義

障害報告書を書く目的は「犯人探し」ではありません。目的は3つです。

1つ目は、再発防止策の実行を確実にすること。障害のたびに「次は気をつけよう」と口頭で話すだけでは、同じ障害が繰り返されます。文書化して期限と担当者を明記することで、再発防止策が確実に実行されます。

2つ目は、チーム全体の知見にすること。障害対応した本人だけが知っている情報は、その人が不在のときに役立ちません。報告書として残すことで、次に同じ障害が発生したとき、チームの誰でも対応できます。

3つ目は、設計の改善サイクルを回すこと。障害報告書の再発防止策は、設計書の修正につながります。シナリオ2の報告書では「詳細設計書のresources設計セクションにルールを追記する」「運用設計書に監視項目を追加する」という対策を挙げました。障害は、設計を磨く機会でもあります。

9.10.4 成果物をファイルとして保存する

本回の成果物——障害対応手順書と障害報告書テンプレート——をファイルとして保存します。第10回で「マネージドK8s環境での差分整理」を行う際に参照します。

[Execution User: developer]
# 成果物ディレクトリを作成
mkdir -p ~/k8s-production/09-incident-response/

# 障害対応手順書を保存(切り分けフローチャート + 6シナリオの対応手順)
cat <<'EOF' > ~/k8s-production/09-incident-response/incident-response-procedure.md
# TaskBoard 障害対応手順書

## 1. 障害対応フロー
検知 → 切り分け → 調査 → 復旧 → 恒久対策 → 報告

## 2. 切り分けフローチャート

### Podは起動しているか?(kubectl get pods -n <ns>)
- CrashLoopBackOff → logs / describe → アプリエラー / OOMKilled / 設定エラー
- Pending → describe pod Events → PVC障害 / スケジュール不能
- ImagePullBackOff / ErrImageNeverPull → イメージ名・タグ・imagePullPolicy確認
- Running (READY 0/1) → readinessProbe失敗 → DB接続 / NW障害

### Nodeは正常か?(kubectl get nodes)
- NotReady → Node障害

### 通信は正常か?(kubectl exec で接続テスト)
- 特定経路遮断 → NetworkPolicy誤設定

## 3. シナリオ別対応手順

### シナリオ1: CrashLoopBackOff(Pod層)
- 検知: kubectl get pods → ErrImageNeverPull(kind) / ImagePullBackOff(本番) / CrashLoopBackOff
- 切り分け: describe pod Events
- 復旧: 正しいイメージタグに修正

### シナリオ2: OOMKilled(Pod層)
- 検知: kubectl get pods → OOMKilled + RESTARTS増加
- 切り分け: describe pod → Last State: OOMKilled, Exit Code: 137
- 復旧: limits.memory を設計値に修正

### シナリオ3: Node障害(Node層)
- 検知: kubectl get nodes → NotReady
- 切り分け: kubectl get pods -o wide で影響Pod特定
- 復旧: Node復旧(kind: docker start)→ 基盤確認(kube-proxy/Calico)→ Terminating Pod強制削除(--force --grace-period=0)→ MySQL復旧待ち → HPA replica数確認

### シナリオ4: NW障害(Network層)
- 検知: kubectl get pods → READY 0/1(readinessProbe失敗)
- 切り分け: kubectl exec で接続テスト + describe networkpolicy
- 復旧: NetworkPolicyのラベルセレクタを通信制御マトリクスと照合して修正

### シナリオ5: ストレージ障害(Storage層)
- 検知: kubectl get pods → Pending
- 切り分け: describe pod Events + get pvc → StorageClass不一致
- 復旧: 正しいStorageClass名でStatefulSet再作成 + バックアップからリストア

### シナリオ6: 設定ミス(設定層)
- 検知: kubectl get pods → CrashLoopBackOff
- 切り分け: kubectl logs → 設定ファイルのエラーメッセージ
- 復旧: 正規ConfigMapマニフェストから復元(kubectl apply -f nginx-configmap.yaml)+ rollout restart
EOF

# 障害報告書テンプレートを保存
cat <<'EOF' > ~/k8s-production/09-incident-response/incident-response-report-template.md
# 障害報告書テンプレート

## 1. 障害概要
- 発生日時:
- 検知方法:
- 影響範囲:
- 障害レベル: □ 重大(サービス全断)  □ 中程度(一部機能停止)  □ 軽微(性能劣化)

## 2. タイムライン
- HH:MM  検知
- HH:MM  切り分け開始
- HH:MM  原因特定
- HH:MM  暫定復旧
- HH:MM  完全復旧

## 3. 原因分析
- 直接原因:
- 根本原因:

## 4. 復旧対応
- 暫定対処の内容:
- 暫定対処の確認結果:

## 5. 再発防止策
- 恒久対策:
- 実施期限:
- 担当者:

## 6. 教訓
- 今回の障害から学んだこと:
EOF

# 保存結果を確認
ls -l ~/k8s-production/09-incident-response/
-rw-r--r-- 1 developer developer  xxxx  x月 xx xx:xx incident-response-procedure.md
-rw-r--r-- 1 developer developer  xxxx  x月 xx xx:xx incident-response-report-template.md

2つのファイルが保存されました。これで、第10回でマネージドK8s環境での差分整理を行う際に、この手順書を参照できます。

9.11 この回のまとめ

9.11.1 成果物の確認 — 障害対応手順書 + 障害報告書テンプレート

本回の成果物は2つです。

障害対応手順書: 9.3節の障害対応フローと切り分けフローチャート、および6シナリオの対応手順をまとめたものです。チームに共有し、障害発生時の初動対応に使用します。

障害報告書テンプレート(記入例付き): 9.10節のテンプレートとOOMKilledの記入例です。障害対応後、このテンプレートに沿って報告書を作成し、再発防止策の実行を管理します。

9.11.2 6シナリオの振り返りと切り分けの体系化

6つの障害シナリオを一覧表で振り返ります。

シナリオ障害レイヤー検知の手がかり切り分けの鍵復旧方法
1. CrashLoopBackOffPod層kubectl get pods: ErrImageNeverPulldescribe podのEvents正しいイメージタグに修正
2. OOMKilledPod層kubectl get pods: OOMKilled + RESTARTS増加describe podのLast State: OOMKilledlimits.memoryを設計値に修正
3. Node障害Node層kubectl get nodes: NotReady影響Podの-o wideでNode配置確認Node復旧 → 基盤確認(kube-proxy/Calico)→ Terminating Pod強制削除 → MySQL復旧確認 → HPA replica数確認
4. NW障害Network層kubectl get pods: READY 0/1(readiness失敗)テスト用Podの接続テスト + describe networkpolicyNetworkPolicyのラベルセレクタ修正
5. ストレージ障害Storage層kubectl get pods: Pendingdescribe podのEvents + get pvcStorageClass名修正 + StatefulSet再作成
6. 設定ミス設定層kubectl get pods: CrashLoopBackOffkubectl logsのNginxエラーメッセージ正規ConfigMapマニフェストから復元 + rollout restart

6つのシナリオはそれぞれ異なるレイヤーの障害ですが、すべて同じ障害対応フロー(検知→切り分け→調査→復旧→恒久対策→報告)で対処しました。このフローが身に染みたら、未知の障害に遭遇しても「まず何を見るか」を迷わなくなります。

各シナリオでは、これまでの実践編で作成した成果物が切り分けと復旧に活用されました。

実践編の成果物障害対応での活用
構成図(第1回)「どこが壊れたか」を構成図上で特定する
基本設計書(第2回)PDB設計、NetworkPolicy方針が切り分けの参照情報に
詳細設計書(第3回)Probeパラメータ、resources設計値が障害原因の分析に
通信制御マトリクス(第6回)NW障害時に「設計上は許可/遮断どちらか」を判断する基準
運用設計書(第7回)バックアップ設計がストレージ障害からのデータ復旧に
変更管理手順書(第8回)ConfigMap変更手順、Helmロールバックが復旧手段に

設計書は「作って終わり」ではなく、障害対応のときに最も価値を発揮します。

9.11.3 次回予告 — 本番への道、そしてシリーズの締めくくり

実践編最後の第10回では、kindの学習環境から本番環境への橋渡しをします。

kindで学んだこと——マニフェスト、kubectl操作、設計書の書き方、障害対応の手法——はマネージドK8s(EKS / AKS / GKE)でもそのまま活かせます。しかし、「そのまま使えるもの」と「変わるもの」があります。LoadBalancer、StorageClass、IAM連携、監視——kindと本番の差分を整理し、次のステップを明確にします。

本回の障害対応手順書も、マネージドK8s移行時に「何が変わるか」を差分リストで整理します。kindではNode障害をdocker stopでシミュレーションしましたが、本番ではクラウドプロバイダーのインフラ障害として発生します。対応の考え方は同じですが、具体的な手段は変わる。その違いを整理するのが次回です。

入門編から始まった旅も、いよいよ終着点が見えてきました。

AI活用 — エラーメッセージの分析・切り分け補助

本回のAI活用は、9.7.2節のNW障害シナリオに組み込みました。ここでは、AI活用のポイントを補足します。

障害対応でAIが最も役立つのは、「エラーメッセージの分析」と「切り分けの方向性の提示」です。kubectl describekubectl logsの出力をAIに貼り付けて「原因の候補を挙げてほしい」と依頼すると、一般的な原因のリストと対処法を素早く得られます。

ただし、AIの分析には重要な限界があります。

  • AIはあなたの環境を知らない: NetworkPolicyの設定状況、Probeのパラメータ、resources設計値——これらは設計書にしか記載されていません。AIの分析は「一般論として正しい」けれど、「あなたの環境でどの原因が最も可能性が高いか」は自分で判断する必要があります
  • AIは最近の変更を知らない: 「5分前にNetworkPolicyを変更した」という情報は、AIに明示的に伝えない限り考慮されません。障害対応では「最近何を変更したか」が最も重要な手がかりになることが多い
  • AIの回答を鵜呑みにしない: AIが「DBが落ちている可能性が高い」と言っても、kubectl get pods -n dbでMySQLが正常に稼働していれば、別の原因を疑うべきです

AIは「切り分けの出発点を素早く得るためのツール」として活用し、最終判断は自分の設計書と実際の環境から下す——これが障害対応におけるAI活用の正しい姿勢です。