microrain头像

SagooIoT 的观测入口只有一个,出口有三个

写这篇文章的起因,是同一周里三个不同岗位的人问了我三个问题。

做设备接入的同事问:"那条属性设置指令,到底是在哪个进程、哪一秒被处理的?"运维问:"昨天下午四点那阵子卡,是数据库慢还是我自己的 SQL 慢?"负责部署的同事问:"我新加一个进程,要接几处才能让它出现在监控里?"

三个问题,其实指向同一件事:一套系统好不好排障,不取决于它有没有日志,而取决于观测的入口是不是只有一个。入口多了,每个进程自己接一套,问题就变成"这次该看哪一份"。

SagooIoT 在这一块的做法比较克制:启动链上留一个统一入口,然后向三个方向出口——链路(trace)、日志(log)、运行时剖面(pprof)。这篇文章就按这个顺序,把三个出口分别拆开看。

一、入口在 AllSystemInit 里,排在环境检查之前

所有走标准启动流程的进程,都会先经过 command.AllSystemInit。它在里面按固定顺序铺开几件事:解析启动参数、设置运行模式、锁定时区、初始化链路追踪,然后才去检查数据库、Redis、时序库能不能连上。

func AllSystemInit(ctx context.Context) {
	//运行参数处理
	FlagInit(ctx)
	// 设置gf运行模式
	SetGFMode(ctx)
	// 默认上海时区
	if err := gtime.SetTimeZone("Asia/Shanghai"); err != nil {
		_ = gtime.SetTimeZone("Local")
		g.Log().Debugf(ctx, "时区设置异常,系统时区设置为:Local 。err:%+v", err)
	}
	// 初始化链路追踪
	InitTrace(ctx)

	// 检查环境
	if err := CheckEnvironment(ctx); err != nil {
		fmt.Println("系统启动失败,环境检查未通过:", err.Error())
		os.Exit(1)
	}
	// ...缓存适配器、系统参数、规则引擎数据
}

顺序是有讲究的。InitTrace 放在环境检查前面,意味着启动失败这件事本身也能被观测到。如果反过来,时序库连不上、进程直接退出,追踪还没起来,这条最关键的"起不来"记录就只能靠翻终端输出。

时区也放在前面,是因为日志时间戳、gtime 的默认时区、以及后面所有 span 的时间都受它影响。默认上海时区,取不到就退回 Local,并且这条降级本身也打一条 Debug 日志——降级要留下痕迹,但不该升级成 Error 把人半夜叫起来。

进程入口本身很短,短到几乎没有可观测性的痕迹。这正是设计意图:main 里不该出现"初始化 Jaeger"这种字眼。

func main() {
	version.ShowLogo(BuildVersion, BuildTime, CommitID)
	ctx := gctx.GetInitCtx()
	command.AllSystemInit(ctx)
	app := command.GetServer(ctx)
	cmd := command.NewMainCommand(app.Server)
	cmd.Run(ctx)
}

二、一个 bool 开关,决定这个进程要不要长出链路

链路追踪默认是关的。InitTrace 的第一件事就是读配置,读不到就直接返回,一行都不往下走。

func InitTrace(ctx context.Context) {
	if !g.Cfg().MustGet(ctx, "jaeger.switch").Bool() {
		return
	}

	tp, err := otlptrace.Init(helper.AppName(ctx), g.Cfg().MustGet(ctx, "jaeger.endpoint").String())
	if err != nil {
		g.Log().Fatal(ctx, err)
	}

	helper.Event().Register(consts.EventServerClose, func(ctx context.Context, args ...interface{}) {
		_ = tp.Shutdown(ctx)
		g.Log().Debug(ctx, "jaeger closed ..")
	})
}

配置里对应两行:

jaeger:
  endpoint: "http://127.0.0.1:4318" # Jaeger服务地址

