分散アプリケーションの依存発見に向いたTCP/UDPソケットに基づく低負荷トレーシング

この記事は、分散アプリケーションを構成するネットワークサービス間の依存関係マップを構築するための基礎技術の改善提案をまとめたものである。第8回WebSystemArchitecture研究会での発表と同等の内容であり、そのときのスライドを以下に掲載しておく。

また、本手法のプロトタイプと評価実験のためのコードを次のGitHubリポジトリに公開している。

1. はじめに

クラウドの普及により、サービス事業者は機能追加やアクセス増加への対応が容易となっている。その一方で、クラウド上に展開される分散アプリケーション内の構成要素の個数と種類が増加しているため、構成要素の依存関係が複雑化している。そのため、システム管理者が、システムを変更するときに、変更の影響範囲を特定できず、想定よりも大きな障害につながりうる。よって、システム管理者の手によらず、ネットワークサービス(ネットワーク通信するOSプロセス)の依存を自動で発見することが望ましい。

自動で依存を発見する手法として、ネットワーク通信を傍受するアプローチと、アプリケーションの通信の前後に依存発見のための処理をコードに追加するアプローチがある。アプリケーションやミドルウェアを修正する手間を避けるため、コードを追加せずに依存を発見できることが望ましい。また、既存のサーバやアプリケーション処理に悪影響を与えないように、低オーバヘッドで依存を発見する必要がある。

Linuxカーネル内のソケットを傍受する手法12345は、分散アプリケーションの主要なネットワーク通信プロトコルがTCPまたはUDPであることに着目して、Linuxカーネル内のTCP/UDPの処理内容を傍受することにより、通信先と通信元の組(フロー)を追跡する。TCP/UDP処理に介入することにより、より上位のアプリケーション層プロトコルによらずに依存を発見可能となり、アプリケーションコードを修正する必要がなくなる。Nevesらによる追跡手法は4とDatadogの手法5は、カーネル内で傍受された同一のフローを一定時間内で集約する。集約処理により、TCP/UDPのメッセージ送受信数が増加した際に、カーネル空間からユーザ空間へのフローの転送に要するCPU負荷を低減させている。しかし、持続的な接続が利用される環境と比較して、TCPの短命接続数が大きな環境では、時間あたりの接続数が増加するため、フロー数が増大する。フロー数の増加に対して、カーネル空間からユーザ空間への転送コストが増加するという課題がある。

そこで、我々は、ネットワークサービス間のTCP/UDP通信による依存を自動で発見するために、フロー数の増大に対しても、低オーバヘッドを維持可能なカーネル追跡手法を提案する。手法は、ネットワークサービスが動作するホスト上のカーネル内で、同一のネットワークサービスとの通信であれば、その通信のTCP/UDPメッセージに含まれる複数の異なるフローを単一のフローに集束する。集束の結果、カーネル空間からユーザ空間へ転送されるフロー数が低減され、追跡処理に要するCPU負荷を低減できる。実装には、Linuxカーネル上のサンドボックス環境で、制約の範囲内で任意のプログラムを実行可能とするeBPF(extended Berkeley Packet Filter)を利用した。実験の結果、フロー数の増大に対して、提案手法がCPU負荷のオーバヘッドを2.2%以下に維持したことを確認した。また、本手法により発生するアプリケーションへの遅延オーバヘッドが十分に小さいことを確認した。

(追記:次の図に、各手法の差異を図解する)

2. 提案するカーネル内フロー集束法

クラウド上の分散アプリケーションでは、構成要素間のネットワーク通信を並行して処理するために、クライアントが同一のIPアドレスで複数の異なる短命ポートからサーバに接続することがある。例えば、Webアプリケーションであれば、Webサーバは複数のHTTP要求を並行して処理するために、複数のワーカープロセスを持ち、各ワーカープロセスが、同一のデータベースサーバに接続する。したがって、クライアントの異なる短命ポート番号から、同一の待ち受けポートへ接続する複数のフローを単一のフローと見なしても、依存が欠落することはない。そこで、複数のフローをカーネル内で集束させるための設計と実装を以下に示す。

