Go 服务优雅关闭
停止接收新工作后有界排空或取消在途,再释放依赖;区分 HTTP Shutdown、请求 Context 与 worker 的退出策略。
[!info] related notes
Go 服务优雅关闭
这个概念为什么会出现
进程总会停:发版、弹性缩容、节点抢占、人为重启。粗暴 SIGKILL 或直接 os.Exit 会导致:
- 半写响应、半提交业务
- 连接被负载均衡认为“活着”却已无服务
- 后台 goroutine 写到已关的 DB
- 消息未 ack 造成重复或丢失(视语义)
优雅关闭(graceful shutdown)要在有界时间内完成:停入口 → 按策略排空或取消 → 有界等待 → 释放依赖 → 退出。它不是“永不杀”,而是先礼后兵。
[!abstract] 一句话理解 用信号派生可取消的 root context,停止接收新请求/新任务,对 HTTP 调
Server.Shutdown,对 worker 先停止生产,再按排空或取消策略等待,全程带超时,超时后降级为强制退出。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:发版时让在途「慢任务」接口先收尾再退出
订单服务有一个 GET /work,内部要跑约 2s(模拟慢查询 / 外部 RPC)。
K8s 发版会发 SIGTERM:若进程立刻死掉,负载均衡上的在途请求会半截断开。
优雅关闭路径:
- 收到信号 → 停止接新连接
- 等在途 Handler 结束(有总预算,如 10s)
- 超时再
Close强制拆掉
Shutdown 不会自动取消在途请求的 Context,而是等待它们自然结束。Handler 应响应客户端或应用的取消,但是否在停机时主动取消它,需要由应用决定。
本例会 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 本身不会取消这个请求的 Context。
func handleSlowWork(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
// 正常完成路径。
_, _ = w.Write([]byte("done"))
case <-r.Context().Done():
// 客户端或应用取消:协作退出。
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():
stop() // 立即解除本次注册;无其他注册时,后续信号恢复默认行为
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
在途请求若超过 10s 尚未结束,本例调用 Close 关闭残余普通 HTTP 连接。Close 不会强杀任意 goroutine,也不等待已 hijack 的连接。
结合场景再看三个关注点
-
signal.NotifyContext= 进程级取消根
发版/缩容信号变成ctx.Done();本例没有把该 Context 用作请求 BaseContext,因此停机信号不会立刻取消/work。 -
Shutdown有界等待
停新连接 + 等在途;预算用WithTimeout包一层。 -
Handler 必须协作
/work响应客户端取消;停机时则允许预算内完成。取消和排空是不同策略。
核心概念与准确模型
关闭阶段
下面示意 HTTP 排空与 worker 停止协调;worker 的取消或排空策略需预先选定,不能认为 close channel 与 cancel 等价。
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. 选择策略 | 排空已接收工作,或主动取消请求/任务;不要混为一谈 |
| 4. 排空 | WaitGroup / errgroup / Shutdown 等待 |
| 5. 释放 | DB、缓存、文件、消息 client |
| 6. 退出 | 可观测日志与非 0 码策略 |
HTTP:Shutdown vs Close
| API | 行为 |
|---|---|
Shutdown(ctx) | 停 listen、等在途、ctx 超时则返回错误 |
Close() | 关闭普通活动/空闲连接;不等待 handler 或已 hijack 的连接 |
生产默认走 Shutdown;超时再 Close。见 Server.Shutdown。
与 context 的关系
- 请求级:
r.Context()在客户端连接关闭、HTTP/2 请求取消或 handler 返回时取消;Shutdown本身不触发它。 - 进程级:
signal.NotifyContext或手动WithCancel。 - 依赖级:DB/HTTP 客户端调用传同一派生 ctx。
若把信号 Context 直接用于 Server.BaseContext,信号会立即传给请求,可能放弃原本可排空的工作。需要先排空、超时后取消时,应分开管理信号与业务 Context。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()
上述 worker 同时监听取消与 jobs;若先取消 ctx,可能丢下缓冲区中的任务,因此不是保证排空的代码。要排空,应停止并等待全部生产者,再由唯一所有者关闭 jobs,让 worker 读到 channel 关闭;任务处理与等待也需要有界策略。WaitGroup.Wait() 自身没有超时。
原则:由协调全部发送方的唯一所有者关闭;关闭顺序与 pipeline 一致(pipeline)。
K8s / 容器场景
- Pod 开始终止后,宽限期开始计时;若配置了
preStop,先执行 hook,再发送 TERM。 - EndpointSlice 的终止/就绪状态更新与应用关闭并行发生;固定 sleep 不能证明所有代理都已摘流。
preStop、应用排空和清理共同消耗宽限期,应用 Shutdown 预算应小于剩余时间。- 宽限期耗尽仍未退出时,运行时会强制终止。
预算要端到端对齐:反代超时、LB、应用、编排宽限期。
设计动机
- 正确性:减少半截事务与重复副作用(仍需幂等兜底)。
- 体验:发版不对用户“整页失败风暴”。
- 资源:连接、文件描述符、锁有序释放。
- 可运维:日志能看出“信号 → 排空 → 退出”时间线。
边界情况与反直觉行为
1. 优雅 ≠ 无限等
必须有 deadline。卡死的下游会拖死发布。
2. Shutdown 不等待你在 Handler 里另起的失控 goroutine
只覆盖该请求的 ServeHTTP 返回;go process() 若未挂 ctx/WaitGroup,进程仍可能提前退出或泄漏。
3. 长连接 / SSE / WebSocket
SSE 通常仍是活动 HTTP 请求,会参与 Shutdown 等待;升级或 hijack 后的 WebSocket 不由 Shutdown/Close 自动等待关闭。应分别设计通知、关闭和完成等待,RegisterOnShutdown 回调本身也不会被 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()错误:已经发生客户端或应用取消,阻塞工作仍不退出。 正确:阻塞点全部selectDone 或用带 ctx 的 API。
[!warning] 常见误区:先关 DB 再排空请求 错误:在途请求全部报错。
正确:先停入口与接新任务,再等在途,最后关依赖。
[!warning] 常见误区:关闭时仍往已关 channel 发送 错误:panic。
正确:用 ctx 停生产者,单一关闭点,发送侧负责 close。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| context | 取消信号载体;本篇是进程生命周期编排 |
| net/http | 提供 Shutdown;要嵌入整体策略 |
| worker pool | 池的排空是关闭子问题 |
| 健康检查 fail | 摘流手段之一,不能替代进程内 Shutdown |
| 强制杀死 | 最后手段;破坏在途 |
工程实践
- 协调统一停机流程,但将“停止接新工作”和“取消已接收工作”的信号按策略分开。
- 超时分层:信号到强制退出总预算;内部再切分 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(func() error { return srv.Run(ctx) })
g.Go(func() error { return consumer.Run(ctx) })
// Run 必须主动处理 ctx;取消不会强制停止任意函数
err := g.Wait()
可验证实验
实验 1:在途请求
对 /work 发 curl,在 sleep 期间 Ctrl+C 服务,观察是否仍返回(预算内)。
实验 2:忽略 context
先验证 Shutdown 不取消请求 Context;再主动取消客户端请求,对比 Handler 是否及时退出。只删除 Done 分支,不应被误认为能改变 Shutdown 的取消机制。
实验 3:worker 排空
启动固定 worker,关闭时统计已处理与未处理任务数。
实验 4:超时强制
把 Shutdown 超时调到 100ms,Handler sleep 1s,确认走 Close 与错误日志。
本节总结
- 本质:有界、有序地停入口、取消、排空、释放。
- HTTP:Shutdown 等待自然完成;主动取消是独立策略。
- 后台:明确排空与取消的区别,协调生产者、关闭输入并有界等待。
- 下一步:把策略嵌进 项目结构 的
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 | 官方博客 | 阶段关闭与取消 |
| Kubernetes Pod termination | 官方文档 | preStop、TERM 与终止预算 |
笔记元信息
- 建议文件名:
go-graceful-shutdown.md - 所属阶段:服务工程化
- 学习顺序:net/http 与 context 之后
- 建议下一篇:worker pool 或 构建与部署
- 本篇状态:已深化(结构完整;含 HTTP 与 worker 统一生命周期)