Go 服务优雅关闭

收到停止信号后停止接新工作、取消在途、有界等待并释放资源;HTTP Shutdown、context 与 worker 收尾必须同一条生命周期链。

#type / concept #status / growing #tech / dev #tech / dev / backend #resource / go

[!info] 关联笔记

Go 服务优雅关闭

这个概念为什么会出现

进程总会停:发版、弹性缩容、节点抢占、人为重启。粗暴 SIGKILL 或直接 os.Exit 会导致:

  • 半写响应、半提交业务
  • 连接被负载均衡认为“活着”却已无服务
  • 后台 goroutine 写到已关的 DB
  • 消息未 ack 造成重复或丢失(视语义)

优雅关闭(graceful shutdown)要在有界时间内完成:停入口 → 传播取消 → 等在途 → 释放依赖 → 退出。它不是“永不杀”,而是先礼后兵

[!abstract] 一句话理解 用信号派生可取消的 root context,停止接收新请求/新任务,对 HTTP 调 Server.Shutdown,对 worker 关输入并 Wait,全程带超时,超时后降级为强制退出。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:发版时让在途「慢任务」接口先收尾再退出

订单服务有一个 GET /work,内部要跑约 2s(模拟慢查询 / 外部 RPC)。
K8s 发版会发 SIGTERM:若进程立刻死掉,负载均衡上的在途请求会半截断开。

优雅关闭路径:

  1. 收到信号 → 停止接新连接
  2. 等在途 Handler 结束(有总预算,如 10s)
  3. 超时再 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 signalbye

若在 Shutdown 窗口内仍有不听 ctx 的 handler,会拖到 10s 预算再 Close

结合场景再看三个关注点

  1. signal.NotifyContext = 进程级取消根
    发版/缩容信号变成 ctx.Done(),与业务取消模型一致。

  2. Shutdown 有界等待
    停新连接 + 等在途;预算用 WithTimeout 包一层。

  3. Handler 必须协作
    /workr.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 / 容器场景

  1. 收到 SIGTERM
  2. preStop 可先从 Service 摘流(sleep 1–5s 等 endpoint 更新)
  3. 应用 Shutdown 预算 < terminationGracePeriodSeconds
  4. 超时后 Kubelet SIGKILL

预算要端到端对齐:反代超时、LB、应用、编排宽限期。

设计动机

  1. 正确性:减少半截事务与重复副作用(仍需幂等兜底)。
  2. 体验:发版不对用户“整页失败风暴”。
  3. 资源:连接、文件描述符、锁有序释放。
  4. 可运维:日志能看出“信号 → 排空 → 退出”时间线。

边界情况与反直觉行为

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 错误:<-sigos.Exit(0)
正确:先 Shutdown/Wait 再退出。

[!warning] 常见误区:Handler 忽略 r.Context() 错误:Shutdown 卡满超时。
正确:阻塞点全部 select Done 或用带 ctx 的 API。

[!warning] 常见误区:先关 DB 再排空请求 错误:在途请求全部报错。
正确:先停入口与接新任务,再等在途,最后关依赖。

[!warning] 常见误区:关闭时仍往已关 channel 发送 错误:panic。
正确:用 ctx 停生产者,单一关闭点,发送侧负责 close。

与相邻概念对比

概念差异
context取消信号载体;本篇是进程生命周期编排
net/http提供 Shutdown;要嵌入整体策略
worker pool池的排空是关闭子问题
健康检查 fail摘流手段之一,不能替代进程内 Shutdown
强制杀死最后手段;破坏在途

工程实践

  1. 单一 root cancel,组件订阅同一生命周期。
  2. 超时分层:信号到强制退出总预算;内部再切分 Shutdown/worker/DB。
  3. 可观测:日志打印 shutdown 开始、各阶段耗时、超时强制。
  4. 幂等:仍假设会有重试与重复投递。
  5. 测试:写集成测模拟信号或调用 Shutdown,断言在途完成/超时行为。
  6. 与部署:对齐 K8s terminationGracePeriodSeconds、反代 idle/timeout。
  7. errgroupgolang.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 与错误日志。

本节总结

  • 本质:有界、有序地停入口、取消、排空、释放。
  • HTTPShutdown + Handler 协作取消。
  • 后台:关输入、Wait、统一 ctx。
  • 下一步:把策略嵌进 项目结构cmd;并发侧复盘 取消关系

自测题

概念题

  1. ShutdownClose 的核心差别?
  2. 为什么优雅关闭必须有超时?
  3. 谁应负责 close(jobs)

代码推理题

Shutdown 返回后主函数立刻 db.Close(),但某 Handler 里 go writeDB() 无同步。可能发生什么?

工程思考题

K8s terminationGracePeriodSeconds=30,反代超时 60s,应用 Shutdown 60s。有何问题?如何改?

参考答案

展开
  1. Shutdown 停新连接并尝试等在途;Close 立即切断。
  2. 防止故障依赖拖死发布与编排强杀不可控。
  3. 发送方(所有权方),并先停生产。
    代码题:进程退出或 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 统一生命周期)
创建于 2026/6/20 更新于 2026/7/15