Files
hotime/.cursor/plans/优雅停机方案_0b2a1432.plan.md
T
hoteas 6b689f3a1b chore(logging): 更新日志重定向与捕获功能
- 在 .gitignore 中添加调试日志文件的忽略规则,避免不必要的调试信息被提交
- 修改 application.go 中的 stdout 重定向逻辑,使用 log.CaptureStream 以支持更灵活的日志捕获
- 更新 README 文档,增加对日志重定向功能的说明
2026-07-13 07:45:51 +08:00

7.9 KiB
Raw Blame History

name, overview, todos, isProject
name overview todos isProject
优雅停机方案 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_app.cmd 检测正常退出不重启。
id content status
framework-core hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown() completed
id content status
framework-windows hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口) completed
id content status
framework-other hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现 completed
id content status
nginx-config Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2 completed
id content status
cmd-exit-check run_app.cmd:检测 EXIT_CODE == 0 时不重启直接退出 completed
id content status
docs 新建 hotimev1.5/docs/graceful-shutdown.md 文档 completed
false

优雅停机方案

整体流程

sequenceDiagram
    participant Op as 运维
    participant OS as 操作系统
    participant App as Go 进程
    participant Nginx as Nginx

    Op->>OS: Ctrl+C / kill / taskkill / 关窗口
    OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT
    App->>App: shuttingDown = truesync.Once 保证只执行一次)
    Note over App,Nginx: 新请求立即返回 503 + Connection:close
    Nginx->>App: 新请求
    App-->>Nginx: 503 Service Unavailable
    Nginx->>Nginx: proxy_next_upstream http_503 → 转另一台
    Note over App: 等 DrainTimeout(默认5s,让nginx完成切流)
    App->>App: server.Shutdown(ShutdownTimeout=30s)
    Note over App: 等所有在途请求跑完
    App->>OS: os.Exit(0)
    Note over Op: run_app.cmd 检测 exit 0 → 不重启

信号触发场景对照表

操作方式 平台 信号/事件 是否可拦截 备注
Ctrl+C Windows/Linux os.Interrupt 标准中断
kill <pid>kill -15 <pid> Linux syscall.SIGTERM 推荐生产关闭方式
taskkill /PID <pid>(无 /F Windows os.Interrupt 等价于 Ctrl+C
关闭控制台窗口 X 按钮 Windows CTRL_CLOSE_EVENT (≤5s 限制) Windows 给 ~5s 后强杀
taskkill /F /PID <pid> Windows 强制 SIGKILL 无法拦截,强杀
kill -9 <pid> Linux SIGKILL 无法拦截,强杀

关窗口说明Windows 在触发 CTRL_CLOSE_EVENT 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 taskkill /PID 代替关窗口做计划性停机。


需要改动/新建的文件

1. hotimev1.5/application.go

Application struct 新增字段:

type Application struct {
    // ...原有字段...
    shuttingDown    atomic.Bool
    shutdownOnce    sync.Once     // 保证多路触发只执行一次
    DrainTimeout    time.Duration // 等 nginx 切流,默认 5s
    ShutdownTimeout time.Duration // 等在途请求,默认 30s
}

ServeHTTP 新增停机检测:

func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    if that.shuttingDown.Load() {
        w.Header().Set("Connection", "close")
        http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
        return
    }
    that.handler(w, req)
}

提取 initiateGracefulShutdown(drain, shutdown time.Duration) 方法(被信号 goroutine 和 Windows 关窗 handler 共同调用):

func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
    that.shutdownOnce.Do(func() {
        that.shuttingDown.Store(true)
        that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain)
        time.Sleep(drain)
        ctx, cancel := context.WithTimeout(context.Background(), shutdown)
        defer cancel()
        that.Log.Infof("等进行中请求完成(最多 %v...", shutdown)
        if err := that.Server.Shutdown(ctx); err != nil {
            that.Log.Errorf("优雅停机超时: %v", err)
        }
        that.Log.Infof("服务已安全关闭")
        os.Exit(0)
    })
}

Run() 加信号监听 goroutine(在启动 ListenAndServe 之后):

// 注册 Windows 关窗口处理(Linux 上是空实现)
that.registerWindowsCloseHandler()

// 跨平台:Ctrl+C / SIGTERM / kill
go func() {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    <-sigCh
    drain := that.DrainTimeout
    if drain == 0 { drain = 5 * time.Second }
    shutdown := that.ShutdownTimeout
    if shutdown == 0 { shutdown = 30 * time.Second }
    that.initiateGracefulShutdown(drain, shutdown)
}()

defer recover 加停机保护:

defer func() {
    if err := recover(); err != nil {
        if that.shuttingDown.Load() {
            return // 停机中的 panic 不触发重启
        }
        that.Log.Warnf("%v", err)
        that.Run(router)
    }
}()

新增 import"context", "os/signal", "sync", "sync/atomic", "syscall"


2. hotimev1.5/signal_windows.go(新建)

//go:build windows

package hotime

import (
    "time"
    "golang.org/x/sys/windows"
)

func (that *Application) registerWindowsCloseHandler() {
    windows.SetConsoleCtrlHandler(func(ctrl uint32) bool {
        // CTRL_CLOSE_EVENT=2  CTRL_LOGOFF_EVENT=5  CTRL_SHUTDOWN_EVENT=6
        if ctrl == 2 || ctrl == 5 || ctrl == 6 {
            that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
            // 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行
            // Windows 约 5s 后强杀,这里最多等 6s 兜底
            time.Sleep(6 * time.Second)
            return true
        }
        return false
    }, true)
}

golang.org/x/sys 已在 xbc/go.mod 中作为间接依赖存在(v0.29.0),无需新增依赖。


3. hotimev1.5/signal_other.go(新建)

//go:build !windows

package hotime

func (that *Application) registerWindowsCloseHandler() {}

4. Nginx 配置(在已有 location 块中追加)

# 让后端返回 503 时自动切换到另一台(优雅停机核心)
proxy_next_upstream error timeout http_503;
proxy_next_upstream_tries 2;

已有的 max_fails=1 fail_timeout=3s 负责连接级失败(强杀后的端口拒绝),两者互补。


5. run_app.cmd

goto run_app 前插入 exit code 判断:

:: 优雅退出 (exit 0) 不重启
if %EXIT_CODE% EQU 0 (
    echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%"
    echo.
    echo [优雅停机] 服务已安全关闭,不再自动重启。
    pause
    exit /b 0
)

6. hotimev1.5/docs/graceful-shutdown.md(新建)

包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。


滚动重启操作步骤(Windows

# 1. 找到端口 9998 的进程 PID
netstat -ano | findstr 9998

# 2. 优雅关闭该实例(不加 /F
taskkill /PID <pid>

# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动
# 4. 再对 9999 执行同样操作

滚动重启操作步骤(Linux

# 找 PID
pgrep xbc   # 或 ps aux | grep xbc

# 优雅关闭
kill <pid>          # 发 SIGTERM,触发优雅停机
# 或
kill -2 <pid>       # 发 SIGINT,等同 Ctrl+C

# 等日志出现 "服务已安全关闭" 再部署新版