LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

LBaaSにおけるQUIC-LBの実装と検証(インターンレポート)

はじめに

こんにちは。京都大学大学院情報学研究科修士1年の上田蒼一朗と申します。

私はCloud Infrastructure本部NetDev部というところで、6週間のインターンシップに参加しました。本レポートでは、私のインターンシップでの取り組みである、L4ロードバランサのQUICサポートについて紹介します。

背景

LINEヤフーのプライベートクラウドはLBaaS、つまりロードバランサをサービスとして社内に提供しています。L4ロードバランサはLBaaSの中の1つで、TCPやUDPなどのトランスポート層までの情報を元にパケットを複数のサーバーに振り分けるものです。

L4ロードバランサの一部はLinuxのXDPという機能で実装されています。XDPとはLinuxカーネル内に実装されたパケット処理機構で、ユーザーが独自に実装したパケット処理のプログラムをカーネル内に組み込むことができます。また、XDPで組み込まれたコードは、カーネルによるパケット処理より前に実行されるため、非常に高速に動作することが特徴です。

L4ロードバランサはXDPによって図1のように動作します。まずクライアントはVIP(ロードバランサに紐付く仮想IPアドレス)宛にパケットを送信します。それをロードバランサが受け取り、5tuple(送信元と送信先のそれぞれのIPアドレス・ポート番号とプロトコル番号という5つの値)からハッシュ値を計算し、それを元に転送先サーバーを決定します。このように転送先サーバーを5tupleから決定することで、同一コネクションのパケットが同じサーバーに転送されるため、クライアント・サーバー間のコネクションを維持しています。そしてロードバランサは転送先サーバーのIPアドレスに宛てたIPヘッダを付加し(IPIP)パケットを転送します。これを受け取ったサーバーは、付加されたIPヘッダを外すことでクライアントから送られたパケットを受け取り、レスポンスとして送信元IPアドレスをVIP、送信先IPアドレスをクライアントのIPアドレスとしたパケットを返します。そのため、サーバーからクライアントへ向かうパケットは、ロードバランサを介さずに直接クライアントへ届きます(これをDirect Server Returnといいます)。

図1 クライアントからのパケットがL4ロードバランサを介してバックエンドサーバーに送られ、そのレスポンスのパケットがクライアントに届く様子。
図1 クライアントからのパケットがL4ロードバランサを介してバックエンドサーバーに送られ、そのレスポンスのパケットがクライアントに届く様子。

LBaaSの詳細については過去のLINE DevDayの資料でも解説されているので、ぜひご覧ください。

QUICとL4ロードバランサ

QUICとはコネクション指向のトランスポート層ネットワークプロトコルの1つです。従来使われてきたTCPと異なる点として、QUICはUDP上で通信を行い、カーネル内ではなくユーザープログラムとして実装されています。QUICはHTTPの最新のバージョンであるHTTP/3で使われており、これによりHoL(Head of Line)ブロッキングの解消や接続確立時のオーバーヘッドの低減といった効果が出ています。HTTP/3はすでにGoogleやYouTube、Instagramといったサービスで利用されており、LINEヤフーでも将来的にHTTP/3への対応を行う可能性があります(参考:LINEのAndroidアプリがSPDYからHTTP/2へ移行した話)。

QUICの大きな特徴の1つがコネクションをIDベースで管理することです。TCPではコネクションを自分と相手それぞれのIPアドレス・ポート番号、そしてプロトコル番号という計5つの値(これを5tupleと呼びます)を用いて管理します。ところがこれではコネクションの途中でクライアントのIPアドレスやポート番号が変化したときにコネクションを維持できないという問題があります。例えばクライアントがスマートフォンでWi-Fiからモバイル回線に切り替わったときや、コネクションの途中でしばらく通信がなかったときにNATの対応ポートが変更されたときなどにこのような事態が発生します。これに対応するため、QUICはコネクション確立時にコネクションに対してIDを発行し、これを用いてコネクションを管理します。そのため、クライアント側のIPアドレスやポートが変化しても、発行されたコネクションIDを使って通信することでコネクションを維持し続けることができます。このようにIPアドレスやポートの変更後にコネクションを維持することをコネクションマイグレーションと呼びます。

