目录

Go Daemon 开发实战:优雅处理 SIGTERM 与热重载配置

在 NAS 产品中,我需要开发一个 7×24 小时运行的系统守护进程,负责磁盘健康监控和自动任务调度。这篇文章记录了开发过程中的关键技术点:信号处理、优雅退出、热重载配置。

一、为什么要自己写 Daemon?

1.1 场景需求

NAS 设备需要一个"大管家"进程:

  • 定期检查磁盘 SMART 状态
  • 监控 RAID 阵列健康
  • 执行定时备份任务
  • OOM 保护(在内存紧张时主动清理缓存)

1.2 为什么不用 cron?

cron 无法满足的需求:

  • 需要常驻内存,维护状态
  • 任务之间有依赖关系
  • 需要及时响应系统事件(如磁盘插拔)
  • 需要自定义重试逻辑

所以必须写一个长时间运行的守护进程。

二、基本骨架

2.1 最简 Daemon 结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // 创建可取消的 context
    ctx, cancel := context.WithCancel(context.Background())
    
    // 启动业务逻辑
    go runDaemon(ctx)
    
    // 等待退出信号
    waitForShutdown(cancel)
}

func runDaemon(ctx context.Context) {
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            log.Println("收到退出信号,停止工作...")
            return
        case <-ticker.C:
            doPeriodicWork()
        }
    }
}

func waitForShutdown(cancel context.CancelFunc) {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    
    sig := <-sigChan
    log.Printf("收到信号 %v,开始优雅退出...", sig)
    cancel()
    
    // 给业务逻辑一些清理时间
    time.Sleep(5 * time.Second)
    log.Println("退出完成")
}

2.2 信号处理详解

常见信号及处理方式:

信号触发场景推荐处理
SIGINT (2)Ctrl+C优雅退出
SIGTERM (15)kill / systemd stop优雅退出
SIGKILL (9)kill -9无法捕获!
SIGHUP (1)终端断开 / 自定义热重载配置
SIGUSR1 (10)用户自定义打印状态/dump
SIGUSR2 (12)用户自定义日志级别切换
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func setupSignalHandlers(ctx context.Context, cancel context.CancelFunc, configReload chan struct{}) {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, 
        syscall.SIGINT, 
        syscall.SIGTERM, 
        syscall.SIGHUP,
        syscall.SIGUSR1,
    )
    
    go func() {
        for sig := range sigChan {
            switch sig {
            case syscall.SIGINT, syscall.SIGTERM:
                log.Printf("收到 %v,开始优雅退出", sig)
                cancel()
                return
            case syscall.SIGHUP:
                log.Println("收到 SIGHUP,触发热重载")
                configReload <- struct{}{}
            case syscall.SIGUSR1:
                log.Println("收到 SIGUSR1,打印当前状态")
                printDaemonStatus()
            }
        }
    }()
}

三、优雅退出

3.1 为什么需要优雅退出?

如果进程直接被杀死:

  • 正在写入的文件可能损坏
  • 数据库连接没有正常关闭
  • 网络请求没有响应,客户端超时
  • 临时文件没有清理

3.2 使用 context 传播取消信号

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
type DiskMonitor struct {
    ctx    context.Context
    cancel context.CancelFunc
    wg     sync.WaitGroup
}

func NewDiskMonitor(parentCtx context.Context) *DiskMonitor {
    ctx, cancel := context.WithCancel(parentCtx)
    return &DiskMonitor{
        ctx:    ctx,
        cancel: cancel,
    }
}

func (m *DiskMonitor) Start() {
    m.wg.Add(1)
    go func() {
        defer m.wg.Done()
        m.run()
    }()
}

func (m *DiskMonitor) run() {
    ticker := time.NewTicker(5 * time.Minute)
    defer ticker.Stop()
    
    for {
        select {
        case <-m.ctx.Done():
            log.Println("DiskMonitor: 收到取消信号")
            m.cleanup()
            return
        case <-ticker.C:
            m.checkAllDisks()
        }
    }
}

func (m *DiskMonitor) cleanup() {
    log.Println("DiskMonitor: 正在清理资源...")
    // 关闭数据库连接
    // 刷新缓存到磁盘
    // 等等...
}

func (m *DiskMonitor) Stop() {
    log.Println("DiskMonitor: 请求停止")
    m.cancel()
    m.wg.Wait() // 等待 goroutine 真正退出
    log.Println("DiskMonitor: 已完全停止")
}

3.3 超时强制退出

优雅退出不能无限等待:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func gracefulShutdown(cancel context.CancelFunc, components []Stoppable) {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    
    <-sigChan
    log.Println("开始优雅退出...")
    
    // 通知所有组件停止
    cancel()
    
    // 设置超时
    done := make(chan struct{})
    go func() {
        for _, c := range components {
            c.Stop() // 假设每个组件都有 Stop() 方法
        }
        close(done)
    }()
    
    select {
    case <-done:
        log.Println("所有组件已正常停止")
    case <-time.After(30 * time.Second):
        log.Println("超时!部分组件未能正常停止")
    }
    
    log.Println("退出完成")
}

四、热重载配置

4.1 SIGHUP 触发重载

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type Config struct {
    CheckInterval time.Duration `toml:"check_interval"`
    AlertEmail    string        `toml:"alert_email"`
    // ...
}

var (
    config     *Config
    configLock sync.RWMutex
)

func loadConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return err
    }
    
    var newConfig Config
    if _, err := toml.Decode(string(data), &newConfig); err != nil {
        return err
    }
    
    configLock.Lock()
    config = &newConfig
    configLock.Unlock()
    
    log.Printf("配置已重载: %+v", newConfig)
    return nil
}

