《MMO 服务器开发》11.2 多进程编排与验收

上一节手动起了四个进程,这一节把它变成一条命令:一个 Go 编排器按依赖顺序拉起 hub、网关、战斗实例,每个节点健康检查通过才继续,进程崩溃自动重启,收到信号后按相反顺序优雅关停。四个问题各自落成可测代码,全部真跑,并给出起、杀、关三条可执行验收断言。

11.2 多进程编排与验收

上一节为了跑通跨服战场,手动起了四个进程,还靠一句 time.Sleep(300ms) 等它们注册完。这在写测试时勉强能用,到了真实部署就是事故温床:节点 A 还没就绪、节点 B 就开始连它;某个进程悄悄崩了没人拉起;关停时先杀了中枢、留下一堆连不上的边缘进程。

这一节把这些手工活收进一个编排器。它要解决四个具体问题:启动顺序(依赖先起)、就绪门禁(健康检查通过才继续)、崩溃自愈(挂了自动重启)、优雅关停(按相反顺序停,先停边缘再停中枢)。这四件事各自都不复杂,难在它们要同时成立,而且每一条都要能被验收。

本节给出的编排器是一个 Go 程序(cmd/orchestrator),不是 shell 脚本——因为「读子进程打印的就绪地址」「健康检查」「崩溃重启计数」这些逻辑用 shell 写会很快变成一坨不可测的胶水。用 Go 写,它可以被同一个测试包直接调用和断言。

编排要解决的四个问题

先把四个问题摊开,它们决定了编排器长什么样。

启动顺序与依赖等待。 网关和战斗实例都要连 hub,所以 hub 必须先起。编排器不靠固定 sleep,而是读子进程打印到 stdout 的就绪地址:每个节点启动后第一行打印 LISTEN <addr>,编排器扫到这一行才认为它「地址就绪」,再用这个地址去启动下游。

就绪门禁。 地址就绪不等于服务就绪——端口开了,但注册还没完成。编排器在拿到地址后再发一次心跳(CmdNodeHeartbeatReq),拿到 healthy 才继续。这样下游节点启动时,上游一定已经能应答。

崩溃自愈。 每个节点配一个监督协程,cmd.Wait() 一旦返回(进程退出),且不是正在关停,就重新 spawn 一次,并把重启次数记下来;超过上限就放弃,避免崩溃循环无限刷。

优雅关停。 收到 SIGINT/SIGTERM 后,按启动顺序的逆序停:先停战斗实例和网关(它们有连接、有正在跑的战斗),最后停 hub。每个节点停之前先发一条 CmdDrainNotice,让它停止接受新工作。

编排器:一条命令拉起整簇

下面这段是 L2,go test -run TestOrchestrator 会真起进程、真杀一个、再真关停。它把上面四个问题各自落成一小段代码:

// file: internal/battlecluster/orchestrator.go
// tier: L2
// verify: go test -run TestOrchestrator ./internal/battlecluster/
package battlecluster

import (
	"bufio"
	"fmt"
	"io"
	"net"
	"os/exec"
	"strings"
	"sync"
	"time"
)

// NodeSpec describes one process to run. ArgsFor, when set, computes the
// arguments at spawn time from the addresses of already-started nodes, so a
// gateway can be pointed at the hub without the caller knowing its address.
type NodeSpec struct {
	Name    string
	Bin     string
	Args    []string
	ArgsFor func(get func(name string) string) []string
}

// managed is a running node plus the state the orchestrator tracks for it.
type managed struct {
	spec     NodeSpec
	cmd      *exec.Cmd
	addr     string
	restarts int
}

// Orchestrator brings the cluster up in dependency order, gates each step on a
// passing health check, restarts crashed nodes, and shuts down in reverse order.
type Orchestrator struct {
	out         io.Writer
	order       []string
	specs       map[string]NodeSpec
	maxRestarts int

	mu    sync.Mutex
	procs map[string]*managed
	down  bool
}