2.1 設計

(図1: カーネル内フロー集束法によるトレースの全体像)

Input: Socket structure S, listening ports P
Output: Dump all bundled flows on H

new hash table H for storing bundling flows

unction get_listening_port_and_direction(S)
    if P.lookup(S.sport) then
        return S.sport, INCOMING
    else
        return S.dport, OUTCOMING
    end if
end function

function insert_flow(S,proto,msglen)
    lport, dir ← get listening port and direction(S) key ← {S.saddr,S.daddr,lport,dir,proto}
    stats ← H[key]
    if stats == NULL then
        Initialize stats stats.msglen ← msglen H.insert(key, stats)
    else
        stats.msglen+ = msglen
        H.update(key,stats) end if
end function

function probe__tcp_connect(S )
    insert flow(S, TCP, 0)
end function

function probe__tcp_accept(S )
    insert flow(S, TCP, 0)
end function

function probe__tcp_sendmsg(S,msglen)
    insert flow(S, TCP, msglen)
end function

function probe__tcp_recvmsg(S,msglen)
    insert flow(S, TCP, msglen)
end function

function probe__udp_sendmsg(S,msglen)
    insert flow(S, UDP, msglen)
end function

function probe__udp_recvmsg(S, msglen)
    insert flow(S, UDP, msglen)
end function

(アルゴリズム1: カーネル内フロー集束法)

図1に、ホスト上でフローを追跡する全体像を示す。一般に、ホスト上のユーザ空間に配置されたネットワークサービスは、カーネル内のソケットを通じて、TCP/UDP通信を行う。ホスト上に配置されたTracingプロセスは、起動時にフローを追跡するためのプログラムをカーネルへ転送する。このカーネルプログラムの動作手順は次のようになる。

ステップ1: カーネルプログラムが、TCPの接続開始、TCP/UDPのメッセージ送受信に対応する関数呼び出しを傍受する。

ステップ2: カーネルプログラムは、ソケット構造体を入力として、アルゴリズム1に示すカーネル内集約を実行する。アルゴリズム1は、フローの重みとして、データ転送量を計測する。Tracingプロセスの起動時に、フローを集束させ、格納するためのハッシュ表を作成しておく。傍受の対象となるカーネル関数が呼び出されるたびに、フローをハッシュ表に格納する。このとき、統計データとしてメッセージ長を更新する。ハッシュ表のキーとして、<送信元アドレス、送信先アドレス、待ち受けポート番号、フローの方向、IPプロトコル番号>の組を指定する。重み付きの依存グラフを構成する前提で、TCPのメッセージ送受信関数をそれぞれ傍受し、メッセージ長などの統計データを取得する。

次に、フローの方向を決定するための手順を示す。UDPは非接続型のプロトコルであるため、TCPのconnectとacceptのような接続を確立するための手順がない。そのため、待ち受け側は、起動時に、bindシステムコールにより、待ち受けポートを指定することに着目する。カーネルプログラムをbindシステムコールにアタッチさせ、引数からポート番号を取得し、カーネルプログラムの構造体に格納しておく。UDPのメッセージ送受信関数(PROBE__UDP_SENDMSGPROBE__UDP_RECVMSG)が、フローに含まれる宛先ポート番号と構造体内のポート番号を照会し、フローの方向を判定する。

TCPの場合は、カーネルプログラムが呼び出された関数が、connectシステムコールから呼ばれる関数(PROBE__TCP_CONNECT)であれば要求側、acceptシステムコールから呼ばれる関数(PROBE__TCP_ACCEPT)であれば待ち受け側となる。しかしながら、TCPのメッセージ送受信関数(PROBE__TCP_SENDMSGPROBE__TCP_RECVMSG)についても、TCPは双方向でメッセージを送受信するため、関数名から依存の方向を識別できない。そのため、UDPと同様に、bindシステムコールを傍受する方式により、依存の方向を識別する。

