1
/
5

`Wrap(err)` in our production #golang

Wantedly People tribe でエンジニアをやってる泉(@izumin5210)です.今回は Go を書いている人たちにとってはおなじみ Wrap(err) の話をしつつ,Wantedly の本番 Web アプリケーション上でどのようにしてエラーをハンドリングしているかについてです.もし Go を書いてるけど Wrap(err) したことのない人がいれば,この機会にぜひその意義を理解してもらえればと思います.

この記事は Go3 Advent Calendarr 2018 20日目の記事です.

Go3 Advent Calendar 2018 - Qiita
Go2のアドベントカレンダーにも溢れた者たちが集うところ
https://qiita.com/advent-calendar/2018/go3

また,内容は Go(Un)Conference(Goあんこ)LT 大会 4kg でのトーク「Wrap(err) in production」をベースにしています.

Go(Un)Conference(Goあんこ)LT大会 4kg (2018/10/19 19:30〜)
Go(Un)Conferenceについて Goが好きな人たちで軽食と飲み物をお供にGoの知見をゆるく発表していく会の第4回です。 第1回:4/17(火) Go(Un)Conference(Goあんこ)LT大会 1kg togetterのまとめ 第2回:5/25(金) Go(Un)Conference(Goあんこ)LT大会 2kg togetterのまとめ 第3回:7/26(木) Go(Un)Conference(Goあんこ)LT大会 3kg togetterのまとめ また誰かがGoと別の言語を比較してジェ
https://gounconference.connpass.com/event/99487/

復習: なぜ `Wrap(err)` が必要なのか

一言で言うと「エラー発生箇所のコンテキスト・情報(stacktrace, etc.)をちゃんと残すため」です.

ここは実際の挙動を見るとわかりやすいので,コードで説明します.知ってる人は読み飛ばしてもらって大丈夫です.

`Wrap(err)` がないとき

まず「スタックトレースとかをいい感じに表示する helper」と「エラーを返す関数」 を用意します.この「エラーを返す関数」はアプリケーションコードではなく標準 or 外部パッケージのものと考えてください.

// HTTP request や DB アクセスのような,エラーが返る関数のつもり
func occurError() error {
	return stderrors.New("error!")
}

func printError(t *testing.T, err error) {
	printTitle(t, "%#v")
	t.Logf("%#v\n\n", err)
	printTitle(t, "%+v")
	t.Logf("%+v\n\n", err)
	printStacktrace(t)
}

func printStacktrace(t *testing.T) {
	t.Helper()
	printTitle(t, "stacktrace")
	for i := 2; ; i++ {
		pc, src, line, ok := runtime.Caller(i)
		if !ok {
			break
		}
		t.Logf("%s:%d: %s\n", src, line, runtime.FuncForPC(pc).Name())
	}
}

func printTitle(t *testing.T, title string) {
	t.Helper()
	t.Logf("*** %s %s\n", title, strings.Repeat("*", 64-3-1-len(title)-1))
}

ここで,アプリケーションコードから『「エラーを返す関数」を利用する関数』を呼び出します.

package main

import (
	"testing"
)

func TestErrors_NoWrap(t *testing.T) {
	err := process1()

	if err != nil {
		printError(t, err)
	}
}

func process1() error {
	err := occurError()
	return err
}

実際のアプリケーション開発を想定したとき,予期せぬエラーが出た場合はスタックトレースを表示させたいと考えるでしょう.ここで,上記のコードで表示されるスタックトレースを見ると次のようになります.

=== RUN   TestErrors_NoWrap
--- PASS: TestErrors_NoWrap (0.00s)
	main.go:43: *** %#v ********************************************************
	main.go:44: &errors.errorString{s:"error!"}
		
	main.go:45: *** %+v ********************************************************
	main.go:46: error!
		
	main.go:47: *** stacktrace *************************************************
	main.go:47: /go/src/sandbox/main.go:16: main.TestErrors_NoWrap
	main.go:47: /usr/local/go/src/testing/testing.go:777: testing.tRunner
	main.go:47: /usr/local/go/src/runtime/asm_amd64p32.s:968: runtime.goexit

最初に定義した helper により,スタックトレースが表示されています.スタックの一番上, main.go:16 はどこかというと, TestErrors_NoWrap 関数で printError を呼び出している行です.

func TestErrors_NoWrap(t *testing.T) {
	err := process1()

	if err != nil {
		printError(t, err) // main.go:16
	}
}

一方,本来表示されてほしい「エラーの原因行」がどこかというと,おそらく occurError() か process1() でしょう.

func process1() error {
	err := occurError() // ここか
	return err // ここが出てほしい
}

Go のエラーはスタックトレースを保持しないため,エラー発生箇所を特定したければエラーが起きた時点でのスタックトレースを取得しないといけません,そうでないと,今回のように最後にエラーハンドリングをする場所のスタックトレースしか取得できません.

