feat(server): agent 热重载 sing-box(SIGHUP)替代全量重启(#3)
sing-box 支持 SIGHUP 热重载(cmd_run.go:进程内 check()验证→close 旧实例→ start 新实例;坏配置保留旧实例继续跑、不断网)。原 agent 用 systemctl restart (全量重启:断所有连接 + 坏配置让 sing-box 起不来全断)。 - Restarter 接口加 Reload;SystemdRestarter.Reload 取 MainPID(systemctl show -p MainPID)发 SIGHUP——agent 与 sing-box 同 pangolin 用户,免 polkit/不改 unit; PID 取不到或发信号失败回退 Restart - SingBox.started:首次/进程未起走 Restart(冷启动),之后配置变更走 Reload - noopRestarter/fakeRestarter 补 Reload;加 TestWriteAndRestartFirstColdThenReload 价值:切节点/续期/加删用户不整进程重启、不全断流;坏配置不至于打死节点。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,12 @@ import (
|
||||
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
||||
)
|
||||
|
||||
// Restarter applies a freshly-rendered sing-box config. sing-box has no hot
|
||||
// reload, so the production implementation restarts the systemd unit (a ~1-2s
|
||||
// data-plane blip). Tests inject a fake.
|
||||
// Restarter applies a freshly-rendered sing-box config. sing-box 支持 SIGHUP
|
||||
// 热重载(进程内 validate→close 旧→start 新,坏配置保留旧实例不断网),故配置
|
||||
// 变更走 Reload(不整进程重启、不全断流),冷启动走 Restart。Tests inject a fake.
|
||||
type Restarter interface {
|
||||
Restart(ctx context.Context) error
|
||||
Restart(ctx context.Context) error // 冷启动:systemctl restart(~1-2s blip)
|
||||
Reload(ctx context.Context) error // 热重载:SIGHUP 给 sing-box MainPID
|
||||
}
|
||||
|
||||
// Cred is the agent's in-memory view of one data-plane credential. It is the
|
||||
@@ -60,6 +61,11 @@ type SingBox struct {
|
||||
hy2 *agentv1.Hy2Inbound
|
||||
configVersion int64
|
||||
|
||||
// started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动),
|
||||
// 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false,
|
||||
// 下次渲染做一次冷启动以确保与渲染配置一致。
|
||||
started bool
|
||||
|
||||
// debounce machinery
|
||||
dirty chan struct{}
|
||||
done chan struct{}
|
||||
@@ -328,7 +334,21 @@ func (s *SingBox) writeAndRestart(ctx context.Context) error {
|
||||
if err := atomicWrite(s.cfg.SingboxConfigPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.restarter.Restart(ctx)
|
||||
// 首次/进程未被本 agent 启动过 → 冷启动;已在跑 + 配置变更 → SIGHUP 热重载
|
||||
// (Reload 内部在取不到 PID/发信号失败时已自动回退 Restart)。
|
||||
s.mu.Lock()
|
||||
started := s.started
|
||||
s.mu.Unlock()
|
||||
if !started {
|
||||
if err := s.restarter.Restart(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.started = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return s.restarter.Reload(ctx)
|
||||
}
|
||||
|
||||
// ensureHy2Cert 为启用 hy2 的节点准备自签证书(幂等),并把 CertPath/KeyPath 写回当前
|
||||
@@ -396,3 +416,4 @@ func (s *SingBox) Run(ctx context.Context) {
|
||||
type noopRestarter struct{}
|
||||
|
||||
func (noopRestarter) Restart(context.Context) error { return nil }
|
||||
func (noopRestarter) Reload(context.Context) error { return nil }
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// fakeRestarter counts Restart calls.
|
||||
type fakeRestarter struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
mu sync.Mutex
|
||||
n int // Restart 次数(冷启动)
|
||||
reloads int // Reload 次数(热重载)
|
||||
}
|
||||
|
||||
func (f *fakeRestarter) Restart(context.Context) error {
|
||||
@@ -26,12 +27,25 @@ func (f *fakeRestarter) Restart(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRestarter) Reload(context.Context) error {
|
||||
f.mu.Lock()
|
||||
f.reloads++
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRestarter) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.n
|
||||
}
|
||||
|
||||
func (f *fakeRestarter) reloadCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.reloads
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -227,3 +241,26 @@ func assertNoIdentityFields(t *testing.T, blob []byte) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 首次渲染走冷启动(Restart),之后的配置变更走 SIGHUP 热重载(Reload)。
|
||||
func TestWriteAndRestartFirstColdThenReload(t *testing.T) {
|
||||
fr := &fakeRestarter{}
|
||||
sb := NewSingBox(testConfig(t), fr)
|
||||
sb.ApplyConfig(sampleSnapshot(), true) // 设置 inbounds 使渲染成功
|
||||
ctx := context.Background()
|
||||
|
||||
if err := sb.writeAndRestart(ctx); err != nil {
|
||||
t.Fatalf("first writeAndRestart: %v", err)
|
||||
}
|
||||
if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 0 {
|
||||
t.Fatalf("首次后 restarts=%d reloads=%d, 期望 1/0", r, rl)
|
||||
}
|
||||
|
||||
sb.Upsert(&Cred{DpUUID: "z", Protocol: agentv1.ProtocolBoth})
|
||||
if err := sb.writeAndRestart(ctx); err != nil {
|
||||
t.Fatalf("second writeAndRestart: %v", err)
|
||||
}
|
||||
if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 1 {
|
||||
t.Fatalf("变更后 restarts=%d reloads=%d, 期望 1/1(不再 Restart,走 Reload)", r, rl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// logf is the agent's structured-ish logger. It deliberately never logs
|
||||
@@ -48,15 +51,44 @@ type SystemdRestarter struct {
|
||||
Unit string // e.g. "sing-box"
|
||||
}
|
||||
|
||||
// Restart runs `systemctl restart <unit>`.
|
||||
// Restart runs `systemctl restart <unit>` (cold start: ~1-2s data-plane blip).
|
||||
func (r SystemdRestarter) Restart(ctx context.Context) error {
|
||||
unit := r.Unit
|
||||
if unit == "" {
|
||||
unit = "sing-box"
|
||||
}
|
||||
unit := r.unit()
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "restart", unit)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("agentd: restart %s: %w: %s", unit, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload hot-reloads sing-box via SIGHUP to its MainPID. sing-box validates the
|
||||
// new config first and keeps the OLD instance running on error (no full
|
||||
// process restart). agent 与 sing-box 同 pangolin 用户,直接发信号即可(免
|
||||
// polkit/不改 unit)。PID 取不到或发信号失败时回退到 Restart(冷启动)。
|
||||
func (r SystemdRestarter) Reload(ctx context.Context) error {
|
||||
unit := r.unit()
|
||||
pid, err := singboxMainPID(ctx, unit)
|
||||
if err != nil || pid <= 0 {
|
||||
return r.Restart(ctx)
|
||||
}
|
||||
if err := syscall.Kill(pid, syscall.SIGHUP); err != nil {
|
||||
return r.Restart(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r SystemdRestarter) unit() string {
|
||||
if r.Unit == "" {
|
||||
return "sing-box"
|
||||
}
|
||||
return r.Unit
|
||||
}
|
||||
|
||||
// singboxMainPID 读 systemd 记录的主进程 PID(0 表示未在跑)。
|
||||
func singboxMainPID(ctx context.Context, unit string) (int, error) {
|
||||
out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "MainPID", "--value", unit).Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user