Skip to main content

7. 用旧镜像扩容

生产场景​

当前: replicas=5, 正在发版 v1→v2
mysql-0~1: v2(已原地升级)
mysql-2~4: v1(待更新)

业务突增,扩容到 8:
❌ 原生 STS: mysql-5~7 用 spec.template (v2)
→ 新版本没验证完就扩散到新 Pod
→ 8 个 Pod 里有 5 个 v2、3 个 v1,行为不可预测

✅ NeoSet: mysql-5~7 用 status.currentImage (v1)
→ 新 Pod 跟未更新的 Pod 同版本
→ 发版继续时再逐个升级到 v2

核心机制:currentImage 追踪​

NeoSet 在 status 里维护两个字段:

字段含义
currentImage当前稳定版本镜像——扩容时用这个
targetImage目标版本镜像——发版时往这个推

发版过程中两者不同:

发版前:
currentImage = mysql:8.0 ┐ 两者相等
targetImage = mysql:8.0 ┘

用户改了 spec.template.image = mysql:8.1:
currentImage = mysql:8.0 ┐ 不等——正在发版
targetImage = mysql:8.1 ┘

→ 扩容时新 Pod 用 currentImage (mysql:8.0)

发版完成:
currentImage = mysql:8.1 ┐ 重新相等
targetImage = mysql:8.1 ┘

扩容逻辑​

// internal/controller/replicas.go
package controller

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

neov1alpha1 "neo.io/api/v1alpha1"
)

// reconcileReplicas 处理扩缩容
func (r *NeoSetReconciler) reconcileReplicas(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
pods []corev1.Pod,
) error {
logger := log.FromContext(ctx)

desiredReplicas := int32(1)
if neoset.Spec.Replicas != nil {
desiredReplicas = *neoset.Spec.Replicas
}

currentCount := int32(len(pods))

// 1. 扩容
if currentCount < desiredReplicas {
return r.scaleUp(ctx, neoset, pods, desiredReplicas-currentCount)
}

// 2. 缩容
if currentCount > desiredReplicas {
return r.scaleDown(ctx, neoset, pods, currentCount-desiredReplicas)
}

return nil
}

// scaleUp 扩容:新 Pod 用 currentImage
func (r *NeoSetReconciler) scaleUp(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
pods []corev1.Pod,
count int32,
) error {
logger := log.FromContext(ctx)

// 1. 算下一个序号
maxOrdinal := int32(-1)
for _, pod := range pods {
ordinal := getOrdinal(&pod)
if ordinal > maxOrdinal {
maxOrdinal = ordinal
}
}

// 2. 决定用哪个 image 创建新 Pod
// 关键:发版途中用 currentImage,不用 spec.template 的 image
imageToUse := neoset.Status.CurrentImage
if imageToUse == "" {
// 第一次创建,还没有 currentImage,用 spec.template
imageToUse = neoset.Spec.Template.Spec.Containers[0].Image
}

logger.Info("扩容",
"from", len(pods),
"to", len(pods)+int(count),
"image", imageToUse,
"isStableImage", imageToUse != neoset.Spec.Template.Spec.Containers[0].Image)

// 3. 创建新 Pod
for i := int32(0); i < count; i++ {
ordinal := maxOrdinal + 1 + i
podName := fmt.Sprintf("%s-%d", neoset.Name, ordinal)

// 构造 Pod——基于 spec.template,但改 image
pod := r.buildPod(neoset, ordinal, imageToUse)

if err := r.Create(ctx, pod); err != nil && !errors.IsAlreadyExists(err) {
return fmt.Errorf("创建 Pod %s 失败: %w", podName, err)
}

logger.Info("创建新 Pod",
"pod", podName,
"image", imageToUse)
}

return nil
}

// buildPod 构造 Pod 对象
func (r *NeoSetReconciler) buildPod(
neoset *neov1alpha1.NeoSet,
ordinal int32,
image string,
) *corev1.Pod {
podName := fmt.Sprintf("%s-%d", neoset.Name, ordinal)

// 深拷贝 spec.template
podSpec := *neoset.Spec.Template.Spec.DeepCopy()

// 关键:用传入的 image 覆盖 spec.template 里的 image
for i := range podSpec.Containers {
podSpec.Containers[i].Image = image
}

pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: podName,
Namespace: neoset.Namespace,
Labels: neoset.Spec.Template.Labels,
Annotations: map[string]string{
"neoset.neo.io/name": neoset.Name,
"neoset.neo.io/ordinal": fmt.Sprintf("%d", ordinal),
// 标记这个 Pod 是用哪个版本创建的
"neoset.neo.io/created-image": image,
},
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(neoset, neov1alpha1.GroupVersion.WithKind("NeoSet")),
},
},
Spec: podSpec,
}

