Google Cloud
Go で書かれたマイクロサービスの分散トレーシング
Go の大ファンである私たち Google Cloud は先ごろ、分散トレーシング バックエンドの Stackdriver Trace に Go クライアント ライブラリを追加しました。これで、実行環境が Google Cloud Platform(GCP)か他のクラウドかに関係なく、あらゆる Go アプリケーションの難しいパフォーマンス問題を検出(そして解決)しやすくなります。
分散トレーシングを使う理由
あるページのレイテンシ問題を解決しようとしていると仮定しましょう。そのシステムは多くの独立したサービスから作られており、そのページのデータは下流のさまざまなサービスによって生成されています。スローダウンの原因が、それらのサービスのどれによるものかはわかりません。バグなのか、インテグレーションの問題なのか、アーキテクチャのまずさによるボトルネックなのか、あるいはネットワーク パフォーマンスの低さなのかもはっきりしません。
サービスが分散システム内の別プロセスとして実行されると、この問題はさらに解決が難しくなります。モノリシック システムの問題診断で役に立っていた従来のアプローチは使えません。個々のサービスの内部で何が起きているのか、ユーザー要求のライフ サイクル全体にわたってサービスがどのようなやり取りをしているのかを細かく把握できるようにする必要があるのです。
モノリシック システムの場合、プログラムの部品から診断データを収集するのは比較的簡単です。すべてのモジュールが 1 つのプロセスの中にあり、ログやエラー、その他の診断情報を報告するためのリソースを共有しています。しかし、複数のプロセスを使う分散システムになると、フロントエンドのウェブ サーバーからバックエンドまでのさまざまなプロセスを通り、ユーザーに応答が返されるまでの呼び出しをたどるのは難しくなります。

