Skip to main content

8. 就绪判断与进度追踪

为什么就绪判断很关键​

原地升级 PATCH 了 image 后,kubelet 重启容器。但"容器重启了"不等于"服务就绪了"——容器可能还在初始化、连数据库、加载配置。Controller 必须等 Pod 真正就绪,才能更新下一个。

如果判断不准:

  • 太早放行:下一个 Pod 开始更新,但上一个还没 ready,可用副本数掉到 0
  • 太晚放行:明明 ready 了还等,发版慢得像蜗牛

三种就绪判断方式​

方式判断什么精度适用
ProbereadinessProbe 通过高标准方式,必须配 probe
ReadyPod Ready 条件 = true中没有 probe 时的兜底
ImageIDcontainerStatuses 的 imageID 变了低只判断"容器换了",不判断"服务好了"

NeoSet 默认用 Probe 模式——要求用户必须配 readinessProbe(Webhook 会校验)。

// internal/controller/readiness.go

// CheckPodReady 检查 Pod 原地更新后是否就绪
func (r *NeoSetReconciler) CheckPodReady(
pod *corev1.Pod,
neoset *neov1alpha1.NeoSet,
) ReadyResult {
strategy := neoset.Spec.UpdateStrategy.InPlaceUpdateStrategy
readinessType := neov1alpha1.ReadinessTypeProbe
if strategy != nil && strategy.Readiness != "" {
readinessType = strategy.Readiness
}

switch readinessType {
case neov1alpha1.ReadinessTypeProbe:
return r.checkByProbe(pod, neoset)
case neov1alpha1.ReadinessTypeReady:
return r.checkByReadyCondition(pod)
default:
return r.checkByProbe(pod, neoset)
}
}

type ReadyResult struct {
Ready bool
Reason string
Message string
}

func (r *NeoSetReconciler) checkByProbe(
pod *corev1.Pod,
neoset *neov1alpha1.NeoSet,
) ReadyResult {
// 1. 先检查容器是否已经换了 image
// containerStatuses 里的 imageID 反映当前运行的镜像
for _, cs := range pod.Status.ContainerStatuses {
if !isManagedContainer(cs.Name, neoset) {
continue
}
// imageID 还是旧的——容器还没重启
if cs.ImageID == "" || !strings.Contains(cs.ImageID, neoset.Status.TargetImage) {
return ReadyResult{
Ready: false,
Reason: "ContainerNotRestarted",
Message: fmt.Sprintf("容器 %s 还在用旧镜像", cs.Name),
}
}
}

// 2. 检查所有管容器的 Ready 状态
for _, cs := range pod.Status.ContainerStatuses {
if !isManagedContainer(cs.Name, neoset) {
continue
}
if !cs.Ready {
return ReadyResult{
Ready: false,
Reason: "ProbeNotPassing",
Message: fmt.Sprintf("容器 %s readinessProbe 未通过", cs.Name),
}
}
}

return ReadyResult{Ready: true}
}

func (r *NeoSetReconciler) checkByReadyCondition(pod *corev1.Pod) ReadyResult {
cond := meta.FindStatusCondition(pod.Status.Conditions, corev1.PodReady)
if cond == nil {
return ReadyResult{
Ready: false,
Reason: "NoReadyCondition",
Message: "Pod 没有 Ready 条件",
}
}
if cond.Status != corev1.ConditionTrue {
return ReadyResult{
Ready: false,
Reason: cond.Reason,
Message: cond.Message,
}
}
return ReadyResult{Ready: true}
}

Status Conditions 设计​

K8S 生态的习惯——用 conditions 描述多维状态。NeoSet 的 conditions:

// NeoSetStatus 里加 Conditions 字段
type NeoSetStatus struct {
// ... 其他字段

Conditions []metav1.Condition `json:"conditions,omitempty"`
}

const (
// AvailableCondition: NeoSet 是否可用(有足够 ready 的 Pod)
ConditionAvailable = "Available"
// ProgressingCondition: 是否在发版中
ConditionProgressing = "Progressing"
// PausedCondition: 是否暂停
ConditionPaused = "Paused"
// ReadyForUpgradeCondition: 是否可以接受新发版
ConditionReadyForUpgrade = "ReadyForUpgrade"
)
// internal/controller/conditions.go