ステップ3: ユーザ空間内に配置されたTracingプロセスが、ハッシュ表から集束されたフローのリストを取得し、取得したフローをハッシュ表から除去する。これを一定の時間間隔で繰り返す。

2.2 実装

カーネルの関数とシステムコールにカーネルプログラムを傍受するために、Linuxカーネルの動的トレーシング技術であるKprobes(Kernel Probes) を利用する。Kprobesは、カーネルコードのアドレスにブレイクポイントを設定し、ブレイクポイントで事前に定義されたハンドラを実行できる。 また、eBPFのカーネルプログラムをKprobesのブレイクポイントにアタッチできる。カーネルプログラムは、ソケットにアクセスする必要があるため、カーネル内のオブジェクトであるstrcut sock構造体、または、struct sk_buff構造体を引数か返り値にとるカーネル関数にアタッチさせる。

TCPでは、connectシステムコールに対応して呼ばれるtcp_v4_connect関数と、acceptシステムコールに対応して呼ばれるinet_csk_accept関数、TCPのメッセージ送信のためのtcp_sendmsg関数、TCPのメッセージ受信時に呼ばれるtcp_cleanup_rbuf関数にそれぞれカーネルプログラムをアタッチする。また、UDPでは、sendmsgシステムコールの文脈で呼ばれるip_send_skb関数と、recvmsgシステムコールの文脈で呼ばれるskb_consume_udp関数にそれぞれアタッチする。

集束されたフローを格納するために、eBPFのカーネルプログラム間や、カーネル空間とユーザ空間プロセスでデータを共有可能な汎用のデータ構造であるeBPF mapsを利用する。eBPF mapsは、ハッシュ表や配列などの複数種類のデータ構造をサポートする。本実装は、bindシステムコールにより束縛された待ち受けポート番号を格納するためにも、eBPF mapsを利用する。

ユーザ空間のTracingプロセスが集束フローを取得するために、本実装では、eBPF mapsから複数のエントリをアトミックに取得および削除するBPF_MAP_LOOKUP_AND_DELETE_BATCHシステムコールを利用する。 このシステムコールを1秒間隔で繰り返すことにより、最新の集束フローを取得し続ける。

著者らは、以上の実装をGo言語のライブラリとして広く利用できるように、OSSとして公開している https://github.com/yuuki/go-conntracer-bpf。本ライブラリは、Weave Scope3などのネットワーク依存関係の可視化システムに組み込まれて利用されることを想定している。

3. 実験と評価

カーネル内フロー集束手法の有効性を確認するために、依存の追跡処理が、アプリケーションが稼働するホストに与えるCPU負荷と、アプリケーションの遅延を実験により評価する。

3.1 実験の環境と設定

計算機環境 実験用の計算機として、クライアントとサーバのそれぞれ1台ずつ、さくらのクラウドの仮想マシンを用意する。仮想マシンのハードウェア仕様は、CPUがIntel Xeon Gold 6212U 2.40GHz 6コア、メモリが16GiBであり、クライアントとサーバのそれぞれの仮想マシンは同一の仕様とする。各マシンのOSは、Ubuntu 20.10 Kernel 5.8.0であり、仮想マシン間のネットワーク帯域幅は1Gbpsである。

負荷生成 実験では、著者らが開発したTCP/UDP向けの負荷生成ツールconnperfにより、アプリケーションの負荷を擬似的に生成する。connperfのTCPとUDPでの通信内容は、単純なエコー方式であり、クライアントがサーバへメッセージを送信し、サーバが受信したメッセージをクライアントへ返送する。