这里有个取舍值得说:开关用的是独立的 jaeger.switch,而不是"endpoint 为空就不启用"。后者看起来很省事,但语义会变得很滑——地址写错一个字符,系统就静默地不采数据了,而你以为它在采。独立开关把"要不要采"和"采到哪里"分成两个问题,排查时先看开关、再看地址,不会互相干扰。

开发环境不配这个键,默认关闭,本地跑起来不会有任何多余的网络请求;生产打开,一次配置全进程生效。

三、进程身份:名字来自配置,地址来自网卡

链路数据发到后端,第一件事是回答"这是谁发来的"。otlptrace.Init 接两个参数:服务名和端点。服务名不是写死的常量,而是从配置读:

// AppName 应用名称
func AppName(ctx context.Context) string {
	return g.Cfg().MustGet(ctx, "system.name", "sagooiot").String()
}

默认值兜底成 sagooiot。多进程部署时,各进程的配置文件里把 system.name 写成不同的值,后端就能直接按服务名分组——不用在代码里判断"我是 core 还是 admin"。

机器地址则来自网卡枚举,而且有三级兜底:

func getHostIP() (string, error) {
	// 先尝试获取内网 IP
	intranetIPArray, err := gipv4.GetIntranetIpArray()
	if err != nil {
		return "", err
	}

	// 如果内网 IP 为空,尝试获取所有 IP
	if len(intranetIPArray) == 0 {
		intranetIPArray, err = gipv4.GetIpArray()
		if err != nil {
			return "", err
		}
	}

	// 如果有可用 IP,返回第一个
	if len(intranetIPArray) > 0 {
		return intranetIPArray[0], nil
	}

	return defaultHostIP, nil
}

优先内网 IP,是因为物联网系统基本都部署在内网;拿不到内网再退到全部网卡;实在一个都没有,返回常量 NoHostIpFound 而不是空字符串。这个常量的价值在于可查询:后端里看到一堆 NoHostIpFound,说明是容器网络异常,而不是"数据缺字段"。

拿到的地址会被写进 Resource 的三个属性里:

Resource 属性取值来源用途
semconv.ServiceNameKey配置 system.name后端按服务名分组、区分进程
semconv.HostNameKeygetHostIP() 结果标准语义键,兼容通用查询面板
attribute "hostname"同上自定义键,方便按主机筛选

HostNameKey 里填的其实是 IP 而不是主机名。这在容器化部署里是个很实用的选择:主机名常常是随机串,两台机器重名也不稀奇,而 IP 在故障时刻是最短的那条线索——拿到 IP 就能直接登上去看。

四、一个进程只允许有一个 Provider

otel.SetTracerProvider 设置的是全局变量。这件事带来一个容易被忽略的后果:如果初始化被调用两次,第二次会把第一次的 provider 覆盖掉,而第一次那个 provider 的批处理队列里攒着的 span,就再也没人负责导出了。

otlptrace 用 sync.Once 把这件事彻底封住:

var (
	// 使用 sync.Once 确保只初始化一次
	initOnce sync.Once
	// 缓存 TracerProvider 实例
	globalTP *trace.TracerProvider
)

func Init(serviceName, endpoint string) (*trace.TracerProvider, error) {
	var initErr error

	// 使用 sync.Once 确保只初始化一次,避免重复初始化
	initOnce.Do(func() {
		globalTP, initErr = initTracer(serviceName, endpoint)
	})

	if initErr != nil {
		return nil, initErr
	}
	return globalTP, nil
}

Init 被设计成可以随便调:多处调用拿到的是同一个 *trace.TracerProvider 指针,Shutdown 也只需要执行一次。对使用方来说,最省心的写法是"我需要追踪就在启动时调一次",不需要自己维护单例。

导出器用的是 HTTP 版 OTLP,带批处理:

tp := trace.NewTracerProvider(
	// 使用批处理器提高性能
	trace.WithBatcher(exp),
	// 记录应用信息到 Resource
	trace.WithResource(resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceNameKey.String(serviceName),
		semconv.HostNameKey.String(hostIP),
		attribute.String(tracerHostnameTagKey, hostIP),
	)),
)