この問題に対処するため、Google は本番サービスのインストルメンテーションと分析を行う分散トレーシング システム Dapper を開発しました。Dapper に関する論文は、Zipkin をはじめとする多くのオープンソース プロジェクトに影響を与え、Dapper スタイルのトレーシングは業界標準として注目されています。
分散トレーシングを導入すると、次のことが可能になります。
- 大規模システムで生じるレイテンシのインストルメンテーションとプロファイリング。
- ユーザー要求のライフ サイクルにおけるすべての RPC を追跡し、本番環境だけに現れるインテグレーションの問題を可視化すること。
- 当該システムに応用できるパフォーマンスの改善方法を明らかにすること。トレーシング データを収集しなければ、ボトルネックの多くは明るみに出ません。
トレーシングのコンセプト
トレーシングは、サービス間でトレーシング データを受け渡ししていくという基本原則のもとで機能します。ユーザー要求に対する応答を返すまで、個々のサービスはトレースにデータを追加し、トレーシング ヘッダを次のサービスに渡していきます。サービスはそれぞれのトレースをトレーシング バックエンドにアップロードします。すると、トレーシング バックエンドは、まるでパズルのピースのように、サービスのレイテンシ データをつなげていきます。トレーシング バックエンドは、トレースを分析して可視化するための UI も提供します。
Dapper スタイルのトレーシングの場合、個々のトレースは、ユーザー要求のエントリ ポイントから始まり、途中のすべての RPC を含んだサーバー応答で終わるコール ツリーになっています。個々のツリーは、Span と呼ばれる小さなユニットから構成されています。
上図は、TaskQueue.Stats 要求のトレース ツリーを表しています。各行には Span 名が書かれています。
システムが TaskQueue.Stats 要求に応答を返すまでの間に、他のサービスに対して 5 つの RPC が実行されています。まず、TaskQueue.Auth が要求の権限の有無をチェックします。次に、QueueService に 2 つのレポートを問い合わせます。その一方で、他のサービスを使って System.Stats を取得します。レポートとシステム統計が手に入ったら、Graphiz.Render がグラフを描きます。結果として、TaskQueue.Stats は 581 ミリ秒でユーザーに応答を返します。
このようにトレース ツリーを使用すると、呼び出し処理のために何が内部的に起きたのかがはっきりとわかります。このトレース ツリーからは、グラフの描画に予想外の時間を要していることなどが見て取れます。
Span 名については、実行しているタスクを適切に表現する名前を付ける必要があります。そうすれば、TaskQueue.Stats は TaskQueue サービスから統計情報を読み出す要求だということが容易にわかります。
他の Span が完了しないと始められない Span があるときには、その Span は完了する Span に依存します。これらは、トレース ツリー内でスタータ Span の子 Span として表示されます。
Span には、特定の要求に関する詳細な情報を示すラベルも追加できます。トレースに付加されるラベルとしては、リクエスト ID、ユーザー ID、RPC パラメータなどがよく使われます。どのラベルを使うかは、個々のトレース ツリーに何を表示したいのか、収集したデータから何を調べるかに基づいて選択します。
Stackdriver Trace を分散トレーシング バックエンドに
GCP の魅力的な点の 1 つは、Google が毎日使用しているサービスやツールをお客様も Google と同等のスケールで使えることです。私たちは、お客様の分散トレーシング バックエンドとして Stackdriver Trace を立ち上げました。Stackdriver Trace は、お客様のアプリケーションからレイテンシ データを収集し、Google Cloud Console でリスト化と可視化を行い、アプリケーションのレイテンシ プロファイルを分析できるようにします。このツールは、コードが GCP 上で稼働していなくても使用できます。本番環境が GCP で実行されていない場合でも、Google はお客様のトレース データを Stackdriver Trace バックエンドにアップロードできます。
レイテンシ データを収集するために、私たちは最近、Go プログラマーが Span とアノテーションをマーキングしてコードのインストルメンテーションを実行できるようにする cloud.google.com/go/trace パッケージをリリースしました。ただし、このトレース パッケージはまだアルファ版なので注意してください。今後、改善を重ねていく予定であり、今の段階では解決が必要なバグと機能のリクエストを自由にお寄せください。
以下のサンプルを実行するには Google Application Default Credentials が必要です。ない場合は、最初に gcloud コマンドライン ツールで Application Default Credentials を入手してください。
次に、トレース パッケージをインポートします。
import "cloud.google.com/go/trace"
プロジェクト IDを指定して新しいトレース クライアントを作成します。
traceClient, err = trace.NewClient(ctx, "project-id")
if err != nil {
log.Fatal(err)
}
寿命の長い trace.Client インスタンスを使うことをお勧めします。トレース クライアントを作成したら、プログラムが終了するまでそれを使い続けることができます。
サンプル プログラムは HTTP 要求を送信します。この例では、送信する HTTP 要求にトレーシング情報をアタッチし、送信先のサーバーにトレースを伝播できるようにします。
func fetchUsers() ([]*User, error) {
span := traceClient.NewSpan("/users")
defer span.Finish()
// Create the outgoing request, a GET to the users endpoint.
req, _ := http.NewRequest("GET", "https://userservice.corp/users", nil)
// Create a new child span to identify the outgoing request,
// and attach tracing information to the request.
rspan := span.NewRemoteChild(req)
defer rspan.Finish()
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
// Read the body, unmarshal, and return a slice of users.
// ...
}
User サービスが受信した要求のトレーシング情報を展開し、子 Span を作ってアノテーションを付加します。このような形で、1 つの要求のトレースが多くの異なるシステムに伝播します。
func usersHandler(w http.ResponseWriter, r *http.Request) {
span := traceClient.SpanFromRequest(r)
defer span.Finish()
req, _ := http.NewRequest("GET", "https://meta.service/info", nil)
child := span.NewRemoteChild(req)
defer child.Finish()
// Make the request…
}
HTTP ユーティリティを使う方法もあります。HTTPClient を介して、送信する要求にトレーシング コンテキストを追加します。そして HTTPHandler を使用して、受信した要求から Span を展開するのです。
var tc *trace.Client // initiate the client
req, _ := http.NewRequest("GET", "https://userservice.corp/users", nil)
res, err := tc.NewHTTPClient(nil).Do(req)
if err != nil {
// TODO: Handle error.
}
受信側では、提供されているハンドラ ラッパーを使い、受信した要求のコンテキストを介して Span にアクセスします。
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.FromContext(r.Context())
// TODO: Use the span.
})
http.Handle("/foo", tc.HTTPHandler(handler))
gRPC の Go クライアントとサーバーでも、自動トレーシングを有効にする同様のユーティリティを利用できます。
もっとも、すべてのサービスを Go で書かなければならないわけではありません。Stackdriver のヘッダ形式を使ってトレーシング コンテキストを伝播する限りにおいては、他の言語で書かれたサービスにも Span は伝播されます。Stackdriver のヘッダ形式については、Stackdriver Trace のドキュメントをご覧ください。
今後の予定
現状では GCP 用のソリューションを提供していますが、私たちのゴールは GCP だけでなく、Go エコシステム全体に貢献できるものを作ることです。Go のトレーシングに取り組んでいるグループはたくさんあり、方法を統一するためにはやるべきことがたくさんあります。Go プログラマーにとってトレーシングが容易なものとなるように、私たちはこれらのグループと協力していきたいと思っています。私たちが特に解決したいと考えている課題の 1 つは、特定のトレーシング バックエンドに依存せずに、サードパーティのライブラリ開発者たちがトレーシングをすぐに提供できるようにすることです。そうすれば、Span とアノテーションをマーキングするだけで、好みのトレーシング バックエンドのもとでコードをインストルメンテーションできるようなオープンソース ライブラリを作れるようになります。
また、Go プログラマーがコードを大幅に変更しなくても任意の場所でトレーシングを自動的に有効にできる再利用可能なユーティリティも用意したいと考えています。
現在は、業界のエキスパートたちが集まる大規模なグループと協力し、すでに完成しているソリューションを解析して要件を理解するとともに、私たちのトレーシング バックエンドとのインテグレーションを強化するようなソリューションを作っています。こうした第一級の部品とユーティリティが揃えば、分散トレーシングは、Go で構築された本番システムを診断するうえで手軽に頼れるツールになるでしょう。
* この投稿は米国時間 4 月 19 日、Engineer である Jaana Burcu Dogan によって投稿されたもの(投稿はこちら)の抄訳です。
- By Jaana Burcu Dogan, Engineer