比較手法 ソケットベースアプローチをとる既存の手法をカーネル内フロー集束法と比較する。 スナップショットポーリング手法1は、Linuxカーネル内のソケット情報を、カーネル空間とユーザー空間との間でメッセージ通信するための機構であるNetlinkとProcess Filesystem(procfs)を通じて、一定間隔でスナップショットを取得する。 ストリーミング手法2,3は、TCPではtcp_connect_v4関数とinet_csk_accept関数にアタッチして、接続確立時に生成されるフローのみを取得する。ユーザ空間とカーネル空間との間のイベント転送に要するCPU利用率を比較するため、イベント転送箇所以外の処理内容を揃えなければならない。そのため、ストリーミング手法は、カーネル内集約手法と同様の集約処理をユーザ空間で処理する。 カーネル内フロー集約法4は、前節で述べた実装をもとに、フローを格納するeBPF mapのキーとして、送信元と送信先のそれぞれのポート番号を含める。

実験を再現できるように、著者らが管理するリポジトリ https://github.com/yuuki/shawk-experiments に、実験手順を自動化するためのプログラムを公開している。

3.2 実験の結果

CPU利用のオーバヘッドの計測

(図2: CPUオーバヘッド (a)TCP短命接続 (b)TCP永続接続 (c)UDP)

最初の実験では、TCPの短命接続とUDPに対して秒間のラウンドトリップ数、TCPの持続接続に対しては計測期間中に常時持続する接続数をそれぞれ変化させながら、CPU利用率の変化を計測した。新規接続数、持続的接続数、および、メッセージ数のパラメータを5,000から35,000の間で変化させた。そのときの計測値を図2に示す。

図2(a)に示すTCPの短命接続では、カーネル内フロー集束法は、ラウンドトリップ数の増加に対して、1.2%以下のCPU利用率を維持した。ストリーミング手法のCPU利用率は、2.9%から21.3%まで増加し、カーネル内集約法では、2.6%から11.5%まで増加した。スナップショットポーリング手法のCPU利用率は、各手法のなかで、最も低い1%以下となった。これは、スナップショットポーリング手法が、全ての短命接続を追跡できず、取得された接続数が小さくなるためである。

図2(b)に示すTCPの持続的接続では、カーネル内フロー集束法は2.2%以下のCPU利用率となった。スナップショットポーリング手法のCPU利用率は、3%から23.3%まで増加し、カーネル内フロー集約法では、2.1%から5.0%まで増加した。持続的接続の数が増加するにつれて、スナップショットポーリング手法は、スナップショット作成のためのスキャン処理に要するCPU利用が増加する。ストリーミング手法は、接続開始時のフローのみを追跡するため、持続的接続では、計測開始直後に1回だけ追跡する。そのため、ストリーミング手法の負荷は低くなる。

図2(c)におけるUDPの場合のCPU利用率は、TCPの短命接続と同様の変化の傾向となった。connperfにおけるUDPの処理内容は、TCPの短命接続における接続確立の処理が除去されたものとみなせる。そのため、UDPが、TCPの短命接続と同等の傾向になることは妥当である。Linuxのprocfsの制約上、スナップショットポーリング手法はUDPをスキャンできないため、本手法の計測は行わなかった。

TBD (図3: CPUオーバヘッド - 複数ネットワークサービス)

カーネル内フロー集束法は、通信先・通信元のホスト数が増大すると、単一のホストとの通信時と比較し、全体のフロー数に対する集束されたフロー数の割合(フローの集束率)が低下する。そのため、ステップ3におけるカーネル空間からユーザ空間へ転送するフロー数が増加する。その際に処理コストが増加する影響を計測するために、connperfのプロセスをコンテナ上に配置することにより、複数のホストとの通信を擬似的に再現する。

図3に、秒間ラウンドトリップ数または持続的接続数を10,000に固定し、コンテナの個数を200から1,000まで変化させたときのCPU利用率を示す。図3の凡例に、clientと表記したグラフはクライアントの、serverと表記したグラフはサーバのコンテナ数を変化させたときの計測値である。クライアントとサーバ、それぞれのコンテナ数によらず、カーネル内フロー集約法のCPU利用率は2%以下を示した。

遅延オーバヘッドの計測

(図4: 遅延オーバヘッド (a)TCP短命接続 (b)TCP持続的接続 (c)UDP)

