跳到正文

05 - 实例管理:创建、绑定与生命周期 ​

一句话:一千个员工不能对应一千个 StatefulSet,改造按 user_id 哈希分片把 K8s 对象从 O(N) 压回 O(1),三处绑定用 Saga 顺序写入并以 PostgreSQL 为权威每分钟自愈差异;生命周期把 desired 与 observed 分开,暂存只停 Pod 不删卷,省下算力的同时把成本转成持续计费的存储,且强制网络存储——本地盘意味着节点故障就等于员工数据消失。

一、实例创建流程 ​

二、K8s 资源模型 ​

2.1 架构决策:分片 StatefulSet 替代 per-user StatefulSet ​

P0 修复:原方案为每员工创建独立 StatefulSet,1000 实例 = 8000+ K8s 资源对象,逼近 etcd 舒适区上限。改为分片 StatefulSet 模型:按 user_id 哈希分片,每个 StatefulSet 管理 20-50 个 replica,总计 20-50 个 StatefulSet,将 K8s 资源从 O(N) 降低到 O(1)。

分片策略:

维度说明
分片键hash(user_id) % shard_count
分片数默认 50(可按集群规模调整)
每分片 replicas动态(按 user_id 分配,约 20 实例/分片)
Pod 命名qwenpaw-shard-{shard_id}-{index}
PVC 命名qwenpaw-shard-{shard_id}-{index}-{volume_type}
路由寻址控制面维护 user_id → (shard_id, index) 映射表,写入 PostgreSQL registry

资源对比:

指标原方案(per-user)新方案(分片)
StatefulSet 数量1,00050
Service 数量1,000(每实例 ClusterIP)1(Headless Service)
PVC 数量3,0003,000(不变)
K8s 资源总数~8,000~3,200
etcd 存储~6MB~2.5MB
API Server reconcile1,000 并发50 并发

2.2 分片 StatefulSet 定义 ​

yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: qwenpaw-shard-00
  namespace: qwenpaw-instances
  labels:
    app: qwenpaw
    shard_id: "00"
spec:
  replicas: 20
  serviceName: qwenpaw-instances  # 共享 Headless Service
  selector:
    matchLabels:
      app: qwenpaw
      shard_id: "00"
  template:
    metadata:
      labels:
        app: qwenpaw
        shard_id: "00"
        owner_user_id: "zhangsan"   # 动态注入
        instance_id: "inst_abc123"  # 动态注入
    spec:
      # 资源限制
      containers:
        - name: qwenpaw
          image: harbor.example.com/qwenpaw/runtime:v1.2.0
          ports:
            - containerPort: 8088
              name: http
          env:
            - name: QWENPAW_RUNTIME_INTERNAL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: qwenpaw-shard-00-secret  # 分片级 Secret
                  key: runtime_token
            - name: QWENPAW_RUNTIME_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['instance_id']
            - name: QWENPAW_AUTH_ENABLED
              value: "true"
            - name: QWENPAW_ENABLED_CHANNELS
              value: "console"
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              cpu: "2000m"
              memory: "4Gi"
          volumeMounts:
            - name: data
              mountPath: /app/working
            - name: secrets
              mountPath: /app/working.secret
            - name: backups
              mountPath: /app/working.backups
          # 启动探针(防止启动期间 liveness 误杀)
          startupProbe:
            httpGet:
              path: /api/healthz
              port: 8088
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30  # 最多等待 150 秒启动
          # 就绪探针
          readinessProbe:
            httpGet:
              path: /api/healthz
              port: 8088
              httpHeaders:
                - name: x-qwenpaw-runtime-token
                  valueFrom:
                    secretKeyRef:
                      name: qwenpaw-shard-00-secret
                      key: runtime_token
            initialDelaySeconds: 0
            periodSeconds: 10
            failureThreshold: 3
          # 存活探针
          livenessProbe:
            httpGet:
              path: /api/healthz
              port: 8088
            initialDelaySeconds: 0
            periodSeconds: 30
            failureThreshold: 5
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: false
            runAsNonRoot: true
      
      # 后置初始化
      initContainers:
        - name: post-init
          image: harbor.example.com/qwenpaw/post-init:v1.2.0
          command: ["/bin/sh", "-c"]
          args:
            - |
              # 写入模型预置配置
              echo '{"provider_id":"tokenplan","model":"qwen3.8-flash"}' \
                > /app/working.secret/providers/active_model.json
              # 校验 ACP 执行体
              test -f /usr/local/bin/opencode || exit 1
              echo "post-init completed"
          volumeMounts:
            - name: secrets
              mountPath: /app/working.secret
          env:
            - name: QWENPAW_RUNTIME_INTERNAL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: qwenpaw-shard-00-secret
                  key: runtime_token
      
      # 不再使用 postStart hook 注册 Nacos
      # 改由控制面在 Pod 就绪后异步注册(见 §三)
      
      restartPolicy: Always
      
      # 使用 volumeClaimTemplates 自动创建 PVC
      # PVC 名称:data-qwenpaw-shard-00-0, data-qwenpaw-shard-00-1, ...
  volumeClaimTemplates:
    - metadata:
        name: data
        labels:
          volume_type: data
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: qwenpaw-ssd  # 必须为网络存储(Ceph RBD / 云盘)
        resources:
          requests:
            storage: 10Gi
    - metadata:
        name: secrets
        labels:
          volume_type: secrets
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: qwenpaw-ssd
        resources:
          requests:
            storage: 1Gi
    - metadata:
        name: backups
        labels:
          volume_type: backups
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: qwenpaw-hdd
        resources:
          requests:
            storage: 5Gi

