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/)会:
StartAll之后逐个Probe四个节点,全部Healthy;- 杀掉
gateway-1进程,等编排器打印RESTART gateway-1 <新地址>,再Probe新地址确认已恢复; 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 集群承载数字 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。