4.3 优雅停服与有状态服务发布
4.1 定下了「排空预算必须小于宽限期」这条清单约束,4.2 讲了三种发布策略都会在某一刻让旧实例「走」。这一节回答最后一个问题:旧实例收到停服信号之后,到底该做什么、按什么次序做、怎么证明它做对了。
很多线上事故的根因不是「发不出去」,而是「收不干净」:进程收到 SIGTERM 直接 os.Exit,飞到一半的请求没了、还没落库的那一笔丢了、正在交接的状态悬空了。对无状态服务这可能只是丢一次请求;对游戏服务端,丢的可能是玩家刚买的一件装备、刚打完的一场战斗。所以停服不是「关掉进程」,而是一段有次序的收尾。
这一节先把这段收尾拆成四步,再用一个真起子进程、真发 SIGTERM 的测试,断言在途请求一条不丢、账本一条不少;最后讲有状态服务的发布为什么不能只靠停服——它还需要一个**代次(epoch)**来保证同一时刻只有一个主。
优雅停服的四步
进程从收到 SIGTERM 到退出,次序是固定的,每一步都可以观测:
- 停收新连接。先把监听关掉,让编排系统与负载均衡知道「这个实例不再接客」。这一步不处理任何已有请求,只是关上一扇门。
- 排空在途请求。等已经进来的请求处理完。等待是有预算的——这个预算就是 4.1 里必须小于宽限期的那条
DrainBudgetSeconds。预算内排干净就继续,超预算就只能放弃(宁可有界地失败,也不被SIGKILL砍在半路)。 - 落库。把内存里的状态(账本、脏数据、最后一批提交)刷到持久化层。这一步不做,前两步就白做了——请求处理完了,结果却没留下来。
- 退出。四步走完,进程正常退 0。
次序不能乱:先落库再停连接会让新请求涌进来污染状态;先退出再落库等于没落库。跳过第 2 步会丢在途请求,跳过第 3 步会丢最后提交——这两个「跳过」正是本节测试要挡住的错误。
真跑:发一个 SIGTERM,看它排空与落库
光讲次序不够,得有一个真进程被真信号打断。下面这个 Server 是 L2:它监听 TCP、每连接处理一个请求、在 Shutdown 里严格执行上面四步。下面这段是 L2,本机真起进程、真发 SIGTERM,go test 可以跑通:
// file: internal/deploy/shutdown.go
// tier: L2
// verify: go test ./internal/deploy/ -run TestGracefulShutdown -v
// 4.3 的优雅停服:SIGTERM 到退出,次序固定——停收新连接、排空在途请求、落
// 库、返回退 0。跳过排空会丢飞到一半的请求,跳过落库会丢最后一次提交。
package deploy
import (
"bufio"
"fmt"
"io"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
// Server 是极小的 TCP 节点:每连接处理一个请求——读一行、干活、写 OK、记账。
type Server struct {
ln net.Listener
inFlight atomic.Int64
drainTimeout time.Duration
mu sync.Mutex
ledger []string
}
// NewServer 返回一个排空最多等 drainTimeout 的 Server。
func NewServer(drainTimeout time.Duration) *Server {
return &Server{drainTimeout: drainTimeout}
}
// Listen 在 addr 上开始接受连接,返回实际绑定的地址。
func (s *Server) Listen(addr string) (string, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return "", err
}
s.ln = ln
go s.accept()
return ln.Addr().String(), nil
}
func (s *Server) accept() {
for {
c, err := s.ln.Accept()
if err != nil {
return // 监听已被 Shutdown 关掉
}
go s.serve(c)
}
}
func (s *Server) serve(c net.Conn) {
defer c.Close()
s.inFlight.Add(1)
defer s.inFlight.Add(-1)
line, err := bufio.NewReader(c).ReadString('\n')
if err != nil {
return
}
time.Sleep(50 * time.Millisecond) // 代替真实的请求处理
s.mu.Lock()
s.ledger = append(s.ledger, strings.TrimRight(line, "\r\n"))
s.mu.Unlock()
io.WriteString(c, "OK\n")
}
// Shutdown 执行四步排空,返回落库了多少条记录。
func (s *Server) Shutdown(flushPath string) (int, error) {
if s.ln != nil {
s.ln.Close() // 1. 停收新连接
}
deadline := time.Now().Add(s.drainTimeout) // 2. 排空在途请求
for s.inFlight.Load() > 0 {
if time.Now().After(deadline) {
return 0, fmt.Errorf("drain timed out with %d in flight", s.inFlight.Load())
}
time.Sleep(2 * time.Millisecond)
}
s.mu.Lock() // 3. 落库
records := append([]string(nil), s.ledger...)
s.mu.Unlock()
f, err := os.Create(flushPath)
if err != nil {
return 0, err
}
defer f.Close()
w := bufio.NewWriter(f)
for _, r := range records {
fmt.Fprintln(w, r)
}
if err := w.Flush(); err != nil {
return 0, err
}
return len(records), nil // 4. 返回,调用方退 0
}
测试用一个「辅助进程」模式把服务器放进子进程:父进程开 8 条连接各发一个请求,在它们都在途时向子进程发真 SIGTERM,然后断言三件事——在途请求全部拿到 OK、子进程报出落库 8 条、账本文件里正好 8 行:
// file: internal/deploy/shutdown_test.go
// tier: L2
// verify: go test ./internal/deploy/ -run TestGracefulShutdown -v
package deploy
import (
"bufio"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"testing"
"time"
)
// TestHelperProcess 不是真测试:AEGIS_HELPER=1 时在子进程里跑服务器,好让父
// 进程发一个真 SIGTERM。
func TestHelperProcess(t *testing.T) {
if os.Getenv("AEGIS_HELPER") != "1" {
return
}
srv := NewServer(2 * time.Second)
addr, err := srv.Listen("127.0.0.1:0")
if err != nil {
fmt.Println("ERR listen:", err)
os.Exit(2)
}
fmt.Println("READY", addr)
os.Stdout.Sync()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM)
<-sig
flushed, err := srv.Shutdown(os.Getenv("AEGIS_FLUSH"))
if err != nil {
fmt.Println("ERR shutdown:", err)
os.Exit(3)
}
fmt.Printf("SHUTDOWN flushed=%d\n", flushed)
os.Exit(0)
}
func TestGracefulShutdown(t *testing.T) {
flushPath := t.TempDir() + "/ledger.txt"
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess")
cmd.Env = append(os.Environ(), "AEGIS_HELPER=1", "AEGIS_FLUSH="+flushPath)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(stdout)
if !sc.Scan() || !strings.HasPrefix(sc.Text(), "READY ") {
t.Fatalf("no READY line, got %q", sc.Text())
}
addr := strings.TrimSpace(strings.TrimPrefix(sc.Text(), "READY "))
// 8 条连接各发一个请求;handler 睡 50ms,发信号时它们都在途。
const n = 8
conns := make([]net.Conn, 0, n)
for i := 0; i < n; i++ {
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial %d: %v", i, err)
}
fmt.Fprintf(c, "req-%d\n", i)
conns = append(conns, c)
}
time.Sleep(10 * time.Millisecond)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
// 在途请求必须全部拿到响应:是排空,不是丢弃。
for i, c := range conns {
c.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 3)
if _, err := io.ReadFull(c, buf); err != nil || string(buf) != "OK\n" {
t.Errorf("conn %d not drained: resp=%q err=%v", i, buf, err)
}
c.Close()
}
// 监听已关:新连接必须被拒。
if !eventuallyRefused(addr) {
t.Error("listener still accepted a connection after SIGTERM")
}
// 子进程干净退出,并报出落库条数。
var line string
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "SHUTDOWN ") {
line = sc.Text()
}
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with error: %v", err)
}
if want := fmt.Sprintf("SHUTDOWN flushed=%d", n); line != want {
t.Fatalf("child said %q, want %q", line, want)
}
// 落库真的写到磁盘,一条不少。
data, err := os.ReadFile(flushPath)
if err != nil {
t.Fatal(err)
}
if got := strings.Count(string(data), "\n"); got != n {
t.Errorf("ledger has %d records, want %d", got, n)
}
}
func eventuallyRefused(addr string) bool {
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err != nil {
return true
}
c.Close()
time.Sleep(20 * time.Millisecond)
}
return false
}
本机实跑结果(go test ./internal/deploy/ -run TestGracefulShutdown -v):
=== RUN TestGracefulShutdown
--- PASS: TestGracefulShutdown (0.12s)
ok aegis/internal/deploy 0.377s
这个测试证明的是四步次序真的被执行了:8 条连接在信号到达时都在途,如果进程收到 SIGTERM 就 os.Exit,那 8 个 io.ReadFull 会因连接被重置而报错;如果它排空了但没落库,账本文件的行数会是 0。两者都过了,才说明「停收 → 排空 → 落库 → 退出」这条链路是通的。
有状态发布:代次与状态交接
无状态节点停服只要「排空 + 落库」就够了,有状态节点还多一层风险:新主接管旧主的状态时,怎么保证不是两个主同时在服务? 这正是脑裂。解决办法是一个单调递增的代次:每个有状态实例带一个 epoch,状态上戳着它最后一次被写入时的代次。新主只有在自己的 epoch 严格大于状态上的代次时,才被允许接管。下面这段是 L1,go test 可以直接跑:
// file: internal/deploy/epoch.go
// tier: L1
// verify: go test ./internal/deploy/ -run TestEpoch
// 4.3 的有状态半边:发布一个持单写者状态的节点(一个场景实例、一个世界分
// 片),不只是「起个新 Pod」。新主必须证明自己比它接管的状态更新,否则两个
// 主会同时服务同一个实例——脑裂。
//
// 每个有状态实例带一个代次 epoch:单调递增的世代号。只有当来者的 epoch 严格
// 大于状态上戳的 epoch,交接才被允许。
package deploy
// Epoch 是有状态实例的世代号。
type Epoch uint64
// CanTakeOver 报告 epoch 为 e 的主能否接管戳着 current 的状态。相等的 epoch
// 也被拒——两个主处在同一代,正是要防的脑裂。
func (e Epoch) CanTakeOver(current Epoch) bool { return e > current }
// Next 返回新主该采用的 epoch,永远严格大于它接管的状态。
func (e Epoch) Next() Epoch { return e + 1 }
// Handover 是一次有状态发布决策的结果。
type Handover struct {
From Epoch
To Epoch
Allowed bool
Reason string
}
// PlanHandover 判定 epoch 为 to 的主能否接管戳着 from 的状态。
func PlanHandover(from, to Epoch) Handover {
if to.CanTakeOver(from) {
return Handover{From: from, To: to, Allowed: true, Reason: "newer generation"}
}
return Handover{From: from, To: to, Allowed: false, Reason: "epoch not newer: refusing to split brain"}
}
配套测试断言三条:5 → 6 允许(更新的主)、5 → 5 拒绝(同代,脑裂)、5 → 4 拒绝(过期的旧主)。把「同一时刻只有一个主」这条分布式约束,落成一条会失败的断言——这正是 4.1 里 StatefulSet 的 OrderedReady 想要的语义:逐台替换,每台新主都带着一个更大的代次。
本节小结
这一节把「旧实例怎么走」拆成两件事:
- 优雅停服是四步,不是一步:停收新连接、排空在途请求、落库、退出。次序固定,跳过排空丢在途请求,跳过落库丢最后提交。测试用一个真子进程 + 真
SIGTERM把这条链路钉住。 - 有状态发布多一层代次:无状态节点排空即可,有状态节点还要证明「新主比旧状态更新」——用单调递增的
epoch挡住脑裂,这正对应StatefulSet的逐台有序替换。
至此,部署与发布这一章走完了:镜像与清单(4.1)、发布策略(4.2)、停服与交接(4.3)。但「发上去」不等于「改得动」——线上最频繁的动作其实是改配置:改一个掉落率、调一个冷却、换一个活动开关。下一章讲配置中心与热更新,回答「哪些能热更、怎么版本化、改坏了怎么回滚」。
阅读导航:上一节:4.2 蓝绿、灰度与滚动发布 · 下一节:5.1 配置版本化与差量分发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。