@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.
- package/.agent_agy/INSTALLATION.md +786 -0
- package/.agent_agy/README.md +243 -0
- package/.agent_agy/SKILLS_INDEX.md +516 -0
- package/.agent_agy/rules/go-core.copilot-instructions.md +251 -0
- package/.agent_agy/skills/go-api-design/SKILL.md +535 -0
- package/.agent_agy/skills/go-ci-tooling/SKILL.md +533 -0
- package/.agent_agy/skills/go-configuration/SKILL.md +609 -0
- package/.agent_agy/skills/go-database/SKILL.md +412 -0
- package/.agent_agy/skills/go-ddd/SKILL.md +374 -0
- package/.agent_agy/skills/go-dependency-injection/SKILL.md +546 -0
- package/.agent_agy/skills/go-domain-events/SKILL.md +525 -0
- package/.agent_agy/skills/go-examples/SKILL.md +690 -0
- package/.agent_agy/skills/go-graceful-shutdown/SKILL.md +708 -0
- package/.agent_agy/skills/go-grpc/SKILL.md +484 -0
- package/.agent_agy/skills/go-http-advanced/SKILL.md +494 -0
- package/.agent_agy/skills/go-observability/SKILL.md +684 -0
- package/.agent_agy/skills/go-testing-advanced/SKILL.md +573 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/cli/install.js +344 -0
- package/package.json +47 -0
|
@@ -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
|
+
- [ ] 測試錯誤聚合(多個清理函式同時失敗)
|