func (r *NeoSetReconciler) updateConditions(
neoset *neov1alpha1.NeoSet,
pods []corev1.Pod,
) {
readyCount := int32(0)
for _, pod := range pods {
if isPodReady(&pod) {
readyCount++
}
}

// Available: ready 副本数 >= replicas - maxUnavailable
maxUnavail := int32(1)
if neoset.Spec.UpdateStrategy.MaxUnavailable != nil {
val, _ := intstr.GetScaledValueFromIntOrPercent(
neoset.Spec.UpdateStrategy.MaxUnavailable, int(*neoset.Spec.Replicas), true)
maxUnavail = int32(val)
}
minAvailable := *neoset.Spec.Replicas - maxUnavail
available := readyCount >= minAvailable

meta.SetStatusCondition(&neoset.Status.Conditions, metav1.Condition{
Type: ConditionAvailable,
Status: boolToConditionStatus(available),
Reason: func() string {
if available { return "EnoughReplicas" }
return "NotEnoughReplicas"
}(),
Message: fmt.Sprintf("%d/%d ready", readyCount, *neoset.Spec.Replicas),
LastTransitionTime: metav1.Now(),
})

// Progressing: 是否在发版
progressing := neoset.Status.Phase == neov1alpha1.NeoSetPhaseCanary ||
neoset.Status.Phase == neov1alpha1.NeoSetPhaseRollingOut
meta.SetStatusCondition(&neoset.Status.Conditions, metav1.Condition{
Type: ConditionProgressing,
Status: boolToConditionStatus(progressing),
Reason: string(neoset.Status.Phase),
Message: fmt.Sprintf("%d/%d updated", neoset.Status.UpdatedReplicas, *neoset.Spec.Replicas),
LastTransitionTime: metav1.Now(),
})

// Paused
meta.SetStatusCondition(&neoset.Status.Conditions, metav1.Condition{
Type: ConditionPaused,
Status: boolToConditionStatus(neoset.Spec.UpdateStrategy.Pause),
Reason: func() string {
if neoset.Spec.UpdateStrategy.Pause { return "PausedByUser" }
return "NotPaused"
}(),
Message: fmt.Sprintf("pause=%v", neoset.Spec.UpdateStrategy.Pause),
LastTransitionTime: metav1.Now(),
})
}

kubectl rollout status 支持​

kubectl rollout status 靠读 status.conditions 判断发版状态。要让 NeoSet 支持,conditions 里要有 Progressing 和 Available:

$ kubectl rollout status neoset mysql
# Waiting for rollout to finish: 2 out of 5 new pods have been updated...
# 或
# deployment "mysql" successfully rolled out

Kubectl 判断逻辑:

  • Progressing=True 且 UpdatedReplicas < Replicas → "X out of Y updated"
  • Progressing=False 且 Available=True → "successfully rolled out"
  • Progressing=False 且 Available=False → 卡住了

进度可观测​

用户怎么看发版进度:

$ kubectl get neo mysql
NAME REPLICAS READY UPDATED PHASE CURRENT TARGET AGE
mysql 5 4 2 RollingOut mysql:8.0 mysql:8.1 3h

$ kubectl describe neo mysql
...
Status:
Replicas: 5
Ready Replicas: 4
Updated Replicas: 2
Current Image: mysql:8.0
Target Image: mysql:8.1
Phase: RollingOut
Current Update Pod: mysql-2
Conditions:
Available:
Status: True
Reason: EnoughReplicas
Message: 4/5 ready
Progressing:
Status: True
Reason: RollingOut
Message: 2/5 updated
Paused:
Status: False
Reason: NotPaused

一目了然——5 个副本,4 个 ready,2 个已更新到新版本,正在更新 mysql-2。

自定义指标​

// internal/controller/metrics.go
package controller

import (
"github.com/prometheus/client_golang/prometheus"
"sigs.k8s.io/controller-runtime/pkg/metrics"
)

var (
neosetUpdatedReplicas = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "neoset_updated_replicas",
Help: "已更新到目标版本的 Pod 数",
},
[]string{"neoset", "namespace"},
)

neosetReadyReplicas = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "neoset_ready_replicas",
Help: "就绪 Pod 数",
},
[]string{"neoset", "namespace"},
)

