こんにちは、SREグループの水戸 (@y_310)です。GO Inc.では様々なマイクロサービスが動いていますがその中にいくつかGraphQLのサービスが存在します。SREグループでは全てのサービスに対して共通のメトリクスでリクエスト状況やエラーを監視しているのですが、GraphQLについてはステータスコードが基本的に200になってしまいHTTPレベルの監視ではエラーを観測できないためダッシュボードやアラートがあまり機能しない状態になっていました。今回はこの問題を解決しGraphQLのサービスでもエラー監視ができる方法の一例をご紹介します。
SREグループが管理するサービスはダッシュボードが標準化されており、例えばRESTやgRPCのサービスでは以下のようにステータスコードごとのリクエスト数の内訳が観測できていました。
これがGraphQLのサービスになると、このように全てステータスが200になりエラーも1件も観測されていない状態になっていました。
GraphQLであってもRESTやgRPCと同様にリスポンスのステータスを監視できるようにすることがこの記事の目的です。
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の監視が難しいのはHTTPレイヤで得られる情報が限られているためです。逆になんとかGraphQLのステータス情報をHTTPの上に乗せられればIstioなど既存の監視ツールで観測が可能になります。Istioは元々リクエスト数やリクエストにかかった時間を計測しており、そこにHTTPステータスやgRPCステータスなど様々情報をラベルとして付与することでステータスごとのリクエスト数などの集計を可能にしています。GraphQLのステータスもラベルとして付与できれば同様にGraphQLのステータス単位での集計も可能になるはずです。
IstioではTelemetry APIによってHTTPヘッダに含まれる情報をメトリクスのラベルに追加する機能があるため、独自のレスポンスヘッダを追加しそれをIstioによってラベル化させれば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リクエストに対して複数発生する可能性があります。クエリの内容によっては全体の一部のレスポンスはエラーで一部は正常に処理された結果が含まれるということもあります。そのため仕様を厳密にカバーするためにはステータスにおいても部分的な成功という状態を表現する必要があるのですが既存のステータスの扱いとの対応付けが難しいため今回は以下のルールでステータスを集約することにしました。上から順番に評価されます。
現在GO Inc.で稼働しているGraphQLサーバはGo言語のgqlgenを使って実装されており、WebフレームワークにはEchoを使用しています。そのためこれらを使ってGraphQLのステータスを独自のレスポンスヘッダで返します。
全体としては以下のような仕組みで実現されています。
関連コードをパッケージ化したサンプルコードは以下です。(一部社内コードを書き換えているためそのままでは動作しません)
これらを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
}
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で確認しています。
今回は自社の既存の仕組みを生かす形でこういった実装方法を取りましたが他にも以下のようなやり方が考えられます。
GraphQLのエラー監視が難しい問題に対して1つの現実的な解決策をご紹介しました。IstioやGrafana、gqlgenなどGO Inc.で使用している技術スタックに依存した実現方法として紹介しましたが、アイディアのコアは「HTTPレスポンスヘッダにGraphQLのステータス情報を含め、監視ツールでその情報を取得する」という部分になります。そのため他のツールでもレスポンスヘッダの値をメトリクスに含めることができれば同様の対応は可能になるのではないかと思います。一方複数のエラーを1つにまとめてしまうという妥協をしている部分があり厳密な監視にはまだ課題も残ります。より良いアイディアがあればぜひフィードバックいただけると助かります。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @goinc_techtalk のフォローもよろしくお願いします!