return pod
}

// scaleDown 缩容:从最大序号开始删
func (r *NeoSetReconciler) scaleDown(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
pods []corev1.Pod,
count int32,
) error {
logger := log.FromContext(ctx)

// 按序号从大到小排序
sorted := sortPodsByOrdinalDesc(pods)

for i := int32(0); i < count && i < int32(len(sorted)); i++ {
pod := &sorted[i]

// 如果 Pod 有 PVC,也要删(StatefulSet 的行为)
// PVC 名字是 <volumeClaimTemplate.name>-<podName>
for _, pvcTmpl := range neoset.Spec.VolumeClaimTemplates {
pvcName := fmt.Sprintf("%s-%s", pvcTmpl.Name, pod.Name)
pvc := &corev1.PersistentVolumeClaim{}
if err := r.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pod.Namespace}, pvc); err == nil {
_ = r.Delete(ctx, pvc)
}
}

if err := r.Delete(ctx, pod); err != nil {
return fmt.Errorf("删除 Pod %s 失败: %w", pod.Name, err)
}

logger.Info("缩容删除 Pod", "pod", pod.Name)
}

return nil
}

currentImage 的更新时机​

// completeUpdate 发版完成时更新 currentImage
func (r *NeoSetReconciler) completeUpdate(neoset *neov1alpha1.NeoSet) {
// 所有 Pod 都更新到 targetImage 了
// 把 currentImage 更新为 targetImage
neoset.Status.CurrentImage = neoset.Status.TargetImage
neoset.Status.Phase = neov1alpha1.NeoSetPhaseCompleted

// 更新 UpdatedReplicas
neoset.Status.UpdatedReplicas = *neoset.Spec.Replicas
}

关键:currentImage 只在发版完成后更新——所有 Pod 都到 targetImage 了,currentImage 才跟着变。发版途中扩容的新 Pod 用的是旧的 currentImage。

发版途中扩容的完整时序​

初始: replicas=5, 全部 v1
currentImage = mysql:8.0
targetImage = mysql:8.0
phase = Idle

用户改 image = mysql:8.1, partition=4 (金丝雀):
currentImage = mysql:8.0 ← 不变
targetImage = mysql:8.1 ← 记下目标
phase = Canary
→ 原地升级 mysql-4 到 v2

mysql-4 ready:
updatedReplicas = 1
phase = Canary (只发了 1 个)

此时用户扩容 replicas=8:
→ Controller 发现 currentCount=5 < desiredReplicas=8
→ 新 Pod 用 currentImage (mysql:8.0) 创建
→ mysql-5, mysql-6, mysql-7 都是 v1

currentImage = mysql:8.0 ← 还是 v1
targetImage = mysql:8.1 ← 还是 v2
updatedReplicas = 1 (只有 mysql-4 是 v2)
replicas = 8

继续发版 partition=0:
→ 从 mysql-7 开始往小更新
→ mysql-7, mysql-6, mysql-5, mysql-3, mysql-2, mysql-1, mysql-0
→ 每个原地升级到 v2

全部完成:
updatedReplicas = 8
currentImage = mysql:8.1 ← 终于更新
targetImage = mysql:8.1
phase = Completed

跟原生 STS 的对比​

原生 STS 发版途中扩容:
replicas=5, partition=4, 发版 v1→v2
mysql-0~3: v1
mysql-4: v2

扩容到 8:
mysql-5~7: v2 (用 spec.template 的 v2)

结果: 8 个 Pod 里 4 个 v2 (mysql-4~7)、4 个 v1 (mysql-0~3)
新扩的 3 个直接用新版本——没经过金丝雀验证

NeoSet 发版途中扩容:
同样场景

扩容到 8:
mysql-5~7: v1 (用 status.currentImage 的 v1)

结果: 8 个 Pod 里 1 个 v2 (mysql-4)、7 个 v1
新扩的 3 个用稳定版本——跟未更新的 Pod 一致
发版继续时逐个升级——可控

podManagementPolicy:并行扩容​

原生 STS 默认 OrderedReady——严格有序,mysql-0 ready 才建 mysql-1。5 个副本要等 5 轮启动。

NeoSet 支持 Parallel 模式——所有 Pod 同时创建,不等待:

