gin 框架可观测性最佳实践

63 阅读7分钟

背景

在云原生时代的今天,Golang编程语言越来越成为开发者们的首选,而对于Golang开发者来说,最著名的Golang Web框架莫过于Gin框架了,Gin框架作为Golang编程语言官方的推荐框架,其提供了丰富的路由与中间件功能,使得Golang开发者可以轻松地构建复杂的Web应用。对于如此重要的Web框架,如何去快速而全面地对Gin应用进行监控成为了一大难题,本文将着重介绍Gin框架官方推荐的几种可观测性方案并进行对比,从而得出Gin框架可观测性的最佳实践。

观测方案一览

Gin官方提供了丰富的插件来帮助开发者快速地搭建Web应用,在官方提供的插件列表中,提供了对OpenTelemetry的几种支持方案,分别是SDK手动埋点方案编译时注入方案,以及eBPF方案,下面分别来对官方推荐的三种观测方案进行实践:

前置准备

  1. 首先使用Gin框架编写一个简单的Golang应用:

    package main

    import ( "io" "log" "net/http" "time"

        "github.com/gin-gonic/gin"
    

    )

    func main() { r := gin.Default() r.GET("/hello-gin", func(c *gin.Context) { c.String(http.StatusOK, "hello\n") }) go func() { _ = r.Run() }()

        // give time for auto-instrumentation to start up
        time.Sleep(5 * time.Second)
        for {
          resp, err := http.Get("http://localhost:8080/hello-gin")
          if err != nil {
                  log.Fatal(err)
          }
          body, err := io.ReadAll(resp.Body)
          if err != nil {
                  log.Fatal(err)
          }
    
          log.Printf("Body: %s\n", string(body))
          _ = resp.Body.Close()
    
          // give time for auto-instrumentation to report signal
          time.Sleep(5 * time.Second)
        }
    

    }

  2. 根据文档快速拉起OpenTelemetry的各种服务端依赖,比如OpenTelemetry Collector,Jaeger,Prometheus等等。

手动埋点

手动埋点方案即是利用了gin框架的Middleware机制,在gin的请求处理过程中为本次请求生成span,我们需要基于以上代码进行改造:

const (
	SERVICE_NAME       = ""
	SERVICE_VERSION    = ""
	DEPLOY_ENVIRONMENT = ""
	HTTP_ENDPOINT      = ""
	HTTP_URL_PATH      = ""
)

// 设置应用资源
func newResource(ctx context.Context) *resource.Resource {
	hostName, _ := os.Hostname()

	r, err := resource.New(
		ctx,
		resource.WithFromEnv(),
		resource.WithProcess(),
		resource.WithTelemetrySDK(),
		resource.WithHost(),
		resource.WithAttributes(
			semconv.ServiceNameKey.String(SERVICE_NAME), // 应用名
			semconv.ServiceVersionKey.String(SERVICE_VERSION), // 应用版本
			semconv.DeploymentEnvironmentKey.String(DEPLOY_ENVIRONMENT), // 部署环境
			semconv.HostNameKey.String(hostName), // 主机名
		),
	)

	if err != nil {
		log.Fatalf("%s: %v", "Failed to create OpenTelemetry resource", err)
	}
	return r
}

func newHTTPExporterAndSpanProcessor(ctx context.Context) (*otlptrace.Exporter, sdktrace.SpanProcessor) {

	traceExporter, err := otlptrace.New(ctx, otlptracehttp.NewClient(
		otlptracehttp.WithEndpoint(HTTP_ENDPOINT),
		otlptracehttp.WithURLPath(HTTP_URL_PATH),
		otlptracehttp.WithInsecure(),
		otlptracehttp.WithCompression(1)))
	
	if err != nil {
		log.Fatalf("%s: %v", "Failed to create the OpenTelemetry trace exporter", err)
	}

	batchSpanProcessor := sdktrace.NewBatchSpanProcessor(traceExporter)

	return traceExporter, batchSpanProcessor
}

// InitOpenTelemetry OpenTelemetry 初始化方法
func InitOpenTelemetry() func() {
	ctx := context.Background()

	var traceExporter *otlptrace.Exporter
	var batchSpanProcessor sdktrace.SpanProcessor

	traceExporter, batchSpanProcessor = newHTTPExporterAndSpanProcessor(ctx)

	otelResource := newResource(ctx)

	traceProvider := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithResource(otelResource),
		sdktrace.WithSpanProcessor(batchSpanProcessor))

	otel.SetTracerProvider(traceProvider)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))

	return func() {
		cxt, cancel := context.WithTimeout(ctx, time.Second)
		defer cancel()
		if err := traceExporter.Shutdown(cxt); err != nil {
			otel.Handle(err)
		}
	}
}

func main() {
    r := gin.Default()
    // 初始化您的OpenTelemetry
    tp, err := InitOpenTelemetry()
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		if err := tp.Shutdown(context.Background()); err != nil {
			log.Printf("Error shutting down tracer provider: %v", err)
		}
	}()
    // 添加gin的OpenTelemetry中间件实现
    r.Use(otelgin.Middleware("my-server"))
    r.GET("/hello-gin", func(c *gin.Context) {
        c.String(http.StatusOK, "hello\n")
    })
}

通过在代码里面对gin服务添加OpenTelemetry中间件,可以有效地收集到gin应用本身的调用链路信息:

可以看到,手动接入的方案需要对代码进行比较大的改造,需要去手动引入依赖,初始化SDK,并手动注入middleware,此外,该方案只能收集到gin应用本身的链路信息,对于gin的上游和下游应用也需要进行代码的改造才能将整个链路进行打通和串联。

