（令和最新版）GoのgRPCサーバーのトレースからヘルスチェックを除外する

# はじめに

Go言語でgRPCサーバーを使ってマイクロサービスを開発している方の中には、OpenTelemetryを活用して分散トレーシングを実装している方も多いと思います。

しかし、ヘルスチェックリクエストのトレースが出力されて邪魔に感じることはありませんか？

この記事では、調べても意外と出てこない、ヘルスチェックをトレースから除外する最新の方法を解説します！

# OpenTelemetryの計装

以下は動作確認用のgRPCサーバーのコードです。

以前はトレースを出力するために `otelgrpc.UnaryServerInterceptor` を使用していましたが、現在は非推奨とされています。代わりに `otelgrpc.NewServerHandler` を使用しましょう。

```go
package main

import (
	"context"
	"errors"
	"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
	"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/filters"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/sdk/trace"
	"google.golang.org/grpc"
	"google.golang.org/grpc/examples/features/proto/echo"
	"google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/reflection"
	"log"
	"net"
)

// HealthService implements the gRPC health checking service.
type HealthService struct{}

// Check メソッドの実装
func (h *HealthService) Check(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
	// 商用サービスなら、ここでデータベースに接続できるかなどをチェックする
	return &grpc_health_v1.HealthCheckResponse{
		// サーバーが正常な状態でなければ NOT_SERVING を返すようにする
		Status: grpc_health_v1.HealthCheckResponse_SERVING,
	}, nil
}

// Watch メソッドの実装
func (h *HealthService) Watch(req *grpc_health_v1.HealthCheckRequest, srv grpc_health_v1.Health_WatchServer) error {
	// ここでは常に SERVING と NOT_SERVING を連続して返し続けてい
	// 本来はサーバーの状態に応じてステータスが変わったときだけ返すようにする
	for {
		if err := srv.Send(&grpc_health_v1.HealthCheckResponse{
			Status: grpc_health_v1.HealthCheckResponse_SERVING,
		}); err != nil {
			return err
		}

		if err := srv.Send(&grpc_health_v1.HealthCheckResponse{
			Status: grpc_health_v1.HealthCheckResponse_NOT_SERVING,
		}); err != nil {
			return err
		}
	}
}

// EchoService implements the Echo service defined in the proto file.
type EchoService struct {
	echo.UnimplementedEchoServer
}

// UnaryEcho returns the same message sent by the client.
func (e *EchoService) UnaryEcho(ctx context.Context, req *echo.EchoRequest) (*echo.EchoResponse, error) {
	return &echo.EchoResponse{Message: req.GetMessage()}, nil
}

func setupOpenTelemetry(ctx context.Context) (shutdown func(context.Context) error, err error) {
	var shutdownFuncs []func(context.Context) error
	shutdown = func(ctx context.Context) error {
		var err error
		for _, fn := range shutdownFuncs {
			err = errors.Join(err, fn(ctx))
		}
		shutdownFuncs = nil
		return err
	}

	exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
	if err != nil {
		err = errors.Join(err, shutdown(ctx))
		return
	}

	tp := trace.NewTracerProvider(
		trace.WithBatcher(exporter),
	)
	shutdownFuncs = append(shutdownFuncs, tp.Shutdown)
	otel.SetTracerProvider(tp)

	return shutdown, nil
}

func main() {
	ctx := context.Background()

	listener, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	shutdown, err := setupOpenTelemetry(ctx)
	if err != nil {
		log.Fatalf("error setting up OpenTelemetry: %v", err)
	}

	server := grpc.NewServer(
		grpc.StatsHandler(
			otelgrpc.NewServerHandler(),
		),
	)

	// メインのサービスの登録
	echoServer := &EchoService{}
	echo.RegisterEchoServer(server, echoServer)

	// ヘルスサービスの登録
	healthServer := &HealthService{}
	grpc_health_v1.RegisterHealthServer(server, healthServer)

	// grpcurl で proto ファイルを指定せずにサービスを呼び出すため
	reflection.Register(server)

	log.Println("gRPC server is running on port 50051")
	if err := errors.Join(server.Serve(listener), shutdown(ctx)); err != nil {
		log.Fatalf("server exited with error: %v", err)
	}
}
```

## 動作確認

ターミナルを2つ起動し、トレースが正しく出力されることを確認してみましょう。

### ターミナル1: gRPCサーバーの起動

以下のコマンドを実行して、gRPCサーバーを起動します。

```sh
$ go run ./main.go
2025/01/16 22:22:28 gRPC server is running on port 50051
```

### ターミナル2: ヘルスチェックのリクエスト

`grpccurl` を使用して、サーバーのヘルスチェックエンドポイントにリクエストを送信します。

```sh
$ grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
```

### ターミナル1: ログの確認

サーバーのログを確認すると、ヘルスチェックとリフレクションに関連するRPCのトレースが出力されていることがわかります。

```
{
        "Name": "grpc.health.v1.Health/Check",
        ...
}
{
        "Name": "grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
        ...
}
```

これで、サーバーがヘルスチェックリクエストを受け取り、対応したことがトレースログに記録されているのを確認できます。

## ヘルスチェックサービスの除外

このままではすべてのRPCのトレースが出力されてしまいます。しかし、ヘルスチェックのような重要でないトレースを保存する必要はありません。

おそらく2022年以前にgRPCサーバーを実装された方は、独自のInterceptorでトレースを出力しないリクエストをフィルターする処理を実装していたのではないかと思います。

しかし、居間ではこのように特定のRPCのトレースを出力しないために、`otelgrpc` では `filters` パッケージが提供されています。

例えば、ヘルスチェックのRPCのトレースを除外する場合は次のように記述します。「ヘルスチェック以外」のトレースを出力するので、`filters.Not(filters.HealthCheck())` と書いてください。

```go
	server := grpc.NewServer(
		grpc.StatsHandler(
			otelgrpc.NewServerHandler(
				otelgrpc.WithFilter(
					filters.Not(filters.HealthCheck()),
				),
			),
		),
	)
```

`grpc.health.v1.Health/Check` にリクエストしても、`grpc.health.v1.Health/Check` のトレースが表示されなくなります。

```sh
$ go run ./main.go
2025/01/16 22:59:41 gRPC server is running on port 50051
{
        "Name": "grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
}
```

# おわりに

gRPCサーバーにOpenTelemetryを導入する方法についての記事は多く見かけますが、ヘルスチェックのトレースを除外する具体的な方法は意外と見つかりません。そこで、この記事ではその方法を分かりやすく解説しました。参考になれば嬉しいです！