今回のように短いコードであれば問題のあるコードはエラーの原因(occurError がエラーを返した)が自明ですが,現実世界のアプリケーション開発においてはコードベースももっと大きく,エラーが起きうる箇所は無数に存在します.そんななかでエラーが起きたとき,これだけの情報で原因を探せるでしょうか.もちろん,すべてのエラー発生箇所でスタックトレースを表示するわけにもいきません.Go で error を return するのは日常茶飯事なので,毎回スタックトレースを出力してるとログが崩壊します.

`Wrap(err)` があるとき

ここではほぼデファクトスタンダードと言える pkg/errors を利用します.

pkg/errors
Simple error handling primitives. Contribute to pkg/errors development by creating an account on GitHub.
https://github.com/pkg/errors

先ほどと似たようなテストコードを用意しました.違いは「process2() 関数がエラーを返す直前に Wrap(err) をしている」だけです.

package main

import (
	"testing"

	"github.com/pkg/errors"
)

func TestErrors_Wrap(t *testing.T) {
	err := process2()

	if err != nil {
		printError(t, err)
	}
}

func process2() error {
	err := occurError()
	return errors.Wrap(err, "wrapped")
}

実行結果を見てみると,次のようになります.

=== RUN   TestErrors_Wrap
--- PASS: TestErrors_Wrap (0.00s)
	main.go:43: *** %#v ********************************************************
	main.go:44: wrapped: error!
		
	main.go:45: *** %+v ********************************************************
	main.go:46: error!
		wrapped
		main.process2
			/go/src/sandbox/main.go:35
		main.TestErrors_Wrap
			/go/src/sandbox/main.go:21
		testing.tRunner
			/usr/local/go/src/testing/testing.go:777
		runtime.goexit
			/usr/local/go/src/runtime/asm_amd64p32.s:968
		
	main.go:47: *** stacktrace *************************************************
	main.go:47: /go/src/sandbox/main.go:24: main.TestErrors_Wrap
	main.go:47: /usr/local/go/src/testing/testing.go:777: testing.tRunner
	main.go:47: /usr/local/go/src/runtime/asm_amd64p32.s:968: runtime.goexit

一番下のスタックトレースは相変わらず変化はありません.が, %+v で出力した結果にはまた別のスタックトレースのようなものが含まれていることがわかります.先頭が main.process2 関数 main.go:24 となっており,これは process2 関数の return 行を指しています.

func process2() error {
	err := occurError()
	return errors.Wrap(err, "wrapped") // main.go:24
}

これは pkg/errors内部に記録している独自のスタックトレースです.アプリケーションコードで errorreturn するときに必ず errors.Wrap(err, "...") もしくは errors.WithStack(err) をしておくことで,pkg/errors.StackTrace をみれば「アプリケーションコード内で最初にエラーが起きたのはどこか」を調べることができるようになります.

pkg/errors のように error を Wrap するツール群を利用することで,「エラーが起きたときのコンテキスト・情報」をエラーオブジェクトに記録しておくことができ,その情報をもと予期せぬエラーが発生したときの原因究明に役立てることができます.

Web アプリケーションであれば,ユーザにレスポンスを返す直前で honeybadgersentry といったエラー収集サービスに pkg/errors.StackTrace を整形して送る…といったユースケースがメジャーになるでしょう.

pkg/errors に至るまでの以前のエラーハンドリングなどに関しては,Go Conference 2016 Spring でのDave Cheney 氏の 講演資料もしくはブログ記事に詳しいので,そちらも併せて読んでもらうと良いでしょう.

Don't just check errors, handle them gracefully
This post is an extract from my presentation at the recent GoCon spring conference in Tokyo, Japan. I've spent a lot of time thinking about the best way to handle errors in Go programs. I really wanted there to be a single way to do error handling, someth
https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

本番で `Wrap(err)` するために

ここからが本題.pkg/errors などの既存 error wrapper が扱ってくれるエラーコンテキストというのは,おもに「スタックトレース」「メッセージ」の2つだけです.一方で,実 Web アプリケーションにおけるエラーのコンテキストは様々なものが考えられます.

  • どういう種類のエラーなのか
    • そもそもリクエストがおかしい, not found, 依存サービスのタイムアウト...
    • HTTP や gRPC のレスポンスの Status Code をどうするか にもつながってくる
  • そのエラーの発生は開発者が知るべきなのか
    • honeybadger や sentry に通知するかどうか

これらの情報にはエラー発生箇所でしか判断できないものもあります.しかし,ここまでの情報を pkg/errors に乗せることはできません.まじめにエラーオブジェクトを定義すればいいのかもしれませんが,割とキリがないですし,毎回定義してしまうと最後にエラーレポートを投げる箇所の分岐が大変なことになるでしょう.

`srvc/fail` によるちょっとリッチな `Wrap(err)`

