🕸️

GraphQLのパフォーマンス改善をDatadog APMのカスタムタグを活用してスムーズにした話

2024/04/19に公開

はじめに

こんにちは、マイベストでバックエンドエンジニアとして働いている @_shrrk です。
mybest BlogKaigi 2024の5日目を担当させていただきます。

今回は私がここ最近取り組んでいたGraphQL APIのパフォーマンス改善について、特にDatadog APMのカスタムタグを活用した話を中心にご紹介します。
以前パフォーマンス改善についての発表をした内容と少し関連するところもあるので、こちらも合わせて見ていただけると幸いです。
https://speakerdeck.com/nhsykym/huremugurahukowakunai-singeddeshi-merupahuomansugai-shan

Datadog APMとは

Datadogはクラウドインフラやアプリケーションの可視化、分析、モニタリングを行うクラウドサービスです。さまざまな監視機能を提供しており、大規模な環境でも柔軟に活用できるのが特徴です。
その中でもAPM(Application Performance Monitoring)はアプリケーションからトレースデータを収集し、リクエストの処理時間や呼び出し関係、エラー状況などを可視化することができます。
これによりパフォーマンス上のボトルネックの特定や、原因の究明、改善効果の確認などが容易になります。
https://docs.datadoghq.com/ja/tracing/

APMの基本的な仕組みは以下の通りです。

  1. アプリケーションからDatadogのAgent(モニタリングソフトウェア)にトレースデータが送信される
  2. Agentが受け取ったデータをDatadogのサーバーに転送
  3. Datadogのコンソールでデータを分析・可視化


引用: https://docs.datadoghq.com/ja/tracing/trace_collection/

GraphQLとDatadog APM

マイベストではバックエンドのAPIサーバーはGraphQLを採用しています。
GraphQLのトレースデータをDatadog APMで収集するために、graphql-rubyのTracing機能を利用してリクエストごとのトレースデータを送信するように設定しています。
https://github.com/rmosolgo/graphql-ruby

またマイベストでは領域別にGraphQLスキーマを分離しているため、それらを分けてモニタリングできるようにAPM上のサービス名を変えています。
(↓イメージ)

class FrontSchema < GraphQL::Schema
  use GraphQL::Tracing::DataDogTracing, service: 'graphql-front'
end

class MobileSchema < GraphQL::Schema
  use GraphQL::Tracing::DataDogTracing, service: 'graphql-mobile'
end

class MypageSchema < GraphQL::Schema
  use GraphQL::Tracing::DataDogTracing, service: 'graphql-mypage'
end

このように設定することでDatadog APM上ではGraphQLのリクエスト数や処理時間をGraphQLスキーマごと、Query/Mutationごとに可視化することができます。

GraphQLアプリケーションでの課題

前述の通りDatadog APMにトレースデータを送信すること自体はライブラリのおかげで簡単に行えるのですが、実際に運用していく中で課題がありました。
それはクエリの引数がトレースデータとして送信されないという点です。

例えば以下のクエリはContentScreenのデータを取得するクエリのイメージです。
APIサーバーとしては、引数として渡されたidを使ってデータを取得し、指定されたフィールドの値をクライアントに返します。

query ContentScreen {
  content(id: 1) {
    title
    body
    ranking {
      products {
        name
        price
        variations {
          size
          color
        }
      }
    }
  }
}

ここでContentScreenのクエリを使ったリクエストのうち、添付画像のように一部のリクエストが極端に遅いということが分かったとします。
どこに原因があるのか調査するにあたって、引数がAPM側に送信されていないとどのコンテンツに対するリクエストだったのか特定できないという問題がありました。

マイベストで扱っているデータの特性として、コンテンツに掲載されている商品数や商品ごとのバリエーション(サイズ、色など)の数などが大きく異なるため、コンテンツによってボトルネックになり得る箇所が異なります。

遅いリクエストと同じ状況を再現することはパフォーマンス上のボトルネックの特定や修正後の検証をスムーズに行うにあたって非常に重要な要素です。
そこで引数をトレースデータに紐づけて送信する方法を模索しました。

カスタムタグによるリクエストの特定

この課題に対してDatadogのカスタムタグを活用することで解決を図りました。
https://docs.datadoghq.com/ja/tracing/trace_collection/custom_instrumentation/dd_libraries/ruby/?tab=アクティブスパン#カスタムスパンタグを追加する

GraphQLクエリの引数に含まれる識別子(コンテンツID、商品IDなど)をカスタムタグとして、トレースデータに紐づけて送信することでDatadog APM上でどのコンテンツや商品に対するリクエストなのか特定することができるようにしました。

実装するにあたっていくつか工夫した点があるため説明します。

スパンではなくトレースにカスタムタグを付与

Datadog APMではスパンとトレースという概念があります。
スパンはリクエストの中で発生した処理の単位であり、トレースはリクエスト全体を表します。
https://docs.datadoghq.com/ja/glossary/?product=apm#スパン