// 设置全局 TracerProvider
otel.SetTracerProvider(tp)

批处理在这里不是"性能优化",而是隔离:span 结束时只是把数据塞进队列,真正发网络请求是后台协程做的事。业务代码的耗时不会被追踪后端的抖动带偏,后端挂了也不会把请求链路拖慢。

五、端点两种写法,超时只有一个值

配置里的人写地址的习惯不一样:有人写 127.0.0.1:4318,有人写完整的 http://127.0.0.1:4318。getEndpointOption 把两种都接住了:

// 设置超时时间
opts = append(opts, otlptracehttp.WithTimeout(defaultTimeout))

// 如果endpoint是http开头的完整URL
if strings.HasPrefix(endpoint, "http") {
	opts = append(opts, otlptracehttp.WithEndpoint(endpoint))
	return opts, nil
}

// 解析 endpoint 格式
match, err := gregex.MatchString(`(.+):(\d+)`, endpoint)
if err != nil {
	return nil, err
}
if len(match) < 3 {
	return nil, gerror.NewCodef(
		gcode.CodeInvalidParameter,
		`invalid endpoint "%s"`,
		endpoint,
	)
}

// 构造完整的 HTTP endpoint
httpEndpoint := match[1] + ":" + match[2]
opts = append(opts, otlptracehttp.WithEndpoint(httpEndpoint))

// 默认使用 HTTP
opts = append(opts, otlptracehttp.WithInsecure())
配置写法走的分支传输
http://127.0.0.1:4318前缀判断命中按 URL 里的 scheme
127.0.0.1:4318正则拆 host:portWithInsecure(),明文 HTTP
127.0.0.1(无端口)正则不匹配返回 CodeInvalidParameter 并回显原文

第三行的处理方式是这段代码里我最喜欢的一处:参数不合法时不是返回一个含糊的 errors.New("bad config"),而是带上 gcode.CodeInvalidParameter 错误码,并且把原始字符串回显出来。运维看到的是 invalid endpoint "127.0.0.1",一眼知道少了个端口,而不是去翻文档猜格式。

超时统一 5 秒:

defaultTimeout = 5 * time.Second

这个值调得不激进。导出是后台批量做的,5 秒足够一次正常的 OTLP/HTTP 往返;真超时了,Shutdown 的调用方也不会被无限期挂住。

六、批处理队列要有人收尾

批处理有代价:进程退出时,队列里可能还有没发出去的 span。所以 InitTrace 在拿到 tp 之后,立刻把它挂到进程关闭事件上:

helper.Event().Register(consts.EventServerClose, func(ctx context.Context, args ...interface{}) {
	_ = tp.Shutdown(ctx)
	g.Log().Debug(ctx, "jaeger closed ..")
})

Shutdown 会先把队列刷干净再关连接,这正是退出前需要的那一下。

承接这个动作的是一个很小的进程内事件总线,按分组存回调:

type sEvent struct {
	sync.Mutex
	list map[string][]EventFunc // 所有事件的列表
}

func (e *sEvent) Register(group string, callback EventFunc) {
	e.Lock()
	defer e.Unlock()
	e.list[group] = append(e.list[group], callback)
}

func (e *sEvent) Call(group string, ctx context.Context, args ...interface{}) {
	if events, ok := e.list[group]; ok {
		for _, f := range events {
			f(ctx, args...)
		}
	}
}

它的好处是把"收尾动作"从 main 里挪走了。谁申请了资源,谁自己登记释放;关闭流程只负责 Call("server.close"),不需要知道一共有几个组件要收尾。以后再加一个需要优雅退出的组件,改的是组件自己,不是启动代码。

分组用字符串常量,consts.EventServerClose 的值是 "server.close"。常量集中在一处,避免 A 写 server.close、B 写 server-close 这种拼写漂移。

