@gadmin2n/schematics 0.0.78 → 0.0.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ # Graceful Deployment 方案
2
+
3
+ 本文档描述项目在 K8s 环境下实现零停机滚动升级(Graceful Rolling Update)的完整方案。
4
+
5
+ ---
6
+
7
+ ## 1. 问题背景
8
+
9
+ 在 K8s 多 Pod 部署(Ingress + Service)中,镜像升级/重启时如果没有 graceful shutdown 机制,会导致:
10
+ - 正在处理的请求被强制中断(502/504)
11
+ - 定时任务执行到一半被杀死
12
+ - 数据库连接、Temporal 连接未正确释放
13
+
14
+ ---
15
+
16
+ ## 2. 整体时序
17
+
18
+ ```
19
+ K8s 发起 Pod 删除(Deployment rolling update)
20
+
21
+ ├─► [异步1] 从 Service Endpoints 摘除 Pod(kube-proxy 更新 iptables)
22
+ │ → 新流量不再路由到此 Pod
23
+
24
+ └─► [异步2] 执行 lifecycle.preStop hook
25
+
26
+ └─► sleep 5s(等待 iptables 规则同步到所有节点)
27
+
28
+ └─► 发送 SIGTERM 给容器主进程
29
+
30
+ ├─► NestJS server.close()(停止接受新 TCP 连接)
31
+ ├─► 处理完所有 in-flight HTTP 请求
32
+ ├─► 触发 onApplicationShutdown() 生命周期钩子:
33
+ │ ├─► AgendaService: 停止接取新任务,等待当前任务完成
34
+ │ ├─► TemporalService: 断开 Temporal 连接
35
+ │ └─► OpenTelemetry: flush 追踪数据
36
+ └─► 进程退出,容器结束
37
+
38
+ ═══════════════════════════════════════════════════════════════
39
+ terminationGracePeriodSeconds(300s)超时后 → 强制 SIGKILL
40
+ ```
41
+
42
+ **关键点:** `preStop sleep 5s` 和 `SIGTERM` 是串行的,但与 Endpoints 摘除是并行的。sleep 确保在进程开始关闭前,iptables 已完成更新,避免流量打到正在关闭的 Pod。
43
+
44
+ ---
45
+
46
+ ## 3. 代码改动
47
+
48
+ ### 3.1 `server/src/main.ts` — 启用 Shutdown Hooks
49
+
50
+ ```typescript
51
+ // 在 app.listen() 之前
52
+ app.enableShutdownHooks();
53
+ ```
54
+
55
+ **作用:** 使 NestJS 在收到 SIGTERM 时触发所有 `onApplicationShutdown()` / `onModuleDestroy()` 生命周期钩子。没有此行,所有清理逻辑都不会被执行。
56
+
57
+ ### 3.2 `Dockerfile` — STOPSIGNAL 对齐
58
+
59
+ ```diff
60
+ - STOPSIGNAL SIGQUIT
61
+ + STOPSIGNAL SIGTERM
62
+ ```
63
+
64
+ **作用:** 与 K8s 默认发送的 SIGTERM 及 NestJS `enableShutdownHooks()` 监听的信号保持一致。
65
+
66
+ ### 3.3 新增 Health Module
67
+
68
+ | 端点 | 用途 | 响应 |
69
+ |------|------|------|
70
+ | `GET /{DEPLOY_NAME}/api/health/live` | K8s livenessProbe | `{ "status": "ok" }` |
71
+ | `GET /{DEPLOY_NAME}/api/health/ready` | K8s readinessProbe | `{ "status": "ok" }` |
72
+
73
+ 两个端点均通过 `@AllowUnauthorizedRequest()` 跳过认证,可被 K8s kubelet 直接访问。
74
+
75
+ ### 3.4 AgendaService — 优雅关闭
76
+
77
+ `AgendaService.onApplicationShutdown()` 在收到 SIGTERM 后被触发:
78
+
79
+ ```typescript
80
+ async onApplicationShutdown() {
81
+ await this.agenda.stop(); // 停止接取新任务,等待当前 running jobs 完成
82
+ this.logger.log('Agenda stopped');
83
+ }
84
+ ```
85
+
86
+ **`agenda.stop()` 的行为:**
87
+ - 停止从 `agenda_jobs` 表 lock 新任务
88
+ - 等待所有正在执行的 job handler 的 Promise resolve
89
+ - 不会等待未来计划的任务(其他 Pod 会接管)
90
+
91
+ **注意:** 如果未来有执行时间可能超过 295s(300 - 5s preStop)的任务,需要在 job handler 中增加检查点机制提前退出,或加大 `terminationGracePeriodSeconds`。
92
+
93
+ ---
94
+
95
+ ## 4. Spinnaker / K8s Deployment 配置
96
+
97
+ 以下配置需在 Spinnaker 管理的 Deployment manifest 中修改:
98
+
99
+ ```yaml
100
+ apiVersion: apps/v1
101
+ kind: Deployment
102
+ metadata:
103
+ name: gadmin-server
104
+ spec:
105
+ # ─── 滚动升级策略 ───────────────────────────────────────────
106
+ strategy:
107
+ type: RollingUpdate
108
+ rollingUpdate:
109
+ maxSurge: 1 # 先启动 1 个新 Pod
110
+ maxUnavailable: 0 # 零停机:旧 Pod 不提前终止
111
+
112
+ template:
113
+ spec:
114
+ # ─── 优雅关闭宽限期 ─────────────────────────────────────
115
+ terminationGracePeriodSeconds: 300
116
+ # 给 NestJS 足够时间完成:
117
+ # - in-flight HTTP 请求
118
+ # - 正在执行的 Agenda 定时任务(最长可能数分钟)
119
+ # - OpenTelemetry trace flush
120
+ # 超过 300s 未退出将被 SIGKILL 强杀
121
+
122
+ containers:
123
+ - name: server
124
+ image: <registry>/<image>:<tag>
125
+ ports:
126
+ - containerPort: 8000
127
+
128
+ # ─── preStop Hook ───────────────────────────────────
129
+ lifecycle:
130
+ preStop:
131
+ exec:
132
+ command: ["sh", "-c", "sleep 5"]
133
+ # 目的:等待 kube-proxy 完成 iptables 规则同步
134
+ # 确保不会有新流量在 SIGTERM 发出后仍然打到此 Pod
135
+
136
+ # ─── Readiness Probe ────────────────────────────────
137
+ # Pod 通过此探针后才会被加入 Service Endpoints 接收流量
138
+ readinessProbe:
139
+ httpGet:
140
+ path: /<DEPLOY_NAME>/api/health/ready
141
+ port: 8000
142
+ initialDelaySeconds: 5
143
+ periodSeconds: 5
144
+ failureThreshold: 3
145
+ successThreshold: 1
146
+
147
+ # ─── Liveness Probe ─────────────────────────────────
148
+ # 探针失败后 K8s 会重启容器(检测进程死锁/僵死)
149
+ livenessProbe:
150
+ httpGet:
151
+ path: /<DEPLOY_NAME>/api/health/live
152
+ port: 8000
153
+ initialDelaySeconds: 15
154
+ periodSeconds: 10
155
+ failureThreshold: 3
156
+ ```
157
+
158
+ > **注意:** `<DEPLOY_NAME>` 替换为实际部署名称(如 `gadmin-test`),对应 `process.env.DEPLOY_NAME`。
159
+
160
+ ---
161
+
162
+ ## 5. 配置说明
163
+
164
+ ### 5.1 为什么 terminationGracePeriodSeconds = 300?
165
+
166
+ | 因素 | 说明 |
167
+ |------|------|
168
+ | preStop sleep | 5s |
169
+ | In-flight HTTP 请求处理 | 通常 < 10s |
170
+ | Agenda 定时任务完成 | 可能长达数分钟 |
171
+ | OpenTelemetry flush | < 5s |
172
+ | **总计余量** | 300s 覆盖大多数场景 |
173
+
174
+ `agenda.stop()` 会等待当前正在执行的 job 完成。如果 job 执行时间超过 295s(300 - 5s preStop),将被 SIGKILL 强杀。建议:
175
+ - 单个 job 执行时间控制在 4 分钟以内
176
+ - 超长任务使用 `isStopping` 检查点机制提前退出
177
+
178
+ ### 5.2 为什么 preStop sleep 5 秒?
179
+
180
+ K8s 删除 Pod 时,Endpoints 摘除和 preStop/SIGTERM 是**并行**触发的:
181
+ - Endpoints 控制器通知各节点 kube-proxy 更新 iptables 规则需要时间(通常 1-3 秒)
182
+ - 如果不 sleep,SIGTERM 可能在 iptables 更新完成前就让进程开始关闭
183
+ - 5 秒是业界推荐值,足以覆盖绝大部分集群的传播延迟
184
+
185
+ ### 5.3 maxUnavailable: 0 的意义
186
+
187
+ 确保滚动升级过程中**始终有足够 Pod 处理流量**:
188
+ 1. 先启动新 Pod(maxSurge: 1)
189
+ 2. 新 Pod readinessProbe 通过后加入 Service
190
+ 3. 旧 Pod 开始 graceful shutdown
191
+ 4. 旧 Pod 完全退出后再终止下一个
192
+
193
+ ---
194
+
195
+ ## 6. 验证方法
196
+
197
+ ### 6.1 本地验证 Shutdown Hooks
198
+
199
+ ```bash
200
+ cd server && yarn start:prod &
201
+ SERVER_PID=$!
202
+
203
+ # 等服务启动
204
+ sleep 3
205
+
206
+ # 发送 SIGTERM
207
+ kill -TERM $SERVER_PID
208
+
209
+ # 观察日志应输出:
210
+ # "Agenda stopped"
211
+ # 进程正常退出(exit code 0)
212
+ ```
213
+
214
+ ### 6.2 验证 Health 端点
215
+
216
+ ```bash
217
+ # 启动后请求
218
+ curl http://localhost:8000/<DEPLOY_NAME>/api/health/live
219
+ # 期望:{"status":"ok"}
220
+
221
+ curl http://localhost:8000/<DEPLOY_NAME>/api/health/ready
222
+ # 期望:{"status":"ok"}
223
+ ```
224
+
225
+ ### 6.3 K8s 环境验证
226
+
227
+ ```bash
228
+ # 观察滚动升级过程
229
+ kubectl rollout status deployment/gadmin-server -w
230
+
231
+ # 查看 Pod 事件(确认 preStop 执行)
232
+ kubectl describe pod <pod-name> | grep -A5 "Events"
233
+
234
+ # 升级期间持续请求验证零中断
235
+ while true; do curl -s -o /dev/null -w "%{http_code}\n" http://<ingress>/api/health/ready; sleep 0.5; done
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 7. 信号传递链路
241
+
242
+ ```
243
+ K8s kubelet
244
+
245
+ └─► Docker/containerd: 发送 STOPSIGNAL (SIGTERM)
246
+
247
+ └─► start-prod.sh(使用 exec,PID 1 是 node 进程)
248
+
249
+ └─► Node.js process
250
+
251
+ ├─► NestJS enableShutdownHooks() 捕获 SIGTERM
252
+ │ └─► 调用 app.close()
253
+ │ └─► 触发所有生命周期钩子
254
+
255
+ └─► OpenTelemetry process.on('SIGTERM') handler
256
+ └─► flush traces
257
+ ```
258
+
259
+ `start-prod.sh` 中的 `exec node dist/src/main` 确保 Node.js 进程是容器的 PID 1,信号不会被 shell 拦截。
260
+
261
+ ---
262
+
263
+ ## 8. 故障场景与应对
264
+
265
+ | 场景 | 现象 | 应对 |
266
+ |------|------|------|
267
+ | Job 超时被 SIGKILL | 任务中断,锁超时后释放 | 其他 Pod 自动重试(Agenda 分布式锁) |
268
+ | readinessProbe 失败 | Pod 从 Service 摘除 | 不接收新流量,等 liveness 判定是否重启 |
269
+ | preStop 未配置 | 升级时短暂 502 | 添加 preStop sleep 5 |
270
+ | enableShutdownHooks 未启用 | 资源泄漏(连接未关闭) | 已通过本次改造修复 |
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@agendajs/postgres-backend": "^3.0.5",
38
38
  "@azure/identity": "^4.13.0",
39
- "@gadmin2n/nest-common": "^0.0.48",
39
+ "@gadmin2n/nest-common": "^0.0.50",
40
40
  "@nestjs/cache-manager": "^3.0.1",
41
41
  "@nestjs/common": "^10.4.15",
42
42
  "@nestjs/config": "^3.2.0",
@@ -88,8 +88,8 @@
88
88
  },
89
89
  "devDependencies": {
90
90
  "@faker-js/faker": "^10.4.0",
91
- "@gadmin2n/prisma-nest-generator": "^0.0.41",
92
- "@gadmin2n/prisma-react-generator": "^0.0.57",
91
+ "@gadmin2n/prisma-nest-generator": "^0.0.43",
92
+ "@gadmin2n/prisma-react-generator": "^0.0.59",
93
93
  "@nestjs/testing": "^10.4.15",
94
94
  "@types/cookie-parser": "^1.4.3",
95
95
  "@types/express": "^4.17.21",
@@ -19,6 +19,7 @@ import { ServeStaticModule } from '@nestjs/serve-static';
19
19
  import { join } from 'path';
20
20
  import { LogFormat } from './lib/logger';
21
21
  import { AgendaModule } from './modules/agenda/agenda.module';
22
+ import { HealthModule } from './modules/health/health.module';
22
23
  import { RoleModule } from './modules/role/role.module';
23
24
  import { RoleService } from './modules/role/role.service';
24
25
  import { RolesRefresherService } from './modules/role/roles-refresher.service';
@@ -85,6 +86,7 @@ import { RolesRefresherService } from './modules/role/roles-refresher.service';
85
86
 
86
87
  ...modules,
87
88
  AgendaModule,
89
+ HealthModule,
88
90
  ],
89
91
 
90
92
  controllers: [AppController],
@@ -122,6 +122,12 @@ async function bootstrap() {
122
122
  });
123
123
  }
124
124
 
125
+ // 启用 NestJS 生命周期钩子,确保 SIGTERM 时触发 onApplicationShutdown / onModuleDestroy
126
+ app.enableShutdownHooks();
127
+
128
+ // 启用 NestJS 生命周期钩子,确保 SIGTERM 时触发 onApplicationShutdown / onModuleDestroy
129
+ app.enableShutdownHooks();
130
+
125
131
  await app.listen(configService.get('nest').port);
126
132
 
127
133
  console.log(
@@ -0,0 +1,17 @@
1
+ import { Controller, Get } from '@nestjs/common';
2
+ import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
3
+
4
+ @Controller('health')
5
+ export class HealthController {
6
+ @Get('live')
7
+ @AllowUnauthorizedRequest()
8
+ liveness() {
9
+ return { status: 'ok' };
10
+ }
11
+
12
+ @Get('ready')
13
+ @AllowUnauthorizedRequest()
14
+ readiness() {
15
+ return { status: 'ok' };
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { HealthController } from './health.controller';
3
+
4
+ @Module({
5
+ controllers: [HealthController],
6
+ })
7
+ export class HealthModule {}
@@ -422,7 +422,7 @@ export class WorkflowService {
422
422
  const instance = await this.prisma.workflowInstance.findUnique({
423
423
  where: { id: instanceId },
424
424
  include: {
425
- workflow: { select: { name: true } },
425
+ workflow: { select: { name: true, dsl: true } },
426
426
  nodeExecutions: {
427
427
  orderBy: { createdAt: 'asc' },
428
428
  },
@@ -430,7 +430,27 @@ export class WorkflowService {
430
430
  });
431
431
 
432
432
  if (!instance) throw new NotFoundException('Instance not found');
433
- return instance;
433
+
434
+ // Fetch the workflow DSL from the version
435
+ let dsl: any = null;
436
+ if (instance.versionId) {
437
+ const version = await this.prisma.workflowVersion.findUnique({
438
+ where: { id: instance.versionId },
439
+ select: { dsl: true },
440
+ });
441
+ dsl = version?.dsl || null;
442
+ }
443
+
444
+ // Fallback: use DSL from the workflow itself
445
+ if (!dsl && instance.workflow.dsl) {
446
+ dsl = instance.workflow.dsl;
447
+ }
448
+
449
+ return {
450
+ ...instance,
451
+ workflow: { name: instance.workflow.name },
452
+ dsl,
453
+ };
434
454
  }
435
455
 
436
456
  async cancelInstance(instanceId: bigint, temporalService: any) {
@@ -11,7 +11,7 @@
11
11
  "@dnd-kit/sortable": "^7.0.2",
12
12
  "@dnd-kit/utilities": "^3.2.2",
13
13
  "@gadmin2n/charts": "^0.0.7",
14
- "@gadmin2n/react-common": "^0.0.67",
14
+ "@gadmin2n/react-common": "^0.0.69",
15
15
  "@monaco-editor/react": "^4.7.0",
16
16
  "@refinedev/antd": "^5.47.0",
17
17
  "@refinedev/cli": "^2.16.51",