Go 系统服务开发实战:systemd+sdnotify + 看门狗-agent与系统服务
go语言中agent
go
WatchDogNotify.go
package system
import (
"github.com/mdlayher/sdnotify"
"log"
"time"
)
func WatchDogNotify() {
n, err := sdnotify.New()
if err != nil {
log.Error("error create systemd notify: err=%+v", err)
return
}
err = n.(sdnotify.Ready)
if err != nil {
log.Error("error send sdnotify.Ready notify: err=%+v", err)
return
}
go func() {
ti := time.NewTicker(time.Second * 10)
defer ti.Stop()
for true {
select {
case <-ti.C:
log.Debug("send watchdog notify")
err := n.Notify("WATCHDOG=1")
if err != nil {
log.Error("error send WATCHDOG notify: err=%+v", err)
return
}
}
}
}()
}
log
scss
log.go
/*
Copyright 2021 Loggie Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package log
import (
"fmt"
"github.com/rs/zerolog"
"github.com/spf13/viper"
"gopkg.in/natefinch/lumberjack.v2"
"io"
"os"
"path"
"runtime"
"strconv"
"time"
)
var (
defaultLogger *Logger
)
func init() {
viper.SetDefault("log.level", "info")
viper.SetDefault("log.jsonFormat", true)
viper.SetDefault("log.enableStdout", true)
viper.SetDefault("log.enableFile", true)
viper.SetDefault("log.directory", "./")
viper.SetDefault("log.filename", "agent.log")
viper.SetDefault("log.maxSize", 1024)
viper.SetDefault("log.maxBackups", 3)
viper.SetDefault("log.maxAge", 7)
viper.SetDefault("log.timeFormat", "2006-01-02T15:04:05")
viper.SetDefault("log.callerSkipCount", 4)
viper.SetDefault("log.noColor", false)
viper.SetDefault("log.enableRemoteLog", "unix")
viper.SetDefault("log.remoteHost", "/app/cams/log-agent/agent.sock")
viper.SetDefault("log.remotePath", "/logsReceiver")
viper.SetDefault("log.enableSelfLog", true)
}
type LoggerConfig struct {
Level string `yaml:"level,omitempty"`
JsonFormat bool `yaml:"jsonFormat,omitempty"`
EnableStdout bool `yaml:"enableStdout,omitempty"`
EnableFile bool `yaml:"enableFile,omitempty"`
EnableSelfLog bool `yaml:"enableSelfLog,omitempty"`
EnableRemoteLog string `yaml:"enableRemoteLog,omitempty"`
RemoteHost string `yaml:"remoteHost,omitempty"`
RemotePath string `yaml:"remotePath,omitempty"`
Directory string `yaml:"directory,omitempty"`
Filename string `yaml:"filename,omitempty"`
MaxSize int `yaml:"maxSize,omitempty"`
MaxBackups int `yaml:"maxBackups,omitempty"`
MaxAge int `yaml:"maxAge,omitempty"`
TimeFormat string `yaml:"timeFormat,omitempty"`
CallerSkipCount int `yaml:"callerSkipCount,omitempty"`
NoColor bool `yaml:"noColor,omitempty"`
}
type Logger struct {
l *zerolog.Logger
lastError string
}
type onError struct {
}
func (o *onError) Run(e *zerolog.Event, level zerolog.Level, message string) {
if level != zerolog.ErrorLevel {
return
}
_, file, line, ok := runtime.Caller(5)
if ok {
s := file + ":" + strconv.Itoa(line)
ss := fmt.Sprintf("time=%s, caller=%s, message=%s", time.Now().Format("2006-01-02T15:04:05"), s, message)
defaultLogger.lastError = ss
}
}
func InitLogger() {
gLoggerConfig := &LoggerConfig{
Level: viper.GetString("log.level"),
JsonFormat: viper.GetBool("log.jsonFormat"),
EnableFile: viper.GetBool("log.enableFile"),
EnableStdout: viper.GetBool("log.enableStdout"),
Directory: viper.GetString("log.directory"),
Filename: viper.GetString("log.filename"),
MaxSize: viper.GetInt("log.maxSize"),
MaxAge: viper.GetInt("log.maxAge"),
MaxBackups: viper.GetInt("log.maxBackups"),
TimeFormat: viper.GetString("log.timeFormat"),
CallerSkipCount: viper.GetInt("log.callerSkipCount"),
NoColor: viper.GetBool("log.noColor"),
EnableRemoteLog: viper.GetString("log.enableRemoteLog"),
RemoteHost: viper.GetString("log.remoteHost"),
RemotePath: viper.GetString("log.remotePath"),
EnableSelfLog: viper.GetBool("log.enableSelfLog"),
}
logger := NewLogger(gLoggerConfig)
defaultLogger = logger
}
func NewLogger(config *LoggerConfig) *Logger {
var writers []io.Writer
if config.EnableStdout {
writers = append(writers, os.Stderr)
}
if config.EnableFile {
writers = append(writers, newRollingFile(config.Directory, config.Filename, config.MaxBackups, config.MaxSize, config.MaxAge))
}
if len(config.EnableRemoteLog) > 0 {
writers = append(writers, newRemoteWriter(config.EnableRemoteLog, config.RemoteHost, config.RemotePath))
}
if config.EnableSelfLog {
writers = append(writers, newSelfLogWriter())
}
if !config.JsonFormat {
for i, w := range writers {
writers[i] = zerolog.ConsoleWriter{
Out: w,
NoColor: config.NoColor,
TimeFormat: config.TimeFormat,
}
}
}
mw := io.MultiWriter(writers...)
zerolog.TimeFieldFormat = config.TimeFormat
zerolog.CallerSkipFrameCount = config.CallerSkipCount
level, err := zerolog.ParseLevel(config.Level)
if err != nil {
panic("set log level error, choose trace/debug/info/warn/error/fatal/panic")
}
multi := zerolog.MultiLevelWriter(mw)
logger := zerolog.New(multi).Level(level).With().Timestamp().Caller().Logger().Hook(&onError{})
return &Logger{
l: &logger,
}
}
func newRollingFile(directory string, filename string, maxBackups int, maxSize int, maxAge int) io.Writer {
if err := os.MkdirAll(directory, 0777); err != nil {
panic(fmt.Sprintf("can't create log directory %s", directory))
}
return &lumberjack.Logger{
Filename: path.Join(directory, filename),
MaxBackups: maxBackups, // files
MaxSize: maxSize, // megabytes
MaxAge: maxAge, // days
}
}
func LastError() string {
return defaultLogger.lastError
}
func (logger *Logger) Debugf(format string, v ...interface{}) {
logger.Info(format, v...)
}
func (logger *Logger) Errorf(format string, v ...interface{}) {
logger.Error(format, v...)
}
func (logger *Logger) Println(v ...interface{}) {
logger.Info("", v...)
}
func (logger *Logger) Printf(format string, v ...interface{}) {
logger.Info(format, v...)
}
func (logger *Logger) Debug(format string, a ...interface{}) {
if a == nil {
logger.l.Debug().Msg(format)
} else {
logger.l.Debug().Msgf(format, a...)
}
}
func (logger *Logger) Info(format string, a ...interface{}) {
if a == nil {
logger.l.Info().Msg(format)
} else {
logger.l.Info().Msgf(format, a...)
}
}
func (logger *Logger) Warn(format string, a ...interface{}) {
if a == nil {
logger.l.Warn().Msg(format)
} else {
logger.l.Warn().Msgf(format, a...)
}
}
func (logger *Logger) Error(format string, a ...interface{}) {
if a == nil {
logger.l.Error().Msg(format)
} else {
logger.l.Error().Msgf(format, a...)
}
}
func (logger *Logger) Panic(format string, a ...interface{}) {
if a == nil {
logger.l.Panic().Msg(format)
} else {
logger.l.Panic().Msgf(format, a...)
}
}
func (logger *Logger) Fatal(format string, a ...interface{}) {
if a == nil {
logger.l.Fatal().Msg(format)
} else {
logger.l.Fatal().Msgf(format, a...)
}
}
func (logger *Logger) GetLevel() string {
return logger.l.GetLevel().String()
}
func (logger *Logger) RawJson(key string, raw []byte, format string, a ...interface{}) {
if a == nil {
logger.l.Log().RawJSON(key, raw).Msg(format)
} else {
logger.l.Log().RawJSON(key, raw).Msgf(format, a...)
}
}
func DefaultLogger() *Logger {
return defaultLogger
}
func IsDebugLevel() bool {
return defaultLogger.GetLevel() == zerolog.DebugLevel.String()
}
func Debug(format string, a ...interface{}) {
defaultLogger.Debug(format, a...)
}
func Info(format string, a ...interface{}) {
defaultLogger.Info(format, a...)
}
func Warn(format string, a ...interface{}) {
defaultLogger.Warn(format, a...)
}
func Error(format string, a ...interface{}) {
defer afterErrorOpt(format, a...)
defaultLogger.Error(format, a...)
}
func Panic(format string, a ...interface{}) {
defer afterErrorOpt(format, a...)
defaultLogger.Panic(format, a...)
}
func Fatal(format string, a ...interface{}) {
defaultLogger.Fatal(format, a...)
}
func afterErrorOpt(format string, a ...interface{}) {
// TODO error和panic错误日志执行之后坐的操作,可以将它们通过消息队列发送出去,触发一个告警
//var msg string
//if a == nil {
// msg = format
//} else {
// msg = fmt.Sprintf(format, a...)
//}
}
agent系统服务
ini
[Unit]
Description=Operations Agent
# Documentation= none
After=network.target
# Wants=sshd-keygen.service
[Service]
Type=notify
User=root
ExecStart= /app/agent/agent --config.file=/app/agent/agent.yaml --log.directory=/app/agent/logs/
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
WatchdogSec=30s
NotifyAccess=all
Restart=on-failure
RestartSec=5s
TimeoutStartSec=300s
# StartLimitIntervalSec=0
[Install]
WantedBy=multi-user.target
完整解读这个 systemd agent service 文件
ini
[Unit]
Description=Operations Agent
# Documentation= none
After=network.target
# Wants=sshd-keygen.service
[Service]
Type=notify
User=root
ExecStart= /app/agent/agent --config.file=/app/agent.yaml --log.directory=/app/agent/logs/
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
WatchdogSec=30s
NotifyAccess=all
Restart=on-failure
RestartSec=5s
TimeoutStartSec=300s
# StartLimitIntervalSec=0
[Install]
WantedBy=multi-user.target
刚好和你上面Go代码
sdnotify + watchdog配套使用。
Unit 区块:单元基础信息、启动依赖
Description=Operations Agent服务描述,仅展示用途,无执行逻辑,systemctl status agent时会显示这段文字。After=network.target在网络服务就绪之后,才启动本 agent
- 含义:network.target代表系统网络栈(网卡、IP)初始化完成。
- 不代表等待外网连通;只是本机网络子系统就绪。agent如果要访问远端服务(如日志上报、向量库、API),只靠这个不够。
After只是启动顺序,不等于强依赖;网络服务失败,本服务依然会启动。
Service 区块:核心运行配置,和你的Go代码强关联
Type=notify
🔴和你Go代码
sdnotify.Ready配对! systemd会等待你的程序主动发送READY=1通知 ,才标记服务状态为active (running)。
- 程序启动到发送READY之前,状态是
activating - 如果300s(TimeoutStartSec)内没有收到READY,systemd判定启动超时,直接杀掉进程。
User=root进程以root用户运行。⚠生产尽量避免root,权限风险大;但agent要读取很多系统指标、挂载设备时会这么配置。ExecStart=xxx启动命令:运行/app/agent/agent二进制,携带配置文件、日志目录参数。ExecReload=/bin/kill -HUP $MAINPID执行systemctl reload agent的动作:给主进程发送SIGHUP。 前提:你的Go程序要实现SIGHUP信号处理,重新加载配置文件;否则这条配置无效。
如果代码没有捕获SIGHUP,reload命令只会发送信号,程序不会做任何配置重载。
KillMode=process当执行systemctl stop agent时:只杀主进程PID,不会去清理主进程派生的所有子进程。
- 对比默认control-group:会杀掉整个cgroup内全部子进程
- 适合你的场景:agent自己托管子协程,goroutine属于进程内线程,不受影响;如果agent拉起外部子进程,子进程stop后会残留。
WatchdogSec=30s
🟡和你Go看门狗代码配对! systemd看门狗超时时间:30秒 。 规则: 如果连续30s没有收到程序发来的
WATCHDOG=1,systemd判定进程卡死,自动重启服务。 你的Go代码每10s发送一次WATCHDOG=1,留有2倍冗余,非常合理。
NotifyAccess=all允许进程所有子进程都可以向systemd发送sd-notify消息(READY/WATCHDOG/STOPPING) 。 默认是main:仅主进程可以发送通知。 你的场景:看门狗是在goroutine发送,goroutine属于同一进程,main默认其实也够用;写all更保险,防止后续fork子进程发送通知失败。Restart=on-failure重启策略:只有进程异常失败才重启。
- 进程正常退出(exit code=0):不重启
- 进程崩溃、被OOM杀死、看门狗超时被杀、非0退出码:触发重启
RestartSec=5s触发重启后,等待5秒再拉起新进程,防止短时间无限疯狂重启。TimeoutStartSec=300s启动超时上限300秒(5分钟) Type=notify模式下:从启动开始计时,如果5分钟内程序没有发送READY=1,systemd直接杀掉进程,标记启动失败。
适合agent需要初始化、拉取配置、连接远端存储,启动较慢的场景。
Install 区块:安装启用配置
WantedBy=multi-user.target 执行 systemctl enable agent 的时候,会在多用户运行级别建立软链接。 含义:系统进入多用户文本模式(非图形桌面)时,自动启动这个服务。服务器(Linux服务器无图形界面)标准写法。
✅ 配套校验清单(对应你的Go代码)
- Go代码发送
sdnotify.Ready对应Type=notify✔匹配 - Go每10s发送WATCHDOG=1,
WatchdogSec=30s✔匹配 - 启动超时300s,给agent充足初始化时间 ✔合理
⚠️当前service文件潜在风险
KillMode=process:如果agent创建外部子进程,stop的时候子进程不会被清理,容易出现僵尸残留。User=root权限过高,安全隐患。- ExecReload依赖代码实现SIGHUP捕获;如果代码没有处理SIGHUP,
systemctl reload是无效操作。 - 看门狗:哪怕进程还活着,但goroutine卡住不发WATCHDOG,就会被重启,正好用来捕获死锁/阻塞。
常用操作命令
bash
# 加载新service配置
systemctl daemon-reload
# 设置开机自启
systemctl enable agent
# 启动
systemctl start agent
# 查看状态
systemctl status agent
# 重载(发SIGHUP)
systemctl reload agent
# 停止
systemctl stop agent
# 看日志
journalctl -u agent -f
优化后的 agent.service 完整文件
ini
[Unit]
Description=Operations Agent
Documentation=man:agent(1)
# 等网络栈就绪后再启动,仅保证本机网卡就绪,不保证远端服务连通
After=network.target
# 网络如果启动失败,本服务依然会尝试启动(After不是强依赖)
[Service]
# notify模式,等待程序主动发送 READY=1 标记启动完成
Type=notify
# 建议尽量不要root;如果必须root保留,否则改成普通业务用户
User=root
#Group=agent
# 启动命令
ExecStart=/app/agent/agent --config.file=/app/agent/agent.yaml --log.directory=/app/agent/logs/
# systemctl reload 发送SIGHUP,需要Go代码捕获SIGHUP实现配置热加载
ExecReload=/bin/kill -HUP $MAINPID
# ========== 优化项 ==========
# 改为 control-group:stop时杀掉整个cgroup内所有子进程,防止子进程残留僵尸
KillMode=control-group
# 优雅停止等待超时:发SIGTERM后,等待10s,还没退出强制SIGKILL
TimeoutStopSec=10
# 看门狗配置,和Go代码10s喂狗配套,30s超时
WatchdogSec=30s
# 允许本进程内所有线程/goroutine发送notify消息
NotifyAccess=all
# 重启策略:仅异常失败重启;正常exit(0)不重启
Restart=on-failure
# 崩溃后,延迟5s再重启,避免高频反复拉起
RestartSec=5s
# 启动最长等待时间:5分钟内必须收到READY=1,否则判定启动失败杀掉进程
TimeoutStartSec=300s
# 资源限制(新增,生产必加,防止进程耗尽系统资源)
# 最大打开文件句柄,日志采集agent建议调高
LimitNOFILE=65535
# 最大进程数限制
LimitNPROC=4096
# 核心转储:崩溃生成core文件用于排查死锁(不需要可以注释掉)
LimitCORE=infinity
# 安全加固(即使root运行,降低风险)
# 禁止进程拥有新权限
NoNewPrivileges=yes
# 禁止访问/dev/kmem等内核内存设备
ProtectKernelMemory=yes
# 禁止修改sysctl参数
ProtectKernelTunables=yes
# 只读系统目录,仅保留必要可写目录
ProtectSystem=strict
ReadWritePaths=/app/agent /tmp
[Install]
# 进入多用户模式自动启动
WantedBy=multi-user.target
✅ 主要改动说明
- KillMode=control-group(重点修复) 原来
KillMode=process只会杀主进程,外部子进程残留;改成control-group,systemctl stop会清理所有子进程,杜绝僵尸进程。
goroutine属于进程内线程不受cgroup影响,完全兼容你的Go程序。
- 新增 TimeoutStopSec=10 停止时,先发送SIGTERM,给10秒时间让你的Go代码捕获信号、cancel上下文、发送
STOPPING=1,优雅关闭;超时强制SIGKILL杀死。 - 资源Limit(针对日志/指标采集agent)
LimitNOFILE=65535:agent大量读日志文件、建立网络连接,调高文件句柄上限,避免too many open files。 - 安全加固配置(ProtectSystem、NoNewPrivileges) 即使使用root运行,限制程序修改系统目录、提升权限,缩小安全攻击面。
注意:
ProtectSystem=strict会把系统目录挂载为只读。 如果你的agent需要读取/proc、/sys采集系统指标,需要额外加上ProtectProc=default,或者把ProtectSystem=strict改成ProtectSystem=yes。
如果采集宿主机指标(cpu/mem/disk),建议修改:
ini
# 替换上面 ProtectSystem 三行
ProtectSystem=yes
ProtectProc=default
📌 配套操作步骤
bash
# 编辑service文件
vi /etc/systemd/system/agent.service
# 重新加载systemd配置
systemctl daemon-reload
# 启用开机自启
systemctl enable agent
# 重启服务
systemctl restart agent
# 查看状态
systemctl status agent
# 实时看日志
journalctl -u agent -f
⚠️ 配套Go代码配合要点
- 程序收到SIGTERM信号,调用
cancel()取消全局context - 在ctx.Done分支,调用
n.Notify(sdnotify.Stopping)发送停止通知 - 优雅关闭日志、连接,然后正常退出(exit 0),这样systemd识别为正常退出,不会触发Restart重启
如果你需要,我把完整main函数(带SIGTERM、SIGHUP信号捕获 + 集成StartWatchDogNotify) 一并给你。
简单引用
go
main.go
package main
import(
"system"
)
func main(){
省略。。。。。
system.WatchDogNotify()
}