func getConfig() *Config {
    configLock.RLock()
    defer configLock.RUnlock()
    return config
}

// 配置重载处理
func handleConfigReload(configPath string, reloadChan <-chan struct{}) {
    for range reloadChan {
        if err := loadConfig(configPath); err != nil {
            log.Printf("配置重载失败: %v", err)
        }
    }
}

4.2 使用方式

1
2
3
# 修改配置文件后
kill -HUP $(pidof nas-agent)
# 服务会重新加载配置,无需重启

五、与 systemd 集成

5.1 Service 文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# /etc/systemd/system/nas-agent.service
[Unit]
Description=NAS System Agent
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/nas-agent -config /etc/nas-agent/config.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s

# 资源限制
MemoryMax=512M
MemoryHigh=400M
CPUQuota=50%

# 优雅退出超时
TimeoutStopSec=30s

# 日志
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nas-agent

[Install]
WantedBy=multi-user.target

5.2 关键配置说明

  • Type=simple: 进程启动即就绪(Go 程序通常用这个)
  • ExecReload: systemctl reload nas-agent 会发送 SIGHUP
  • Restart=on-failure: 非正常退出时自动重启
  • TimeoutStopSec: 等待优雅退出的时间,超时则 SIGKILL
  • MemoryMax/CPUQuota: 资源限制(配合 cgroups v2)

5.3 日志集成

使用 journald:

1
2
3
4
5
6
7
8
# 查看服务日志
journalctl -u nas-agent -f

# 查看最近 100 行
journalctl -u nas-agent -n 100

# 按时间查询
journalctl -u nas-agent --since "2023-03-10 10:00" --until "2023-03-10 12:00"

在 Go 代码中,直接使用 logfmt 输出即可,journald 会自动捕获。

六、常见陷阱

6.1 Goroutine 泄漏

错误示例

1
2
3
4
5
6
7
func (m *DiskMonitor) checkDisk(path string) {
    go func() {
        // 如果这里阻塞,goroutine 永远不会退出
        data, _ := exec.Command("smartctl", "-a", path).Output()
        m.process(data)
    }()
}

正确做法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (m *DiskMonitor) checkDisk(path string) {
    go func() {
        ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
        defer cancel()
        
        cmd := exec.CommandContext(ctx, "smartctl", "-a", path)
        data, err := cmd.Output()
        if err != nil {
            if ctx.Err() == context.DeadlineExceeded {
                log.Printf("smartctl 超时: %s", path)
            }
            return
        }
        m.process(data)
    }()
}

6.2 忽略 context

错误

1
2
3
4
5
6
7
func doWork() {
    for {
        // 这个循环永远不会停止!
        time.Sleep(time.Second)
        doSomething()
    }
}

正确

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func doWork(ctx context.Context) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            doSomething()
        }
    }
}

6.3 资源清理顺序

1
2
3
4
5
6
7
func shutdown(components []Component) {
    // 反向顺序关闭(后启动的先关闭)
    for i := len(components) - 1; i >= 0; i-- {
        log.Printf("正在停止: %s", components[i].Name())
        components[i].Stop()
    }
}

七、完整示例

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

type Daemon struct {
    ctx        context.Context
    cancel     context.CancelFunc
    wg         sync.WaitGroup
    reloadChan chan struct{}
}

func NewDaemon() *Daemon {
    ctx, cancel := context.WithCancel(context.Background())
    return &Daemon{
        ctx:        ctx,
        cancel:     cancel,
        reloadChan: make(chan struct{}, 1),
    }
}

func (d *Daemon) Run() {
    // 启动业务协程
    d.wg.Add(2)
    go d.diskMonitorLoop()
    go d.taskSchedulerLoop()
    
    // 启动信号处理
    d.handleSignals()
    
    // 等待所有协程退出
    d.wg.Wait()
    log.Println("Daemon 已完全退出")
}

func (d *Daemon) diskMonitorLoop() {
    defer d.wg.Done()
    ticker := time.NewTicker(5 * time.Minute)
    defer ticker.Stop()
    
    for {
        select {
        case <-d.ctx.Done():
            log.Println("DiskMonitor: 退出")
            return
        case <-ticker.C:
            log.Println("DiskMonitor: 检查磁盘...")
        }
    }
}

func (d *Daemon) taskSchedulerLoop() {
    defer d.wg.Done()
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()
    
    for {
        select {
        case <-d.ctx.Done():
            log.Println("TaskScheduler: 退出")
            return
        case <-ticker.C:
            log.Println("TaskScheduler: 检查定时任务...")
        case <-d.reloadChan:
            log.Println("TaskScheduler: 热重载配置")
        }
    }
}

func (d *Daemon) handleSignals() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
    
    for sig := range sigChan {
        switch sig {
        case syscall.SIGINT, syscall.SIGTERM:
            log.Printf("收到 %v,开始优雅退出...", sig)
            d.cancel()
            return
        case syscall.SIGHUP:
            log.Println("收到 SIGHUP,触发配置重载")
            select {
            case d.reloadChan <- struct{}{}:
            default:
                log.Println("重载请求已在队列中")
            }
        }
    }
}

func main() {
    log.SetFlags(log.LstdFlags | log.Lshortfile)
    log.Println("Daemon 启动")
    
    daemon := NewDaemon()
    daemon.Run()
}

八、总结

要点实现方式
信号捕获signal.Notify
取消传播context.WithCancel
等待退出sync.WaitGroup
热重载SIGHUP + channel 通知
超时保护context.WithTimeout
资源限制systemd cgroups

核心原则:每个 goroutine 都必须响应 context 取消,每个资源都必须有清理机制。