七、日志:三种来源,落同一个文件

链路只覆盖"请求怎么走",日志覆盖"发生了什么"。SagooIoT 的日志有两个原生来源和两个外来的来源,logadapter 就是那道闸门。

日志来源适配方式最终落点
业务代码 g.Log()gf 原生resource/log/server
标准库 log.LoggercustomWriter 实现 io.Writer同上
log/slog(MQTT Broker)SlogAdapter 实现 slog.Handler同上
SQL 日志gf ORM 内建resource/log/sql

先是标准库这一路。核心就是让 io.Writer 的 Write 把字节转手给 gf:

type customWriter struct {
	ctx    context.Context
	prefix string
}

func (w *customWriter) Write(p []byte) (n int, err error) {
	// 使用 gf 的日志功能记录日志
	g.Log().Debugf(w.ctx, "%s: %s", w.prefix, string(p))
	return len(p), nil
}

// GetLogAdapter 返回标准库logger实例
func GetLogAdapter(ctx context.Context, prefix string) *log.Logger {
	writer := &customWriter{
		ctx:    ctx,
		prefix: prefix,
	}
	// 使用 log.New 创建新的 Logger 实例
	return log.New(writer, "", log.LstdFlags)
}

关键在于 return len(p), nil。io.Writer 的约定是"没有报错就必须返回完整的长度",很多适配器在这里返回 0 或者部分长度,后果是调用方认为写失败、反复重试,最后日志重复。这里老老实实返回 len(p),语义是正确的。

再进一步是 LoggerWrapper,它把 writer 留了个引用出来:

func (l *LoggerWrapper) SetContext(ctx context.Context) {
	l.writer.ctx = ctx
}

func (l *LoggerWrapper) SetPrefix(prefix string) {
	l.writer.prefix = prefix
}

有了这两个方法,一个长期存在的 logger 可以在不同请求之间切换 ctx——日志里的 RequestId 跟着变,而不用为每个请求都新建一个 logger。

八、slog 那一桥:把 Broker 的日志并进来

MQTT Broker 用的是 mochi-mqtt v2,它的 Options.Logger 类型是 *slog.Logger。如果不管它,Broker 的日志会走到 stderr,和业务日志分家,排障时要开两个窗口。

SlogAdapter 做的就是把 slog.Handler 实现一遍,转发到 *glog.Logger:

func (a *SlogAdapter) Handle(ctx context.Context, r slog.Record) error {
	// 构建日志内容
	attrs := make(map[string]interface{})
	r.Attrs(func(attr slog.Attr) bool {
		attrs[attr.Key] = attr.Value.Any()
		return true
	})

	// 根据不同级别调用对应的日志方法
	switch r.Level {
	case slog.LevelDebug:
		a.logger.Debug(ctx, r.Message, attrs)
	case slog.LevelInfo:
		a.logger.Info(ctx, r.Message, attrs)
	case slog.LevelWarn:
		a.logger.Warning(ctx, r.Message, attrs)
	case slog.LevelError:
		a.logger.Error(ctx, r.Message, attrs)
	}
	return nil
}

两个细节值得注意。

第一,slog.Record 里的属性是链式的,用 Attrs 回调收集成 map,再作为 gf 的结构化字段传进去。这样 Broker 输出的 {"id": "tcp", "protocol": "http", "address": ":1883"} 这类键值,在日志文件里会是可读的键值对,而不是被压成一行字符串。

第二,Enabled 恒返回 true:

func (a *SlogAdapter) Enabled(ctx context.Context, level slog.Level) bool {
	return true
}

这不是偷懒,而是把过滤权收归一处。gf 的日志级别在 config.yaml 的 logger.level 里配(默认 all),如果这里再用 slog 自己的 level 过滤一遍,就会出现"两个地方都能关日志"的局面——调了一个发现没生效,因为另一个也在拦。恒 true 表示"我不拦,交给下面的 gf 决定"。

