はじめに
Kyashでサーバサイドのエンジニアをしているhirobeです。
Kyashでは30ほどのマイクロサービスを運用しており、マイクロサービス間ではREST/gRPCでの同期通信やSQSを介した非同期通信をしています。
マイクロサービスを運用しているとContext Propagationという単語を聞く機会があると思います。
Context Propagationとは、マイクロサービスをまたがって情報を紐付けすることを可能にする仕組みを指します。1
Context Propagationによって実現できることとしてまず挙げるべきはTracingでしょう。
Datadog、New Relicなどのツールを利用することでマイクロサービスをまたがって処理時間等を確認することができ、マイクロサービスの運用に欠かせないものになっています。
その他に、Context PropagationによってBaggageを伝播することが可能です。Baggageとはマイクロサービスをまたがって受け渡したいキーバリュー形式のデータです。
KyashではDatadogを3年以上使っており、Tracingについては当然のことながら調査などに活用し役立ててきたのですが、Baggageに関しては利用できてませんでした。
そこで基盤としてBaggageをREST/gRPC/SQSで伝播できるようにしたというのが今回のテーマです!
また、TracingについてはSQSではできていなかったので、この機会にできるようにしました。こちらについては最後に軽く触れる程度にしたいと思います。
まとめると、
通信方法 | Tracing | Baggageの伝播 |
---|---|---|
REST | o | x |
gRPC | o | x |
SQS | x | x |
を以下にした話です。
通信方法 | Tracing | Baggageの伝播 |
---|---|---|
REST | o | o |
gRPC | o | o |
SQS | o | o |
なお、弊社はベンダーとしてはDatadog、言語としてはGoを使っているのでその前提でこのブログを書きますが、その他のベンダー、言語でも参考になると思います。
Baggageに含めるとよさそうなもの
よくあげられる例としては、UserIDやProductID、ソースIPアドレスなどのリクエストに紐づくデータが挙げられます。注意点として、誤って外部のベンダーにBaggageが渡ってしまう(注意しないとあり得ます)ことを考慮し、セキュアな情報は含めないようにすべきです。
他には、featureフラグを運用しているのであれば、feature情報をBaggageに含めると良いでしょう。
BFFなどの通信の入り口でどのfeatureかを判断し、その後のマイクロサービス間での通信はその情報を常に渡してあげることでマイクロサービス全体で同じfeatureを提供することができます。
以前、軽量feature flag導入の手引きというブログを書いたのですが、その時にはBaggageの伝播を行う手段がなかったため軽量な方法で対応せざるを得ませんでした。
同じ理屈で、最近割と流行っていると思われるマイクロサービスでの開発環境を可能な限りリソースを共有して立ち上げる機能の実現する際には、各マイクロサービスがどのバージョンの挙動をすべきかを把握するためにBaggageの伝播は必須となります。
OpenTelemetryとは
まず、OpenTelemetry という団体を説明します。以下OTelと略します。
以前は、観測可能(Tracing,Metrics,Logs等)にするツールを提供しているベンダーで実装やインタフェースがバラバラでそれらをベンダーに送る統一化されたデータフォーマットがありませんでした。
ベンダーごとに違うのは良くないので標準化の流れになったのですが、以下のように2つの団体が立ち上がってしまった過去があります。
Recognizing the need for standardization, the cloud community came together, and two open-source projects were born: OpenTracing (a Cloud Native Computing Foundation (CNCF) project) and OpenCensus (a Google Open Source community project).
OpenTracing: provided a vendor-neutral API for sending telemetry data over to an Observability back-end; however, it relied on developers to implement their own libraries to meet the specification.
OpenCensus: provided a set of language-specific libraries that developers could use to instrument their code and send to any one of their supported back-ends.
が、2019年にめでたくmergeされて、OTelとなりました!
ということで、Baggageをどのようなインタフェースで定義すべきか、どのように渡すべきかというのを検討する際にはまずOTelを検討すべきでしょう。
最後に注意点ですが、OTel自体はTracing等を実現するインフラを提供していないことに注意しましょう。あくまでインタフェースの整理とライブラリの提供です。そのようなインフラにはDatadog,New Relic等のベンダーのサービスが必要となります。ただ、共通のインタフェースで問題なく動作するようベンダーごとのプラグインも実装していたりします。
OpenTelemetryのBaggage仕様
早速、OTelのBaggage仕様を見ていきます。
先述の通り、キーバリュー形式のデータですが、細かく規定されています。
ドキュメントとしてはこちらでGoのライブラリでの実装としてはこちら にあります。
キーバリュー形式のデータから、Get/GetAll/Set/Deleteできるように規定されています。
そして、Baggageはimmutableとされています。つまり、Set/Delete等の更新処理の場合は更新するのではなく、新しいBaggageを返すことになります。
また、Context2という用語があります。ContextとはContext Propagationの「Context」と同じ用語で、マイクロサービスをまたがって伝播したいもので、情報の紐付けに必要な情報を含むものです。Contextもimmutableとされており、BaggageはContextに紐付けたり、取り出したり、削除したりできる必要があります。
Goに慣れている読者は、ContextってGoのcontext.Context
をそのまま使えそうでは?と思うかもしれませんが、その通りです!ライブラリでもcontext.Context
として表現され、context.WithValue
でBaggageやTracingに必要なTraceID、SpanIDを保持しています。
Baggageを伝播する際にどのようにフォーマットすべきかですが、これはW3Cの仕様に準拠しています。3
ヘッダ名はbaggage
とすることが指定されています。
baggage
はカンマ区切りのmember
で、member
にはkey/valueに加えてproperty
を付与することが可能です。
key、valueの前後には空白が許容されるが全て除かれて解釈されます。
keyの許容文字は一般的なheaderに利用できる文字と同じで4、valueの許容文字はUTF-8文字をパーセントエンコードしたものです。
property
は;k1=v1;k2;k3=v3
のようにメンバの後ろにkey=value
の形で任意回続きます。propertyはkeyだけのもの(例だとk2
)があり得て、同様に前後の空白は許容されるが全て除かれて解釈されます。property
に何を含めるべきかは規定されていません。
Goを利用した具体例で見た方がわかりやすいと思います。
以下では、TracingもOTelを利用している場合を想定してspanの作成もしています。
import (
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel"
)
func hoge(ctx context.Context) {
// 新規にspanを作成
// 紐づいたcontextが返ってくる
ctx, span := otel.Tracer("").Start(ctx, "test")
// Baggageを定義していく
m1, _ := baggage.NewMember("user_id", "10")
// m2はpropertyを2つ持つ
prop1, _ := baggage.NewKeyValueProperty("is_primary", "true")
prop2, _ := baggage.NewKeyValueProperty("name", "test")
m2, _ := baggage.NewMember("product_id", "20", prop1, prop2)
// m1とm2の2つメンバを持つ
bg, _ := baggage.New(m1, m2)
// ctxにBaggageを紐づける
ctx = baggage.ContextWithBaggage(ctx, bg)
// 外部のマイクロサービスに送る
if err := sender.Send(ctx, msg, nil); err != nil {
panic(err)
}
baggage
というヘッダ名で、以下の値が格納される。
user_id=10,product_id=20;is_primary=true;name=test
結局Datadogのライブラリを採用した
前章にて、OTelでのBaggageの仕様について確認しました。ほとんどの人は、Baggageの伝播を導入したい時にはまずはOTelのライブラリの利用を検討すべきでしょう。
ただ、KyashではもともとTracingにDatadogを利用していてライブラリもDatadogのライブラリを利用していたということもあり、検討した結果、OTelは利用せずにDatadogのライブラリでBaggageの伝播を実現することにしました。
この章ではその理由を記載したいと思います!
DatadogでBaggageを利用する場合はどのような感じになるかだけ最初に示しておきます。
以下では、TracingもDatadogのライブラリを利用している場合を想定してspanの作成もしています。
package main
import (
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
func hoge(ctx context.Context) {
// 新規にspanを作成
// 紐づいたcontextが返ってくる
span, ctx := tracer.StartSpanFromContext(ctx, "testing")
...
// spanに対してBaggageを定義
span.SetBaggageItem("user_id", "10")
span.SetBaggageItem("product_id", "20")
if err := sender.Send(ctx, msg, nil); err != nil {
panic(err)
}
上記のコードで、ot-baggage-user_id
というヘッダ名で、10
が格納され、ot-baggage-product_id
というヘッダ名で、20
が格納されます。
OTelの場合はTracingの伝播とBaggageの伝播それぞれ別々にContextに対して管理する必要がありましたが、Datadogの場合はTracingのSpanの属性としてBaggageが定義されています。親子関係にあるSpanについてはBaggageもコピーされていきます。
また、ヘッダ名はカスタマイズ可能ですが、デフォルトでは、ot-baggage-{baggageのkey}
となっており、値も特に構造があるわけではなく、OTelに比べるとかなりシンプルです。
では、なぜTracingとBaggage共にDatadogのライブラリを使うことにしたのか、検討した3パターンについての評価を書いていきたいと思います。
TracingとBaggage共にOTelを利用するパターン
このパターンはそもそも最初から検討しませんでした。KyashではDatadogのTracingのロジックが既に大量に全マイクロサービスに存在し、それを書き換えるコストが膨大だからです。
では、Tracingもしていない状態でゼロから検討した場合にどうしたかというと、その状況でもDatadogのライブラリを使っていたかもしれません。
一言で言うとDatadogのインターフェースとしてOTelを使うのが枯れてなさそうだからです。
裏側にあるベンダー(Datadogなど)とどのようにデータをやり取りするかを司る部分をcollectorと呼ぶのですが、この部分は対ベンダーに対しての作り込みが必要であり、Datadogであればこちらにあったりします。
この部分の情報はあまりWebになかったり、Datadogのdocにあるように、そもそもTraceIdとSpanIdのフォーマットがOTelのそれと以下のように異なるなど、どこかでこのような差異でハマりそうな予感がします。
OpenTelemetry TraceId and SpanId properties differ from Datadog conventions. Therefore it’s necessary to translate TraceId and SpanId from their OpenTelemetry formats (a 128bit unsigned int and 64bit unsigned int represented as a 32-hex-character and 16-hex-character lowercase string, respectively) into their Datadog Formats(a 64bit unsigned int).
2022年5月にDatadog AgentがOTelのプロトコルを理解できるようになるなど、より良い方向に進んでいる気はしますが、十分に時間をかけて検討する必要はあると思います。
TracingにDatadog、BaggageにOTelを利用するパターン
当初、Datadogのライブラリを利用してBaggageを伝播できることに気づいておらず、実装までしたのですが、その後に気づいてボツにしました。
TracingとBaggageそれぞれについて、Contextとの紐付け作業を行う必要があります。
また、通信時にContext内に含まれる伝播したい情報をヘッダ等に変換する処理(次の章で紹介します)をTracingについては実装済みですが、Tracingと同じような処理をBaggageについても全マイクロサービスの接続部分全てに書いていく必要があり、大変です。
TracingとBaggageにDatadogを利用するパターン
圧倒的に楽です!
実際のイメージはこの章の冒頭に書いた通り、spanに対してBaggageをsetするだけで伝播できます。
Kyashの場合は、Tracingは既にしており、Context内に含まれる伝播したい情報をヘッダ等に変換する処理はREST、gRPCについては実装済みであったため、自然にそれに乗っかることができました。
つまり、SQS以外のREST、gRPCについては追加実装なく、Baggageを伝播できる状態になることができました!
このパターンの注意点としては、TracingとBaggageが密結合していることだと思います。
他の2パターンについては、Tracingは行わず、Baggageのみ行うことが可能ですが、このパターンについては、spanの属性としてBaggageを指定する都合上、必然的にTracingしていることが前提となります。
また、確認する限りは、少なくともGoのライブラリでは、tracer.Start
を呼んでDatadogのagentに定期的にデータを送るようにしないとBaggageの伝播は行われない挙動になっています。
そのため、基本的にDatadog Agentが実際に動いている環境で使う前提になりますが、回避策として、mocktracerを起動するというハックを発見しました。mocktracer.Start
を呼べばDatadogのバックエンドにデータを連携する以外の全ての処理が正しく行われるようなのでBaggageの伝播は問題なく行われます。当然のことながらmocktracerはテストのために用意されているので好ましい使い方ではないでしょう。
いずれにしろ、Kyashでは全てのマイクロサービスでDatadog Agentが動いていて、Tracingが行われているので問題にはなりませんでした。
Contextにある情報をどのように通信に載せるか
Datadogのライブラリを利用して、Contextに載せてTracingの情報やBaggageをアプリケーション内で伝播する方法とソースコードを紹介しました。
では、ネットワークをまたいでContextを伝播するのはどのようにすればいいのでしょうか。
クライアント側ではContext内の情報をencodeして通信内容のヘッダなどに詰めて、サーバ側ではdecodeしてContextに詰めれば良さそうですね!
この辺りの話はBaggage特有の話ではなく、Tracingも同様であり、このブログの主テーマはBaggageなので正直書くか迷ったのですが、この辺りがわからないと読者は半分くらいしか理解できていない気持ちのままになりそうですし、ネット上だとあまり情報がないので書くことにします。
Goを前提に書きますが、他の言語でも全く同じかと思います。
以下のTextMapWriter、TextMapReaderを実装すれば良いです。
Ingect
、Extract
の引数のcarrier
の型がinterface{}
になっているのでややこしいのですが、実質的にはそれぞれ、TextMapWriter
、TextMapReader
となります。
TextMapWriter
、TextMapReader
はkey,valueのデータ(Tracingに必要なtraceID,spanIDやBaggageのデータなど)について、Contextと通信データの間でどのように変換するかを抽象化したinterfaceになっています。
これを利用して、Propagator
というinterafaceが伝播するのですが、この処理についてはライブラリ側で行われるので気にする必要はないです。具体的には、例えばBaggageをExtractする(サーバ側で通信データからBaggageを取り出してContextに詰める)処理は、TextMapReader
を呼び、prefixがot-baggage-
であるkeyをBaggageデータとみなし、prefixを除いてspanのContextにセットしています。
// Propagator implementations should be able to inject and extract
// SpanContexts into an implementation specific carrier.
type Propagator interface {
// Inject takes the SpanContext and injects it into the carrier.
Inject(context ddtrace.SpanContext, carrier interface{}) error
// Extract returns the SpanContext from the given carrier.
Extract(carrier interface{}) (ddtrace.SpanContext, error)
}
// TextMapWriter allows setting key/value pairs of strings on the underlying
// data structure. Carriers implementing TextMapWriter are compatible to be
// used with Datadog's TextMapPropagator.
type TextMapWriter interface {
// Set sets the given key/value pair.
Set(key, val string)
}
// TextMapReader allows iterating over sets of key/value pairs. Carriers implementing
// TextMapReader are compatible to be used with Datadog's TextMapPropagator.
type TextMapReader interface {
// ForeachKey iterates over all keys that exist in the underlying
// carrier. It takes a callback function which will be called
// using all key/value pairs as arguments. ForeachKey will return
// the first error returned by the handler.
ForeachKey(handler func(key, val string) error) error
}
具体的なイメージがあった方がわかりやすいと思うのでhttpのcarrier実装だけ引用します。datadogのライブラリが提供している実装です。単純にヘッダに詰めているだけですね。
// HTTPHeadersCarrier wraps an http.Header as a TextMapWriter and TextMapReader, allowing
// it to be used using the provided Propagator implementation.
type HTTPHeadersCarrier http.Header
var _ TextMapWriter = (*HTTPHeadersCarrier)(nil)
var _ TextMapReader = (*HTTPHeadersCarrier)(nil)
// Set implements TextMapWriter.
func (c HTTPHeadersCarrier) Set(key, val string) {
http.Header(c).Set(key, val)
}
// ForeachKey implements TextMapReader.
func (c HTTPHeadersCarrier) ForeachKey(handler func(key, val string) error) error {
for k, vals := range c {
for _, v := range vals {
if err := handler(k, v); err != nil {
return err
}
}
}
return nil
}
gRPCであれば、datadogのcontribのライブラリに定義されています。gRPCのmetadataに格納されているのがわかります。grpcのmetadataのkeyはcase-insentiveなのでBaggageのkeyは小文字のみにしておいた方が無難そうです。
ここまでできたらclient,serverそれぞれでInject
、Extract
を呼ぶだけです。
例えば、httpのクライアントだと
_ = tracer.Inject(span.Context(), tracer.HTTPHeadersCarrier(req.Header))
です。
server側はmiddlewareとして実装するのが一般的です。例えば、ginだとmiddlewareが定義してあり、そこから呼ばれる関数内の処理でExtractが呼ばれています。
gRPCであれば、middlewareが定義してあり、そこから呼ばれる関数内の処理でExtractが呼ばれています。
SQSのcarrierは探す限り特にライブラリがないので自作しました。最後にSQSのcarrier実装を紹介して終わりにしたいと思います。
import (
"github.com/aws/aws-sdk-go/aws"
sqssdk "github.com/aws/aws-sdk-go/service/sqs"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
// SQSMessageAttributeCarrier adapts map of MessageAttribute to satisfy tracer.TextMapWriter/tracer.TextMapReader interface.
type SQSMessageAttributeCarrier map[string]*sqssdk.MessageAttributeValue
var _ tracer.TextMapWriter = (*SQSMessageAttributeCarrier)(nil)
var _ tracer.TextMapReader = (*SQSMessageAttributeCarrier)(nil)
// Get returns the value associated with the passed key.
func (mc SQSMessageAttributeCarrier) Get(key string) string {
if mc == nil {
return ""
}
v := mc[key]
if v == nil {
return ""
}
if *v.DataType != "String" {
return ""
}
return *v.StringValue
}
// Set stores the key-value pair.
func (mc SQSMessageAttributeCarrier) Set(key string, value string) {
v := &sqssdk.MessageAttributeValue{
DataType: aws.String("String"),
StringValue: aws.String(value),
}
mc[key] = v
}
// ForeachKey will iterate over all key/value pairs.
func (mc SQSMessageAttributeCarrier) ForeachKey(handler func(key, val string) error) error {
for k := range mc {
if err := handler(k, mc.Get(k)); err != nil {
return err
}
}
return nil
}
まとめ
OTelのBaggageの仕様を紹介してはいるものの、OTelのライブラリを利用しませんでしたが、どのベンダーもOTelの挙動をデフォルトの挙動として提供される未来になると良いですね。例えば、DatadogであればOTelへの貢献を宣言するなど各社協力的な姿勢のようなので良いなと思いました。
大衆向けのテーマではなかったと思いますが、ネット上にまとまっている情報が少なかったり、社内向けに説明する必要があったりしたため、今回時間をかけて文量多めに書きました。
Kyashではこのように基盤の整理を行っています。
より良い方向に向かっているのではと思ってます。
興味があればぜひ!
-
正確にはマイクロサービスだけでなく、サーバーレスアプリケーションなども含みますが、説明を簡略化しています。そして、この界隈の用語が難しく、文脈によっては少しニュアンスが異なる意味で使われているかもしれません。正直、この界隈の話は煩雑で、理解が難しいです。ドキュメント、実装をそれなりに読んでいるつもりですが、誤りがあったら優しく指摘してください。 ↩
-
「A Context is a propagation mechanism 〜」と説明が始まるけど、この主語はContextではなくてContext Propagationな気がする? ↩
-
OTelの仕様とw3cとの乖離が気になったが、実装を確認する限り、w3cではmessageのkey重複を許容するが、OTelでは許容せず上書きされるというくらいが差異のよう。 ↩
-
ASCII文字の一部。具体的には"!" / "#" / "$" / "%" / "&" / "'" / "*"/ "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"/ DIGIT / ALPHA ↩