はじめに
Go言語でgRPCサーバーを使ってマイクロサービスを開発している方の中には、OpenTelemetryを活用して分散トレーシングを実装している方も多いと思います。
しかし、ヘルスチェックリクエストのトレースが出力されて邪魔に感じることはありませんか?
この記事では、調べても意外と出てこない、ヘルスチェックをトレースから除外する最新の方法を解説します!
OpenTelemetryの計装
以下は動作確認用のgRPCサーバーのコードです。
以前はトレースを出力するために otelgrpc.UnaryServerInterceptor
を使用していましたが、現在は非推奨とされています。代わりに otelgrpc.NewServerHandler
を使用しましょう。
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サーバーを起動します。
$ go run ./main.go
2025/01/16 22:22:28 gRPC server is running on port 50051
ターミナル2: ヘルスチェックのリクエスト
grpccurl
を使用して、サーバーのヘルスチェックエンドポイントにリクエストを送信します。
$ 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())
と書いてください。
server := grpc.NewServer(
grpc.StatsHandler(
otelgrpc.NewServerHandler(
otelgrpc.WithFilter(
filters.Not(filters.HealthCheck()),
),
),
),
)
grpc.health.v1.Health/Check
にリクエストしても、grpc.health.v1.Health/Check
のトレースが表示されなくなります。
$ 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を導入する方法についての記事は多く見かけますが、ヘルスチェックのトレースを除外する具体的な方法は意外と見つかりません。そこで、この記事ではその方法を分かりやすく解説しました。参考になれば嬉しいです!