@vincent119/go-copilot-rules 1.0.0

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,708 @@
1
+ ---
2
+ name: go-graceful-shutdown
3
+ description: |
4
+ Go 優雅關機(Graceful Shutdown)模式:Signal 處理、HTTP Server shutdown、gRPC GracefulStop、
5
+ Worker/Consumer 停止、Kubernetes 整合、Context 取消機制、資源清理流程。
6
+
7
+ **適用場景**:實作 HTTP Server 優雅關機、gRPC Server 停止、Background Worker 終止、
8
+ Kubernetes 部署配置、處理 SIGTERM/SIGINT、實作 preStop hook、避免請求中斷。
9
+
10
+ **關鍵字**:graceful shutdown, signal handling, SIGTERM, SIGINT, http shutdown,
11
+ grpc graceful stop, kubernetes, prestop hook, context cancellation, drain, worker shutdown
12
+ ---
13
+
14
+ # Go 優雅關機(Graceful Shutdown)規範
15
+
16
+ > **相關 Skills**:本規範建議搭配 `go-grpc`(gRPC Server)與 `go-http-advanced`(HTTP Server)
17
+
18
+ ---
19
+
20
+ ## 推薦套件
21
+
22
+ **優先選擇**:[vincent119/commons/graceful](https://github.com/vincent119/commons/tree/main/graceful)
23
+
24
+ **核心特性**:
25
+ - 統一的生命週期管理介面(訊號監聽、Context 管理、資源清理)
26
+ - 內建 HTTP Server 支援(`HTTPTask`)
27
+ - 靈活的清理機制(LIFO 順序、timeout 控制)
28
+ - 支援 `log/slog` 結構化日誌
29
+ - 自動錯誤聚合(`errors.Join`)
30
+
31
+ **安裝**:
32
+ ```bash
33
+ go get github.com/vincent119/commons/graceful
34
+ ```
35
+
36
+ ### 基本使用(HTTP Server)
37
+
38
+ ```go
39
+ package main
40
+
41
+ import (
42
+ "context"
43
+ "log/slog"
44
+ "net/http"
45
+ "os"
46
+ "time"
47
+
48
+ "github.com/vincent119/commons/graceful"
49
+ )
50
+
51
+ func main() {
52
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
53
+ srv := &http.Server{Addr: ":8080"}
54
+
55
+ // 初始化資源
56
+ // db, _ := sql.Open(...)
57
+
58
+ err := graceful.Run(
59
+ // 1. 主要任務:HTTPTask 封裝 srv.ListenAndServe
60
+ graceful.HTTPTask(srv),
61
+
62
+ // 2. 設定 Logger
63
+ graceful.WithLogger(logger),
64
+
65
+ // 3. 設定 Shutdown Timeout(預設 30s)
66
+ graceful.WithTimeout(10*time.Second),
67
+
68
+ // 4. 註冊清理函式(LIFO 順序執行)
69
+ graceful.WithCleanup(func(ctx context.Context) error {
70
+ logger.Info("shutting down server...")
71
+ return srv.Shutdown(ctx)
72
+ }),
73
+
74
+ // 5. 註冊 io.Closer 資源(自動呼叫 Close())
75
+ // graceful.WithCloser(db),
76
+ // graceful.WithClosers(redis, cache), // 批量註冊
77
+ )
78
+
79
+ if err != nil {
80
+ logger.Error("application exited with error", "error", err)
81
+ os.Exit(1)
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### 通用 Worker 用法
87
+
88
+ **任何符合 `func(ctx context.Context) error` 的任務都可使用**:
89
+
90
+ ```go
91
+ func MyWorker(ctx context.Context) error {
92
+ ticker := time.NewTicker(10 * time.Second)
93
+ defer ticker.Stop()
94
+
95
+ for {
96
+ select {
97
+ case <-ctx.Done():
98
+ // 收到訊號,優雅退出
99
+ return nil
100
+ case <-ticker.C:
101
+ // 執行工作
102
+ if err := doWork(); err != nil {
103
+ return err
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ func main() {
110
+ graceful.Run(
111
+ MyWorker,
112
+ graceful.WithTimeout(5*time.Second),
113
+ )
114
+ }
115
+ ```
116
+
117
+ ### 進階選項
118
+
119
+ ```go
120
+ err := graceful.Run(
121
+ task,
122
+ // 設定 shutdown timeout(預設 30s)
123
+ graceful.WithTimeout(15*time.Second),
124
+
125
+ // 設定 logger(預設 slog.Default())
126
+ graceful.WithLogger(logger),
127
+
128
+ // 註冊清理函式(LIFO 順序)
129
+ graceful.WithCleanup(func(ctx context.Context) error {
130
+ // 先註冊的後執行
131
+ return db.Close()
132
+ }),
133
+ graceful.WithCleanup(func(ctx context.Context) error {
134
+ // 後註冊的先執行
135
+ return srv.Shutdown(ctx)
136
+ }),
137
+
138
+ // 註冊單個 io.Closer
139
+ graceful.WithCloser(db),
140
+
141
+ // 批量註冊多個 io.Closer(按順序關閉)
142
+ graceful.WithClosers(redis, cache, queue),
143
+ )
144
+ ```
145
+
146
+ **重要注意事項**:
147
+ 1. **清理順序**:`WithCleanup` 採用 LIFO (後進先出)。建議先註冊底層資源(DB),再註冊上層服務(HTTP Server),確保關機時先停止服務再關閉資料庫
148
+ 2. **錯誤合併**:若主任務與清理工作皆發生錯誤,`graceful.Run` 使用 `errors.Join` 返回所有錯誤
149
+ 3. **超時控制**:每個 Cleanup 函式必須尊重 `ctx.Done()`,避免阻塞整體關機流程
150
+
151
+ ---
152
+
153
+ ## 進階實作(手動模式)
154
+
155
+ > **注意**:以下為不使用 `commons/graceful` 的手動實作方式,僅供理解原理或特殊場景使用
156
+
157
+ ### 核心原則
158
+
159
+ **必須實作優雅關機的元件**:
160
+ - HTTP Server
161
+ - gRPC Server
162
+ - Background Worker / Scheduler
163
+ - Message Queue Consumer
164
+ - 所有外部資源連線(DB、Cache、Message Queue)
165
+
166
+ **關機流程順序**:
167
+ 1. 接收系統訊號(`SIGINT`, `SIGTERM`)
168
+ 2. 停止接受新請求(HTTP Server `Shutdown` / gRPC `GracefulStop`)
169
+ 3. 等待進行中的請求或任務完成
170
+ 4. 在 timeout 到期後強制結束
171
+ 5. 關閉所有外部資源(DB、Cache、Queue、Tracer)
172
+
173
+ **禁止行為**:
174
+ - 禁止在正常關機流程中使用 `os.Exit()`
175
+ - 禁止在 server goroutine 使用 `log.Fatal` / `logger.Fatal`(會跳過 defer 與資源收尾)
176
+ - 禁止忽略 `ctx.Done()` 造成關機卡死
177
+
178
+ ---
179
+
180
+ ## Signal 處理(Go 1.16+)
181
+
182
+ ### 使用 signal.NotifyContext
183
+
184
+ ```go
185
+ import (
186
+ "context"
187
+ "os"
188
+ "os/signal"
189
+ "syscall"
190
+ )
191
+
192
+ func main() {
193
+ // 將 OS 訊號轉為可取消的 context
194
+ ctx, stop := signal.NotifyContext(
195
+ context.Background(),
196
+ os.Interrupt, // SIGINT (Ctrl+C)
197
+ syscall.SIGTERM, // SIGTERM (Kubernetes 預設)
198
+ )
199
+ defer stop() // 釋放資源
200
+
201
+ // 啟動 Server(會阻塞)
202
+ if err := runServer(ctx); err != nil {
203
+ log.Fatalf("server error: %v", err)
204
+ }
205
+ }
206
+ ```
207
+
208
+ ---
209
+
210
+ ## HTTP Server 優雅關機
211
+
212
+ ### 標準模式
213
+
214
+ ```go
215
+ func runHTTPServer(
216
+ srv *http.Server,
217
+ shutdownTimeout time.Duration,
218
+ closeResources func(ctx context.Context) error, // 關閉 DB/Redis/Scheduler
219
+ logger *zap.Logger,
220
+ ) error {
221
+ // 1) 訊號轉 ctx
222
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
223
+ defer stop()
224
+
225
+ // 2) 監控 server 是否異常退出(避免只等訊號,卻漏掉 server 先掛)
226
+ srvErr := make(chan error, 1)
227
+
228
+ go func() {
229
+ // ListenAndServe 正常因 Shutdown/Close 退出會回傳 http.ErrServerClosed
230
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
231
+ srvErr <- err
232
+ }
233
+ close(srvErr) // 關閉 channel 表示 server goroutine 已結束
234
+ }()
235
+
236
+ // 3) 等待:訊號 or server 異常
237
+ select {
238
+ case <-ctx.Done(): // 收到關機訊號
239
+ logger.Info("received shutdown signal")
240
+ case err := <-srvErr: // 服務異常退出
241
+ if err != nil {
242
+ logger.Error("http server stopped unexpectedly", zap.Error(err))
243
+ return err
244
+ }
245
+ }
246
+
247
+ // 4) 統一走 graceful shutdown:先停止接新請求,再收尾資源
248
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
249
+ defer cancel()
250
+
251
+ if err := srv.Shutdown(shutdownCtx); err != nil {
252
+ // Shutdown 會等待 in-flight request,若卡住要有最後手段
253
+ logger.Error("http server shutdown failed", zap.Error(err))
254
+ _ = srv.Close() // 最後手段:避免卡住(可能中斷連線)
255
+ }
256
+
257
+ // 5) 關閉外部資源(scheduler/worker/redis/db...)
258
+ var resErr error
259
+ if closeResources != nil {
260
+ resErr = closeResources(shutdownCtx)
261
+ if resErr != nil {
262
+ logger.Error("close resources failed", zap.Error(resErr))
263
+ }
264
+ }
265
+
266
+ logger.Info("server exited")
267
+ return resErr
268
+ }
269
+ ```
270
+
271
+ ### Gin Framework 範例
272
+
273
+ ```go
274
+ import "github.com/gin-gonic/gin"
275
+
276
+ func main() {
277
+ r := gin.Default()
278
+ r.GET("/ping", func(c *gin.Context) {
279
+ c.JSON(200, gin.H{"message": "pong"})
280
+ })
281
+
282
+ srv := &http.Server{
283
+ Addr: ":8080",
284
+ Handler: r,
285
+ }
286
+
287
+ // 使用通用 runHTTPServer 函式
288
+ if err := runHTTPServer(srv, 30*time.Second, nil, logger); err != nil {
289
+ log.Fatal(err)
290
+ }
291
+ }
292
+ ```
293
+
294
+ ---
295
+
296
+ ## gRPC Server 優雅關機
297
+
298
+ ### GracefulStop 範例
299
+
300
+ ```go
301
+ func runGRPCServer(
302
+ grpcServer *grpc.Server,
303
+ lis net.Listener,
304
+ shutdownTimeout time.Duration,
305
+ logger *zap.Logger,
306
+ ) error {
307
+ // 1) 訊號監聽
308
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
309
+ defer stop()
310
+
311
+ // 2) Server goroutine
312
+ srvErr := make(chan error, 1)
313
+ go func() {
314
+ if err := grpcServer.Serve(lis); err != nil {
315
+ srvErr <- err
316
+ }
317
+ close(srvErr)
318
+ }()
319
+
320
+ // 3) 等待訊號或錯誤
321
+ select {
322
+ case <-ctx.Done():
323
+ logger.Info("received shutdown signal")
324
+ case err := <-srvErr:
325
+ if err != nil {
326
+ logger.Error("grpc server error", zap.Error(err))
327
+ return err
328
+ }
329
+ }
330
+
331
+ // 4) Graceful Stop(等待進行中的請求完成)
332
+ stopped := make(chan struct{})
333
+ go func() {
334
+ grpcServer.GracefulStop() // 阻塞直到所有請求完成
335
+ close(stopped)
336
+ }()
337
+
338
+ // 5) 等待 GracefulStop 或 timeout
339
+ select {
340
+ case <-stopped:
341
+ logger.Info("grpc server stopped gracefully")
342
+ case <-time.After(shutdownTimeout):
343
+ logger.Warn("graceful stop timeout, forcing shutdown")
344
+ grpcServer.Stop() // 強制停止
345
+ }
346
+
347
+ return nil
348
+ }
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Background Worker 優雅關機
354
+
355
+ ### Worker 模式
356
+
357
+ ```go
358
+ type Worker struct {
359
+ logger *zap.Logger
360
+ }
361
+
362
+ func (w *Worker) Run(ctx context.Context) error {
363
+ ticker := time.NewTicker(10 * time.Second)
364
+ defer ticker.Stop()
365
+
366
+ for {
367
+ select {
368
+ case <-ctx.Done():
369
+ // 收到取消訊號,停止拉取新任務
370
+ w.logger.Info("worker shutting down")
371
+ return ctx.Err()
372
+ case <-ticker.C:
373
+ // 執行週期性任務
374
+ if err := w.processTask(ctx); err != nil {
375
+ w.logger.Error("task failed", zap.Error(err))
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ func (w *Worker) processTask(ctx context.Context) error {
382
+ // 長時間任務需定期檢查 ctx.Done()
383
+ for i := 0; i < 100; i++ {
384
+ select {
385
+ case <-ctx.Done():
386
+ w.logger.Warn("task interrupted")
387
+ return ctx.Err()
388
+ default:
389
+ // 執行任務片段
390
+ time.Sleep(100 * time.Millisecond)
391
+ }
392
+ }
393
+ return nil
394
+ }
395
+ ```
396
+
397
+ ### Message Queue Consumer 範例
398
+
399
+ ```go
400
+ type Consumer struct {
401
+ queue *Queue
402
+ logger *zap.Logger
403
+ }
404
+
405
+ func (c *Consumer) Start(ctx context.Context) error {
406
+ for {
407
+ select {
408
+ case <-ctx.Done():
409
+ c.logger.Info("consumer stopping, draining queue...")
410
+
411
+ // 處理剩餘訊息(或設定 timeout)
412
+ drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
413
+ defer cancel()
414
+
415
+ for c.queue.HasMessages() {
416
+ select {
417
+ case <-drainCtx.Done():
418
+ c.logger.Warn("drain timeout, some messages may be lost")
419
+ return drainCtx.Err()
420
+ default:
421
+ msg, err := c.queue.Receive(drainCtx)
422
+ if err != nil {
423
+ return err
424
+ }
425
+ _ = c.processMessage(drainCtx, msg)
426
+ }
427
+ }
428
+
429
+ return nil
430
+ default:
431
+ msg, err := c.queue.Receive(ctx)
432
+ if err != nil {
433
+ if errors.Is(err, context.Canceled) {
434
+ return err
435
+ }
436
+ c.logger.Error("receive failed", zap.Error(err))
437
+ continue
438
+ }
439
+
440
+ if err := c.processMessage(ctx, msg); err != nil {
441
+ c.logger.Error("process failed", zap.Error(err))
442
+ }
443
+ }
444
+ }
445
+ }
446
+ ```
447
+
448
+ ---
449
+
450
+ ## 資源清理函式
451
+
452
+ ### 統一清理介面
453
+
454
+ ```go
455
+ type Resources struct {
456
+ db *gorm.DB
457
+ redis *redis.Client
458
+ scheduler *Scheduler
459
+ tracer func() // OTel Shutdown
460
+ }
461
+
462
+ func (r *Resources) Close(ctx context.Context) error {
463
+ var errs []error
464
+
465
+ // 1. 停止 Scheduler
466
+ if r.scheduler != nil {
467
+ if err := r.scheduler.Stop(ctx); err != nil {
468
+ errs = append(errs, fmt.Errorf("stop scheduler: %w", err))
469
+ }
470
+ }
471
+
472
+ // 2. 關閉 Tracer(OTel)
473
+ if r.tracer != nil {
474
+ r.tracer()
475
+ }
476
+
477
+ // 3. 關閉 Redis
478
+ if r.redis != nil {
479
+ if err := r.redis.Close(); err != nil {
480
+ errs = append(errs, fmt.Errorf("close redis: %w", err))
481
+ }
482
+ }
483
+
484
+ // 4. 關閉 DB(最後才關閉)
485
+ if r.db != nil {
486
+ sqlDB, err := r.db.DB()
487
+ if err == nil {
488
+ if err := sqlDB.Close(); err != nil {
489
+ errs = append(errs, fmt.Errorf("close db: %w", err))
490
+ }
491
+ }
492
+ }
493
+
494
+ // 聚合錯誤
495
+ if len(errs) > 0 {
496
+ return errors.Join(errs...)
497
+ }
498
+
499
+ return nil
500
+ }
501
+ ```
502
+
503
+ ### 使用範例
504
+
505
+ ```go
506
+ func main() {
507
+ // 初始化資源
508
+ resources := &Resources{
509
+ db: initDB(),
510
+ redis: initRedis(),
511
+ scheduler: initScheduler(),
512
+ tracer: initTracer(),
513
+ }
514
+
515
+ // 建立 HTTP Server
516
+ srv := &http.Server{
517
+ Addr: ":8080",
518
+ Handler: buildHandler(),
519
+ }
520
+
521
+ // 啟動並等待關機
522
+ if err := runHTTPServer(srv, 30*time.Second, resources.Close, logger); err != nil {
523
+ log.Fatal(err)
524
+ }
525
+ }
526
+ ```
527
+
528
+ ---
529
+
530
+ ## Kubernetes 整合
531
+
532
+ ### terminationGracePeriodSeconds 配置
533
+
534
+ **原則**:
535
+ - `terminationGracePeriodSeconds` ≥ 應用層 Shutdown timeout + buffer(建議 5-10 秒)
536
+
537
+ **範例**:
538
+ ```yaml
539
+ # deployment.yaml
540
+ apiVersion: apps/v1
541
+ kind: Deployment
542
+ metadata:
543
+ name: myapp
544
+ spec:
545
+ template:
546
+ spec:
547
+ terminationGracePeriodSeconds: 45 # 應用層 30s + buffer 15s
548
+ containers:
549
+ - name: app
550
+ image: myapp:latest
551
+ env:
552
+ - name: SHUTDOWN_TIMEOUT
553
+ value: "30"
554
+ ```
555
+
556
+ ### preStop Hook
557
+
558
+ **目的**:確保 Pod 從 Service Endpoints 移除後再開始 shutdown(避免新請求進來)
559
+
560
+ ```yaml
561
+ lifecycle:
562
+ preStop:
563
+ exec:
564
+ command: ["sleep", "5"] # 等待 Endpoints 更新
565
+ ```
566
+
567
+ ### 完整範例
568
+
569
+ ```yaml
570
+ apiVersion: apps/v1
571
+ kind: Deployment
572
+ metadata:
573
+ name: myapp
574
+ spec:
575
+ replicas: 3
576
+ template:
577
+ spec:
578
+ terminationGracePeriodSeconds: 45
579
+ containers:
580
+ - name: app
581
+ image: myapp:latest
582
+ ports:
583
+ - containerPort: 8080
584
+ name: http
585
+ env:
586
+ - name: SHUTDOWN_TIMEOUT
587
+ value: "30"
588
+ lifecycle:
589
+ preStop:
590
+ exec:
591
+ command: ["sleep", "5"]
592
+ livenessProbe:
593
+ httpGet:
594
+ path: /healthz
595
+ port: 8080
596
+ initialDelaySeconds: 10
597
+ periodSeconds: 10
598
+ readinessProbe:
599
+ httpGet:
600
+ path: /readyz
601
+ port: 8080
602
+ initialDelaySeconds: 5
603
+ periodSeconds: 5
604
+ ```
605
+
606
+ ---
607
+
608
+ ## 測試優雅關機
609
+
610
+ ### 模擬關機測試
611
+
612
+ ```go
613
+ func TestGracefulShutdown(t *testing.T) {
614
+ srv := &http.Server{
615
+ Addr: ":0", // 隨機 Port
616
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
617
+ time.Sleep(2 * time.Second) // 模擬慢請求
618
+ w.WriteHeader(200)
619
+ }),
620
+ }
621
+
622
+ // 啟動 Server
623
+ lis, err := net.Listen("tcp", srv.Addr)
624
+ if err != nil {
625
+ t.Fatal(err)
626
+ }
627
+
628
+ go func() {
629
+ _ = srv.Serve(lis)
630
+ }()
631
+
632
+ // 發送請求
633
+ reqDone := make(chan bool)
634
+ go func() {
635
+ resp, err := http.Get("http://" + lis.Addr().String())
636
+ if err != nil {
637
+ t.Errorf("request failed: %v", err)
638
+ }
639
+ resp.Body.Close()
640
+ reqDone <- true
641
+ }()
642
+
643
+ // 等待請求開始
644
+ time.Sleep(100 * time.Millisecond)
645
+
646
+ // 觸發關機
647
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
648
+ defer cancel()
649
+
650
+ if err := srv.Shutdown(ctx); err != nil {
651
+ t.Errorf("shutdown failed: %v", err)
652
+ }
653
+
654
+ // 驗證請求完成
655
+ <-reqDone
656
+ }
657
+ ```
658
+
659
+ ---
660
+
661
+ ## 檢查清單
662
+
663
+ **優先推薦**
664
+ - [ ] 使用 `github.com/vincent119/commons/graceful` 套件(統一生命週期管理)
665
+ - [ ] `graceful.Run()` 作為主程式入口
666
+ - [ ] 使用 `graceful.HTTPTask()` 包裝 HTTP Server
667
+ - [ ] 使用 `graceful.WithCleanup()` 註冊清理函式(LIFO 順序)
668
+ - [ ] 使用 `graceful.WithCloser()` / `WithClosers()` 註冊資源(自動 Close)
669
+ - [ ] 設定適當的 `WithTimeout()`(建議 10-30 秒)
670
+ - [ ] 整合 `log/slog` 結構化日誌
671
+
672
+ **Signal 處理(手動實作)**
673
+ - [ ] 使用 `signal.NotifyContext` 監聽 `SIGINT` 與 `SIGTERM`
674
+ - [ ] 不在正常流程使用 `os.Exit()`
675
+ - [ ] 不在 server goroutine 使用 `log.Fatal()`
676
+
677
+ **HTTP Server**
678
+ - [ ] 使用 `srv.Shutdown(ctx)` 而非 `srv.Close()`
679
+ - [ ] 設定 shutdown timeout(建議 30 秒)
680
+ - [ ] 處理 `http.ErrServerClosed`(正常退出)
681
+ - [ ] Shutdown 失敗時強制 `srv.Close()`
682
+
683
+ **gRPC Server**
684
+ - [ ] 使用 `grpcServer.GracefulStop()` 而非 `Stop()`
685
+ - [ ] GracefulStop 設定 timeout(避免無限等待)
686
+ - [ ] Timeout 後強制 `grpcServer.Stop()`
687
+
688
+ **Background Worker**
689
+ - [ ] 監聽 `ctx.Done()` 停止拉取新任務
690
+ - [ ] 長時間任務定期檢查 `ctx.Done()`
691
+ - [ ] Message Queue Consumer 實作 drain 邏輯
692
+
693
+ **資源清理**
694
+ - [ ] 按順序關閉:Scheduler → Tracer → Cache → DB
695
+ - [ ] 使用 `errors.Join` 聚合清理錯誤
696
+ - [ ] 清理函式接受 `context.Context`(支持 timeout)
697
+
698
+ **Kubernetes**
699
+ - [ ] `terminationGracePeriodSeconds` ≥ shutdown timeout + buffer
700
+ - [ ] 配置 `preStop` hook(sleep 5s)
701
+ - [ ] 實作 `/readyz` endpoint(關機時回傳 503)
702
+
703
+ **測試**
704
+ - [ ] 模擬 SIGTERM 測試關機流程
705
+ - [ ] 驗證進行中的請求能完成
706
+ - [ ] 驗證 timeout 後強制關閉
707
+ - [ ] 測試清理函式 LIFO 執行順序(使用 `graceful`)
708
+ - [ ] 測試錯誤聚合(多個清理函式同時失敗)