接线只有两行:

adapter := logadapter.NewSlogAdapter(ctx, g.Log())
server := mqtt.New(&mqtt.Options{
	// ...
	InlineClient: true,
	Logger:       slog.New(adapter),
})

InlineClient: true 让 Broker 自带一个内联客户端,配合前面提到的 $SYS 主题,Broker 自己的状态和业务日志就都在同一个进程、同一份文件里了。

九、RequestId 是把一次请求串成一条线的钩子

一次 HTTP 请求会碰十几张表、调几个服务,日志会散成几十行。把它们重新串起来靠的是 ctx 里的请求 ID——gf 的 logger 支持 ctxKeys,配置了哪些键,就把 ctx 里对应的值自动打进每行日志的头部。

# 日志配置
logger:
  ctxKeys: []        # 业务日志这一路,留空
# 数据库连接配置
database:
  logger:
    path: "resource/log/sql"
    level: "error"
    stdout: false
    ctxKeys: [ "RequestId" ]

数据库这一路已经开了 RequestId,所以慢 SQL 能和业务日志对上;业务日志这一路留了空位,是因为它在不同部署形态下取值不同,留给现场按需填。要串起来,把同一行写成 ctxKeys: [ "RequestId" ] 即可。

这是一个我很喜欢的配置设计:留空而不是写死。写死了,某个环境不产生这个键,日志头部就会多出一对空的 [];留空,则是在需要的时候填一行,不填也没有噪音。

十、日志分目录:业务一份,SQL 一份

日志落盘的位置和轮转策略都在配置里,两个目录分开:

配置项值作用
logger.pathresource/log/server业务日志目录
logger.file{Y-m-d}.log一天一个文件
logger.rotateExpire1d按天回滚
logger.rotateBackupLimit7只留最近 7 天
logger.stdouttrue同时打到终端,便于前台调试
database.logger.pathresource/log/sqlSQL 日志单独目录
database.logger.levelerror只记错误级 SQL

按天切、留 7 天,是在"够查"和"别把磁盘写满"之间取的一个中间值——大部分线上问题当天就发现,隔周再翻的概率很低。

SQL 日志单独一路并且只记 error,是为了避免它淹掉业务日志:ORM 的 SQL 日志在 all 级别下会把每次查询都打出来,一个页面几十条,业务日志夹在中间根本看不见。只留错误级,等于把它定位成"出问题时才需要看的那份"。

十一、运行时剖面:只在打开开关时才有成本

第三个出口是运行时的自我剖面。InitPProf 也是先看开关:

func InitPProf(signalChannel chan os.Signal, defaultPprofPort string) {
	//开启系统性能分析
	enablePProf := g.Cfg().MustGet(context.Background(), "system.enablePProf").Bool()
	if enablePProf {
		pprofPort := g.Cfg().MustGet(context.Background(), "system.pprofPort").String()
		if pprofPort == "" {
			pprofPort = defaultPprofPort
		}
		RunSystemAnalysis(signalChannel, pprofPort) // 运行系统分析
	}
}

端口可以从配置读,读不到才用调用方传进来的默认值。这个"配置优先、默认兜底"的写法让多进程部署时不必改代码——各进程在自己的配置里写 system.pprofPort 即可。

真正启动的部分做了三件事:

func RunSystemAnalysis(stopSignal chan os.Signal, pprofPort string) {
	// 开启性能分析
	runtime.SetMutexProfileFraction(1) // (非必需)开启对锁调用的跟踪
	runtime.SetBlockProfileRate(1)     // (非必需)开启对阻塞操作的跟踪
	// 将Go程序运行时的各种内部数据进行可视化的展示,如可以展示:堆、对象、协程、GC等信息
	err := statsviz.RegisterDefault()
	if err == nil {
		g.Log().Infof(context.Background(), "System performance analysis browser to http://localhost:%s/debug/statsviz/", pprofPort)
		go func() {
			defer func() {
				if err := recover(); err != nil {
					g.Log().Debug(context.Background(), "RunSystemAnalysis panic: %+v", err)
				}
			}()

			s := &http.Server{
				Addr:         ":" + pprofPort,
				ReadTimeout:  30 * time.Second,
				WriteTimeout: 30 * time.Second,
				IdleTimeout:  30 * time.Second,
			}
			if err := s.ListenAndServe(); err != nil {
				fmt.Println(err)
			}
			stopSignal <- syscall.SIGQUIT
		}()
	}
}

SetMutexProfileFraction(1) 和 SetBlockProfileRate(1) 是采样的开关,1 表示全量采集。全量会带来一点开销,所以它们被包在 enablePProf 里面——默认关闭时,这两行根本不会执行,运行时零成本。

statsviz.RegisterDefault() 提供的是浏览器里的实时视图:堆、对象、协程、GC,刷新页面就能看到曲线,不需要先在终端敲 go tool pprof 再去读火焰图。对一个需要长时间观察协程数量变化的物联网服务来说,这个视角比一次性的快照有用得多。

剖面服务跑在独立端口上,和业务端口分开;http.Server 三个超时都设成 30 秒,避免慢连接占着 goroutine 不放。监听失败时把 SIGQUIT 送回停止信号通道,让进程按正常路径退出,而不是留一个半死的服务。

多进程部署时各进程端口不同,比如 core 用 58088、admin 用 58089,配置里各写各的,不冲突。

十二、三个出口的边界

回头看,这三个出口各自回答一个不同层次的问题,边界划得比较清楚:

出口回答的问题数据形态开关
链路 trace这次调用经过了哪些环节、各花多久结构化 span,发往 OTLP 后端jaeger.switch
日志 log那一刻系统认为发生了什么文本 + 结构化字段,落盘logger.level
剖面 pprof进程内部的资源在怎么用运行时快照与实时曲线system.enablePProf

三者不重叠,也不互相替代。链路能告出"慢在哪一环",但说不出"这一环里的锁等了多久";日志能说清"做了什么决定",但说不出"决定完花了多久";剖面能指出"协程在涨",但不知道"是哪个请求带来的"。

把三者接在同一个启动入口上,好处是加进程的时候成本很低:新进程只要走标准的 AllSystemInit,链路和剖面的开关就自动生效;日志因为走的是 gf 的全局配置,也是自动生效;只有在用到第三方库(比如 Broker)时才需要额外接一个 slog 桥,而那也只有两行。

结语

可观测性这件事,难点很少在"要不要做",而在"接在哪里"。接在每个进程的 main 里,十个进程就是十份重复代码;接在一个统一入口上,加进程只需要复制一段 main。

SagooIoT 的选择是后者:AllSystemInit 里一行 InitTrace,配置里一个 jaeger.switch,日志靠 gf 的全局配置,剖面靠 system.enablePProf。三个开关、一个入口,剩下的都是适配器的事——把标准库的、slog 的、Broker 的日志,统统导进同一份文件。

代码量不大,pkg/otlptrace 一百多行、pkg/logadapter 一百来行,但把"起不来时也能看见"、"退出前把队列刷干净"、"两种地址写法都能接"、"两个地方都能关日志"这些容易漏的边角都照顾到了。这类基础组件写得好不好,平时看不出来,出问题时差别很大。

项目地址

开源仓库:https://github.com/sagoo-cloud

在线文档:https://iotdoc.sagoo.cn

本文代码基于本地部署的 SagooIoT 专业版,涉及 pkg/otlptrace、pkg/logadapter、pkg/utility/helper、command/global.go、command/pprof.go、mqttserver/mqtt.go 与 manifest/config/config.yaml。社区版源码可在 GitHub 组织页查看,两版在可观测性这一层的设计思路一致。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/microrain/article/details/169030463

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--