ところが現状のL4ロードバランサのアルゴリズムではQUICのコネクションマイグレーションに対応できません。なぜかというとクライアント・サーバー間でコネクションIDでコネクションを識別していても、ロードバランサはそれを考慮することなく5tupleで転送先を決定してしまうので、コネクションマイグレーション後のパケットがロードバランサによって別のサーバーに送られるようになり、コネクションが維持できなくなるという問題があります。これを解決するためにはL4ロードバランサがQUICを意識してサーバーへの転送先を決定することで、コネクションマイグレーションが起こった後でも元の転送先と同じサーバーに転送し続けることが必要です。

本インターンシップでは、将来的なL4ロードバランサのQUICのコネクションマイグレーション機能への対応のために何が必要かを調査し、実際に実装を行ってそれを実証することを目的として取り組みました。

QUIC-LBの実装

このようなQUICとL4ロードバランサとの組み合わせによって起こる問題は社内だけでなく一般に議論されています。この問題への対処方法を標準化しようとする取り組みとしてQUIC-LBというインターネットドラフトが議論されています。

QUICではコネクションIDはクライアント側・サーバー側それぞれで発行し、その組み合わせによってコネクションが識別されます。QUIC-LBの根本的なアイディアはサーバーが生成するコネクションIDの中にサーバーのIDを含めるというものです。L4ロードバランサはコネクション確立時は5tupleによって転送先サーバーを決定し、コネクション確立後はQUICパケット内のコネクションIDからサーバーIDを読み取り、それを用いて転送先サーバーを決定することが可能になります。その結果、コネクションマイグレーションによって5tupleが変化してもL4ロードバランサは送られてきたQUICパケットからサーバーIDを読み取ることで正しい転送先サーバーを得ることができます。

このアルゴリズムを実装するために、本インターンシップでは以下の実装を行いました。

  • QUIC-LBに対応したQUICサーバーの実装:自身のサーバーIDを含めたコネクションIDを生成するようにする。
  • L4ロードバランサの実装:QUICパケットからサーバーIDを読み取りそれを用いて転送先サーバーを決定するようにする。

QUICサーバーの実装

QUIC-LBではロードバランサとサーバーの両方で対応が必要です。今回はmvfstというC++で書かれたOSSのQUICの実装を改造してQUIC-LBに対応しました。実装の内容としては、図2のようなQUIC-LBで標準化されているフォーマットでコネクションIDを生成するようにしました。またQUICのコネクションIDの長さは可変なので、Direct Server Returnのためロードバランサはサーバーが生成したコネクションIDの長さを知ることができずコネクションIDをパースして取り出すことができません。よってあらかじめサーバーとロードバランサでコネクションIDの長さをそろえておくようにしました。

図2 QUIC-LBで標準化されたコネクションIDのフォーマット。
図2 QUIC-LBで標準化されたコネクションIDのフォーマット。

コネクションIDを生成するロジックは以下のようなコードで実装されています。図2のフォーマットに従ったバイト列を生成しています。

std::vector<uint8_t> connIdData(config_.connectionIdLength());
 
uint8_t firstOctet = folly::Random::secureRand32();
firstOctet = (firstOctet >> 3) | (config_.cr << 5);
connIdData[0] = firstOctet;
 
std::vector<uint8_t> plaintext(config_.serverIdLen + config_.nonceLen);
std::copy(serverId_.begin(), serverId_.end(), plaintext.begin());
 
std::vector<uint8_t> nonce(config_.nonceLen);
folly::Random::secureRandom(nonce.data(), nonce.size());
std::copy(nonce.begin(), nonce.end(), plaintext.begin() + serverId_.size());
std::copy(plaintext.begin(), plaintext.end(), connIdData.begin() + 1);

mvfstを元に実装を行った理由は、mvfstではコネクションIDを生成する部分のロジックがインターフェースによって切られていたため、そこにQUIC-LBに対応した独自のロジックを実装して差し替えることで比較的小さい修正で済みそうだと判断したためです。結果的にはおおむね見通していた通り、コネクションIDの生成ロジックに対する変更と受け取ったパケットのパースの仕方に対する変更のみでQUIC-LBに対応させることができました。

ロードバランサの実装