// New builds an orchestrator; nodes start in the order given.
func New(specs []NodeSpec, out io.Writer) *Orchestrator {
	o := &Orchestrator{out: out, specs: map[string]NodeSpec{}, procs: map[string]*managed{}, maxRestarts: 5}
	for _, s := range specs {
		o.order = append(o.order, s.Name)
		o.specs[s.Name] = s
	}
	return o
}

// StartAll starts every node in order. Each node must answer a heartbeat before
// the next is started, so no node comes up before its dependency.
func (o *Orchestrator) StartAll() error {
	for _, name := range o.order {
		m, err := o.spawn(o.specs[name])
		if err != nil {
			return fmt.Errorf("start %s: %w", name, err)
		}
		o.mu.Lock()
		o.procs[name] = m
		o.mu.Unlock()
		fmt.Fprintf(o.out, "UP %s %s\n", name, m.addr)
		go o.supervise(name)
	}
	return nil
}

// spawn runs one process, reads its advertised address, and blocks until it
// answers a heartbeat (or the readiness budget runs out).
func (o *Orchestrator) spawn(spec NodeSpec) (*managed, error) {
	args := spec.Args
	if spec.ArgsFor != nil {
		args = spec.ArgsFor(o.Addr)
	}
	cmd := exec.Command(spec.Bin, args...)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	cmd.Stderr = o.out
	if err := cmd.Start(); err != nil {
		return nil, err
	}

	addrCh := make(chan string, 1)
	go func() {
		sc := bufio.NewScanner(stdout)
		for sc.Scan() {
			if f := strings.Fields(sc.Text()); len(f) >= 2 && f[0] == "LISTEN" {
				addrCh <- f[1]
				return
			}
		}
	}()

	var addr string
	select {
	case addr = <-addrCh:
	case <-time.After(5 * time.Second):
		_ = cmd.Process.Kill()
		return nil, fmt.Errorf("%s never advertised a LISTEN address", spec.Name)
	}
	if err := o.waitHealthy(addr, 5*time.Second); err != nil {
		_ = cmd.Process.Kill()
		return nil, err
	}
	return &managed{spec: spec, cmd: cmd, addr: addr}, nil
}

// waitHealthy polls the node's heartbeat until it answers or the budget ends.
func (o *Orchestrator) waitHealthy(addr string, budget time.Duration) error {
	deadline := time.Now().Add(budget)
	for time.Now().Before(deadline) {
		if h, err := Probe(addr); err == nil && h.Healthy {
			return nil
		}
		time.Sleep(20 * time.Millisecond)
	}
	return fmt.Errorf("node at %s never became healthy", addr)
}

// supervise waits for its node to exit and restarts it unless a shutdown is in
// progress. Restarts are bounded so a crash loop cannot spin forever.
func (o *Orchestrator) supervise(name string) {
	for {
		o.mu.Lock()
		m := o.procs[name]
		o.mu.Unlock()
		_ = m.cmd.Wait()

		o.mu.Lock()
		if o.down {
			o.mu.Unlock()
			return
		}
		m.restarts++
		over, spec := m.restarts > o.maxRestarts, m.spec
		o.mu.Unlock()
		if over {
			fmt.Fprintf(o.out, "GIVEUP %s\n", name)
			return
		}
		nm, err := o.spawn(spec)
		if err != nil {
			fmt.Fprintf(o.out, "RESTART_FAIL %s %v\n", name, err)
			time.Sleep(100 * time.Millisecond)
			continue
		}
		o.mu.Lock()
		o.procs[name] = nm
		o.mu.Unlock()
		fmt.Fprintf(o.out, "RESTART %s %s\n", name, nm.addr)
	}
}

// Addr returns a node's advertised address.
func (o *Orchestrator) Addr(name string) string {
	o.mu.Lock()
	defer o.mu.Unlock()
	if m := o.procs[name]; m != nil {
		return m.addr
	}
	return ""
}

