主题
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,000 | 50 |
| Service 数量 | 1,000(每实例 ClusterIP) | 1(Headless Service) |
| PVC 数量 | 3,000 | 3,000(不变) |
| K8s 资源总数 | ~8,000 | ~3,200 |
| etcd 存储 | ~6MB | ~2.5MB |
| API Server reconcile | 1,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: 5Gi2.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: httpPod 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-ssd | Ceph RBD / 云盘(如 AWS gp3 / 阿里云 ESSD) | data + secrets 卷 | 3000+ | 可跨节点迁移 |
| qwenpaw-hdd | Ceph 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 API | 10-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_state | observed_state | 含义 |
|---|---|---|
| active | active | 正常运行 |
| active | pending | 正在创建 |
| active | starting | Pod 已创建,等待 healthz |
| staged | staged | 已暂存(Pod 停止) |
| staged | restoring | 正在恢复 |
| retired | retired | 已回收 |
| active | failed | 运行异常 |
| staged | failed | 暂存/恢复异常 |
六、闲置检测
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 事件流完成后更新
- 网关在转发响应时异步更新(不阻塞请求)