こんにちは、SREグループの水戸 (@y_310)です。GO Inc.では様々なマイクロサービスが動いていますがその中にいくつかGraphQLのサービスが存在します。SREグループでは全てのサービスに対して共通のメトリクスでリクエスト状況やエラーを監視しているのですが、GraphQLについてはステータスコードが基本的に200になってしまいHTTPレベルの監視ではエラーを観測できないためダッシュボードやアラートがあまり機能しない状態になっていました。今回はこの問題を解決しGraphQLのサービスでもエラー監視ができる方法の一例をご紹介します。
対応前の状況
SREグループが管理するサービスはダッシュボードが標準化されており、例えばRESTやgRPCのサービスでは以下のようにステータスコードごとのリクエスト数の内訳が観測できていました。
これがGraphQLのサービスになると、このように全てステータスが200になりエラーも1件も観測されていない状態になっていました。
GraphQLであってもRESTやgRPCと同様にリスポンスのステータスを監視できるようにすることがこの記事の目的です。
GO Inc.におけるエラー監視の前提
GO Inc.においてエラーを監視する方法は主に2つあります。1つはKubernetesクラスタ上のIstioによって取得できるHTTP/gRPCのステータスコードをPrometheusで収集し、Grafanaで可視化、アラート設定をする方法です。もう1つはアプリケーションにインストールされたSentryによってエラー情報をSentry上で参照、アラート設定する方法です。
それぞれ観測するレイヤーが違いメリット・デメリットがあります。IstioはHTTP/gRPCのレイヤで観測するため対応したプロトコルで通信するサービスであればアプリケーション自体に手を入れること無くメトリクスが取得でき、またメトリクスに送信元サービスや受信したPodの情報など様々なラベルが付与されるため柔軟な集計もできます。一方通信に含まれないアプリケーション内部の情報は観測できませんし、対応していないプロトコルの通信も観測できません。gRPCはHTTP2の上で動いていますが、EnvoyがgRPCのプロトコルも理解して処理できるためgRPCステータスのようなgRPC固有の情報も得ることができています。GraphQLはそういった対応がないためHTTPレベルでの情報しか得られずエラーが観測できません。
Sentryは反対にアプリケーションにインストールする形で実装するためアプリケーション単位で個別に対応をする必要がありますが、アプリケーション内部の情報を取得できるためエラーのスタックトレースやその時のメモリ上にある情報などより詳細な情報が得られます。そのためGraphQLのエラー情報なども観測が可能になります。一方Sentryは基本的にエラートラッキングツールでありインフラ監視のツールではないので、得られた情報をGrafana上で統一的に可視化したりアラート設定することが難しくIstioで得られるメトリクスのように柔軟に様々な軸で集計もできません。
以上のような特性からGO Inc.では基本的にGrafanaをサービス稼働状況の把握や障害検知に使用し、Sentryを発生したエラーの調査に使うという使い分けをしています。
GraphQLのサービスについてはSentryによるエラー調査はできるものの、Grafanaによる状況把握や障害検知の部分が欠落してしまうという状況にありました。
GraphQLのエラーを観測する
GraphQLの監視が難しいのはHTTPレイヤで得られる情報が限られているためです。逆になんとかGraphQLのステータス情報をHTTPの上に乗せられればIstioなど既存の監視ツールで観測が可能になります。Istioは元々リクエスト数やリクエストにかかった時間を計測しており、そこにHTTPステータスやgRPCステータスなど様々情報をラベルとして付与することでステータスごとのリクエスト数などの集計を可能にしています。GraphQLのステータスもラベルとして付与できれば同様にGraphQLのステータス単位での集計も可能になるはずです。
IstioではTelemetry APIによってHTTPヘッダに含まれる情報をメトリクスのラベルに追加する機能があるため、独自のレスポンスヘッダを追加しそれをIstioによってラベル化させればGraphQLのステータスも観測可能になると考えました。
全体としては以下のような構成になります。
カスタムレスポンスヘッダを実装する
GraphQLステータスの定義
ここまで便宜上GraphQLのステータスという言葉を使ってきましたが実際にはGraphQLの仕様自体にはステータスという概念はありません。エラーが発生した際にはレスポンスボディにerrorsという配列型のフィールドが追加され、その中にエラーメッセージや発生箇所、extensionsと呼ばれる追加情報を含められるフィールドがあるというだけです。
以下はhttps://spec.graphql.org/October2021/#example-8b658 から引用したerrorsフィールドの例です。エラーオブジェクトのトップレベルには message
locations
path
extensions
しかなくステータスを表現する情報はありません。extensions
の中にはcode
がありますがこの部分は任意の構造が入れられるため仕様として決まっているものではありません。
{ "errors": [ { "message": "Name for character with ID 1002 could not be fetched.", "locations": [{ "line": 6, "column": 7 }], "path": ["hero", "heroFriends", 1, "name"], "extensions": { "code": "CAN_NOT_FETCH_BY_ID", "timestamp": "Fri Feb 9 14:33:09 UTC 2018" } } ] }
そのため、まずGraphQLのステータスという概念をどう表現するか独自で定義する必要があります。GO Inc.では共通ライブラリとしてプロトコルに関わらず共通のエラー構造体を定義して使用しています。詳細は割愛しますがその中に type
というフィールドがあり17種類のステータスを表現できるようになっています。具体的な type
のリストは以下の定義を使用しています。
https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
上記のページにある通りtype
はgRPCのステータスを表現しているのですが、HTTPステータスとも対応付けられており INVALID_ARGUMENT(3)
であれば400、NOT_FOUND(5)
であれば404で応答されます。
この仕組みを用いてGraphQLの場合はgRPCと同じステータスコードを採用することにしました。extensions
フィールドにエラーの文字列表現を含めつつレスポンスヘッダでは数値表現で返す形としました。
// HTTP Response Header X-GraphQL-Status: 5 // Response Body { "errors": [ { (snip) "extensions": { "type": "NOT_FOUND" } } ] }
複数エラーへの対応
エラーのフィールド名が複数形になっていることからも分かるようにGraphQLのエラーは1リクエストに対して複数発生する可能性があります。クエリの内容によっては全体の一部のレスポンスはエラーで一部は正常に処理された結果が含まれるということもあります。そのため仕様を厳密にカバーするためにはステータスにおいても部分的な成功という状態を表現する必要があるのですが既存のステータスの扱いとの対応付けが難しいため今回は以下のルールでステータスを集約することにしました。上から順番に評価されます。
- 1つでもサーバサイドエラーが含まれていたら1つ目のサーバサイドエラーのステータスを採用する
- 1つでもクライアントサイドエラーが含まれていたら1つ目のクライアントサイドエラーのステータスを採用する
- エラーが1つもない場合は正常応答のステータスとする
カスタムレスポンスヘッダの実装
現在GO Inc.で稼働しているGraphQLサーバはGo言語のgqlgenを使って実装されており、WebフレームワークにはEchoを使用しています。そのためこれらを使ってGraphQLのステータスを独自のレスポンスヘッダで返します。
全体としては以下のような仕組みで実現されています。
- gqlgenのresponse extensionでGraphQLエラーオブジェクトをコンテキストに保存
- Echoのmiddlewareでコンテキストからエラーオブジェクトを取り出し、エラー内容からステータスを決定、レスポンスヘッダに
X-GraphQL-Status
を追加
関連コードをパッケージ化したサンプルコードは以下です。(一部社内コードを書き換えているためそのままでは動作しません)
これらをmiddleware/extensionとして設定することでカスタムレスポンスヘッダを返すことができます。
// context.go package graphqlmetrics import ( "context" "github.com/vektah/gqlparser/v2/gqlerror" ) type dedicatedKey string const contextKey dedicatedKey = "graphqlobs_response_context" type Context struct { GqlErrors gqlerror.List } func setContext(ctx context.Context, gqlCtx *Context) context.Context { return context.WithValue(ctx, contextKey, gqlCtx) } func getContext(ctx context.Context) *Context { if value, ok := ctx.Value(contextKey).(*Context); ok { return value } return nil }
// error.go package graphqlmetrics type errorCategory int const ( errorCategoryServer errorCategory = iota errorCategoryClient ) type Error struct { *compact.Error Type errorpb.Type `json:"-"` category errorCategory `json:"-"` } func NewError(err error) Error { // TODO: errから何らかの方法でtypeとcategoryを決定 // categoryは"client"か"server"のいずれかの値でそのエラーがクライアントエラーなのかサーバーエラーなのかを示す return Error{Error: err, Type: type, category: category} } func (e Error) IsServerError() bool { return e.category == errorCategoryServer }
// echo_middleware.go package graphqlmetrics import ( "net/http" "strconv" "github.com/labstack/echo/v4" ) type Config struct { Paths []string ErrorFieldName string } func containsPath(targetPaths []string, path string) bool { for _, targetPath := range targetPaths { if path == targetPath { return true } } return false } func EchoMiddleware(conf Config) func(echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ec echo.Context) error { r := ec.Request() // 対象のパス以外では何もしない if !containsPath(conf.Paths, r.URL.Path) { return next(ec) } ctx := setContext(r.Context(), &Context{}) ec.SetRequest(r.WithContext(ctx)) ec.Response().Before(func() { // GraphQLクエリのパースエラーなどはステータスが422になる // その場合はhttpレベルで監視が可能で、かつextensionsフィールドにErrorFieldNameが入らないためカスタムヘッダは付与しない if ec.Response().Status >= http.StatusBadRequest { return } status := 0 // OK // gqlgen ExtensionのInterceptResponseでgqlerror.Errorオブジェクトをlocal contextに保存している gqlCtx := getContext(ctx) if gqlCtx != nil { // GraphQLのerrorsフィールドは複数のエラーを含んでいるため以下のルールで1つのステータスに集約する // - エラーが1つもない場合: OK // - serverエラーが1つ以上ある場合: 配列の最初のserverエラーのコードを使用 // - serverエラーが無くclientエラーが1つ以上ある場合: 配列の最初のclientエラーのコードを使用 for _, gqlErr := range gqlCtx.GqlErrors { err, ok := gqlErr.Extensions[conf.ErrorFieldName].(Error) // ErrorFieldNameを持っていないエラーは無視 if !ok { continue } if err.IsServerError() { status = err.Type break } else if status == 0 { status = err.Type } } } ec.Response().Header().Set("X-GraphQL-Status", strconv.Itoa(int(status))) }) return next(ec) } } }
// gqlgen_extension.go package graphqlmetrics import ( "context" "github.com/99designs/gqlgen/graphql" ) type ( Tracer struct{} ) var _ interface { graphql.HandlerExtension graphql.ResponseInterceptor } = Tracer{} func (a Tracer) ExtensionName() string { return "Metrics" } func (a Tracer) Validate(schema graphql.ExecutableSchema) error { return nil } func (a Tracer) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response { res := next(ctx) gqlCtx := getContext(ctx) if gqlCtx != nil { gqlErrs := graphql.GetErrors(ctx) gqlCtx.GqlErrors = gqlErrs } return res }
Istioの設定
https://istio.io/latest/docs/tasks/observability/metrics/telemetry-api/ こちらの解説を参考にIstioのTelemetry APIを使用して追加したレスポンスヘッダからメトリクスにカスタムラベルを追加します。
apiVersion: telemetry.istio.io/v1alpha1 kind: Telemetry metadata: name: custom-tags namespace: istio-system spec: metrics: - overrides: - match: metric: REQUEST_COUNT tagOverrides: graphql_response_status: value: value: "'X-GraphQL-Status' in response.headers ? response.headers['X-GraphQL-Status'] : ''" - match: metric: REQUEST_DURATION tagOverrides: graphql_response_status: value: value: "'X-GraphQL-Status' in response.headers ? response.headers['X-GraphQL-Status'] : ''" providers: - name: prometheus
なお、ページ冒頭に記載がありますがIstio 1.18より前のバージョンを使用している場合この設定を追加するとメトリクスが重複してしまう問題が起きます。その場合以下の設定によってEnvoyFilterを削除する必要があります。合わせてメトリクスのprovider設定がない場合メトリクスが出力されなくなってしまうためdefaultProvidersの設定も必要です。これらの設定はサービスメッシュ全体に影響するため注意して実行してください。
apiVersion: install.istio.io/v1alpha1 kind: IstioOperator spec: meshConfig: defaultProviders: metrics: - prometheus values: telemetry: enabled: true v2: enabled: false
実行結果
上記の実装を反映すると期待通り graphql_response_status
というラベルが istio_requests_total
とistio_request_duration_milliseconds
に追加され集計軸として使えるようになりました。以下は実際にその情報を使って集計した結果になります。既存のダッシュボードにグルーピング条件を追加するだけでHTTP/gRPC/GraphQLをまとめて可視化することができるようになりました。
なお、どのGraphQLクエリによってエラーが発生したかという情報はクエリの種類がクライアント入力によって無限に発散してしまう可能性があるためPrometheusでは扱いづらい情報になります。そのためそういった情報は前述のSentryで確認しています。
その他の実装案
今回は自社の既存の仕組みを生かす形でこういった実装方法を取りましたが他にも以下のようなやり方が考えられます。
- エラーステータスの詳細を追わずエラーの有無だけを観測するのであれば https://github.com/99designs/gqlgen-contrib/tree/master/prometheus のprometheusメトリクスを実装する
- ただしクライアントエラーとサーバエラーの区別ができないためアラートが難しくなることが考えられます
- EnvoyにGraphQLを解釈するフィルターを実装する
- 実装難度は上がりますが前述のようにGraphQLのステータス定義を社内で共通化できれば、アプリケーション側の追加実装無しで透過的に観測できる可能性がありそうです
まとめ
GraphQLのエラー監視が難しい問題に対して1つの現実的な解決策をご紹介しました。IstioやGrafana、gqlgenなどGO Inc.で使用している技術スタックに依存した実現方法として紹介しましたが、アイディアのコアは「HTTPレスポンスヘッダにGraphQLのステータス情報を含め、監視ツールでその情報を取得する」という部分になります。そのため他のツールでもレスポンスヘッダの値をメトリクスに含めることができれば同様の対応は可能になるのではないかと思います。一方複数のエラーを1つにまとめてしまうという妥協をしている部分があり厳密な監視にはまだ課題も残ります。より良いアイディアがあればぜひフィードバックいただけると助かります。