1. ホーム
  2. スクリプト・コラム
  3. ゴラン

Go サービスでリンクトレースを行う方法を説明します。

2022-02-15 03:38:20

Goでマイクロサービスを開発する場合、各リクエストのアクセスリンクを追跡する必要がありますが、Goにはこれに対する良いソリューションがありません。

これは、Javaでは、プロセス内でリクエストのRequestIdを共有するMDCを使うことで解決しやすくなります。

Goでリンクトレースを実装するには2つの考え方があります。1つはプロジェクトのグローバルマップを使い、キーはgoroutineのユニークId、値はRequestIdとする方法、もう1つはコンテキストを使って実装する考え方があります。

これを実装するために、ginフレームワークをベースにした以下のようなコードがあります。

1. グローバルマップを使用して実装する

マップソリューションを使うには、入ってくるリクエストごとに RequestId を生成するグローバルマップを維持し、ログが印刷されるたびに、このマップから goid で RequestId を取得してログに印刷することが必要です。

コードの実装はシンプルです。

var requestIdMap = make(map[int64]string) // global Map

func main() {
    r := gin.Default()
    r.Use(Logger()) // use middleware

    r.GET("/index", func(c * gin.Context) {
        Info("main goroutine") // print the log

        c.JSON(200, gin.H{
            "message": "index",
        })
    })
    r.Run()
}

func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        requestIdMap[goid.Get()] = uuid.New().String() // set in the logging middleware for each request
        c.Next()
    }
}

func Info(msg string) {
    now := time.Now()
    nowStr := now.Format("2006-01-02 15:04:05")
    fmt.Printf("%s [%s] %s\n", nowStr, requestIdMap[goid.Get()], msg) // print log
}



これは単純な実装ですが、多くの問題点があります。

第一の問題は、Goプログラムでは一つのリクエストが複数のgoroutineを含むことがあり、このように複数のgotoutine間でRequestIdを渡すことが困難であることである。

以下のコードでは、新しいゴルーチンが開始された場合、RequestIdはログに取得されない。

func main() {
    r := gin.Default()
    r.Use(Logger())

    r.GET("/index", func(c *gin.Context) {
        Info("main goroutine")

        go func() { // A new goroutine is started here
            Info("goroutine1")
        }()

        c.JSON(200, gin.H{
            "message": "index",
        })
    })
    r.Run()
}

ゴルーチンIDの取得も正規のやり方ではなく、ハッキングで行うのが普通ですが、これはもう推奨できません。そして、このグローバルマップは、並行性安全のために実際にはロックが必要になることもあり、並行性が高い状況ではどうしても性能に影響が出てしまいます。

また、各リクエストの終了時にマップからrequestIdを手動で削除する必要があります。そうしないと、メモリリークの原因になります。

全体として、これはmapを使った実装としては、あまり良い方法とは言えません。

2. Contextを使った実装

上記のコードでは、ゴルーチンIDを取得するためにハックを使用していますが、これは長い間推奨されるものではなく、むしろContextのようなものです。

RequestIdを渡すシナリオでは、Contextを使って同じことを実現することもできます。Context を使うことの利点は明らかです。Context はリクエストと同じライフサイクルを持ち、手動で破棄する必要がありません。Context はリクエストごとに一意なので、並行処理のセキュリティを気にする必要はありません。また、Context はゴルーチン間で受け渡しすることができます。

Contextを使って実装したコードは以下の通りである。

func main() {
    r := gin.Default()
    r.Use(Logger())

    r.GET("/index", func(c *gin.Context) {

        ctx, _ := c.Get("ctx")

        Info(ctx.(context.Context) , "main goroutine")

        go func() {
            Info(ctx.(context.Context), "goroutine1")
        }()

        c.JSON(200, gin.H{
            "message": "index",
        })
    })
    r.Run()
}

func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        valueCtx := context.WithValue(c.Request.Context(), "RequestId", uuid.New().String())
        c.Set("ctx", valueCtx)
        c.Next()
    }
}

func Info(ctx context.Context, msg string) {
    now := time.Now()
    nowStr := now.Format("2006-01-02 15:04:05")
    fmt.Printf("%s [%s] %s\n", nowStr, ctx.Value("RequestId"), msg)
}

この方法では、リクエスト内のすべてのgotroutineは同じRequestIdを取得し、メモリリークや並行処理の安全性を心配する必要はありません。

しかし、Contextを使用する際の問題点として、毎回渡す必要があり、多くの人はそのような使い方に慣れていないことが挙げられます。実際、Go では長い間 Context の使用を推奨しており、通常は関数の最初の引数として使用します。関数が構造体を引数として使う場合は、構造体のフィールドとして Context を使うこともできます。

ContextはRequestIdを渡す以外にも、goroutineのライフサイクルを制御するために使うことができる。詳しくは前回のContextの記事で説明したので、興味のある方はそちらを参照してほしい。

3. 概要

このゴルーチンIDの取得方法は捨てるべきで、Goが長い間推奨してきたContextを使用することをお勧めします。上記では、RequestId を渡すために Context を使いましたが、認証トークンなどの単一のリクエストスコープの値を渡すために使うこともできます。Contextの使い方に慣れておくとよいでしょう。

[1] https://blog.golang.org/context

Goサービスでリンクトレースを行う方法についての記事は以上です。Go サービスでリンク トレースを行う方法の詳細については、Script House の過去の記事を検索するか、次の記事を引き続き参照してください。