図4は、CPU利用率の計測実験と同様の環境とパラメータを変化させたときのeBPFプログラムの実行時間を示す。スナップショットポーリング手法は、他手法のように通信経路へ介入しないため、本実験では、スナップショットポーリング手法による遅延オーバヘッドを計測しない。一度のラウンドトリップの間に、複数のeBPFプログラムが実行される場合には、各プログラムの平均実行時間を合計した値をプロットした。

図4では、各計測値のなかでも最大の実行時時間は、高々6マイクロ秒である。カーネル内フロー集束法の遅延オーバヘッドは、TCP短命接続では、カーネル内フロー集約法に対して0.4-4%増、および、ストリーミング手法に対して54-58%増である。TCP持続的接続における同オーバヘッドは、カーネル内フロー集約法に対して-7.0-0.7%増、UDPでは、カーネル内集約法に対して14-25%減、ストリーミング手法に対して8-12%増となった。ストリーミング手法は、TCPの接続確立時のフローのみを追跡するため、他手法と比較し、TCP短命接続ではオーバヘッドが低く、TCPの持続的接続では、オーバヘッドが0となった。

4. まとめ

クラウド上に展開された分散アプリケーションを構成するネットワークサービス間の依存を低オーバヘッドで発見するために、TCP/UDPのフローをカーネル内で集束するためのカーネル内フロー集束法を提案した。カーネル内フロー集束法は、TCPの短命接続と持続的接続のいずれの方式が利用されたアプリケーションであっても、低オーバヘッドでフローを追跡可能である。実験の結果、カーネル内フロー集束法は、1,000個以下の数のネットワークサービスへの通信であっても、CPU負荷が2.2%以下を維持できていることを確認した。また、アプリケーションに与える遅延オーバヘッドは、ラウンドトリップあたり最大でも6マイクロ秒程度であった。

今後は、本手法を活用し、フローデータを永続化するためのトレーシングアプリケーションの開発を進めていきたい。

あとがき

昨年末にeBPFを学び始めてから、以前の研究にeBPFならではの改善を加えて発展させるとこんな研究になった。アイデアとしてはナイーブではあるけど、既存の論文やツールに対する小さな貢献をちゃんと示せたようには思う。論文誌に英文で投稿して採録されたのでよかった。この研究の経験を踏まえて、eBPFの概要からBPFトレーシングツールの実装に至るまでのガイドラインとなるような記事を執筆中なのでお楽しみに。

参考文献

  • [1]: Chen, P., Qi, Y., Zheng, P. and Hou, D.: CauseInfer: Automatic and Distributed Performance Diagnosis with Hierarchical Causality Graph in Large Distributed Systems, IEEE Conference on Computer Communications (INFOCOM), pp. 1887–1895 (2014).
  • [2]: Lin, J., Chen, P. and Zheng, Z.: Microscope: Pinpoint Performance Issues with Causal Graphs in Micro-Service Environments, International Conference on Service-Oriented Computing (ICSOC), pp. 3–20 (2018).
  • [3]: Weaveworks Ltd.: Weave Scope, https://github.com/weaveworks/scope
  • [4]: Neves, F., Vila ̧ca, R. and Pereira, J.: Black-box inter-application traffic monitoring for adaptive container placement, Annual ACM Symposium on Applied Computing (SAC), pp. 259–266 (2020).
  • [5]: Datadog, Inc.: Datadog Network Performance Monitoring, https://docs.datadoghq.com/networkmonitoring/performance/
  • [6]: Sigelman, B. H., Barroso, L. A., Burrows, M., Stephenson, P., Plakal, M., Beaver, D., Jaspan, S. and Shanbhag, C.: Dapper, a Large-Scale Distributed Systems Tracing Infrastructure, Technical report, Google (2010).
  • [7]: The OpenTelemetry Authors: OpenTelemetry, https://opentelemetry.io/.

著者 坪内 佑樹(*1), 古川 雅大(*2), 松本 亮介(*1)
所属 (*1) さくらインターネット株式会社 さくらインターネット研究所、(*2) 株式会社はてな
研究会 第8回Webシステムアーキテクチャ研究会