// Shutdown stops nodes in reverse startup order: battles first, hub last. Each
// node gets a drain notice before it is stopped.
func (o *Orchestrator) Shutdown() {
	o.mu.Lock()
	o.down = true
	o.mu.Unlock()
	for i := len(o.order) - 1; i >= 0; i-- {
		name := o.order[i]
		o.mu.Lock()
		m := o.procs[name]
		o.mu.Unlock()
		if m == nil {
			continue
		}
		drain(m.addr)
		_ = m.cmd.Process.Kill()
		fmt.Fprintf(o.out, "DOWN %s\n", name)
	}
}

// drain tells a node to stop accepting new work (best effort).
func drain(addr string) {
	c, err := net.Dial("tcp", addr)
	if err != nil {
		return
	}
	peer := NewPeer(c, nil, nil)
	_ = peer.Push(CmdDrainNotice, nil)
	peer.Close()
}

cmd/orchestrator/main.go 只负责把节点清单拼出来——关键是网关和战斗实例的地址依赖用 ArgsFor 在启动那一刻才求值,于是 hub 的地址不需要事先知道:

// snippet: cmd/orchestrator/main.go 里构造节点清单的一段
specs := []battlecluster.NodeSpec{{Name: "hub", Bin: *hubBin}}
for i := 1; i <= *gateways; i++ {
	n := strconv.Itoa(i)
	specs = append(specs, battlecluster.NodeSpec{
		Name: "gateway-" + n,
		Bin:  *gwBin,
		ArgsFor: func(get func(string) string) []string {
			return []string{"-hub", get("hub"), "-server", n}
		},
	})
}

验收:起、杀、关

验收不看日志好看不好看,看三条断言能不能失败。本机实跑 TestOrchestrator(go test -run TestOrchestrator -v ./internal/battlecluster/)会:

  1. StartAll 之后逐个 Probe 四个节点,全部 Healthy;
  2. 杀掉 gateway-1 进程,等编排器打印 RESTART gateway-1 <新地址>,再 Probe 新地址确认已恢复;
  3. Shutdown 之后检查日志里 DOWN 行的顺序:battle-0 在 gateway-1 之前、gateway-1 在 hub 之前。

直接跑 cmd/orchestrator 这条命令时,stdout 是这样的:

UP hub 127.0.0.1:56854
UP gateway-1 127.0.0.1:56857
UP gateway-2 127.0.0.1:56860
UP battle-0 127.0.0.1:56863
CLUSTER READY
(……运行中,杀掉某个网关会打印 RESTART gateway-1 127.0.0.1:57xxx……)
DOWN battle-0
DOWN gateway-2
DOWN gateway-1
DOWN hub

UP 的顺序就是依赖顺序:hub 先、网关和战斗实例后;DOWN 的顺序正好反过来。这正是「起要按依赖、停要按依赖的逆序」这条规则落到 stdout 上的样子。CLUSTER READY 出现在全部节点通过健康检查之后,脚本或 CI 可以 grep 这一行判断「簇起好了」。

本机实跑结果(go test -run TestOrchestrator -v):

=== RUN   TestOrchestrator
--- PASS: TestOrchestrator (4.59s)
PASS
ok  	aegis/internal/battlecluster	4.6s

本节小结 / 实测结论

  • 编排器把「手动起进程」升级成「一条命令拉起整簇」,解决四个问题:启动顺序、就绪门禁、崩溃自愈、优雅关停。
  • 依赖等待不靠 sleep:读子进程的 LISTEN 地址 + 心跳健康检查双重门禁,上游就绪才起下游。
  • 崩溃自愈:每节点一个监督协程,Wait() 返回即重启,重启次数有上限,避免崩溃循环。
  • 优雅关停:按启动逆序停,先发 CmdDrainNotice 再停;本机验收证明 DOWN 顺序是 battle → gateway → hub。
  • 三条验收断言全绿:启动后全健康、杀掉网关后自动重启并恢复、关停顺序正确。所有数字同机相对量级,绝对数值不可移植。

下一节把「簇能起能停」升级成「簇能扛多少」:用 B 层基准给出节点数、单网关连接数、跨服匹配吞吐与跨服消息延迟分位。

阅读导航:上一节:11.1 跨服战场端到端 · 下一节:11.3 集群承载数字 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标