当初はGraphQLのResolver内でその時点でアクティブなスパンに対してタグ付けをするという実装を考えていましたが、特定のスパンのみにタグが付与されてしまうため該当のスパンをフレームグラフの中から探す必要があり使い勝手が悪いと感じました。

module Resolvers
  module Mobile
    class ContentResolver < BaseResolver
      def resolve(id:)
        content = Content.find(id)

        # アクティブスパンに対するタグ付け
        Datadog::Tracing.active_span&.set_tag('content.id', content.id)
        content
      end
    end
  end

↓アクティブスパンにタグ付けをすると大量のスパンの中から特定の1つのスパンを探す必要がある

またコンテンツごとのリクエストの処理時間を集計をするにあたっても、特定のスパンにタグが紐づいている状態だと集計が難しいためGraphQLの処理全体に対してタグ付けを行うが好ましいと考えました。

そこでサービスエントリスパン(GraphQLサービスにおけるルートスパン)へのタグ付けを試みましたが、サービスエントリスパンを取得することが技術的に難しいようでしたので、トレース(リクエスト全体)へのタグ付けを行うという判断をしました。
※厳密にいうとControllerなど処理も含んだ処理時間になってしまっていますが、傾向を掴むという目的においては誤差程度かなという判断で許容しています。

https://github.com/DataDog/dd-trace-rb/issues/2799

module Resolvers
  module Mobile
    class ContentResolver < BaseResolver
      def resolve(id:)
        content = Content.find(id)

        # トレースに対するタグ付け
        Datadog::Tracing.active_trace&.set_tag('content.id', content.id)
        content
      end
    end
  end

↓トレースにタグ付けをするとトレースを見ればカスタムタグが確認できる

FieldExtensionとして共通化

また各Resolver内に書いていくのもいいと思いますが、実装の一貫性を保つためにgraphql-rubyのFieldExtensionを利用して共通化しました。

FieldExtensionはフィールドの呼び出しにフックしてフィールドの定義や処理内容をカスタマイズできる機能です。
使用できるフックにはいくつか種類がありますが、今回はフィールドのresolve後に実行されるafter_resolveを選択しています。
https://github.com/rmosolgo/graphql-ruby/blob/4e241f87bdac4748bcd0c593b47c6e9c722c4644/lib/graphql/schema/field_extension.rb#L137-L150

その他のフックに関してはドキュメントを参照ください。
https://graphql-ruby.org/type_definitions/field_extensions.html

Extensionをフィールドにアタッチする際には、identifierというオプションを渡すことでどの引数を識別子としてタグ付けするかを指定できるようにしています。
またGraphQLのリクエストのトレースはGraphQLへのリクエストで共通で使われているGraphqlController#executeになるため、クエリを識別するためのquery_nameというタグを別途追加しています。

module ObjectTypes
  class MobileQueryType < BaseObject
    field :content, resolver: Resolvers::Mobile::ContentResolver, extensions: [TraceExtension => { identifier: 'id' }]
    field :product, resolver: Resolvers::Mobile::ProductResolver, extensions: [TraceExtension => { identifier: 'id' }]
  end
end
class TraceExtension < GraphQL::Schema::FieldExtension
  def after_resolve(value:, context:, arguments:, **args)
    operation_name = context.query.operation_name
    Datadog::Tracing.active_trace&.set_tag('query_name', operation_name)
    # => query_name: ContentScreen

    tag_prefix = field.name
    identifier = options[:identifier]
    tag_key = "#{tag_prefix}.#{identifier}"
    tag_value = arguments[identifier.to_sym]    
    Datadog::Tracing.active_trace&.set_tag(tag_key, tag_value)
    # => content.id: 1

    value
  end
end

set_tagのキーはカンマ区切りの文字列を渡すことでAPM上でネストして表示されます。


GraphQLのArgumentに含まれる識別子をトレースデータとともに送信することができるようになればあとはDatadog APM上で遅いリクエストを見つけた時に、それがどのコンテンツ、商品に対するリクエストなのか特定することはもちろん、これらのタグを使って集計をすることでコンテンツごとのリクエストの処理時間を可視化することができるようになります。

効果

これまではフレームグラフに表示されているフィールドの種類や取得数などを元にどのような種類のコンテンツかエスパーするしかなく、状況の再現に非常に苦労していました。
しかしカスタムタグの付与によってDatadogのAPM上でコンテンツ・商品単位でのパフォーマンス状況を把握できるようになり、遅いリクエストの特定から原因の究明、改善までの一連の流れをスムーズに行うことができるようになりました。

まとめ

本記事では、DatadogのAPMとGraphQLの連携における課題と、その解決策としてのカスタムタグの活用について説明しました。
今後の展望としては今回ご紹介したFieldExtensionは一部のクエリに対して試験的に適用している状態なのでその適用対象を拡大したり、追加で調査に必要な値を送信できるようにしたりできたらいいなと考えています。

ぜひ参考にしてくださると幸いです。

Discussion