2.3 Headless Service(共享寻址) ​

yaml
apiVersion: v1
kind: Service
metadata:
  name: qwenpaw-instances
  namespace: qwenpaw-instances
spec:
  clusterIP: None  # Headless Service
  selector:
    app: qwenpaw
  ports:
    - port: 8088
      targetPort: 8088
      protocol: TCP
      name: http

Pod DNS 格式:qwenpaw-shard-{shard_id}-{index}.qwenpaw-instances.qwenpaw-instances.svc.cluster.local 控制面通过 registry 表中的 (shard_id, pod_index) 计算 Pod DNS,网关通过控制面 API 获取实例端点。

2.4 StorageClass 要求 ​

P0 修复:原方案未明确 StorageClass 底层存储类型。如果 PVC 使用本地存储(local PV),节点故障时 PVC 无法跨节点迁移,导致数据丢失。必须使用网络存储。

StorageClass底层存储用途IOPS迁移能力
qwenpaw-ssdCeph RBD / 云盘(如 AWS gp3 / 阿里云 ESSD)data + secrets 卷3000+可跨节点迁移
qwenpaw-hddCeph HDD / 云盘低频(如 AWS st1 / 阿里云高效云盘)backups 卷100-200可跨节点迁移

禁止使用:hostPath、local-path-provisioner 等节点本地存储(节点故障 = 数据丢失)。

三、三处绑定与 Saga 事务 ​

实例与员工的绑定关系在三处保持一致:

K8s labels (owner_user_id=zhangsan)
    = Nacos metadata (user_id=zhangsan)
    = PostgreSQL registry (user_id=zhangsan, unique)
存储用途一致性保证
K8s labels资源归属、NetworkPolicy 匹配、kubectl 查询StatefulSet 创建时写入,不可变
Nacos metadata网关路由寻址控制面异步注册,心跳续约
PostgreSQL registry控制面权威数据(生命周期、审计)控制面直接写入

3.1 Saga 模式保证创建一致性 ​

P0 修复:原方案三处绑定顺序执行(K8s → Nacos → PostgreSQL),任一步失败导致不一致,且只告警不修复。改为 Saga 模式:PostgreSQL 先写入(desired_state=pending),再创建 K8s 资源,最后 Nacos 注册。任一步失败,自动补偿回滚。

3.2 一致性校验与自动修复 ​

P0 修复:原方案一致性校验只告警不修复。改为以 PostgreSQL 为权威源,自动修复 K8s labels 和 Nacos metadata。

python
async def verify_and_repair_binding_consistency():
    """三处绑定一致性校验 + 自动修复(每 1 分钟执行)"""
    instances = await registry.list_active()
    
    for inst in instances:
        # 1. 校验 K8s labels
        k8s_labels = await k8s_client.get_pod_labels(inst.shard_id, inst.pod_index)
        if k8s_labels.get("owner_user_id") != inst.user_id:
            await k8s_client.patch_pod_labels(
                inst.shard_id, inst.pod_index,
                {"owner_user_id": inst.user_id, "instance_id": inst.instance_id}
            )
            audit_log.record("k8s_label_repair", inst.instance_id, inst.user_id)
        
        # 2. 校验 Nacos 注册
        nacos_meta = await nacos_client.get_instance_metadata(inst.instance_id)
        if nacos_meta is None:
            # Nacos 中不存在 → 补注册
            pod_dns = f"qwenpaw-shard-{inst.shard_id}-{inst.pod_index}.qwenpaw-instances.qwenpaw-instances.svc.cluster.local"
            await nacos_client.register_instance(
                service="qwenpaw",
                ip=pod_dns,
                port=8088,
                metadata={"user_id": inst.user_id, "instance_id": inst.instance_id}
            )
            audit_log.record("nacos_register_repair", inst.instance_id, inst.user_id)
        elif nacos_meta.get("user_id") != inst.user_id:
            # Nacos 中 user_id 不一致 → 以 PostgreSQL 为准修复
            await nacos_client.update_metadata(inst.instance_id, {"user_id": inst.user_id})
            audit_log.record("nacos_metadata_repair", inst.instance_id, inst.user_id)
        
        # 3. 校验 K8s Pod 状态与 registry 一致
        pod_status = await k8s_client.get_pod_status(inst.shard_id, inst.pod_index)
        if pod_status == "Running" and inst.observed_state != "active":
            await registry.update_observed_state(inst.instance_id, "active")
        elif pod_status != "Running" and inst.observed_state == "active":
            await registry.update_observed_state(inst.instance_id, "failed")
            alert(f"Pod not running but registry says active: {inst.instance_id}")