spec:
podManagementPolicy: Parallel
// scaleUp 支持 Parallel
func (r *NeoSetReconciler) scaleUp(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
pods []corev1.Pod,
count int32,
) error {
policy := neoset.Spec.PodManagementPolicy
if policy == "" {
policy = appsv1.OrderedReadyPodManagement
}

switch policy {
case appsv1.ParallelPodManagement:
// 并行:一次创建所有缺失的 Pod
for i := int32(0); i < count; i++ {
ordinal := maxOrdinal + 1 + i
pod := r.buildPod(neoset, ordinal, imageToUse)
_ = r.Create(ctx, pod)
}

case appsv1.OrderedReadyPodManagement:
// 有序:只建下一个,等 ready 再建下下个
if maxOrdinal >= 0 {
lastPod := findPodByOrdinal(pods, maxOrdinal)
if lastPod != nil && !isPodReady(lastPod) {
return nil // 上一个没 ready,等着
}
}
ordinal := maxOrdinal + 1
pod := r.buildPod(neoset, ordinal, imageToUse)
_ = r.Create(ctx, pod)
}
return nil
}

两种模式对比:

OrderedReady: 创建 mysql-0 → 等 ready → 创建 mysql-1 → 等 ready → ...
Parallel: 同时创建 mysql-0~4 → 全部 ready

适合 Parallel 的场景: Redis 集群、无依赖的独立服务
适合 OrderedReady 的场景: MySQL 主从(从库依赖主库)

PVC 保留策略:缩容时删不删 PVC​

原生 STS 缩容时 PVC 保留——下次扩容同一序号的 Pod 重新挂上。但这会占存储计费。

K8S 1.27+ 支持 persistentVolumeClaimRetentionPolicy:

spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain # NeoSet 删除时:保留 PVC
whenScaled: Delete # 缩容时:删 PVC
// scaleDown 根据 PVC 策略决定是否删 PVC
func (r *NeoSetReconciler) scaleDown(...) error {
deletePVC := false
if policy := neoset.Spec.PersistentVolumeClaimRetentionPolicy; policy != nil {
if policy.WhenScaled == appsv1.DeletePersistentVolumeClaimRetentionPolicyType {
deletePVC = true
}
}

for i := int32(0); i < count; i++ {
pod := &sorted[i]

if deletePVC {
// 删关联的 PVC
for _, pvcTmpl := range neoset.Spec.VolumeClaimTemplates {
pvcName := fmt.Sprintf("%s-%s", pvcTmpl.Name, pod.Name)
pvc := &corev1.PersistentVolumeClaim{}
if r.Get(ctx, ..., pvc) == nil {
_ = r.Delete(ctx, pvc)
}
}
}

_ = r.Delete(ctx, pod)
}
return nil
}

常见组合 whenDeleted: Retain, whenScaled: Delete——缩容时清理 PVC 省钱,删 NeoSet 时保留 PVC 防误删。

前置阅读​

思考题​

问题 1:发版途中扩容的新 Pod 用 v1。但 spec.template 已经是 v2。Controller 重启后,它怎么知道新 Pod 该用 v1 还是 v2?

问题 2:缩容时从最大序号开始删。如果正在发版(mysql-4 是 v2,mysql-0~3 是 v1),缩容到 3,删的是 v2 还是 v1?

问题 3:用旧镜像扩容的新 Pod(mysql-5~7,v1),跟已更新的 Pod(mysql-4,v2)在同一个 Service 后面。流量怎么分配?


解答​

问题 1:靠 status.currentImage。这个字段存在 etcd 里,Controller 重启后从 etcd 读出来。只要发版没完成(currentImage != targetImage),扩容就用 currentImage。不需要内存状态——一切状态都在 etcd 里,这是声明式 Controller 的核心设计。

问题 2:删 v2(mysql-4)。缩容从最大序号开始——mysql-4 是序号最大的。但这里有风险——mysql-4 是已更新的新版本,删了它等于金丝雀的成果丢了。NeoSet 可以优化:缩容时优先删未更新的 Pod(序号大但 image 还是旧的)。但原生 STS 的语义是"从最大序号删",NeoSet 保持一致更简单。如果用户想保护已更新的 Pod,可以先调小 partition 让发版完成再缩容。

问题 3:流量随机分配。Service 的负载均衡不区分 Pod 版本——它只看 Endpoints 里的 IP。mysql-4(v2)和 mysql-5~7(v1)都在 Endpoints 里,流量按策略(随机/轮询)分发。这就是发版途中扩容的风险——新老版本混在 Service 后面,行为可能不一致。NeoSet 的"用旧镜像扩容"保证新 Pod 跟"大多数未更新 Pod"同版本,减少不一致范围。但根本解决办法是——发版途中别扩容,或者扩容后尽快完成发版。

下一章​

下一章实现就绪判断与进度追踪——原地更新后的 readiness 检查、status 进度更新、kubectl rollout status 支持。