XDPで受信したパケットを処理するコードに以下のロジックを追加しました。

  • もしQUICが有効化されたVIP宛のパケットであれば、5tupleからハッシュを計算する前に、それをQUICパケットとして解釈しコネクションIDに含まれるサーバーIDを取得する。
  • そのサーバーIDから宛先サーバーのIPアドレスを取得し、転送を行う。
  • もしサーバーIDの読み取りや宛先IPアドレスの取得に失敗した場合は5tupleによる転送先の決定に切り替える。
  • サーバーIDと宛先サーバーとの対応や、どのVIPがQUICサーバーに紐づいているかなどの情報をロードバランサに持たせる。

QUICのパケットにはショートヘッダパケットとロングヘッダパケットの2種類があります。前者はデータのやり取りに、後者はハンドシェイク時に主に使われます。図3のように、これらのパケットでは名前の通りヘッダの構造が異なるため、読み取るべきコネクションIDの位置が異なります。ショートヘッダは先頭1ビット(Header Form)が0に、ロングヘッダでは1にセットされているため、これを見ることでパケットの種類を特定しコネクションIDの位置を知ることができます。

また、QUICでは送信先、送信元でそれぞれコネクションIDが生成されます。サーバーIDが含まれているのはサーバーが生成したコネクションIDなので、図3のDestination Connection IDを読み取ります。

図3 QUICのパケット構造。
図3 QUICのパケット構造。

コネクションIDを読み取るロジックは以下のようなコードで実装されています。ロングヘッダパケットかショートヘッダパケットかで分岐して、それぞれQUICパケット中の適切な位置をサーバーIDとして読み取っています。

__u8 server_id[MAX_SERVER_ID_SIZE];
if (quic_pkt + 1 <= quic_pkt_end && *quic_pkt & 0x80) {
  // Long Header Packet
  quic_pkt += 7; // flags(1), version(4), dcid_len(1), cr(1)
  if (quic_pkt + MAX_SERVER_ID_SIZE <= quic_pkt_end) {
    memcpy(server_id, quic_pkt, MAX_SERVER_ID_SIZE);
  }
} else {
  // Short Header Packet
  quic_pkt += 2; // flags(1), cr(1)
  if (quic_pkt + MAX_SERVER_ID_SIZE <= quic_pkt_end) {
    memcpy(server_id, quic_pkt, MAX_SERVER_ID_SIZE);
  }
}

なお、QUIC-LBではコネクションIDの中に含まれるサーバーIDを暗号化することが推奨されていますが、今回の実装では平文で入れています。サーバーIDの暗号化を行うためには、XDPにはOpenSSLなどの外部ライブラリを呼び出すことができないという制約があるので、XDPコードの中に暗号化の処理を直接組み込む必要があります。今回は実装するための時間が限られていたためこれを避けて平文でサーバーIDを持たせるという判断を行いました。

デモ

実装した機能が正しく動作することを確認するために、コンテナとLinuxの仮想ネットワーク機能を使って図4のような仮想ネットワークを1台のLinuxマシン上に構築しました。

まず、動作確認するためにはロードバランサ、バックエンドサーバー、そしてクライアントの3つが必要です。これらをコンテナとして用意しました。また、このL4ロードバランサはロードバランサと各バックエンドサーバーがL3で接続されていることを前提としています。そのために、ルータとして機能するコンテナを用意し、このコンテナとロードバランサコンテナ、バックエンドサーバーコンテナをルータコンテナとvethで直に接続しました。また、ルータコンテナとクライアントコンテナが通信できるようにするため、ホスト上にBridgeを2つ用意しそれぞれクライアントコンテナとルータコンテナに接続しました。また、VIP(10.10.10.1/32)への経路をホストとルータコンテナに設定することで、VIP宛のパケットがロードバランサに届くようになっています。ロードバランサに届いたパケットはIPIP encapされ、宛先のバックエンドのIPをソースIPとしたIPパケットとして宛先バックエンドに届きます。そこで付加されたIPヘッダをdecapし、バックエンドはクライアントからのパケットを受け取ることになります。なお、この仮想ネットワークはtinetというツールを用いて構築しました。

図4 動作検証用の仮想ネットワーク。
図4 動作検証用の仮想ネットワーク。

この環境を用いて以下の2つのシナリオで機能検証を行いました。

クライアントのIPアドレスが変わっても正しいバックエンドに転送され続けコネクションが持続する。NAT rebindが起こっても正しいバックエンドに転送され続けコネクションが持続する。まず1つ目のシナリオについては、QUICコネクションが確立された状態でクライアントコンテナの中で別のプロセスからipコマンドでIPアドレスを変更することで再現できます。2つ目のシナリオについては、ホスト上でiptablesを用いてNATの設定を行うことでNATを介してクライアントとバックエンドサーバー間でQUICコネクションを確立します。その状態でホスト上でconntrackエントリを削除することで再現できます。