编译时注入自动埋点

除了手动埋点方案,官方还推荐了编译时自动注入方案来实现在零代码修改的观测方案,用户可以参考阿里巴巴开源的编译时自动插桩项目对上述实例程序进行插桩:

step 1: 下载Golang Agent二进制包

首先,可以进入主页下载最新版本的Golang Agent二进制包

step 2: 使用Golang Agent二进制包编译Golang应用

在拥有了Golang Agent的二进制包后,即可使用该二进制包代替go build编译Golang应用的二进制程序。

otel-linux-amd64 go build .

在执行上述命令后,即可在对应应用的根目录下找到具有可观测能力的Golang二进制程序。

step 3: 配置上报端点,运行二进制程序

最后,通过文档配置观测数据的上报端点,并且启动上一步中编译出来的具有可观测能力的Golang二进制程序:

可以看到,编译出来的二进制Golang程序可以完整地展示出应用的调用链路。

除了链路,编译出来的二进制Golang程序还可以有效地收集gin应用的运行时指标,比如gin应用的调用耗时,gc数量,内存申请次数等等:

eBPF自动埋点

官方提供的最后一种gin应用的观测办法是通过OpenTelemetry的eBPF方案进行自动埋点,eBPF方式只需要在部署应用时在应用进程命名空间下添加一个特权级的sidecar容器,特权级的sidecar容器会自动捕捉应用容器产生的观测数据,并且进行上报。

我们还是对第一步中使用的简易Golang应用进行观测,在Kubernetes环境中部署以下yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/name: emoji
    app.kubernetes.io/part-of: emojivoto
    app.kubernetes.io/version: v11
  name: emoji
  namespace: emojivoto
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: emoji-svc
      version: v11
  template:
    metadata:
      labels:
        app: emoji-svc
        version: v11
    spec:
      containers:
        - env:
            - name: HTTP
              value: '8080'
          image: 'registry.cn-hangzhou.aliyuncs.com/private-mesh/ginotel:latest'
          imagePullPolicy: Always
          name: emoji-svc
          ports:
            - containerPort: 8080
              name: grpc
              protocol: TCP
          resources:
            requests:
              cpu: 100m
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
        - env:
            - name: OTEL_GO_AUTO_TARGET_EXE
              value: /usr/local/bin/app
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: 'http://jaeger.default.svc:4318'
            - name: OTEL_SERVICE_NAME
              value: emojivoto-emoji
          image: >-
            ghcr.io/open-telemetry/opentelemetry-go-instrumentation/autoinstrumentation-go:v0.19.0-alpha
          imagePullPolicy: IfNotPresent
          name: emojivoto-emoji-instrumentation
          resources: {}
          securityContext:
            privileged: true
            runAsUser: 0
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      shareProcessNamespace: true
      terminationGracePeriodSeconds: 0

gin应用产生的观测数据将会被自动地收集并上报至jaeger中:

eBPF方案看起来非常的美好,但是实际使用时却有着各种限制,比如其对于Golang的小版本非常的敏感,demo中的应用,如果我们使用Go 1.23.4版本(升级1个小版本)来进行编译,eBPF就将因为Golang的版本不匹配而无法收集到任何观测数据:

此外,eBPF方案还有其他较多的限制,比如client传递的HTTP Header不能超过8个,又比如eBPF对操作系统的内核版本的要求较高等等,具体可以参照这篇文章

观测方案对比

手动埋点

编译时注入自动埋点

eBPF自动埋点

接入成本

高,需要手动更改较多代码

中,不需要更改代码,但是需要重新编译

低,不需要改代码,也不需要重新编译

兼容性

好,使用限制较少

好,使用限制较少

差,使用限制较多

观测完整度

差,只能观测gin应用这一跳

好,可以方便地观测到上下游,甚至应用的运行指标

中,可以方便地观测到上下游,无法感测应用的运行指标

安全

差,需要使用特权级容器

性能

低,eBPF uProbe对性能影响较大

维护成本

高,需要手动更新依赖

低,无需手动更新依赖

低,无需手动更新依赖

总的来说,手动埋点的自由度更高,但是接入和维护的成本也最高,适合技术能力强的用户自己完全控制。eBPF自动埋点方案接入成本最低,但是随之而来的是性能的开销以及使用场景的各种限制。而编译时注入自动埋点的方案相对来说解决了前两种方案的各种问题,在降低了用户接入维护成本的同时也解决了插桩的性能,安全性等问题,某种程度上是目前最适合客户的gin应用观测方案!

总结和展望

Golang Agen成功解决了Golang应用监控中繁琐的手动埋点问题,并已商业化上线至阿里云公有云,为客户提供强大的监控能力。这项技术最初的设计初衷是为了让用户能够在不改动现有代码的前提下轻松地插入监控代码,从而实现对应用程序性能状态的实时监测与分析,但它的实际应用领域超q越预期,包括服务治理、代码审计、应用安全、代码调试等,甚至在许多未被探索的领域中也展现出潜力。

我们已经将这项创新方案开源,并成功捐赠给OpenTelemetry社区。开源不仅促进技术共享与提升,借助社区的力量还可以持续探索该方案在更多领域上的可能。

最后诚邀大家试用我们的商业化产品,并加入我们的钉钉群(开源群:102565007776,商业化群:35568145),,共同提升Go应用监控与服务治理能力。通过群策群力,我们相信能为Golang开发者社区带来更加优质的云原生体验。