neosetPhase = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "neoset_phase",
Help: "发版阶段 (0=Idle, 1=Canary, 2=RollingOut, 3=Paused, 4=Completed, 5=Failed)",
},
[]string{"neoset", "namespace"},
)

neosetInplaceUpdateDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "neoset_inplace_update_duration_seconds",
Help: "单次原地升级耗时",
Buckets: prometheus.ExponentialBuckets(1, 2, 10), // 1s 到 512s
},
[]string{"neoset", "namespace", "pod"},
)
)

func init() {
metrics.Registry.MustRegister(
neosetUpdatedReplicas,
neosetReadyReplicas,
neosetPhase,
neosetInplaceUpdateDuration,
)
}

// UpdateMetrics 更新指标
func UpdateMetrics(neoset *neov1alpha1.NeoSet) {
labels := prometheus.Labels{
"neoset": neoset.Name,
"namespace": neoset.Namespace,
}

neosetUpdatedReplicas.With(labels).Set(float64(neoset.Status.UpdatedReplicas))
neosetReadyReplicas.With(labels).Set(float64(neoset.Status.ReadyReplicas))

phaseMap := map[neov1alpha1.NeoSetPhase]float64{
neov1alpha1.NeoSetPhaseIdle: 0,
neov1alpha1.NeoSetPhaseCanary: 1,
neov1alpha1.NeoSetPhaseRollingOut: 2,
neov1alpha1.NeoSetPhasePaused: 3,
neov1alpha1.NeoSetPhaseCompleted: 4,
neov1alpha1.NeoSetPhaseFailed: 5,
}
neosetPhase.With(labels).Set(phaseMap[neoset.Status.Phase])
}

Grafana 面板可以建:

  • 更新进度曲线(updatedReplicas / replicas)
  • 发版阶段(phase 热力图)
  • 单次原地升级耗时 p50/p95/p99

ReadinessGate:拿回 Ready 控制权​

K8S 默认 Pod 的 Ready 条件由 kubelet 管——readinessProbe 通过就 Ready。但原地升级时有个问题:image PATCH 后容器重启,readinessProbe 短暂失败,Pod 变 NotReady,EndpointSlice 把它摘了——但这个 Pod 其实在更新中,不该被摘。

参考 OpenKruise,给 Pod 注入 readinessGates,让 NeoSet Controller 成为 Ready 条件的裁判之一:

spec:
readinessGates:
- conditionType: InPlaceUpdateReady # NeoSet 自己的条件

kubelet 的 readinessProbe 通过后,Pod 还不一定 Ready——还要 NeoSet Controller 把 InPlaceUpdateReady 设为 True。

// internal/controller/readiness_gate.go

const ReadinessGateType = "InPlaceUpdateReady"

// InjectReadinessGate 给 Pod 注入 readinessGate(如果还没有)
func (r *NeoSetReconciler) InjectReadinessGate(
ctx context.Context,
pod *corev1.Pod,
) error {
// 检查是否已注入
for _, gate := range pod.Spec.ReadinessGates {
if gate.ConditionType == ReadinessGateType {
return nil // 已注入
}
}

// 注入 readinessGate
pod.Spec.ReadinessGates = append(pod.Spec.ReadinessGates,
corev1.PodReadinessGate{
ConditionType: ReadinessGateType,
})

return r.Update(ctx, pod)
}

// SetReadinessGate 设置 readinessGate 条件
func (r *NeoSetReconciler) SetReadinessGate(
ctx context.Context,
pod *corev1.Pod,
status corev1.ConditionStatus,
) error {
// 在 Pod status 里更新条件
// 注意:status 要用 /status 子资源更新
cond := corev1.PodCondition{
Type: ReadinessGateType,
Status: status,
LastTransitionTime: metav1.Now(),
}

// 用 Status().Patch 更新 Pod status
patch := map[string]interface{}{
"status": map[string]interface{}{
"conditions": []corev1.PodCondition{cond},
},
}
patchBytes, _ := json.Marshal(patch)
return r.Status().Patch(ctx, pod, client.RawPatch(types.MergePatchType, patchBytes))
}

ReadinessGate 的完整流程​

创建 Pod 时:
→ InjectReadinessGate(注入 InPlaceUpdateReady)
→ SetReadinessGate=True(初始值,允许 Ready)

