# Graceful Deployment 方案

本文档描述项目在 K8s 环境下实现零停机滚动升级（Graceful Rolling Update）的完整方案。

---

## 1. 问题背景

在 K8s 多 Pod 部署（Ingress + Service）中，镜像升级/重启时如果没有 graceful shutdown 机制，会导致：
- 正在处理的请求被强制中断（502/504）
- 定时任务执行到一半被杀死
- 数据库连接、Temporal 连接未正确释放

---

## 2. 整体时序

```
K8s 发起 Pod 删除（Deployment rolling update）
    │
    ├─► [异步1] 从 Service Endpoints 摘除 Pod（kube-proxy 更新 iptables）
    │          → 新流量不再路由到此 Pod
    │
    └─► [异步2] 执行 lifecycle.preStop hook
                 │
                 └─► sleep 5s（等待 iptables 规则同步到所有节点）
                      │
                      └─► 发送 SIGTERM 给容器主进程
                           │
                           ├─► NestJS server.close()（停止接受新 TCP 连接）
                           ├─► 处理完所有 in-flight HTTP 请求
                           ├─► 触发 onApplicationShutdown() 生命周期钩子：
                           │    ├─► AgendaService: 停止接取新任务，等待当前任务完成
                           │    ├─► TemporalService: 断开 Temporal 连接
                           │    └─► OpenTelemetry: flush 追踪数据
                           └─► 进程退出，容器结束

    ═══════════════════════════════════════════════════════════════
    terminationGracePeriodSeconds（300s）超时后 → 强制 SIGKILL
```

**关键点：** `preStop sleep 5s` 和 `SIGTERM` 是串行的，但与 Endpoints 摘除是并行的。sleep 确保在进程开始关闭前，iptables 已完成更新，避免流量打到正在关闭的 Pod。

---

## 3. 代码改动

### 3.1 `server/src/main.ts` — 启用 Shutdown Hooks

```typescript
// 在 app.listen() 之前
app.enableShutdownHooks();
```

**作用：** 使 NestJS 在收到 SIGTERM 时触发所有 `onApplicationShutdown()` / `onModuleDestroy()` 生命周期钩子。没有此行，所有清理逻辑都不会被执行。

### 3.2 `Dockerfile` — STOPSIGNAL 对齐

```diff
- STOPSIGNAL SIGQUIT
+ STOPSIGNAL SIGTERM
```

**作用：** 与 K8s 默认发送的 SIGTERM 及 NestJS `enableShutdownHooks()` 监听的信号保持一致。

### 3.3 新增 Health Module

| 端点 | 用途 | 响应 |
|------|------|------|
| `GET /{DEPLOY_NAME}/api/health/live` | K8s livenessProbe | `{ "status": "ok" }` |
| `GET /{DEPLOY_NAME}/api/health/ready` | K8s readinessProbe | `{ "status": "ok" }` |

两个端点均通过 `@AllowUnauthorizedRequest()` 跳过认证，可被 K8s kubelet 直接访问。

### 3.4 AgendaService — 优雅关闭

`AgendaService.onApplicationShutdown()` 在收到 SIGTERM 后被触发：

```typescript
async onApplicationShutdown() {
  await this.agenda.stop();  // 停止接取新任务，等待当前 running jobs 完成
  this.logger.log('Agenda stopped');
}
```

**`agenda.stop()` 的行为：**
- 停止从 `agenda_jobs` 表 lock 新任务
- 等待所有正在执行的 job handler 的 Promise resolve
- 不会等待未来计划的任务（其他 Pod 会接管）

**注意：** 如果未来有执行时间可能超过 295s（300 - 5s preStop）的任务，需要在 job handler 中增加检查点机制提前退出，或加大 `terminationGracePeriodSeconds`。

---

## 4. Spinnaker / K8s Deployment 配置

以下配置需在 Spinnaker 管理的 Deployment manifest 中修改：

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gadmin-server
spec:
  # ─── 滚动升级策略 ───────────────────────────────────────────
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 先启动 1 个新 Pod
      maxUnavailable: 0    # 零停机：旧 Pod 不提前终止

  template:
    spec:
      # ─── 优雅关闭宽限期 ─────────────────────────────────────
      terminationGracePeriodSeconds: 300
      # 给 NestJS 足够时间完成：
      # - in-flight HTTP 请求
      # - 正在执行的 Agenda 定时任务（最长可能数分钟）
      # - OpenTelemetry trace flush
      # 超过 300s 未退出将被 SIGKILL 强杀

      containers:
      - name: server
        image: <registry>/<image>:<tag>
        ports:
        - containerPort: 8000

        # ─── preStop Hook ───────────────────────────────────
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 5"]
        # 目的：等待 kube-proxy 完成 iptables 规则同步
        # 确保不会有新流量在 SIGTERM 发出后仍然打到此 Pod

        # ─── Readiness Probe ────────────────────────────────
        # Pod 通过此探针后才会被加入 Service Endpoints 接收流量
        readinessProbe:
          httpGet:
            path: /<DEPLOY_NAME>/api/health/ready
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
          successThreshold: 1

        # ─── Liveness Probe ─────────────────────────────────
        # 探针失败后 K8s 会重启容器（检测进程死锁/僵死）
        livenessProbe:
          httpGet:
            path: /<DEPLOY_NAME>/api/health/live
            port: 8000
          initialDelaySeconds: 15
          periodSeconds: 10
          failureThreshold: 3