前述の問題を解決すべく,自分(@izumin5210)と International tribe の岩永(@creasty)で fail という pkg/errors と相互運用可能な新しい error wrapper を作りました.@creasty がメインアイディア・実装,@izumin5210 がインタフェースの調整をしています.

srvc/fail
Better error handling solution especially for application servers - srvc/fail
https://github.com/srvc/fail

fail を利用するときに覚えておくインタフェースは2つ,WrapWith... です.コード例を見てもらうほうが早いでしょう.

_, err := ioutil.ReadAll(r)
if err != nil {
	return fail.Wrap(
		err,
		fail.WithMessage("read failed"),
		fail.WithCode(http.StatusBadRequest),
		fail.WithIgnorable(),
	)
}

基本は fail.Wrap(err) ですが,functional option の形でいくつかのメタデータ(fail のドキュメント上では Annotator と読んでいます)を付与できます.上記の例ではメッセージ,ステータスコード,ignore していいか(エラーレポートしなくていいフラグ)が渡されています.Wrap 時に付与した annotation は fail.Unwrap() することで *fail.Error として取り出すことができます.

type Error struct {
    // Err is the original error (you might call it the root cause)
    Err error
    // Messages is an annotated description of the error
    Messages []string
    // Code is a status code that is desired to be contained in responses, such as HTTP Status code.
    Code interface{}
    // Ignorable represents whether the error should be reported to administrators
    Ignorable bool
    // Tags represents tags of the error which is classified errors.
    Tags []string
    // Params is an annotated parameters of the error.
    Params H
    // StackTrace is a stack trace of the original error
    // from the point where it was created
    StackTrace StackTrace
}

レスポンスを返す直前に fail.Unwrap して適切に Status Code をセットしたり,エラーレポートを送ったりするのが想定されているユースケースです.

Wrap のタイミング == 任意のタイミングで Status Code やエラーレポーティングの可否が決定できるので,より正確かつコンテキストを正しく反映したエラー情報伝播が実現可能になります.

interceptor でのエラーハンドリング

Wantedly の Go のマイクロサービスはすべて grapi を利用して開発されているため,実装者は gRPC サーバを実装することになります.Go による gRPC サーバ開発では Interceptor という仕組みを利用することで,すべての RPC の前後に共通して処理を挟み込むことが可能です.この gRPC の interceptor でのエラーハンドリングを簡単に書くためのパッケージとして,grpc-errors というものを用意しています.

srvc/grpc-errors
middleware providing better error handling to resolve errors easily - srvc/grpc-errors
https://github.com/srvc/grpc-errors

この grpc-errors は interceptor の後処理フェーズで fail.Unwrap し,取り出されたエラーの annotation をもとに予め設定された適切なハンドラ(e.g. StatusCodeMapper など)を呼び出します.

Wantedly では,社内共通ライブラリに grpc-errors を利用したエラーハンドリングを記述しており, 漏れのない形で Status Code の設定および honeybadger へのエラーレポート送信を実現しています.

独自 Error code の定義

Status Code の決定を永続化レイヤに近いところでやりたい一方で,永続化レイヤではサーバのレスポンスに関すること(要するに presentation レイヤの関心)を扱いたくないという問題があります.この問題に対しては,社内共通の Error code の定義を用意しておき,grpc-errors で 独自 Error code と gRPC Status Code のマッピングを行うことで対処しています.

`srvc/wraperr` で `Wrap(err)` 忘れを防ぐ

ここまでで説明したとおり,Wantedly における Go サーバのエラーハンドリングはすべてが error を Wrap することが起点であり絶対条件になっています.Wrap が漏れてしまうとエラーレポートが超貧弱化する上にすべてのエラーが codes.Unknown (HTTP だと Internal Server Error)で返ってしまいます.一方で,「すべての error が正しく Wrap されているか」を人間がすべてコードレビューするのはかなり面倒です.

このレビューの手間を省略すべく,「Wrap されていない error」を見つけて警告する wraperr という linter を実装しました.

srvc/wraperr
Check that error return value are wrapped. Contribute to srvc/wraperr development by creating an account on GitHub.
https://github.com/srvc/wraperr

Wantedly では wraperr を自動レビューツール reviewdog と組み合わせることにより,pull request 上で完全自動の wrap されてない error 検出を実現しています.

まとめ

長々と書きましたが,伝えたいことは次の3点だけです!

  • とりあえず,エラーを返すときは pkg/errors などで必ず Wrap しよう
  • エラーによりリッチな情報をのせたいときは srvc/fail で Wrap & Annotate しよう
  • Wrap わすれを人間がチェックするのは不毛なので,wraperr で自動化しよう
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?
Wantedly, Inc.では一緒に働く仲間を募集しています
9 いいね!
9 いいね!

同じタグの記事

今週のランキング

泉 将之さんにいいねを伝えよう
泉 将之さんや会社があなたに興味を持つかも