これらのシナリオを実行することで意図的にコネクションマイグレーションを起こし、その結果QUICコネクションを途切れさせないようにバックエンドサーバーへの転送が可能になったことを確認しました。1つ目のシナリオを実行した様子が以下の動画です。上下に分割されたターミナルの上の方でQUICクライアントがサーバーとコネクションを確立し、メッセージを送るとサーバーからそのメッセージがそのまま返ってきます。4分割されたターミナルではそれぞれ4つのバックエンドサーバーでQUICサーバーが起動しており、今回は左上のサーバーがクライアントと通信しています。次に、上下に分割されたターミナルの下の方で chip.sh というスクリプトを用いてクライアントのIPアドレスを変更しています。その後でもクライアントはサーバーと通信し続けることができています。バックエンドサーバーの方を見ても、同じバックエンドにパケットが転送され続け、コネクションを維持できていることが確認できます。

ベンチマーク

QUIC-LBの実装がL4ロードバランサに大きなパフォーマンス上の影響を与えていないことを確認するためにベンチマークを取りました。ベンチマークでは以下の2つの項目を計測しました。

  • 最大のスループット(Mpps)
  • パケットロスを起こさずに処理できる最大のスループット(Mpps)

また、ベンチマークのシナリオとして以下の3つを用意しました。

  • ショートヘッダパケットが大量に届き、それらのコネクションIDからサーバーIDが取得できるケース(確立済みのコネクションでデータが送られる状況)
  • ショートヘッダパケットが大量に届き、それらのコネクションIDからサーバーIDが取得できないケース(確立済みのコネクションでデータが送られるが、サーバーがQUIC-LBに対応していない)
  • ロングヘッダパケットが大量に届き、それらがコネクションIDからサーバーIDが取得できないケース(新しいコネクションの接続要求が届いている状況)

各シナリオについてペイロードのサイズを64、128、256、512、1,024、1,478バイトの6種類用意しました。また、各シナリオについて単一のVIPにパケットを送信する単一フローのケースと、複数のVIPにパケットを送信する複数フローのケースを用意しました。これらのシナリオを、QUIC-LBに対応したロードバランサと対応していないロードバランサそれぞれを対象に実行しました。

その結果を図5に示します。図の通り、QUIC-LBの処理を行った転送も、通常の5-tuple方式と同等の転送性能であることが確認できました。

図5 ベンチマークの結果。
図5 ベンチマークの結果。

なお、64Byteのロングヘッダパケットのケースでは単一フローのパケットロスなしでの最大スループットが突出している結果が出ていますが、この要因等は分析できていません。

まとめ

この取り組みはL4ロードバランサでQUICに対応するためには何が必要かを知見として得ることが目的でした。調査を行った結果、QUIC-LBを実装する必要があり、このためにはロードバランサ側とサーバー側両方で実装が必要になることがわかりました。よって実際の運用ではサービス開発者側にQUIC-LBを実装してもらうことを期待するのは難しいので、QUIC-LBに対応したリバースプロキシを用意しておき、それを前段に置くことでユーザーに使ってもらう形が現実的です。ただし、将来的にQUIC-LBが普及したら各QUICの実装がQUIC-LBに対応してユーザーが直接使うこともできるようになるかもしれません。

またQUIC-LBをXDPを用いて実装する際には、XDPから外部のライブラリを呼び出すことができないという制約のためコネクションIDを暗号化することが大変であるということもわかりました。本インターンシップでは行っていませんが、暗号化する際にはXDPのコードの中に暗号化する処理も含めて自前で書く必要があります。

最後に

本インターンシップでは社内で提供されるLBaaSのL4ロードバランサとmvfstにQUIC-LBを実装しました。XDPやQUICなど私にとって初めての技術に触れ、さらにインターネットドラフトを読んで実装するという大変チャレンジングな内容で6週間楽しんで取り組むことができました。また、私はクラウドの内部で動作するシステムソフトウェアに興味があり、実際に内製されているソフトウェアロードバランサのコードベースを触ることができ感激しました。長い期間サポートしてくださったメンターの方やチームの方々には大変お世話になりました。ありがとうございました。