```

> **注意：** `<DEPLOY_NAME>` 替换为实际部署名称（如 `gadmin-test`），对应 `process.env.DEPLOY_NAME`。

---

## 5. 配置说明

### 5.1 为什么 terminationGracePeriodSeconds = 300？

| 因素 | 说明 |
|------|------|
| preStop sleep | 5s |
| In-flight HTTP 请求处理 | 通常 < 10s |
| Agenda 定时任务完成 | 可能长达数分钟 |
| OpenTelemetry flush | < 5s |
| **总计余量** | 300s 覆盖大多数场景 |

`agenda.stop()` 会等待当前正在执行的 job 完成。如果 job 执行时间超过 295s（300 - 5s preStop），将被 SIGKILL 强杀。建议：
- 单个 job 执行时间控制在 4 分钟以内
- 超长任务使用 `isStopping` 检查点机制提前退出

### 5.2 为什么 preStop sleep 5 秒？

K8s 删除 Pod 时，Endpoints 摘除和 preStop/SIGTERM 是**并行**触发的：
- Endpoints 控制器通知各节点 kube-proxy 更新 iptables 规则需要时间（通常 1-3 秒）
- 如果不 sleep，SIGTERM 可能在 iptables 更新完成前就让进程开始关闭
- 5 秒是业界推荐值，足以覆盖绝大部分集群的传播延迟

### 5.3 maxUnavailable: 0 的意义

确保滚动升级过程中**始终有足够 Pod 处理流量**：
1. 先启动新 Pod（maxSurge: 1）
2. 新 Pod readinessProbe 通过后加入 Service
3. 旧 Pod 开始 graceful shutdown
4. 旧 Pod 完全退出后再终止下一个

---

## 6. 验证方法

### 6.1 本地验证 Shutdown Hooks

```bash
cd server && yarn start:prod &
SERVER_PID=$!

# 等服务启动
sleep 3

# 发送 SIGTERM
kill -TERM $SERVER_PID

# 观察日志应输出：
# "Agenda stopped"
# 进程正常退出（exit code 0）
```

### 6.2 验证 Health 端点

```bash
# 启动后请求
curl http://localhost:8000/<DEPLOY_NAME>/api/health/live
# 期望：{"status":"ok"}

curl http://localhost:8000/<DEPLOY_NAME>/api/health/ready
# 期望：{"status":"ok"}
```

### 6.3 K8s 环境验证

```bash
# 观察滚动升级过程
kubectl rollout status deployment/gadmin-server -w

# 查看 Pod 事件（确认 preStop 执行）
kubectl describe pod <pod-name> | grep -A5 "Events"

# 升级期间持续请求验证零中断
while true; do curl -s -o /dev/null -w "%{http_code}\n" http://<ingress>/api/health/ready; sleep 0.5; done
```

---

## 7. 信号传递链路

```
K8s kubelet
    │
    └─► Docker/containerd: 发送 STOPSIGNAL (SIGTERM)
         │
         └─► start-prod.sh（使用 exec，PID 1 是 node 进程）
              │
              └─► Node.js process
                   │
                   ├─► NestJS enableShutdownHooks() 捕获 SIGTERM
                   │    └─► 调用 app.close()
                   │         └─► 触发所有生命周期钩子
                   │
                   └─► OpenTelemetry process.on('SIGTERM') handler
                        └─► flush traces
```

`start-prod.sh` 中的 `exec node dist/src/main` 确保 Node.js 进程是容器的 PID 1，信号不会被 shell 拦截。

---

## 8. 故障场景与应对

| 场景 | 现象 | 应对 |
|------|------|------|
| Job 超时被 SIGKILL | 任务中断，锁超时后释放 | 其他 Pod 自动重试（Agenda 分布式锁） |
| readinessProbe 失败 | Pod 从 Service 摘除 | 不接收新流量，等 liveness 判定是否重启 |
| preStop 未配置 | 升级时短暂 502 | 添加 preStop sleep 5 |
| enableShutdownHooks 未启用 | 资源泄漏（连接未关闭） | 已通过本次改造修复 |
