はじめに
Kubernetesには自己修復の仕組みがあるため、ノードに障害が発生してもPodは別のノードへ再スケジュールされ、大きな問題にはならないと考えている方は多いと思います。
ただ、実際に障害が発生してからPodが再スケジュールされるまでにどのような状態遷移を経るのか、そしてその過程が自分たちの利用しているワークロードにとって影響があるかどうかを、具体的に説明できる人は多くありません。Node Lifecycle Controller・TaintManager・PodGCという複数のコントローラーが段階的に関わっており、しかもPodを管理しているリソースの種類によって挙動がまるで違います。特にStatefulSetは、デフォルトの設定のままではノード障害から自動で復旧しません。
この記事では、ワーカーノードが物理的に壊れて応答不能になった場合に、Nodeオブジェクトがどう状態遷移するか、そしてその上で動いていたPodがDeployment/StatefulSet/DaemonSet/Jobそれぞれの管理単位でどう扱われるかを整理しました。
対象コンポーネントとバージョン
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| kubelet | v1.37.1 | kubernetes/kubernetes | ノードのハートビート(NodeStatus/Lease)送信の実装確認 |
| kube-controller-manager(Node Lifecycle Controller / TaintManager / PodGC / Attach/Detach Controller / ReplicaSet / StatefulSet / DaemonSet / Job controller) | v1.37.1 | kubernetes/kubernetes | ノードの生死判定・Taint付与・Pod削除・Volumeのデタッチ・各ワークロードの再作成ロジックの実装確認 |
| cloud-controller-manager(CloudNodeLifecycleController) | v1.37.1 | kubernetes/kubernetes | クラウドプロバイダのAPIと連携したNodeオブジェクトの自動削除ロジックの実装確認 |
| kube-apiserver(DefaultTolerationSeconds Admission Plugin) | v1.37.1 | kubernetes/kubernetes | PodへのデフォルトのToleration付与ロジックの実装確認 |
TL;DR
-
ノードが本当に応答しなくなってから
NotReadyと判定されるまでは、デフォルト値だけで見ても最短で約50秒(NodeMonitorGracePeriod)かかります。 kubeletのハートビートが数秒止まっただけではNotReadyにはなりません。 -
NotReadyになったノードにはNoExecuteのTaintが付与されますが、Podの削除が実行されるのはさらにデフォルトの300秒(DefaultTolerationSeconds)後です。 ノード障害の発生から数えると、デフォルト値の合計はおよそ350秒(6分弱)になります。 -
TaintManagerが行うPodの削除はGraceful Delete要求であり、Force Deleteではありません。 ノードが応答不能なままだと、kubelet側の終了処理が走らずPodは
Terminatingのまま残り続けます。 - Kubernetes自身は、ノードが本当に停止しているのか、一時的なネットワーク不通で実際には動き続けているだけなのかを区別できません。 強制削除・強制デタッチの自動化がどこまでできるかは、すべてこの区別がつかないという制約に行き着きます。
-
ネットワーク障害が本当に一時的で、Delete要求(T+350秒)より前に復旧すれば、Podは一度も削除されずに済みます。 ただしDelete要求の後に復旧した場合は
DeletionTimestampを取り消す手段が無く、復旧したkubelet自身がそのPodを正常に終了させることになります。 - DeploymentのPod(ReplicaSet)とJobのPod(デフォルトのPodReplacementPolicy)は、
Terminatingな旧Podの実終了を待たずに、代替Podを即座にほかのノードへ作成します。 -
StatefulSetはデフォルトのOrderedReadyポリシーで、同じOrdinalのPodが
Terminatingのままの間、代替Podの作成をブロックします。 このブロックはStatefulSet controller自身では解除されず、PodGCがnode.kubernetes.io/out-of-serviceTaintを検知して強制削除するまで解除されません。 - DaemonSetのPodはデフォルトでnot-ready/unreachable両方のNoExecute Taintに無期限のTolerationを持っており、そもそもTaint-based Evictionの対象になりません。 ノードに紐づけられたまま、ノードの復旧か削除を待つことになります。
- PersistentVolumeClaimを使うPodには、Podの削除とは別にVolumeのデタッチという制約が加わります。 Volumeのデタッチにはデフォルトで6分のタイムアウトによる自動フォールバックがありますが、Podオブジェクトの削除には無く、この制約はStatefulSetに限らずDeployment・Jobでも同様に発生します。
-
out-of-serviceTaintの手動付与という制約は、KEP-2268自身が「将来的には自動検知・フェンシングに取り組む」と明記している暫定的なものです。 ただし、この記事の執筆時点でその後継となるKEPは見当たりません。
管理単位ごとの挙動の違いをまとめると、次のようになります。
| 管理単位 | 代替Podの作成タイミング | ノード障害時の挙動 |
|---|---|---|
| Deployment(ReplicaSet) |
Terminatingな旧Podの実終了を待たず、即座に作成(6.1) |
別の健全なノードへ即座にスケジュールされる。PVCを使う場合は、Volumeのデタッチが完了するまでContainerCreatingのまま待たされる(7.3) |
StatefulSet(デフォルトのOrderedReady) |
同じOrdinalのPodオブジェクトが実際にetcdから削除されるまで作成されない(6.2) | Nodeオブジェクトの削除、またはout-of-serviceTaintの付与のいずれかが起きるまで復旧しない(3.1・3.2)。podManagementPolicy: ParallelにするとDeploymentと同様の挙動になる(6.2) |
| DaemonSet | 作成されない(そもそも他ノードへ再作成する設計ではない)(6.3) | デフォルトでTaint-based Evictionの対象外。ノードに紐づいたまま、ノードの復旧か削除を待つ(6.3) |
Job(デフォルトのTerminatingOrFailed) |
Terminatingな旧Podの実終了を待たず、Deploymentと同様に即座に作成(6.4) |
DeletionTimestampが付いた事実だけでbackoffLimitの消費にカウントされ得る。PVCを使う場合はDeploymentと同様にVolumeのデタッチ待ちが起きる(7.3)。podReplacementPolicy: FailedにするとFailed確定を待つようになる(6.4) |
第1章: kubeletのハートビートが止まってからNotReadyになるまで
ノードに障害が発生してから、NodeオブジェクトがNotReadyのステータスに遷移するまでの流れを説明します。
1.1 kubeletが送るノードステータス更新とLease
kubeletはデフォルトで10秒ごとに、自身が動作しているNodeオブジェクトのstatusをkube-apiserverへ更新します。
if obj.NodeStatusUpdateFrequency == zeroDuration {
obj.NodeStatusUpdateFrequency = metav1.Duration{Duration: 10 * time.Second}
}
(参考: defaults.go#L146-L147)
この更新はwait.JitterUntilによってnodeStatusUpdateFrequency間隔で繰り返し呼び出されています(参考: kubelet.go#L1956)。
NodeStatusの更新に加えて、kubeletはより軽量なNode Lease(coordination.k8s.io/v1 のLeaseオブジェクト)の更新も行っています。デフォルトのNodeLeaseDurationSecondsは40秒です。
if obj.NodeLeaseDurationSeconds == 0 {
obj.NodeLeaseDurationSeconds = 40
}
(参考: defaults.go#L149-L150、Node Heartbeats)
1.2 Node Lifecycle Controllerによる生死判定
kube-controller-manager内のNode Lifecycle Controllerは、デフォルトの5秒間隔(NodeMonitorPeriod)でNodeの状態をポーリングし、最後に更新があった時刻(NodeStatusまたはLeaseのRenewTime)から一定時間応答が無いノードを不健全と判定します。この一定時間がNodeMonitorGracePeriodで、デフォルト値は50秒です。
// NodeMonitorGracePeriod is set to a default value of 50 seconds.
// This value should be greater than the sum of HTTP2_PING_TIMEOUT_SECONDS (30s)
// and HTTP2_READ_IDLE_TIMEOUT_SECONDS (15s) from the http2 health check
// to ensure that the server has adequate time to handle slow or idle connections
// properly before marking a node as unhealthy.
if obj.NodeMonitorGracePeriod == zero {
obj.NodeMonitorGracePeriod = metav1.Duration{Duration: 50 * time.Second}
}
if obj.NodeStartupGracePeriod == zero {
obj.NodeStartupGracePeriod = metav1.Duration{Duration: 60 * time.Second}
}
if obj.NodeMonitorPeriod == zero {
obj.NodeMonitorPeriod = metav1.Duration{Duration: 5 * time.Second}
}
(参考: defaults.go#L36-L52)
コメントにある通り、50秒という値はHTTP/2のPINGタイムアウト(30秒)とアイドルタイムアウト(15秒)の合計より大きく取る必要がある、という通信レイヤーの制約から決められています。NodeStartupGracePeriod(60秒)は、ReadyのConditionがまだ存在しない、新規に参加したノードにのみ使われる猶予期間です。
実際の判定処理はtryUpdateNodeHealthで行われます。Node LeaseのRenewTimeが更新されていれば、NodeStatus自体が更新されていなくても生存確認とみなされる点が実装上のポイントです(参考: node_lifecycle_controller.go#L844-L962)。
1.3 Ready ConditionのUnknownへの書き換えとTaintの付与
NodeMonitorGracePeriodを超えて応答が無いと判定されると、NodeのReady ConditionがUnknownへ書き換えられます。この時点でkubectl get nodesのSTATUS列はNotReadyと表示されます(参考: Node Controller)。
Node Lifecycle Controllerは、Ready Conditionの状態に応じて次のTaintをNodeに付与します。
nodeConditionToTaintKeyStatusMap = map[v1.NodeConditionType]map[v1.ConditionStatus]string{
v1.NodeReady: {
v1.ConditionFalse: v1.TaintNodeNotReady,
v1.ConditionUnknown: v1.TaintNodeUnreachable,
},
...
(参考: node_lifecycle_controller.go#L74-L94)
TaintNodeNotReady = "node.kubernetes.io/not-ready"
TaintNodeUnreachable = "node.kubernetes.io/unreachable"
(参考: well_known_taints.go#L20-L27)
両方ともEffect: NoExecuteのTaintです。ハートビートが完全に止まった(応答不能)場合はReadyがUnknownになるためnode.kubernetes.io/unreachableが、kubelet自体は動いているがヘルスチェックが不健全と自己申告している場合はReadyがFalseになるためnode.kubernetes.io/not-readyが付与される、という違いになります(参考: Taint Nodes by Condition)。
第2章: Taint-based EvictionによるPodの削除要求
NotReadyになったノードにTaintが付与された後、そのノード上のPodがどのタイミングで、どういう形で削除要求を受けるのかを説明します。
2.1 DefaultTolerationSecondsが付与するデフォルトの許容時間
NoExecuteのTaintが付いたノード上のPodは、対応するTolerationを持っていない限り即座に削除対象になります。ただし多くのPodは、明示的にTolerationを書いていなくても即座には削除されません。これはDefaultTolerationSecondsというAdmission Pluginが、Pod作成時にデフォルトのTolerationを自動で追加しているためです。
defaultNotReadyTolerationSeconds = int64(300)
defaultUnreachableTolerationSeconds = int64(300)
(参考: admission.go#L44-L45)
Podがnode.kubernetes.io/not-ready:NoExecute・node.kubernetes.io/unreachable:NoExecuteのいずれかに対する明示的なTolerationを持たない場合、このAdmit処理がtolerationSeconds: 300のTolerationをPodに追加します(参考: admission.go#L125以降)。つまり、Taintが付いてからデフォルトの300秒(5分)は、そのノード上のPodはまだ削除されません。
PodのマニフェストでtolerationSecondsを指定しない、または長いToleration(あるいはTolerationSecondsを省略した無期限Toleration)を明示すれば、このデフォルトの動作を上書きできます。
2.2 TaintManagerが行うのはGraceful Delete
tolerationSecondsが経過すると、TaintManager(pkg/controller/tainteviction)が対象のPodを削除します。ここで実際に呼ばれているのは通常のGraceful Delete要求です。
func addConditionAndDeletePod(ctx context.Context, c clientset.Interface, name, ns string) (err error) {
...
return c.CoreV1().Pods(ns).Delete(ctx, name, metav1.DeleteOptions{})
}
(参考: taint_eviction.go#L129-L147)
metav1.DeleteOptions{}はGracePeriodSecondsを明示的に0にしていない、デフォルトのDelete呼び出しです。Force Delete(grace-period=0相当)ではありません。この呼び出しでPodにDeletionTimestampが付与され、あとはそのノード上のkubeletがこのタイムスタンプを検知してコンテナの終了処理を行い、最終的にPodオブジェクトの削除が完了する、という通常のPod終了の流れに委ねられます。
2.3 応答しないノードではPodが"Terminating"のまま残る
問題は、この終了処理を担うはずのkubeletが応答不能な状態にある点です。DeletionTimestampは付与されますが、それを検知してコンテナを止め、Podオブジェクトの削除を完了させる主体が存在しません。結果として、そのノード上のPod(DaemonSetのPodを除きます。理由は第6章で説明します)はkubectl get pods上でTerminatingのまま残り続けます。
この状態を解消する手段は、次の第3章で説明する2つの強制削除の仕組みのいずれかに限られます。
第3章: 残り続けるPodを強制的に取り除く2つの手段
Terminatingのまま残ったPodを最終的に取り除く仕組みを、cloud-controller-managerが動いているかどうかで分けて説明します。
この2つの手段を理解するうえで押さえておきたいのが、Kubernetesの各コンポーネントが観測できるのは「一定時間、応答が無い」という事実だけだという点です。それが、ハードウェア障害・電源断でノードが本当に停止しているのか、それとも一時的なネットワーク不通でノード自体(kubelet、その上のコンテナ)は正常に動き続けているだけなのかを、Kubernetes自身は区別できません。この区別がつかないという制約が、以降で見る「強制削除の自動化がどこまでできるか」を左右します。
3.1 cloud-controller-managerが動いている場合: Nodeオブジェクトの削除に追随するPodGCの自動処理
cloud-controller-managerにはCloudNodeLifecycleControllerという、クラウドプロバイダのAPIでインスタンスの生死を確認するコンポーネントがあります。
// MonitorNodes checks to see if nodes in the cluster have been deleted
// or shutdown. If deleted, it deletes the node resource. If shutdown it
// applies a shutdown taint to the node
func (c *CloudNodeLifecycleController) MonitorNodes(ctx context.Context) {
(参考: node_lifecycle_controller.go#L143-L146)
このコントローラーが、インスタンスが実際に無くなったことを確認すると、Nodeオブジェクトを削除します。
if err := c.kubeClient.CoreV1().Nodes().Delete(ctx, node.Name, metav1.DeleteOptions{}); err != nil {
(参考: node_lifecycle_controller.go#L195)
このNode削除をきっかけに、PodGCController(pkg/controller/podgc)のgcOrphaned処理が動きます。
対象のノードが(デフォルトで40秒のquarantineTimeの猶予を置いたうえで)ノード一覧に存在しないと判定されると、そのノードに紐づく全Pod(コントローラーの種類を問いません)がPodのStatusをFailedへ更新したうえで、GracePeriodSeconds 0のForce Deleteで削除されます。
const (
quarantineTime = 40 * time.Second
)
func (gcc *PodGCController) gcOrphaned(ctx context.Context, pods []*v1.Pod, nodes []*v1.Node) {
...
(参考: gc_controller.go#L51、gc_controller.go#L229-L268、gc_controller.go#L346-L366)
cloud-controller-managerが動いている場合、クラウドプロバイダのAPIで「インスタンスが本当に無くなった」ことを確認してからNodeオブジェクトを削除するため、この一連の処理はおおむね自動で完結します。
3.2 cloud-controller-managerが動いていない場合: Non-Graceful Node ShutdownとNode.out-of-service Taint
一方、cloud-controller-managerが動いていない、あるいはインスタンスの生死を確認する手段を持たない環境(例えばオンプレミスのベアメタルサーバーなど)では、ハードウェア障害が起きてもNodeオブジェクトが自動で削除されるとは限りません。誰かが明示的に削除しない限り、Nodeオブジェクトはクラスタ上に残り続けます。この場合、3.1のPodGCの自動処理は働かず、PodはTerminatingのまま放置されます。
この状況に対応するために用意されているのが、Non-Graceful Node Shutdown(KEP-2268)という機能です。v1.28でGAになった、Kubernetesの標準機能です(参考: Non-graceful node shutdown handling、Kubernetes 1.28: Non-Graceful Node Shutdown Moves to GA)。実際に動作検証した記事としてKubernetes: Non Graceful Node Shutdownの動作検証も参考になります。
この機能は、運用者がノードの死亡を確認したうえで、node.kubernetes.io/out-of-serviceというTaintをNodeへ手動で付与することを起点に動きます。
func (gcc *PodGCController) gcTerminating(ctx context.Context, pods []*v1.Pod) {
...
if !nodeutil.IsNodeReady(node) && taints.TaintKeyExists(node.Spec.Taints, v1.TaintNodeOutOfService) {
// 強制削除の対象にする
(参考: gc_controller.go#L148-L169)
PodGCはNodeがReadyでなく、かつこのTaintが付いている場合に、TerminatingなPodを強制削除します。さらに、Volumeの強制デタッチを担うAttach/Detach Controllerも同じTaintを見ています。
func (rc *reconciler) hasOutOfServiceTaint(nodeName types.NodeName) (bool, error) {
...
return taints.TaintKeyExists(node.Spec.Taints, v1.TaintNodeOutOfService), nil
}
(参考: reconciler.go#L147-L153)
これにより、kubeletからの「アンマウントが完了した」という確認を待たずにPersistentVolumeを強制的にデタッチできます。PVを使うStatefulSetの復旧では、このVolume側の強制デタッチも合わせて動くことが重要になります(詳細は第7章で説明します)。
Kubernetes自体は、ノードが本当に停止しているのか、単にネットワークが分断されて実際には動き続けているだけなのかを区別できません。そのためnode.kubernetes.io/out-of-serviceTaintの付与は自動化されておらず、運用者が(クラスタの外部から、ノードのOSに依存しない方法で)ノードの停止を確認してから手動で行う操作として設計されています。復旧の可能性が残っている段階でこのTaintを付けると、旧Podが実際には動き続けたまま新しいPodが別ノードで起動し、同じデータへ同時に書き込むといった事故につながる可能性があります。付与する前に、ノードが本当に停止していることを確認してからにするのが良いと思います。
第4章: 状態遷移の全体タイムライン
第1〜3章で見た内容を、デフォルト値だけを使ったタイムラインとして1つの表にまとめます。実際の値は環境ごとのkube-controller-managerのフラグやPodのTolerationの設定次第で変わります。
| 経過時間(デフォルト値) | 何が起きるか |
|---|---|
| T+0 | ノードが物理障害で応答不能になる。kubeletからのNodeStatus/Lease更新が止まる |
| 〜T+50秒 | Node Lifecycle ControllerがNodeMonitorGracePeriod超過を検知。Ready ConditionをUnknownに、node.kubernetes.io/unreachable(NoExecute)Taintを付与。kubectl get nodesはNotReady
|
| 〜T+350秒 |
DefaultTolerationSecondsのデフォルトの300秒が経過。TaintManagerが対象Podに対しGraceful Delete要求(DeletionTimestamp付与)。DaemonSetのPodはこの対象になりません(6.3) |
| T+350秒以降 | kubeletが応答しないため実際の終了処理が進まず、対象PodはTerminatingのまま残る。Deployment/Jobはこの時点で既に代替Podの作成を開始している(6.1・6.4)。StatefulSetは同じOrdinalの代替Podをブロックしたまま待機する(6.2) |
| Nodeオブジェクトが削除された時点(cloud-controller-manager経由) | PodGCのgcOrphanedが(40秒のquarantineTime後に)対象PodをFailedへ更新して強制削除する(3.1) |
運用者がout-of-serviceTaintを付与した時点(cloud-controller-manager無し) |
PodGCのgcTerminatingとAttach/Detach Controllerが強制削除・強制デタッチを行い、StatefulSetのブロックが解除される(3.2) |
第5章: 一時的なネットワーク障害から復旧した場合
ここまでは、ノードが応答不能なまま復旧しないケースを前提に説明してきました。第3章で見た通り、Kubernetes自身はノードが本当に停止しているのか、一時的なネットワーク不通で実際には動き続けているだけなのかを区別できません。ここでは後者だった場合、つまりPodの削除が進んでいる途中でネットワークが復旧した場合に何が起きるかを、復旧のタイミング別に確認します。
| 復旧のタイミング | Ready Condition・Taint | Podの状態 |
|---|---|---|
| T+50秒以内(NodeMonitorGracePeriod以内) | 一度もFalse/Unknownに変わらず、Taintも付与されない |
何も起きない。kubectl get nodes上でNotReadyが観測されることすらない(5.1) |
| T+50秒〜T+350秒(Delete要求まで) | 一度NotReadyと判定されTaintは付与されるが、ReadyがTrueに戻ると自動で除去される |
TaintManagerが既にスケジュール済みの削除予定をキャンセルするため、Podは一度も削除されない(5.2) |
| T+350秒以降(Delete要求より後) | Taintは除去されるが、既に発行されたDelete要求(DeletionTimestamp)自体は取り消せない |
復旧したkubeletが通常の終了処理を引き継いで完了させる。管理単位やPVCの有無によっては、それだけでは済まない影響が残る(5.3〜5.5) |
5.1 そもそもNotReadyと判定されない場合(50秒以内の復旧)
1.2節で見たtryUpdateNodeHealthは、最後に更新があった時刻(NodeStatusまたはLeaseのRenewTime)からNodeMonitorGracePeriod(デフォルト50秒)を超えて応答が無い場合にのみ、Ready Conditionを書き換えます。逆に言えば、ハートビートがこの50秒以内に再開すれば、Ready Conditionは一度もFalse/Unknownに変わらず、Taintも一切付与されません。
この場合、Node Lifecycle Controller・TaintManagerのどちらにも処理は発生せず、ユーザーから見てもkubectl get nodes上でNotReadyが観測されることすらありません。以下の5.2〜5.5節で扱うのは、50秒を超えて一度NotReadyと判定され、Taintが付与された後に復旧するケースです。
この50秒という閾値は、実際には複数の要因で変動します。kubeletのNode Lease更新間隔はNodeLeaseDurationSeconds(デフォルト40秒)の25%、つまりデフォルトで10秒ごとです。
// nodeLeaseRenewIntervalFraction is the fraction of lease duration to renew the lease
nodeLeaseRenewIntervalFraction = 0.25
(参考: kubelet.go#L236-L237)
leaseDuration := time.Duration(kubeCfg.NodeLeaseDurationSeconds) * time.Second
renewInterval := time.Duration(float64(leaseDuration) * nodeLeaseRenewIntervalFraction)
(参考: kubelet.go#L1155-L1156)
Node Lifecycle Controllerが基準にする「最後の更新時刻」は、障害発生の直前に成功した更新の時刻であり、障害が発生した瞬間そのものではありません。障害が次のLease更新の直前(前回の更新からほぼ10秒が経過した時点)で起きた場合、基準時刻は既に障害発生の約10秒前を指していることになるため、実際にノードが応答しなくなってからNotReadyと判定されるまでの猶予は最短で約40秒まで縮まります。逆に、障害がLease更新の直後に起きた場合は基準時刻と障害発生がほぼ一致するため、猶予はNodeMonitorPeriod(デフォルト5秒、1.2節)のポーリング遅延の分だけ上乗せされ、最長で約55秒になります。つまり実際にNotReadyと判定されるまでの実時間はおおよそ40秒〜55秒の範囲でばらつきます。そのため「50秒以内に復旧すれば安全」という閾値は、厳密な固定値ではなく目安として捉えるのが良いと思います。
5.2 Delete要求(T+350秒)より前に復旧した場合
Node Lifecycle Controllerは、ReadyがTrueに戻るとmarkNodeAsReachableを呼び出し、not-ready・unreachable両方のNoExecute Taintを除去します。
case v1.ConditionTrue:
removed, err := nc.markNodeAsReachable(ctx, node)
if err != nil {
logger.Error(nil, "Failed to remove taints from node. Will retry in next iteration", "node", klog.KObj(node))
}
if removed {
logger.V(2).Info("Node is healthy again, removed all taints", "node", klog.KObj(node))
}
(参考: node_lifecycle_controller.go#L820-L826)
func (nc *Controller) markNodeAsReachable(ctx context.Context, node *v1.Node) (bool, error) {
err := controller.RemoveTaintOffNode(ctx, nc.kubeClient, node.Name, node, UnreachableTaintTemplate)
...
err = controller.RemoveTaintOffNode(ctx, nc.kubeClient, node.Name, node, NotReadyTaintTemplate)
...
return nc.zoneNoExecuteTainter[nodetopology.GetZoneKey(node)].Remove(node.Name), nil
}
(参考: node_lifecycle_controller.go#L1310-L1326)
このTaint除去をTaintManager(pkg/controller/tainteviction)が検知し、まだ実行していない削除予定を明示的にキャンセルします。
if len(taints) == 0 {
logger.V(4).Info("All taints were removed from the node. Cancelling all evictions...", "node", klog.KObj(node))
for i := range pods {
tc.cancelWorkWithEvent(logger, types.NamespacedName{Namespace: pods[i].Namespace, Name: pods[i].Name})
}
return
}
(参考: taint_eviction.go#L575-L580)
つまり、ネットワーク障害が本当に一時的で、Delete要求が発行される前(T+350秒より前)に復旧すれば、Podは一度も削除されず、代替Podも作られません。
5.3 Delete要求より後に復旧した場合
TaintManagerのaddConditionAndDeletePod(2.2節)が既にPods().Delete()を呼んでいる場合、PodのDeletionTimestampはetcdに確定済みです。Kubernetesには「削除の取り消し」という操作が無いため、この後でTaintを除去してもDeletionTimestampは消えません。
ただし、復旧した(ハートビートが再開した)kubeletは、自分が担当するPod一覧を同期する際にこのDeletionTimestampを見つけ、通常の終了処理(preStopフック・コンテナ停止・Volumeのアンマウント)を実行します。2.3節で説明した「応答しないノードではPodがTerminatingのまま残る」状態は、kubelet自身が戻ってくることで自然に解消します。
5.4 管理単位によって復旧後の見え方が変わる
Delete要求より後に復旧した場合、旧Podが実際に終了するまでの間、第6章で見る管理単位ごとの判断が先に進んでしまっている点に注意が必要です。
| 管理単位 | 復旧後に起きること |
|---|---|
| Deployment(ReplicaSet)・Job | 代替PodはT+350秒の時点で既に作成済み(6.1・6.4節)のため、復旧しても取り消されません。旧Podが正常終了するまでの間、一時的に望ましいレプリカ数より1つ多いPodが同時に稼働します |
| StatefulSet(デフォルトのOrderedReady) | 同じOrdinalの代替Podはブロックされたままでしたが(6.2節)、旧Podが実際にetcdから削除されることで、out-of-serviceTaintや強制削除を使わずに通常の形でブロックが解除されます。ネットワーク障害が本当に一時的だったなら、これが正規の回復です |
StatefulSetが、自身のコントローラーの外側にあるPodGCやout-of-serviceTaintに頼らず自力で回復できるのは、このようにネットワークが実際に復旧した場合だけです。本当にノードが停止している場合は、第3章・第8章で説明した強制削除の仕組みを待つ必要があります。
5.5 PVCがある場合: 6分タイマーの起点
第7章で見た6分の自動強制デタッチ(ReconcilerMaxWaitForUnmountDuration)のカウント起点を確認すると、ノードがNotReadyになった時刻(T+50秒)ではなく、Attach/Detach ControllerがそのVolumeを実際にデタッチすべきと判断した時刻でした。
// If there is no previous detach request, set it to the current time
if nodeObj.detachRequestedTime.IsZero() {
nodeObj.detachRequestedTime = time.Now()
...
}
return time.Since(nodeObj.detachRequestedTime), nil
(参考: actual_state_of_world.go#L420-L439)
この判断は、PodがDeleteされて望ましい状態から外れたタイミング(おおむねT+350秒)に行われるため、6分のカウントもここから始まり、強制デタッチはT+710秒(約12分)前後で発火します。
ネットワーク復旧がT+350秒〜T+710秒の間で、かつ旧Podが(実際には生きていて)Volumeを使い続けていた場合、強制デタッチはコントロールプレーン側の認識だけを切り替えるため、旧ノード側では実際にはまだマウントされたまま、という食い違いが起こり得ます。 これは7.1節で引用した公式ドキュメントの警告(force-detachされたVolumeを使い続けるワークロードはCSI仕様違反を起こし、データ破損の可能性がある)が想定している状況そのもので、out-of-serviceTaintを手動付与していなくても、自動タイムアウトだけで起こり得る点に注意が必要です。
第6章: 管理単位ごとのPodの挙動
ここまではTerminatingになったPod一般の話でしたが、実際にどう再作成されるか(あるいはされないか)はPodを管理しているリソースの種類によって異なります。Deployment/StatefulSet/DaemonSet/Jobの4種類について、それぞれのコントローラーの実装から確認します。
6.1 Deployment(ReplicaSet)
ReplicaSet controllerは、望ましいレプリカ数を計算する際にIsPodActiveでPodを絞り込みます。
func IsPodActive(p *v1.Pod) bool {
return v1.PodSucceeded != p.Status.Phase &&
v1.PodFailed != p.Status.Phase &&
p.DeletionTimestamp == nil
}
(参考: controller_utils.go#L1085-L1089)
DeletionTimestampが付いた(第2章で見た、Taint-based EvictionによるTerminatingな)Podはアクティブなレプリカとして数えられません。ReplicaSet controllerはこの絞り込み後のactivePodsをもとに、不足分を即座に補充します。
allActivePods := controller.FilterActivePods(logger, allRSPods)
...
manageReplicasErr = rsc.manageReplicas(ctx, activePods, rs)
(参考: replica_set.go#L804、replica_set.go#L819)
diff := len(activePods) - int(*(rs.Spec.Replicas))
...
err := rsc.podControl.CreatePods(ctx, rs.Namespace, &rs.Spec.Template, rs, metav1.NewControllerRef(rs, rsc.GroupVersionKind))
(参考: replica_set.go#L650、replica_set.go#L678)
つまり、Deploymentが管理するPodは、旧Podが実際にそのノード上で終了しているかどうかを待たずに、DeletionTimestampが付いた時点で不足分として数えられ、代替Podが別のノードへ即座にスケジュールされます。第4章のタイムラインで言えば、デフォルト値ではT+350秒の時点で代替Podの作成が始まります。旧Podは、第3章で説明したいずれかの強制削除の仕組みが働くまでそのままTerminatingとして残りますが、レプリカ数の充足という観点では既に解決済みの扱いになります。
6.2 StatefulSet
StatefulSetのisTerminating判定もDeletionTimestampの有無だけで決まります。
func isTerminating(pod *v1.Pod) bool {
return pod.DeletionTimestamp != nil
}
(参考: stateful_set_utils.go#L491-L493)
ここからの挙動がReplicaSetとは対照的です。StatefulSetはデフォルトでspec.podManagementPolicy: OrderedReady(未指定時のデフォルト値)で動作しており、この場合の更新ポリシーは実装コード中ではmonotonicと呼ばれています。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: sample
spec:
podManagementPolicy: OrderedReady # 省略した場合もこれがデフォルト値
replicas: 3
...
// UpdateStatefulSet executes the core logic loop for a stateful set, applying the predictable and
// consistent monotonic update strategy by default - scale up proceeds in ordinal order, no new pod
// is created while any pod is unhealthy, and pods are terminated in descending order.
(参考: stateful_set_control.go#L84-L86、OrderedReady Pod Management)
このポリシーのもとでは、同じOrdinal(番号)のPodがTerminatingである間、新しいPodの作成そのものがブロックされます。
if isTerminating(replicas[i]) && monotonic {
logger.V(4).Info(...)
return true, nil
}
(参考: stateful_set_control.go#L467-L471)
スケールダウン対象のPod(ソースコード上の変数名はcondemned)についても同様のブロックが入ります(参考: stateful_set_control.go#L513-L519)。StatefulSetのPodには固定された名前(Ordinal)と、volumeClaimTemplatesから紐づく専用のPersistentVolumeClaimがあり、同じ名前のPodが実際にはまだ動いている可能性がある間に別ノードで新しいPodを起動してしまうと、同じストレージへ二重に書き込むといった問題が起こり得ます。そのため、StatefulSet controllerは自分自身の判断だけでこのブロックを解除しません。
ここまでの挙動はspec.podManagementPolicy: OrderedReady(デフォルト値)を前提にしています。次のようにParallelを指定した場合、monotonicはfalseになり、このブロック自体が発生しません。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: sample
spec:
podManagementPolicy: Parallel
replicas: 3
...
monotonic := !allowsBurst(set)
// allowsBurst is true if the alpha burst annotation is set.
func allowsBurst(set *apps.StatefulSet) bool {
return set.Spec.PodManagementPolicy == apps.ParallelPodManagement
}
(参考: stateful_set_control.go#L662、stateful_set_utils.go#L500-L502)
Parallelは「Podがreadyになる、あるいは終了を完了するのを待たずに、レプリカ数の変更に応じて即座にPodを作成・削除する」ポリシーです(参考: PodManagementPolicyType)。この設定では、Terminatingな旧Podの実終了を待たずに同じOrdinalの代替Podが即座に作成されるため、ノード障害からの復旧という観点ではDeploymentに近い挙動になります。ただしこれは、同じOrdinal・同じPersistentVolumeClaimを持つPodが一時的に2つ同時に存在し得ることを引き換えにした挙動です。同じストレージへの二重書き込みを避けたいワークロードでは、デフォルトのOrderedReadyのままにしておくのが良いと思います。
このブロックが解除されるのは、Podオブジェクトが実際にetcdから削除された時だけです。つまり第3章で説明した、cloud-controller-manager経由のPodGCの自動処理、あるいはout-of-serviceTaintを起点とした強制削除のいずれかが実行されて初めて、StatefulSet controllerは同じOrdinalに対して新しいPodを作成できるようになります。新しいPodは別の(健全な)ノードへスケジュールされますが、名前とPersistentVolumeClaimは元のPodと同じものを引き継ぎます。
緊急対応として、これらの自動化された仕組みを待たずに運用者が手動でkubectl delete pod --grace-period=0 --forceを実行する手段も用意されていますが、ドキュメントでは「対象のPodが本当に終了していることを確認した後にのみ実行するべき」と明記されています(参考: Force Delete StatefulSet Pods)。手動のForce Deleteもout-of-serviceTaintも、判断の中身は同じで、Kubernetesの外側にいる運用者が「そのPodは本当に動いていない」ことを保証する必要がある点は変わりません。
6.3 DaemonSet
DaemonSetのPodは、他のノードへ再作成されることがありません。DaemonSet controllerが新規Podを作る際、そのPodのNodeAffinityを対象ノードの名前で固定するためです。
podTemplate.Spec.Affinity = util.ReplaceDaemonSetPodNodeNameNodeAffinity(
podTemplate.Spec.Affinity, nodesNeedingDaemonPods[ix])
(参考: daemon_controller.go#L1089-L1090)
さらに、DaemonSetのPodには作成時点で、not-ready・unreachable両方のNoExecute Taintに対する無期限のTolerationが自動付与されます。
// Add infinite toleration for taint notReady:NoExecute here
// ...
// Add infinite toleration for taint unreachable:NoExecute here
(参考: daemonset_util.go#L48-L67)
tolerationSecondsを指定しないTolerationは無期限を意味するため、DaemonSetのPodは第2章で説明したTaint-based Evictionの対象にそもそもなりません。ノードがNotReadyになっても、DaemonSetのPodはTerminatingにはならず、そのノードに紐づいたまま残ります。ノードが復旧すれば元のPodがそのまま動き続けますし、ノードオブジェクト自体が削除された場合は、コントローラーの種類を問わないPodGCのgcOrphaned処理(3.1)によって、DaemonSetのPodも他のPodと同様に片付けられます。
6.4 Job
Job controllerも、アクティブなPod数を数える際にReplicaSetと同じFilterActivePods(IsPodActive)を使います。
activePods := controller.FilterActivePods(logger, pods)
(参考: job_controller.go#L1035)
代替Pod作成の判断は次の式で行われます。
if diff := wantActive - terminating - active; diff > 0 {
(参考: job_controller.go#L1820)
ここでterminatingが意味を持つのは、spec.podReplacementPolicyがFailedの場合だけです。
func onlyReplaceFailedPods(job *batch.Job) bool {
return job.Spec.PodReplacementPolicy != nil && *job.Spec.PodReplacementPolicy == batch.Failed
}
(参考: job_controller.go#L2216-L2218)
podReplacementPolicyのデフォルト値はTerminatingOrFailedです(参考: types.go#L154-L165)。このデフォルト値のもとではonlyReplaceFailedPodsはfalseになるため、上の式のterminatingは実質0として扱われ、Job controllerはDeployment(ReplicaSet)と同じく、TerminatingなPodの実終了を待たずに代替Podを即座に作成します。
さらに、デフォルトのpodReplacementPolicyはbackoffLimitの判定にも影響します。
func isPodFailed(p *v1.Pod, job *batch.Job) bool {
if p.Status.Phase == v1.PodFailed {
return true
}
if onlyReplaceFailedPods(job) {
return false
}
// Count deleted Pods as failures to account for orphan Pods that
// never have a chance to reach the Failed phase.
return p.DeletionTimestamp != nil && p.Status.Phase != v1.PodSucceeded
}
(参考: job_controller.go#L2131-L2141)
デフォルトのTerminatingOrFailedポリシーでは、kubeletが実際にFailedフェーズを報告できない(ノードが応答不能な)場合でも、DeletionTimestampが付いているという事実だけでこのPodは失敗としてカウントされ、backoffLimitの消費に加算されます。コメントにある通り、これは「Failedフェーズに到達する機会が無いまま孤立するPod」を救済するための仕組みです。backoffLimitが小さいJobがノード障害のタイミングと重なると、実際にはコンテナが失敗していなくても、この扱いによってBackoffLimitExceededに到達し得ます。
podReplacementPolicy: Failedを明示的に指定した場合は、この即時カウントが行われなくなり、Podが実際にFailedフェーズに確定するまで代替Podの作成もbackoffLimitへの加算も待つようになります。ただしノードが応答不能なままではその確定自体が起こらないため、この設定を使う場合は第3章で説明した強制削除の仕組みが正しく構成されていることが前提になります。
第7章: PersistentVolumeClaimを使うPodの追加の考慮点
ここまではPodオブジェクトの削除・再作成だけを見てきましたが、PersistentVolumeClaim(PVC)を使うPodには、Volumeのデタッチ・アタッチという別の制約が加わります。この制約はStatefulSetに限らず、Deployment・Jobが管理するPodでも同様に発生します。
ただし、ここで説明するのはあくまでPod/VolumeというKubernetesのオブジェクトレベルでの挙動です。Volumeが強制デタッチされた後にファイルシステムが実際に壊れていないか、同じデータへの二重書き込みにアプリケーションが耐えられるかといったストレージ層・アプリケーション層での安全性はこの章の範囲外であり、WAL再生・冪等性・分散ロックといったアプリケーション側の設計で別途担保する必要があります。
7.1 Volumeのデタッチにはデフォルトの自動フォールバックがある
第3章で見たout-of-serviceTaintとは別に、Attach/Detach Controllerにはデフォルトで有効な、タイムアウトベースの自動強制デタッチがあります。
// maxWaitForUnmountDuration is the max amount of time the reconciler will wait
// for the volume to be safely unmounted, after this it will detach the volume
// anyway (to handle crashed/unavailable nodes). If during this time the volume
// becomes used by a new pod, the detach request will be aborted and the timer
// cleared.
var DefaultTimerConfig = TimerConfig{
ReconcilerLoopPeriod: 100 * time.Millisecond,
ReconcilerMaxWaitForUnmountDuration: 6 * time.Minute,
...
(参考: reconciler.go#L62-L66、attach_detach_controller.go#L87-L91)
forceDetachTimeoutExpired := maxWaitForUnmountDurationExpired && !rc.disableForceDetachOnTimeout
...
forceDetach := !isHealthy && forceDetachTimeoutExpired
...
if attachedVolume.MountedByNode && !forceDetach && !hasOutOfServiceTaint {
continue
}
(参考: reconciler.go#L225-L240)
disableForceDetachOnTimeoutはkube-controller-managerの--disable-force-detach-on-timeoutフラグに対応し、デフォルト値はfalseです。
fs.BoolVar(&o.DisableForceDetachOnTimeout, "disable-force-detach-on-timeout", false, "Prevent force detaching volumes based on maximum unmount time and node status. If this flag is set to true, the non-graceful node shutdown feature must be used to recover from node failure. ...")
(参考: attachdetachcontroller.go#L40)
つまりデフォルトの構成では、ノードがUnhealthyになってから6分(ReconcilerMaxWaitForUnmountDuration)経過すると、out-of-serviceTaintが無くてもVolumeは自動的に強制デタッチされます。公式ドキュメントでも、このデフォルトの動作は明確にリスクとともに説明されています。
In any situation where a pod deletion has not succeeded for 6 minutes, kubernetes will force detach volumes being unmounted if the node is unhealthy at that instant. Any workload still running on the node that uses a force-detached volume will cause a violation of the CSI specification... In such circumstances, volumes on the node in question might encounter data corruption.
(参考: Forced storage detach on timeout)
7.2 Volumeのデタッチと、Podオブジェクトの削除は別々に進む
この6分タイムアウトは、あくまでVolumeの状態に関する処理です。第6章6.2で見たStatefulSetの代替Pod作成のブロックは、Volumeの状態とは無関係に、PodオブジェクトのDeletionTimestampの有無だけで判定されています。PodGCのgcTerminatingがPodを強制削除するトリガーはout-of-serviceTaintの有無だけであり、Volumeのような「一定時間経過で自動的に」という仕組みはPodの強制削除には存在しません。
つまりPVCを使うStatefulSetのPodがノード障害に遭遇した場合、次の2つは別々の条件で進みます。
| 対象 | デフォルトの自動フォールバック |
|---|---|
| Volumeのデタッチ | あり。ノードUnhealthyからデフォルトで6分後に自動強制デタッチ(disableForceDetachOnTimeoutがデフォルトでfalse) |
| Podオブジェクトの削除 | 無し。Nodeオブジェクトの削除、またはout-of-serviceTaintの付与のいずれかが無い限り進まない |
Volumeが6分後に自動でデタッチされても、StatefulSetのPodオブジェクト自体はTerminatingのまま残るため、同じOrdinalの代替Podは作られません。cloud-controller-managerが動いていないなど、Nodeオブジェクトが自動削除されない構成では、Podの削除だけは結局、運用者(または外部システム)によるout-of-serviceTaintの付与を待つ必要があります。
7.3 この制約はStatefulSetに限らない: Deployment/Jobでも同じVolume待ちが起きる
Volume側の制約は、Podを管理しているのがStatefulSetかDeploymentかを問いません。Attach/Detach Controllerは、同じPVC(ノード単位でしかアタッチできない、いわゆるsingle-attachのVolume)がまだ別ノードでアタッチ済みと判定している間、新しいPodへのアタッチを待たせます。
// reportMultiAttachError sends events and logs situation that a volume that
// should be attached to a node is already attached to different node(s).
func (rc *reconciler) reportMultiAttachError(logger klog.Logger, volumeToAttach cache.VolumeToAttach, nodes []types.NodeName) {
...
simpleMsg, _ := volumeToAttach.GenerateMsg("Waiting for detach", "Volume is already exclusively attached to one node, waiting on detach before it can be attached to another node")
(参考: reconciler.go#L376-L396)
第6章6.1で見た通り、Deployment(ReplicaSet)はTerminatingな旧Podの実終了を待たずに代替Podをすぐ作成します。しかし、その代替Podが新しいノードへスケジュールされた後、PVCが指すVolumeがまだ旧ノードにアタッチされたままだと、新しいPodはFailedAttachVolumeイベント(Waiting for detach: ...)を出しながらContainerCreatingのまま待たされます。つまりDeploymentでは代替Podオブジェクトはすぐできるものの、実際にコンテナが起動できるようになるタイミングは、StatefulSetと同じくVolumeのデタッチ完了に左右されます。
Jobも同じです。第6章6.4で見た通り、デフォルトのpodReplacementPolicy(TerminatingOrFailed)では、JobもTerminatingな旧Podの実終了を待たずに代替Podを即座に作成します。JobにはStatefulSetのようなvolumeClaimTemplatesが無いため、複数のcompletions/parallelismで1つのPVCを直接参照する構成は基本的に取りませんが、parallelism: 1のJobが単一のPVCを直接マウントしている場合、Deploymentと同じ理由で代替PodがContainerCreatingのままVolumeのデタッチを待たされます。
podReplacementPolicy: Failedを明示的に指定している場合は少し事情が変わります。この設定では代替Podの作成自体がPodのFailed確定を待つため、out-of-serviceTaintによる強制削除(3.2)が実行された場合、PodがFailedになった時点でVolumeの強制デタッチも(verifySafeToDetachをスキップして)同時に進んでいます。そのため、代替Podが作成される頃にはVolumeのデタッチも既に完了しており、DeploymentやデフォルトポリシーのJobで起きる「Volume待ちのContainerCreating」は発生しにくくなります。ただし6分タイムアウトによる自動デタッチ(7.1)だけに頼っている場合は、Podオブジェクト自体が削除されないため、この効果は得られません。
複数レプリカのDeploymentが同じ1つのPVCを共有する構成は一般的ではありません。それができるのはReadWriteManyに対応したVolumeだけで、その多くはNFS等のネットワークファイルシステムです。この種のVolumeを扱うCSIドライバーの多くはCSIDriverのattachRequired: falseでAttachステップ自体を不要と宣言しており、その場合はここで説明した制約自体が発生しません(この点はCSIドライバー実装に依存するため、未検証・一般知識として扱ってください)。この問題が実際に起きやすいのは、replicas: 1のDeploymentが単一ノードにPVCをマウントしている、実質StatefulSetの1台構成に近い使い方をしている場合だと考えています。
第8章: 手動でのTaint付与を自動化する動きの現状
第3章で見た、運用者がnode.kubernetes.io/out-of-serviceTaintを手動で付与しなければならないという制約について、これを解消しようとする取り組みがkubernetes/enhancementsにあるかを確認します。
8.1 KEP-2268自身が明記する今後の課題
Non-Graceful Node Shutdown(KEP-2268)自身のGoalsの節には、次のように明記されています。
Requires user intervention to detect node shutdown/failure cases, because currently there is no good way of detecting whether node is in the middle of restarting.
Once the feature in this KEP is developed and tested, we plan to work on automatic way of detecting and fencing node for shutdown/failure use cases and re-evaluating some approaches listed in the Alternatives section.
(参考: KEP-2268: Non graceful node shutdown)
つまりKEP-2268自体が、手動でのTaint付与が必要な現状は暫定的なものであり、将来的には自動検知・フェンシングに取り組む予定だと明言しています。ただし、この自動化そのものを実装する後継のKEPは、kubernetes/enhancementsリポジトリのv1.38時点のツリーには見当たりません。KEP内で代替案として検討された「Node Fencing」も、ノード停止に必要なコマンドが環境ごとに異なり、汎用的なin-tree実装としては踏み込みすぎているという理由で採用が見送られています(参考: 同README「Node fencing」節)。
8.2 Taintの判定自体は、付与した主体を区別しない
後継のKEPが無いからといって、この手動ステップを今すぐ自動化できないわけではありません。PodGCのgcTerminatingとAttach/Detach ControllerのhasOutOfServiceTaintは、いずれもnode.kubernetes.io/out-of-serviceTaintの有無だけを見ており、それを人間がkubectl taintで付けたのか、他の主体が付けたのかを区別していません。
if !nodeutil.IsNodeReady(node) && taints.TaintKeyExists(node.Spec.Taints, v1.TaintNodeOutOfService) {
(参考: gc_controller.go#L148-L169)
func (rc *reconciler) hasOutOfServiceTaint(nodeName types.NodeName) (bool, error) {
...
return taints.TaintKeyExists(node.Spec.Taints, v1.TaintNodeOutOfService), nil
}
(参考: reconciler.go#L147-L153)
公式ドキュメントでも「a user can manually add the taint」という表現が使われていますが、これは想定される典型的な運用フローの説明であり、技術的な制約ではありません。
To mitigate the above situation, a user can manually add the taint
node.kubernetes.io/out-of-service...
(参考: Non-graceful node shutdown handling)
つまり、ノードのヘルスチェックを行い、本当に停止していると判断した場合にこのTaintをNodeオブジェクトへ付与するカスタムコントローラーを用意すれば、8.1で見た「手動介入」の部分だけを自動化でき、PodGCとAttach/Detach Controller側の強制削除・強制デタッチはそのまま動作します。8.1のKEP-2268で見送られた「Node Fencing」という代替案も、まさにこの「外部システムが確認してノードを隔離する」というパターンであり、in-tree実装としては採用されなかっただけです。
ただし、このヘルスチェックをどこから行うかには注意が必要です。node-problem-detector(NPD)はノードの健全性を監視する代表的なプロジェクトですが、カーネルデッドロックやファイルシステム破損、コンテナランタイムの無応答といった項目を、NPD自身がそのノード上で動き続けて検知し、API Serverへ報告する作りになっています。
node-problem-detector aims to make various node problems visible to the upstream layers in the cluster management stack. It is a daemon that runs on each node, detects node problems and reports them to apiserver.
(参考: node-problem-detector README)
NPDが「unhealthy」を報告できた時点で、そのノードはまだAPI Serverと通信できている、つまりNode Lifecycle Controllerから見てもReadyな(少なくともUnknownではない)状態です。一方out-of-serviceTaintが必要になるのは、本章・第3章で扱ってきた「ノードが完全に沈黙していて、本当の停止かネットワーク分断か区別できない」場面であり、ノードが本当に停止していればそのノード上のNPDも一緒に沈黙します。つまりNPDが報告できる障害と、out-of-serviceTaintが前提とする障害は、ちょうどすれ違っています。
この判断に使えるヘルスチェックは、ノードのOSやネットワークスタックに依存しない、外部からの確認手段である必要があります。IPMI/BMC/Redfishでの電源状態の問い合わせや、ハイパーバイザー側からの停止確認、複数の観測点の合意を必要とするフェンシングといった仕組みがこれにあたります。NPDのREADMEが挙げている「Remedy Systems」の中には、まさにこうした確認と対処を組み合わせた実装の例があります。
- mediK8S is an umbrella project for automatic remediation system build on Node Health Check Operator (NHC)... Poison-Pill is a remediator that will reboot the node and make sure all statefull workloads are rescheduled.
- MachineHealthCheck of Cluster API are responsible for remediating unhealthy Machines.
(参考: node-problem-detector README「Remedy Systems」節)
これらのプロジェクトが内部で具体的にどのような手段(IPMI経由の再起動、out-of-serviceTaintの付与、Nodeオブジェクトの削除など)でノードを隔離しているかは、本記事ではソースコードまで確認しておらず、一般的な理解に基づく記述です。
8.3 KEP-6371: ノード生存判定の外部化(直接の解決策ではない)
自動化の後継KEPは見当たりませんが、隣接する領域で1つ新しいKEPが動いています。KEP-6371「External Node Liveness Detection」です。v1.38でAlpha導入予定で、2026-09-14に起票されたばかりの、まだ新しいKEPです(kubernetes/kubernetes側にはfeature gate ExternalNodeLivenessDetectionの実装がまだ反映されていません)。
このKEPは、第1章で説明したNode Lifecycle Controller自身の生存判定を、外部の実装に置き換え可能にするものです。動機は自動化の高度化ではなくコストで、5000ノード規模のスケーラビリティ検証ではNode Leaseの更新がkube-apiserverへの全書き込みの約29%を占めており、外部システムの方がノードの健全性をより高速に検知できる場合がある、としています。
Node lease writes can be expensive. At scale, they are the largest single class of writes to the API server (~29% of all writes in a 5000-node scalability run), and the cost grows with every node added.
(参考: KEP-6371: External Node Liveness Detection)
このKEPが解決しようとしているのは、あくまで第1章で説明した「ノードが本当に応答しなくなってからNotReadyと判定されるまで」の検知手段の差し替えであり、第3章で説明した「out-of-serviceTaintを誰が・どう付与するか」という判断そのものを自動化するものではありません。実際、Motivationの節では、外部システムがReady=Unknownを書き込んでも、現状のNode Lifecycle Controllerの実装(monitorNodeHealthが自身の更新前後だけを比較する作り)ではPodをNotReadyにできないという別のギャップも指摘されています。
Today, the node lifecycle controller does not cooperate well with external sources of node liveness. If an external system writes
Ready=Unknown, the node lifecycle controller taints and evicts the node but never marks the node's pods NotReady.
(参考: KEP-6371: External Node Liveness Detection)
KEP-6371はまだAlpha段階で、Non-Goalsにも「デフォルトの挙動を変えない」「外部の生存判定コンポーネント自体は提供しない」と明記されています。ノード障害検知の高速化や大規模クラスタでのコスト削減には寄与し得ますが、StatefulSetが応答不能ノード上で自動復旧するかどうかとは別の話として捉えるのが良いと思います。
おわりに
ノード障害時の該当ノード上で動作している Pod の復旧パターンを整理したくて確認したやつを、記事として整形しました。(正直、これであってたっけ?思わなくもないけど、未来の自分向けのメモを他の人も見られるようにしてだけって認識なので、途中から気にしてないです。)
障害は起こるものなので設計の段階で備える必要がありますが、k8s は管理コンポーネントが非同期なので、障害時に起こることを想定したり、まとめたり、場合分けしたりして情報を整理するのが難しいですね。
昔、SIer 時代に DB のクラスタを組む前提で、ハードウェアの発注を整理したり、構築してた経験が検討の基礎になっているけど、k8s から入って処理のロジックだけ追っかけて、障害パターンを整理するのはかなり厳しそうな気がする。
今からオンプレで k8s クラスタを作る人は以下を考えると、日々の運用が楽になるんじゃないかなと思いました。
- クラスタの外部からのノードのヘルスチェックをして、該当のノードに、
out-of-serviceTaintをつける仕組みを用意する - 障害を検知して新しいノードをクラスタに追加し、
NotReadyになったノードをクラスタから削除する仕組みを用意する。ただし本格的な自動化にはVMや物理サーバーのプロビジョニング基盤が要るので、難しい場合はリブートするだけでも十分かもしれません(ハードウェア故障でなければ、systemd経由でkubeletが復活しLeaseも再開するため。第8.2節のPoison-Pillと同じ発想です)。
「2」は贅沢なのかもしれないですけど、「1」は正直、最低限欲しい。
Pod の復旧の観点で分けると主に以下の2つがあると思います。
-
StatefulSet
DB の目的で主に使われることが多いと思いますし、構築の難易度も高いと思います。ただ、k8s 上で DB の構築に取り組む人は、元々、オンプレで DB の構築経験があり、k8s を勉強して取り組む人が多いので、障害対応やリストア試験の重要性を認識した上で、設計しているので、意外と問題じゃないのかなと思っています。難易度は高いけど、難易度を理解した上で挑んでいる人が多いので、そんなに深刻じゃない気がします。 -
PVC を利用している Pod
StatefulSetを除くと、Deployment/DaemonSetでPVCを使うケースはあまり見かけないので、問題になるのは主にJobのケースだと思います。Job は魔界だと思っています。ノード障害を想定した場合に検討するべき事項は結構あるのですが、シンプルに使えると捉えられがちな気がします。k8sは障害が起こる前提で、ノードを使い捨てとして扱う設計になっているため、Job で管理する Pod も冪等性や再実行可能性が求められますが、実際に動いているPodの実装がそうなっているとは限りません。特に Job のケースだと、マルチテナントで動かそうという話がよく出ると思いますが、requestが適切に設定されておらず、eviction が多発するケースをよく耳にします。Job の起動時に外部からファイルをダウンロードするけど、それに必要なメモリ/ディスクの使用量が request に考慮されていないとかは、よくある話な気がします。
プラットフォームチームがいて、namespace 単位に、ResourceQuota をしかけても、eviction はノード単位で発生するので気休めにしかならないし、API Serverのレイヤで
LimitRangeによるバリデーションをかけても、設定できるのは最小/最大/デフォルト値までで、アプリごとに運用上妥当な値かどうかまでは機械的に評価できないので、結構厳しい印象がある。(Job の場合は、ノード障害対応というか、その手前の、ノイジーネイバーの問題ではある)
この記事が誰かの役に立つと幸いです。
参考
参考にした情報を記事に載せた順番で紹介します。
- Node Heartbeats
- Node Controller
- Taint Nodes by Condition
- Non-graceful node shutdown handling
- Kubernetes 1.28: Non-Graceful Node Shutdown Moves to GA
- Kubernetes: Non Graceful Node Shutdownの動作検証
- OrderedReady Pod Management
- Force Delete StatefulSet Pods
- Forced storage detach on timeout
- KEP-2268: Non graceful node shutdown
- node-problem-detector README
- KEP-6371: External Node Liveness Detection