原地升级前:
→ SetReadinessGate=False
→ Pod 变 NotReady
→ EndpointSlice 摘除 Pod
→ 流量不再进来

原地升级后:
→ 等容器 ready(readinessProbe 通过)
→ SetReadinessGate=True
→ Pod 变 Ready
→ EndpointSlice 加回 Pod
→ 流量恢复

Controller 拿回了 Ready 的控制权——什么时候算"就绪"由 Controller 说了算,不被 kubelet 的 readinessProbe 绑架。

minReadySeconds:就绪后等一等​

Pod Ready 后立刻让流量进来可能超时——端口通了但还在加载缓存、连数据库。minReadySeconds 让 Pod Ready 后再等一段才算"真正可用":

spec:
minReadySeconds: 30
// IsPodAvailable 判断 Pod 是否"可用"(ready + minReadySeconds)
func IsPodAvailable(pod *corev1.Pod, minReadySeconds int32) bool {
if !isPodReady(pod) {
return false
}
if minReadySeconds <= 0 {
return true
}

// 找 Ready 条件变为 True 的时间
readyCond := meta.FindStatusCondition(pod.Status.Conditions, corev1.PodReady)
if readyCond == nil || readyCond.Status != corev1.ConditionTrue {
return false
}

// 从 Ready 变 True 那一刻起,过了 minReadySeconds 才算可用
minReadyDuration := time.Duration(minReadySeconds) * time.Second
return time.Since(readyCond.LastTransitionTime.Time) >= minReadyDuration
}

// canUpdateMore 里用 IsPodAvailable 而不是 isPodReady
func (r *NeoSetReconciler) canUpdateMore(pods []corev1.Pod, neoset *neov1alpha1.NeoSet) bool {
// ...
minReady := int32(0)
if neoset.Spec.MinReadySeconds != nil {
minReady = *neoset.Spec.MinReadySeconds
}

unavailable := int32(0)
for i := range pods {
if !IsPodAvailable(&pods[i], minReady) {
unavailable++
}
}
return unavailable < maxUnavail
}

minReadySeconds 让发版更保守——每个 Pod Ready 后再等 30 秒才更新下一个,给应用充分的时间预热。Controller 重启后也不影响——LastTransitionTime 存在 Pod status 里,从 etcd 读。

前置阅读​

思考题​

问题 1:Probe 模式检查 imageID 是否包含 targetImage。但 image 可能是 mysql:8.1 这种 tag,imageID 是 sha256:abc123...。怎么匹配?

问题 2:conditions 里的 LastTransitionTime 有什么用?为什么不每次 Reconcile 都更新它?

问题 3:原地升级超时后 phase=Failed。用户怎么从这个状态恢复?


解答​

问题 1:不能直接字符串匹配。正确做法是用 containerStatuses[].Image(kubelet 填的实际运行的 image 引用)跟 targetImage 比。但 image tag 可能被解析成 digest——mysql:8.1 可能实际是 mysql@sha256:xxx。更稳的做法:PATCH 前记录 pod.Annotations["neoset.neo.io/inplace-target-image"],检查时比 containerStatuses[].Image 跟这个 annotation。如果一致,说明 kubelet 已经把 image 换过来了。

问题 2:LastTransitionTime 记的是"状态从 True 变 False(或反过来)的时间"。监控告警靠它——"Available 条件为 False 超过 5 分钟"才告警,不是"Available 为 False 就告警"。如果每次 Reconcile 都更新,告警会反复触发。meta.SetStatusCondition 只在 status 变化时更新 LastTransitionTime,status 不变时只更新 ObservedGeneration 和 Message。

问题 3:两种方式。一是修好问题后 kubectl patch neoset mysql --type='json' -p='[{"op":"replace","path":"/spec/updateStrategy/pause","value":false}]',Controller 会从 CurrentUpdatePod 重新检查。二是 kubectl rollout undo neoset mysql,回滚到上一个版本——回滚会把 targetImage 改回旧版本,Controller 把已更新的 Pod 改回去。第三种是直接清 status.phase——但这是 Controller 内部状态,用户不该手动改。

下一章​

最后一章——生产化部署——Leader Election、e2e 测试、部署 YAML、完整项目结构。