校验频率调整:从每 5 分钟缩短到每 1 分钟,减少不一致窗口期。

四、生命周期状态机 ​

4.1 暂存(Stage) ​

触发条件(满足任一):

  • 闲置超时:last_active 超过 30 天(可配置)
  • 手动暂存:员工点击"休眠助手"
  • 管理员治理:批量暂存低活跃实例

暂存流程:

4.2 恢复(Restore) ​

触发条件:员工访问(网关发现 staged)或手动唤醒

恢复场景:

场景PVC 状态恢复方式恢复时间
常规暂存恢复PVC 保留(数据完整)Pod 重建后数据自动从 PVC 恢复,无需调用 restore API10-15 秒
灾难恢复PVC 丢失或损坏从 MinIO 下载最新备份 → 调用 restore API 恢复30-60 秒

常规恢复流程(PVC 保留):

灾难恢复流程(PVC 丢失):

恢复时间估算:

  • Pod 启动 + healthz 200:约 10-15 秒
  • 常规恢复(PVC 保留):总计 10-15 秒
  • 灾难恢复(从 MinIO 恢复):备份下载 5-10 秒 + 恢复 10-30 秒 = 总计 25-55 秒
  • eta_seconds 按历史数据和恢复类型动态估算

4.3 回收(Retire) ​

触发条件:员工离职

回收流程:

五、registry 数据模型 ​

sql
CREATE TABLE instance_registry (
    id              BIGSERIAL PRIMARY KEY,
    instance_id     VARCHAR(64) UNIQUE NOT NULL,
    user_id         VARCHAR(64) UNIQUE NOT NULL,
    
    -- 分片 StatefulSet 定位(P0 修复:新增)
    shard_id        VARCHAR(10) NOT NULL,   -- 分片 ID(如 "00", "01", ...)
    pod_index       INTEGER NOT NULL,       -- Pod 在 StatefulSet 中的序号
    
    -- 状态机
    desired_state   VARCHAR(20) NOT NULL DEFAULT 'active',  -- 期望状态
    observed_state  VARCHAR(20) NOT NULL DEFAULT 'pending', -- 观测状态
    revision        INTEGER NOT NULL DEFAULT 1,             -- 乐观锁
    
    -- 资源引用
    keys_ref        VARCHAR(256),   -- Vault 密钥引用
    snapshot_ref    VARCHAR(512),   -- 备份归档引用(MinIO 路径)
    image_ref       VARCHAR(256),   -- 镜像版本
    pod_dns         VARCHAR(512),   -- Pod DNS(如 qwenpaw-shard-00-5.qwenpaw-instances...)
    
    -- 时间戳
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_active_at  TIMESTAMPTZ,    -- 最后活跃时间(闲置判定)
    registered_at   TIMESTAMPTZ,    -- Nacos 注册时间
    retired_at      TIMESTAMPTZ,
    
    -- 元数据
    metadata        JSONB DEFAULT '{}',
    last_error      TEXT
);

CREATE INDEX idx_registry_user ON instance_registry(user_id);
CREATE INDEX idx_registry_state ON instance_registry(observed_state);
CREATE INDEX idx_registry_active ON instance_registry(observed_state) WHERE observed_state = 'active';
CREATE INDEX idx_registry_shard ON instance_registry(shard_id, pod_index);

分片定位:通过 (shard_id, pod_index) 可唯一定位一个实例 Pod。Pod DNS 格式:qwenpaw-shard-{shard_id}-{pod_index}.qwenpaw-instances.qwenpaw-instances.svc.cluster.local。控制面和网关通过 registry 表查询实例端点,不再依赖 Nacos 作为唯一寻址源。

desired vs observed 状态分离:

desired_stateobserved_state含义
activeactive正常运行
activepending正在创建
activestartingPod 已创建,等待 healthz
stagedstaged已暂存(Pod 停止)
stagedrestoring正在恢复
retiredretired已回收
activefailed运行异常
stagedfailed暂存/恢复异常

六、闲置检测 ​

python
async def check_idle_instances():
    """定时检查闲置实例(每 10 分钟执行)"""
    threshold = datetime.utcnow() - timedelta(days=IDLE_DAYS)  # 默认 30 天
    
    idle_instances = await registry.list_by_query(
        observed_state="active",
        last_active_before=threshold
    )
    
    for inst in idle_instances:
        # 排除最近有任务的实例
        recent_tasks = await task_store.count_recent(inst.instance_id, hours=24)
        if recent_tasks > 0:
            continue
        
        # 触发暂存
        await lifecycle_manager.stage(inst.instance_id, reason="idle_timeout")
        await audit_log.record("idle_stage", inst.instance_id, inst.user_id)

last_active_at 更新时机:

  • 每次 POST /api/console/chat 成功响应后更新
  • 每次 SSE 事件流完成后更新
  • 网关在转发响应时异步更新(不阻塞请求)

七、镜像管理 ​

7.1 镜像版本策略 ​

7.2 镜像升级 ​

把 AI 工程讲到既懂也会用