前回の記事では、異なる観測信号間の相関関係に焦点を当てて、Didi における可観測性の実践と実装について説明しました。サービス間の関係はどのように直列につながっているのか? 今業界でブームとなっているebpfはDidiではどのような用途に使われているのか? この記事ではそれを明らかにします。
背景
事業紹介:ビジネスインターフェース通話観察
Didi の MTL 機能の構築を担当することに加えて、Didi の観察可能なプラットフォームには、ビジネス側に偏ったデータとサービス インターフェイス呼び出しの観察も含まれます。
インターフェイス呼び出しトポロジの観察については、曖昧さを避けるために、ここで最初に説明しましょう。次の図は呼び出し関係を示しています。
リクエストとレスポンスのプロセス
ここでは [caller=A, caller-func=/a, callee=B, callee-func=/b] を使用します。これは [A, /a, B, /b] および [A, /a, C,/c] は、サービス A の /a がトリガーされた後に B:/b と C:/c を呼び出すアクションを記述します。特定のビジネスの複数の呼び出しエントリ (上記の例では [A, /a] など) を指定し、インターフェース呼び出しリンクを継続的に連結することによって、十分なインターフェース呼び出しデータが取得されると、いくつかの重要なデータを整理できます。ビジネス内のリンクを呼び出します。
コールリンクの構築はサービスの安定性を確保する上で非常に重要であり、災害復旧、オンデマンドビジネスの拡大、ピーク時業務状況検査バームなど、すべてはコアコールリンクの構築に依存します。経験の観点から見ると、実際の障害処理とキャパシティ評価では、インターフェイス レベルの呼び出しトポロジは、サービス レベルまたはコンテナ/物理マシン レベルの呼び出しトポロジよりもはるかに効果的です。
一般に、インターフェイス粒度でのサービス トポロジは、ログの呼び出しまたはメトリックの呼び出しによって連結できます。Didi Observable は以前、通話ログと通話メトリクスの組み合わせを使用して、サービス インターフェイスの通話トポロジを生成していました。その後、統合サービス ガバナンスの進歩に伴い、ビジネス レポートのメトリクスで通話ログ内の通話関係を完全にカバーできるようになり、インターフェイス トポロジの生成コストが大幅に削減されたため、インターフェイス トポロジの生成シナリオでは、インターフェイス トポロジの生成コストが調整されました。サービス呼び出しに基づいてメトリックに変換し、生成するデータ。
メトリックによるシリアル インターフェイス トポロジの概略図
ビジネス上の問題: サービス インターフェイス トポロジの検証
インターフェイスを介してメトリックを呼び出してリンクを連続的に呼び出すのが一般的な方法のようですが、生成された結果には明らかに次の問題があります。
生成されたデータには検証方法がありません。データはビジネス側のコードによって報告されるため、ユニバーサル SDK が導入されている場合でも、caller-func 情報はコードが呼び出されたときにアクティブに渡されることのみに依存できます。実際の経験から判断すると、caller-func の送信ミスと誤送信の問題は明らかです。
関係の検証と生成を呼び出すコストは高くなります。ビジネス コードのレポートに依存するということは、コードが特定の標準に従う必要があることを意味します。よりコアな呼び出しリンクの場合、コード変更を促進するのが比較的容易であり、高度なビジネス協力が得られます。ただし、コア以外の呼び出しリンクや、長期間安定して実行されているレガシー プロジェクトの場合、コードの標準化された変更を促進することは困難です。手動で追加するにはプロジェクトを手動で並べ替える必要があり、実際には 1,000 件近い呼び出しを伴うリンクを収容する余地はありません。
上記の 2 つの問題は、メトリック シリアル サービス インターフェイス トポロジを使用する場合の一般的な問題です。
Didi の観察可能な実践から判断すると、コア リンクの複雑さが数千のオーダーに達すると、ビジネス通話リンクのメトリック アクセス管理を推進する専門チームがあったとしても、通話関係のかなりの部分が欠落しているか、エラーが発生します。
理想的な環境下では通常の結果が得られる
メトリクス情報が正しくない場合に考えられる結果
サービス インターフェイス トポロジの検証の問題に対応して、Didi Observable は、eBPF (以下で特に断りのない限り、BPF と呼びます) テクノロジーに基づいてサービス インターフェイス トポロジを非侵入的に収集するソリューションを開発しました。メトリック+BPF収集の組み合わせにより、インタフェーストポロジデータの精度検証と欠落データの補完を実現します。同時に、MTL の統合など、BPF のより深い使用方法がさらに検討されました。
プラン
BPF の概要
BPF はもともと Berkeley Packet Filter の略語でした。カーネルは 3.15 から BPF を拡張しました。BPF プログラム レジスタの数を増やし、BPF プログラムが使用できるメモリを拡張し、複数の BPF イベントを追加することで、BPF は高度にカスタマイズ可能です。拡張前の BPF と区別するため、3.15 より前の BPF を cBPF (classic BPF)、拡張された BPF を eBPF (extended BPF) と呼び、BPF も略語というよりは技術として定着しています。名前。
カーネル 4.18 バージョンの時点で、BPF でサポートされているイベント タイプの一部とその簡単な紹介は次のとおりです。
この記事では、uprobe と kprobe について説明します。ほとんどのカーネル関数は、kprobe を通じてフックできます。ユーザー定義プログラムでは、シンボル テーブルに存在する関数を uprobe 経由でフックすることもできます。
kprobe、uprobeをトリガーすると、対象関数のパラメータまたはスタック情報のみを取得できます。次のコードは、bpftrace を通じて /bin/bash を監視し、readline の戻り値を取得してユーザーの bash コマンドを監視する例です。
#!/usr/bin/bpftrace
BEGIN
{
printf("开始观测bash...\n使用Ctrl-C停止\n");
}
uretprobe:/bin/bash:readline
{
printf("cmd: %s\n", str(retval));
}
このうち、bash のソースコードでは以下のように readline が定義されており、対象関数のソースコードを参照すると BPF のロジックがよりよく理解できます。
/* Read a line of input. Prompt with PROMPT. A NULL PROMPT means none. */
extern char *readline (const char *);
実行後、対象のカーネル関数が実行されると、トリガーは以下のようになります。
$ sudo bpftrace ./bashreadline.bt
Attaching 2 probes...
开始观测bash...
使用Ctrl-C停止
cmd: ls -l
cmd: pwd
cmd: crontab -e
cmd: clear
eBPF が 3.15 カーネルに導入されてから、その機能は継続的に拡張されています。より重要な拡張機能の 1 つは、4.18 カーネルでの BTF (BPF Type Format) の導入です。BTF テクノロジにより、BPF バイトコードのロードと使用が容易になります。
BPFの開発
さまざまな関数を実装するために、ネイティブ BPF は通常、制限された C 言語を使用して bpf-helpers 関数を呼び出し、次に LLVM を使用してそれを BPF コードのバイトコードにコンパイルし、システム コールを通じてロードします。ネイティブ C 言語の記述方法は比較的面倒ですが、iovisor プロジェクトは BPF 開発の利便性を高めるために bcc ライブラリを立ち上げ、同時にワンライナー スタイルをサポートし非常に使いやすい bpftrace ツールを維持しました。業界でよく知られている繊毛も cirium-ebpf を維持しています。bcc、bpftrace、cilium-ebpf に加えて、生産サイクル全体をサポートする Coolbpf や、Rust を使用して libc に基づく BPF サポートを提供する aya などのツールもあります。
BPF の生態、写真は ebpf.io より
BPF を使用してサービス インターフェイス トポロジの問題を解決する
前の章では、生成されたトポロジ データがサービス インターフェイス トポロジで検証できないと述べましたが、この問題は現在、Didi Observable の BPF によって解決されています。ここでは、簡単な例と、bpftrace スクリプトを使用して構築されたソリューションを通じてその効果を示します。
例: 単純な golang サービス
以下は、go1.16 に基づく単純な golang サービスです。処理コードからわかるように、ここでの 4 タプルは [local, /handle, local, /echo] です。例の説明の便宜上、ここでの「ハンドル」のロジックと下流リクエストのロジックはシリアルであり、「ゴルーチン」は使用されていません。これは重要なので後ほど説明します。
func echo(c *gin.Context) {
c.JSON(http.StatusOK, &Resp{
Errno: 0,
Errmsg: "ok",
})
return
}
/*
s := http.Server{
Addr: "0.0.0.0:9932",
}
r := gin.Default()
r.GET("/echo", echo)
r.GET("/handle", handle)
s.Handler = r
*/
func handle(c *gin.Context) {
client := http.Client{}
req, _ := http.NewRequest(http.MethodGet,
"http://0.0.0.0:9932/echo", nil)
resp, err := client.Do(req)
if err != nil {
fmt.Println("failed to request", err.Error())
c.JSON(http.StatusOK, &Resp{
Errno: 1,
Errmsg: "failed to request",
})
return
}
respB, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("read resp failed")
c.JSON(http.StatusOK, &Resp{
Errno: 2,
Errmsg: "failed to read request",
})
return
}
defer resp.Body.Close()
fmt.Println("resp: ", string(respB))
c.JSON(http.StatusOK, &Resp{
Errno: 0,
Errmsg: "request okay",
})
return
}
収集ロジックと実行効果:
uprobe:./http_demo:net/http.serverHandler.ServeHTTP
{
$req_addr = sarg3;
$url_addr = *(uint64*)($req_addr+16);
$path_addr = *(uint64*)($url_addr+56);
$path_len = *(uint64*)($url_addr+64);
// 在http请求触发处,依据pid将caller_func存储起来
@caller_path_addr[pid] = $path_addr;
@caller_path_len[pid] = $path_len;
@callee_set[pid] = 0;
}
uprobe:./http_demo:"net/http.(*Client).do"
{
// 依据 pid 获取 caller 信息
printf("caller: \n caller_path: %s\n",
str(@caller_path_addr[pid], @caller_path_len[pid]));
$req_addr = sarg1;
// 获取 callee 信息
$addr = *(uint64*)($req_addr);
$len = *(uint64*)($req_addr + 8);
printf("callee: \n method: %s\n", str($addr, $len));
$url_addr = *(uint64*)($req_addr + 16);
$addr = *(uint64*)($url_addr + 40);
$len = *(uint64*)($url_addr + 48);
printf(" host: %s\n", str($addr, $len));
$addr = *(uint64*)($url_addr + 56);
$len = *(uint64*)($url_addr + 64);
printf(" url: %s\n\n", str($addr, $len));
@callee_set[pid] = 1
}
uprobe:./http_demo:"net/http.(*response).finishRequest"
{
// 如果没有下游请求,单独输出
if (@callee_set[pid] == 0){
printf("caller: \n caller_path: %s\n",
str(@caller_path_addr[pid], @caller_path_len[pid]));
printf("callee: none\n\n");
@callee_set[pid] = 1;
}
}
収集スクリプトを使用して収集すると、結果は次のようになります。
# 启动采集
$ bpftrace ./http.bt
Attaching 2 probes... # 未触发请求前,停止在这里
caller: # 触发请求后,输出
caller_path: /handle
callee:
method: GET
host: 0.0.0.0:9932
url: /echo
caller:
caller_path: /echo
callee: none
# 开始服务
$ ./http_demo &
# 触发请求
$ curl http://0.0.0.0:9932/handle
bpftrace スクリプトが 4 つのターゲット サービス インターフェイス呼び出しのコレクションを実装していることがわかりますが、これはターゲット サービスのコードを変更することなく実行され、BPF は観測可能なフィールドでその魅力を実証しています。
実際のプログラムの対象範囲と効果
上記の例は、インターフェイス トポロジの観察に BPF を使用する主なアイデアを示しています。なお、この例ではcaller_mapのキーとしてpidを使用していますが、実際のプロジェクトではgolangのgoroutineとpidは1対1に対応していないため、キーとしてgoidを使用する必要があります。
同時に、新しい goroutine が handleFunc でダウンストリーム リクエストを開始するために使用されるため、BPF は特定の goid に関連付けられた呼び出し元情報の損失を避けるために goid の導出関係を維持する必要もあります。このように、golang サービスの場合、実際の処理のアイデアは非常に明確です。
BPF観測サービストポロジの概念図
上の図は、Didi Observable の現在の golang インターフェイス呼び出し監視 BPF ソリューションです。ソリューションを要約すると、そのコアは次のとおりです。
情報収集。caller-func、callee、callee-func、その他の情報を含む情報は、適切なフック ポイントの選択を通じて取得する必要があります。
情報協会。golang サービスの特性上、関連付けには goid を使用します。これにより、発信者情報を着信者情報と関連付けて 4 倍にすることができます。
現在、Didi Observable はこの考えに基づいて golang と PHP サービスのカバーを完了しています。実用結果から判断すると、本ソリューションによる対象サービスの実効カバー率は約80%です。対象の監視コア呼び出しリンクはBPFに4タプルを追加して手動で確認しており、異常な4タプルはありません。メトリックベースのデータと比較すると、一部のコア コール リンクでは、新しい 4 倍コールの数が 20% に達する可能性があります。
質問
失われた関連性
実際、上記の解決策は、現時点で考えられる最も直感的な解決策です。情報収集部分は大きな問題ではありませんが、実際の本番環境で使用するgo1.10~go1.20に関しては、go1.10~go1.20で導入されている関数呼び出しプロトコルに加えて、対象関数のパラメータへの依存を導入したuprobeが使用されています。 go1.17、適応を除けば、他の必要な情報は基本的に変更されません。
さらに厄介なのは情報の関連付けの部分であり、既存の解決策は、ゴルーチンの派生関係を維持することで呼び出し元の情報と呼び出し先の情報の関連付けを実現することですが、現実には満足できないことがよくあります。たとえば、実際のプロジェクトの観点から見ると、次のコードが表示されます。
/*用法1:通过channel来传递request。这种场景下,事件间的关联性丢失,无法形成四元组*/
var reqChan = make(chan *http.Request, 10)
func handle(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Hello, World\n")
reqChan <- req // 这里通过channel来传递请求
return
}
func handleReq() {
for {
select {
case req, ok := <-reqChan:
if !ok {
log.Println("channel closed")
return
}
log.Println("received, ", req.Host, req.Method)
// do some stuff
// 即使这里存在下游请求,也无法和caller关联起来。
}
}
}
func main() {
go handleReq()
http.HandleFunc("/hello", handle)
http.ListenAndServe("0.0.0.0:9999", nil)
return
}
type GoroutinePool interface {
Start() (error, bool)
AddTask(func())
Stop() (error, bool)
}
var pool GoroutinePool
func handle(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Hello, World\n")
pool.AddTask(func() {
// 这里由于采用了goroutine池,goroutine间的派生关系 会丢失,事件无法有效串联
handleReq(req)
})
return
}
func handleReq(req *http.Request) {
log.Println("received, ", req.Host, req.Method)
// do some stuff
}
func main() {
// init pool
// pool = New()
http.HandleFunc("/hello", handle)
http.ListenAndServe("0.0.0.0:9999", nil)
return
}
上記 2 つのシナリオでは、ゴルーチンの導出関係が得られないため、既存の解法では 4 倍を求めることができず、同様の問題が BPF の収集効果に影響を与えます。既存の経験から判断すると、Golang プロジェクトの同様のコードによって影響を受けるクアドルプルの割合は 20% 未満です。
アップローブ: 適応の複雑さ
前のセクションで紹介した後、Didi Observable は、uprobe に基づくサービス インターフェイス トポロジ観察ソリューションであることがわかりました。
BPF アッププローブの使用には、効率的なデータ処理と直感的な全体的なソリューションという特徴があります。uprobe はユーザーのコードに近いため、フレームワーク内の関数呼び出しが遅いなど、ユーザーが強く意識する問題にはより快適です。
しかし、ほとんどのプロジェクトでは、bpftrace の多くの実用的なツールなど、kprobe がさらに使用されます。deepflow の観測機能のほとんどは kprobe に基づいて構築されており、ネットワーク データ処理を伴う kindling のコンテンツも kprobe に基づいて処理されます。
現状では、実際に運用されているプロジェクトのうち、アップローブの建設計画を完全に踏襲したプロジェクトはまだ数件しかありません。その理由は、uprobe の使用には次の 2 つの欠点があるためです。
汎用性が低い。ソリューションの紹介から、uprobe ベースのソリューションと言語 (フレームワークも) が強く関連していることがわかります。また、ターゲットプログラムのシンボルテーブルが存在しない場合、uprobe は動作しません。これは、対象となる使用シナリオが不明瞭な場合、uprobe を使用するにはそれぞれの特定のシナリオに適応する必要があり、全体的な投資と成果が非常に少なくなるということを意味します。
パフォーマンスの問題。uprobe がトリガーされると、ユーザー モードとカーネル モードの間で 2 つの切り替えが必要になります。つまり、uprobe が 1 回で実行されると、パフォーマンスのオーバーヘッドが非常に高くなります (1 つの uprobe のトリガー時間は約 1us ですが、uprobe のトリガー時間は 1 マイクロ秒です)。単一の kprobe の時間は約 100ns)。フックされた関数が頻繁にトリガーされると、対象プロセスのパフォーマンスが低下します。
上記の uprobe の欠点にもかかわらず、Didi Observable は依然として uprobe に基づくソリューションを構築することを選択しました。これは主に、uprobe の開発効率が速く、コストが低いためです。
uprobe を使用して開発すると、見たものがそのまま得られます。データの劣化はなく、トランスポート層メッセージから鍵情報を取得する必要はありません。開発時間を節約するだけでなく、処理の複雑さも大幅に軽減されます。長い http メッセージを考慮すると、uprobe はターゲット関数から URL 情報などの必要なデータを直接取得できますが、kprobe は複数回トリガーされるため、メッセージを処理し、解析して必要な情報を取得します。現在、Didi が観察できる ebpf-agent の実際の CPU オーバーヘッドは、通常、単一コアの 10% 未満です (PHP プロセス、ルーティング nginx サービス CPU などの一般的なビジネス プロセスの方が高くなります)。これは、ターゲットプロセス。影響はほとんど目立ちません。
展望
ユーザーモード VM の要件
Didi Observable は多数のアップローブを使用しており、オフライン環境では、通常、1 台の物理マシンで 1,500 を超えるアップローブ フック ポイントが実行されます。今後、BPFの機能拡張に伴い、アップローブフックポイントの数は増加していきます。カーネルに多数の uprobe を入れると、カーネルに安定性のプレッシャーがかかるだけでなく、BPF VM がカーネル状態で実行されるため、uprobe がトリガーされると、プログラムはカーネル状態とユーザー状態の間で 2 つの切り替えをトリガーします。ターゲットプロセスの関数実行に問題を引き起こす遅延。
これらの点により、ユーザー モード VM の使用が避けられなくなります。uprobeをユーザーモードVMに切り替えて実行するだけで、時間のかかるuprobeの処理を削減でき、uprobeを大規模に利用しても対象サービスへの影響は少なくなります。
BPFベースのMTL融合ソリューション
bpf-helpers をもう一度見てみると、次のような興味深い関数があることがわかります。
long bpf_probe_write_user(void *dst, const void *src, u32 len)
Description
Attempt in a safe way to write len bytes from the buffer src to dst in memory.
It only works for threads that are in user context,
and dst must be a valid user space address.
This helper should not be used to implement any kind of security mechanism because of TOC-TOU attacks,
but rather to debug, divert, and manipulate execution of semi-cooperative processes.
Keep in mind that this feature is meant for experiments,
and it has a risk of crashing the system and running programs.
Therefore, when an eBPF program using this helper is attached,
a warning including PID and process name is printed to kernel logs.
Return 0 on success, or a negative error in case of failure.
この機能の機能は強力で、対象プロセスの空間にBPFデータを直接書き込むことができ、BPFの利用範囲が広がります。MTL 統合のプロセスにおいて、より困難な問題は、トレース情報をメトリクスやログに効果的に関連付けることができないことです。
独自のMTL融合ソリューション
上図に示すように、メトリックまたはログがレポートされるときに正しいトレース情報がレポートされない場合、メトリックとログはトレースに関連付けられません。
また、各リクエストの処理リンクが BPF によって正常に維持され、BPF がリクエストのトレース情報を維持している場合、メトリクスとログは生成時に自然にトレースに関連付けることができます。次の図は、BPF 拡張の 3 つのオプションを示しています。
BPF 強化 MTL 融合ソリューション
BPF+SDKのMTL統合ソリューション
BPFベースのMTL統合ソリューション
要約する
さまざまな観測・収集方法により、大量の観測データが収集されています。このデータは詳細にユーザーに直接配信されますか、それとも指定されたディメンションに従って集計されて表示されますか? 集計にはどのような種類のコンピューティング エンジンが使用されますか (Spark または Flink)。
次の記事では、Didi の可観測性チームがデータ計算をどのように実装するかを説明しますので、ご期待ください。
クラウドネイティブナイトトーク
eBPF テクノロジーによってどのような目に見える問題が解決されると予想されますか? コメント エリアにメッセージを残してください。さらに連絡する必要がある場合は、プライベート メッセージをバックエンドに直接送信することもできます。
最も意味のあるメッセージの中から著者が 1 つを選択し、Didi Yuanqi デニム トートバッグをプレゼントします (賞品は 9 月 21 日午後 9 時に抽選されます)。