Go 服务优雅关闭
收到停止信号后停止接新工作、取消在途、有界等待并释放资源;HTTP Shutdown、context 与 worker 收尾必须同一条生命周期链。
[!info] 关联笔记
Go 服务优雅关闭
这个概念为什么会出现
进程总会停:发版、弹性缩容、节点抢占、人为重启。粗暴 SIGKILL 或直接 os.Exit 会导致:
- 半写响应、半提交业务
- 连接被负载均衡认为“活着”却已无服务
- 后台 goroutine 写到已关的 DB
- 消息未 ack 造成重复或丢失(视语义)
优雅关闭(graceful shutdown)要在有界时间内完成:停入口 → 传播取消 → 等在途 → 释放依赖 → 退出。它不是“永不杀”,而是先礼后兵。
[!abstract] 一句话理解 用信号派生可取消的 root context,停止接收新请求/新任务,对 HTTP 调
Server.Shutdown,对 worker 关输入并Wait,全程带超时,超时后降级为强制退出。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:发版时让在途「慢任务」接口先收尾再退出
订单服务有一个 GET /work,内部要跑约 2s(模拟慢查询 / 外部 RPC)。
K8s 发版会发 SIGTERM:若进程立刻死掉,负载均衡上的在途请求会半截断开。
优雅关闭路径:
- 收到信号 → 停止接新连接
- 等在途 Handler 结束(有总预算,如 10s)
- 超时再
Close强制拆掉
Handler 必须听 r.Context(),否则 Shutdown 只能干等到超时。
本例会 listen;另开终端
curl打/work的同时,在服务端终端Ctrl+C(或kill -TERM)观察日志。
Windows 下常用 Ctrl+C →os.Interrupt。
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// handleSlowWork 模拟“发版时可能还在跑的慢接口”。
//
// 业务意图:正常约 2s 返回 done;若客户端断开或 Server.Shutdown
// 取消请求 ctx,必须立刻停,不能继续占着连接/下游。
func handleSlowWork(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
// 正常完成路径。
_, _ = w.Write([]byte("done"))
case <-r.Context().Done():
// Shutdown / 客户端取消:协作退出,不再写半截成功体。
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /work", handleSlowWork)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// 根生命周期:把 SIGINT/SIGTERM 变成 ctx.Done()。
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // 解除信号注册,允许后续重复通知的默认行为
// Listen 放后台:main 要同时等“听失败”和“停机信号”。
errCh := make(chan error, 1)
go func() {
log.Println("listen", srv.Addr)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
// 端口占用等启动失败;Shutdown 触发的返回是 ErrServerClosed,不算事故。
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
case <-ctx.Done():
log.Println("shutdown signal")
// 给在途请求收尾预算(与 K8s terminationGracePeriod 对齐思考)。
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Println("shutdown:", err)
// 超时降级:强制关掉残余连接。
_ = srv.Close()
}
}
log.Println("bye")
}
建议运行:
go run .
# 终端 A 保持服务;终端 B:
curl -s localhost:8080/work
# 在 curl 等待的 2s 内,回终端 A 按 Ctrl+C,观察 shutdown 日志
期望(无中断、请求跑完):
- curl 输出
done - 之后 Ctrl+C:日志含
shutdown signal与bye
若在 Shutdown 窗口内仍有不听 ctx 的 handler,会拖到 10s 预算再 Close。
结合场景再看三个关注点
-
signal.NotifyContext= 进程级取消根
发版/缩容信号变成ctx.Done(),与业务取消模型一致。 -
Shutdown有界等待
停新连接 + 等在途;预算用WithTimeout包一层。 -
Handler 必须协作
/work听r.Context();否则优雅关闭退化成“干等再强杀”。
核心概念与准确模型
关闭阶段
sequenceDiagram
participant OS
participant Main
participant HTTP as http.Server
participant W as workers
participant Dep as DB/MQ
OS->>Main: SIGTERM
Main->>HTTP: Shutdown(ctx)
Main->>W: cancel / close jobs
HTTP-->>Main: in-flight done or timeout
W-->>Main: Wait done
Main->>Dep: Close
Main->>OS: exit 0
| 阶段 | 动作 |
|---|---|
| 1. 感知 | 信号、K8s preStop、父 context 取消 |
| 2. 停入口 | 摘流量、Shutdown、停消费者 fetch |
| 3. 传播取消 | root cancel → 子 ctx → 下游 RPC |
| 4. 排空 | WaitGroup / errgroup / Shutdown 等待 |
| 5. 释放 | DB、缓存、文件、消息 client |
| 6. 退出 | 可观测日志与非 0 码策略 |
HTTP:Shutdown vs Close
| API | 行为 |
|---|---|
Shutdown(ctx) | 停 listen、等在途、ctx 超时则返回错误 |
Close() | 立即关闭连接,在途中断 |
生产默认走 Shutdown;超时再 Close。见 Server.Shutdown。
与 context 的关系
- 请求级:
r.Context()在连接取消/Shutdown 时 Done。 - 进程级:
signal.NotifyContext或手动WithCancel。 - 依赖级:DB/HTTP 客户端调用传同一派生 ctx。
context 取消是协作式的:代码不检查就停不了。见 context。
后台 worker / 消费者
// 伪代码结构
jobs := make(chan Job)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
handle(ctx, job)
}
}
}()
}
// 关闭:先停生产,再关 channel,再 Wait
stopProduce()
close(jobs)
wg.Wait()
原则:谁发送谁关闭;关闭顺序与 pipeline 一致(pipeline)。
K8s / 容器场景
- 收到
SIGTERM preStop可先从 Service 摘流(sleep 1–5s 等 endpoint 更新)- 应用
Shutdown预算 <terminationGracePeriodSeconds - 超时后 Kubelet
SIGKILL
预算要端到端对齐:反代超时、LB、应用、编排宽限期。
设计动机
- 正确性:减少半截事务与重复副作用(仍需幂等兜底)。
- 体验:发版不对用户“整页失败风暴”。
- 资源:连接、文件描述符、锁有序释放。
- 可运维:日志能看出“信号 → 排空 → 退出”时间线。
边界情况与反直觉行为
1. 优雅 ≠ 无限等
必须有 deadline。卡死的下游会拖死发布。
2. Shutdown 不等待你在 Handler 里另起的失控 goroutine
只覆盖该请求的 ServeHTTP 返回;go process() 若未挂 ctx/WaitGroup,进程仍可能提前退出或泄漏。
3. 长连接 / SSE / WebSocket
与严格 WriteTimeout、Shutdown 交互复杂,需单独注册连接表并在关闭时主动踢。
4. 多 Server / 多入口
HTTP metrics、pprof、主 API 可能多个 Listen:统一纳入 errgroup 与同一 shutdown 序列。
5. Windows 信号差异
SIGINT/SIGTERM 可用性因平台而异;开发与容器路径都要测。
常见误区
[!warning] 常见误区:只监听信号但不 Shutdown 错误:
<-sig后os.Exit(0)。
正确:先Shutdown/Wait再退出。
[!warning] 常见误区:Handler 忽略
r.Context()错误:Shutdown 卡满超时。
正确:阻塞点全部selectDone 或用带 ctx 的 API。
[!warning] 常见误区:先关 DB 再排空请求 错误:在途请求全部报错。
正确:先停入口与接新任务,再等在途,最后关依赖。
[!warning] 常见误区:关闭时仍往已关 channel 发送 错误:panic。
正确:用 ctx 停生产者,单一关闭点,发送侧负责 close。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| context | 取消信号载体;本篇是进程生命周期编排 |
| net/http | 提供 Shutdown;要嵌入整体策略 |
| worker pool | 池的排空是关闭子问题 |
| 健康检查 fail | 摘流手段之一,不能替代进程内 Shutdown |
| 强制杀死 | 最后手段;破坏在途 |
工程实践
- 单一 root cancel,组件订阅同一生命周期。
- 超时分层:信号到强制退出总预算;内部再切分 Shutdown/worker/DB。
- 可观测:日志打印 shutdown 开始、各阶段耗时、超时强制。
- 幂等:仍假设会有重试与重复投递。
- 测试:写集成测模拟信号或调用
Shutdown,断言在途完成/超时行为。 - 与部署:对齐 K8s
terminationGracePeriodSeconds、反代 idle/timeout。 - errgroup:
golang.org/x/sync/errgroup可把多组件 Run/Wait 与 cancel 绑在一起。
// 结构示意:主 Run
g, ctx := errgroup.WithContext(parent)
g.Go(srv.Run)
g.Go(consumer.Run)
// parent 取消或其一失败 → 其他退出
err := g.Wait()
可验证实验
实验 1:在途请求
对 /work 发 curl,在 sleep 期间 Ctrl+C 服务,观察是否仍返回(预算内)。
实验 2:忽略 context
去掉 Handler 里对 r.Context() 的监听,对比 Shutdown 耗时。
实验 3:worker 排空
启动固定 worker,关闭时统计已处理与未处理任务数。
实验 4:超时强制
把 Shutdown 超时调到 100ms,Handler sleep 1s,确认走 Close 与错误日志。
本节总结
- 本质:有界、有序地停入口、取消、排空、释放。
- HTTP:
Shutdown+ Handler 协作取消。 - 后台:关输入、Wait、统一 ctx。
- 下一步:把策略嵌进 项目结构 的
cmd;并发侧复盘 取消关系。
自测题
概念题
Shutdown与Close的核心差别?- 为什么优雅关闭必须有超时?
- 谁应负责
close(jobs)?
代码推理题
Shutdown 返回后主函数立刻 db.Close(),但某 Handler 里 go writeDB() 无同步。可能发生什么?
工程思考题
K8s terminationGracePeriodSeconds=30,反代超时 60s,应用 Shutdown 60s。有何问题?如何改?
参考答案
展开
- Shutdown 停新连接并尝试等在途;Close 立即切断。
- 防止故障依赖拖死发布与编排强杀不可控。
- 发送方(所有权方),并先停生产。
代码题:进程退出或 DB 已关时后台写失败/竞态,数据丢失或 panic。
工程题:编排 30s 会先杀进程,应用 60s 预算无效;应使 应用预算 < 宽限期,并协调反代/LB 超时。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Server.Shutdown | 标准库 | HTTP 优雅停 |
| signal.NotifyContext | 标准库 | 信号 → context |
| Package context | 标准库 | 取消传播 |
| Go Concurrency Patterns: Context | 官方博客 | 请求生命周期 |
| Pipelines and cancellation | 官方博客 | 阶段关闭与取消 |
笔记元信息
- 建议文件名:
go-graceful-shutdown.md - 所属阶段:服务工程化
- 学习顺序:net/http 与 context 之后
- 建议下一篇:worker pool 或 构建与部署
- 本篇状态:已深化(结构完整;含 HTTP 与 worker 统一生命周期)