在 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 会发送 SIGHUPRestart=on-failure: 非正常退出时自动重启TimeoutStopSec: 等待优雅退出的时间,超时则 SIGKILLMemoryMax/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 代码中,直接使用 log 或 fmt 输出即可,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 取消,每个资源都必须有清理机制。