はじめに
GoのWebフレームワークとして著名なものにGinがあります。
GinにおいてHTTPリクエストを取り扱うにはgin.Context型の構造体を取り扱いますが、これをなんとなく使うと危険な使い方をしかねないため、注意が必要というお話です。
何となく書いてたもの
GinでDBアクセスするアプリを書こうとしており、OpenTelemetry(OTEL)を入れるような設定を入れようとしていました。
主な流れを取り出すと以下のようになります。実際はdatabase/sql直接ではなくてORM経由だったり、ファイルが分かれていたりします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
db, err := sql.Open('.....') r := gin.New() // OTELで必要と書かれていたので追加 r.ContextWithFallback = true r.POST('/hoge', func(ctx *gin.Context) { // ~ パラメータ取り出し処理 ~ tx, err := db.BeginTx(ctx); // ~ 何らかの処理 ~ // ~ Commit or Rollback ~ ctx.JSON(...) }); |
r.ContextWithFallback = true
がOTELのために追加したもので、これはGin公式のOTEL向けサンプルを参考にしたものになります。
https://github.com/gin-gonic/examples/tree/master/otel
現在はREADMEが更新されており、This configuration is necessary for the example to work but may not be ideal for production use.
と本番向けではないことが明示されています。
危ないポイント
gin.Context
はGo標準のcontext.Context
インターフェースを実装しているので、context.Context
として振る舞うことができます。
デフォルトではHTTPリクエストのデータを持たず、ContextWithFallbackオプションを有効にすることでリクエストのデータを引き継ぐ…のですが、原則使うべきではありません。
Go標準のcontext.Context
はスレッドセーフであることが期待されますが、gin.Context
はそうではありません。gin.Context
はGin内部でsync.Pool
を使ってプーリングされるように実装されており、中身を消して再利用されます。したがって、
- ハンドラ関数の中でContextを渡され、cancelを待つread処理
- 上記例だとdatabase/sqlが待つ
- ginがContextの中身を消去しようとするwrite処理
が競合することになります。これはテスト時にgo test -race
オプションを付けて実行すると、race detectorが反応することで検出することができます。
正しくはgin.Context
の内部にあるRequest.Context()
を取り出せば良いです。こちらはnet/http
によって作成されるものであり、再利用されることはありません。
Echoなど他のフレームワークでも同じような実装方法となっています。
1 2 3 4 5 6 |
r.POST('/hoge', func(ctx *gin.Context) { // gin.Contextからhttp.Requestのコンテキストを取り出す rCtx := ctx.Request.Context() ... tx, err := db.BeginTx(rCtx); } |
リクエストのライフサイクル
上記によりGin特有の問題は解消されますが、そもそもリクエストのContextを引き継いでよいのかという問題も別途存在します。
リクエストのContextはHTTPコネクション切断によりキャンセルされるため、DBに渡すと未コミットのトランザクションがあればロールバックされる可能性があります。
アプリケーションの要件によりますが、コネクションが切れても処理を続行したいという場合には、別のContextを作成する必要があります。
1 2 3 4 5 6 |
r.POST('/hoge', func(ctx *gin.Context) { // コンテキストを引き継がず、新しいコンテキストを作る nCtx := context.Background() ... tx, err := db.BeginTx(nCtx); } |
ただしこれではContextに含まれる値も初期化されることになります。
これではContext経由でGinのデータにアクセスする処理、たとえば
- OTELのmiddlewareでContextに入れたトレースIDをもとにSpanを作成
- middlewareでContextに入れたリクエストIDをログ出力する
のような機能が使えなくなってしまいます。
このような場合は、キャンセルのみ引き継がないようにします。(要Go 1.21+)
1 2 3 4 5 6 |
r.POST('/hoge', func(ctx *gin.Context) { // cancelを引き継がないコンテキストを作成 nCtx := context.WithoutCancel(ctx.Request.Context()) ... tx, err := db.BeginTx(nCtx); } |
このままだと一切のキャンセル処理がなくなります。安全のため自力でキャンセル処理を入れたい場合、WithoutCancel()してからWithCancel()すればよいでしょう。
1 2 3 4 5 6 7 |
r.POST('/hoge', func(ctx *gin.Context) { // 既存cancelを無効化してからcancel追加 nCtx, cancel := context.WithCancel(context.WithoutCancel(ctx.Request.Context())) defer cancel() ... tx, err := db.BeginTx(nCtx); } |
これで値は引き継ぎつつ、キャンセル伝搬から切り離す事ができます。
おわりに
Ginのgin.Context
はスレッドセーフではありません。context.Context
インターフェースを実装していることから勘違いしがちなのですが、context.Context
として扱わないようにしましょう。
またアプリケーション要件によっては、キャンセルの伝搬を止めるような実装が必要となることもあります。
みなさんも注意していただければと思います。