CUBE SUGAR CONTAINER

技術系のこと書きます。

Network Namespace と HAProxy を使って HTTP/2 を試す

今回は OSS のリバースプロキシである HAProxy 1 を使って HTTP/2 を試してみる。 HTTP/2 は HTTP/1.1 に存在するパフォーマンス面の課題を解決することを目的に生み出された。 Google が開発していた SPDY というプロトコルがベースになっている。

HTTP/1.1 では、HTTP/1.0 からの改良点として 1 つの TCP コネクション上で複数のリクエスト・レスポンスをやり取りできるようになった。 とはいえ、複数のリクエスト・レスポンスはあくまで順番に処理する必要がある。 そのため、重いコンテンツがあると後続の処理が待たされてしまう問題 (HTTP Head-of-Line Blocking) があった。 シンプルに解決する方法としてはサーバに接続する TCP のコネクションを増やすことが考えられる。 しかし、あまり増やすとサーバ側の負荷につながる。 そこで、HTTP/2 ではプロトコルをバイナリ化した上で、ストリームという単位で通信を多重化できるようになっている。 それぞれのストリームでは、フレームという単位でメッセージがやり取りされる。

ただし、HTTP/2 の登場によって HTTP/1.1 が不要になったかというと、そうではない 2。 あくまで効率を改善するためのプロトコルなので、必ず対応しなければいけないわけでもない。 また、システム全体が HTTP/2 に対応している必要は必ずしもない。 たとえば前段のリバースプロキシや Web サーバが HTTP/2 を喋って、バックエンドとは HTTP/1.1 で通信するパターンが考えられる。 今回試す構成も、このパターンになっている。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
$ uname -srm
Linux 5.15.0-87-generic aarch64
$ haproxy -v
HAProxy version 2.4.22-0ubuntu0.22.04.2 2023/08/14 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2026.
Known bugs: http://www.haproxy.org/bugs/bugs-2.4.22.html
Running on: Linux 5.15.0-88-generic #98-Ubuntu SMP Mon Oct 2 15:18:56 UTC 2023 x86_64
$ curl -V
curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 OpenSSL/3.0.2 zlib/1.2.11 brotli/1.0.9 zstd/1.4.8 libidn2/2.3.2 libpsl/0.21.0 (+libidn2/2.3.2) libssh/0.9.6/openssl/zlib nghttp2/1.43.0 librtmp/2.3 OpenLDAP/2.5.16
Release-Date: 2022-01-05
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp 
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM NTLM_WB PSL SPNEGO SSL TLS-SRP UnixSockets zstd
$ openssl version
OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022)
$ tshark --version
TShark (Wireshark) 3.6.2 (Git v3.6.2 packaged as 3.6.2-2)

もくじ

下準備

まずは下準備として、利用するパッケージをインストールする。

$ sudo apt-get -y install haproxy curl python3 iproute2 tshark

ネットワークを作成する

次に Network Namespace を使って仮想的なネットワークを作成していく。

まずは HTTP クライアント、リバースプロキシ、Web サーバに対応する Network Namespace を作成する。

$ sudo ip netns add client
$ sudo ip netns add proxy
$ sudo ip netns add server

それぞれの Network Namespace をつなぐための Virtual Ethernet デバイスのインターフェイスを作成する。

$ sudo ip link add client-veth0 type veth peer name proxy-veth0
$ sudo ip link add server-veth0 type veth peer name proxy-veth1

作成したインターフェイスを Network Namespace に所属させる。

$ sudo ip link set client-veth0 netns client
$ sudo ip link set proxy-veth0 netns proxy
$ sudo ip link set proxy-veth1 netns proxy
$ sudo ip link set server-veth0 netns server

インターフェイスが利用できるように状態を UP に設定する。

$ sudo ip netns exec client ip link set client-veth0 up
$ sudo ip netns exec proxy ip link set proxy-veth0 up
$ sudo ip netns exec proxy ip link set proxy-veth1 up
$ sudo ip netns exec server ip link set server-veth0 up

また、Web サーバに対応する Network Namespace の server についてはループバックインターフェースの状態も UP に設定する。

$ sudo ip netns exec server ip link set lo up

最後に、各インターフェイスに IP アドレスを付与する。

$ sudo ip netns exec client ip address add 192.0.2.1/24 dev client-veth0
$ sudo ip netns exec proxy ip address add 192.0.2.254/24 dev proxy-veth0
$ sudo ip netns exec proxy ip address add 198.51.100.254/24 dev proxy-veth1
$ sudo ip netns exec server ip address add 198.51.100.1/24 dev server-veth0

なお、HAProxy は L7 で動作するリバースプロキシなので、L3 のルーティングを有効にする (net.ipv4.ip_forward=1) 必要はない。

Web サーバ (Python) をセットアップする

今回は HAProxy を使った HTTP/2 の検証が主題になる。 そのため、Web サーバについては簡単のために Python の簡易 Web サーバを利用する。

まずは HTTP でやり取りするコンテンツとして HTML ファイルを用意する。

$ mkdir -p /var/tmp/www
$ cat << 'EOF' > /var/tmp/www/index.html
<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>
EOF

そして、次のようにして上記のコンテンツを配信する Web サーバを起動する。

$ sudo ip netns exec server python3 -m http.server -b 0.0.0.0 -d /var/tmp/www 80

ひとまず、別のターミナルを開いて curl(1) でアクセスできることを確認しておこう。

$ sudo ip netns exec server curl http://198.51.100.1
<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

リバースプロキシ (HAProxy) をセットアップして HTTP/2 を試す

ここからは HAProxy を設定して、実際に HTTP/2 の通信を試していく。

なお、HTTP/2 の通信を開始する方法には、以下の 2 つがある。

  • Starting HTTP/2 for "https" URIs
  • Starting HTTP/2 with Prior Knowledge

前者の Starting HTTP/2 for "https" URIs は、通信に TLS (Transport Layer Security) を利用することが前提となる。 その上で、HTTP/1.1 と HTTP/2 のいずれを使うかを ALPN (Application-Layer Protocol Negotiation) を使って決定する。

後者の Starting HTTP/2 with Prior Knowledge は、事前にその Web サーバが HTTP/2 のサービスを提供していることを何らかの方法で知っていることが前提となる。 前提知識は必要となるものの、こちらであれば平文で通信をやり取りできる。

なお、過去には HTTP/1.1 のリクエストで Upgrade: h2c というヘッダを使って HTTP/2 にプロトコルをアップグレードする方法も定義されていた 3。 しかし、このやり方は利用が広がらなかったことから現在は廃止されている。 また、HAProxy もこのやり方を実装していない。

Starting HTTP/2 with Prior Knowledge

まずは平文で通信の内容を確認できる Starting HTTP/2 with Prior Knowledge から試す。

はじめに HAProxy の設定ファイルを次のように用意する。 ポイントは frontend web_proxybind *:80proto h2 としているところ。 これで、HAProxy が TCP の 80 番ポートで Starting HTTP/2 with Prior Knowledge なリクエストを受け付けられるようになる。

$ cat << 'EOF' > haproxy.cfg
defaults
  mode http
  timeout client 1m
  timeout server 1m
  timeout connect 10s

frontend web_proxy
  bind *:80 proto h2
  default_backend web_servers

backend web_servers
  server s1 198.51.100.1:80
EOF

上記の設定ファイルを使って HAProxy を実行する。

$ sudo ip netns exec proxy haproxy -f haproxy.cfg

この状態で Web クライアントからリバースプロキシにリクエストを送る。 curl(1) では --http2-prior-knowledge というオプションをつけることで Starting HTTP/2 with Prior Knowledge に対応したリクエストを送ることができる。

$ sudo ip netns exec client curl --include --http2-prior-knowledge http://192.0.2.254
HTTP/2 200 
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 07:48:57 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 07:40:23 GMT

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

先頭に HTTP/2 200 とあるように、通信が HTTP/2 でやり取りされていることが確認できる。

実際のコンテンツを持っている Python の Web サーバには HTTP/1.1 でリクエストが送られている。 HAProxy は、あくまでアプリケーションレイヤーでのプロキシに過ぎない。

$ sudo ip netns exec server python3 -m http.server -b 0.0.0.0 -d /var/tmp/www 80
198.51.100.254 - - [05/Sun/2023 09:56:13] "GET / HTTP/1.1" 200 -

ちなみに、理屈の上では HTTP/2 (Starting HTTP/2 with Prior Knowledge) と HTTP/1.1 を同じエンドポイントで提供できる。 しかし、HAProxy ではその機能を実装していないため、オプションをつけずにリクエストしてもレスポンスは得られない。

$ sudo ip netns exec client curl --include http://192.0.2.254
curl: (52) Empty reply from server

先に述べたとおり Starting HTTP/2 with Prior Knowledge では平文で通信の内容を観察できる。 試しに tshark(1) を使って通信の内容を確認してみよう。 tshark(1) は -Y http2 オプションを使うことで、表示する内容を HTTP/2 に絞ることができる。 それぞれの行が HTTP/2 のフレームに対応している。

$ sudo ip netns exec proxy tshark -Y http2 -i proxy-veth0
...
    4 0.000122686    192.0.2.1192.0.2.254  HTTP2 90 Magic
    5 0.000129571    192.0.2.1192.0.2.254  HTTP2 93 SETTINGS[0]
    6 0.000132302    192.0.2.1192.0.2.254  HTTP2 79 WINDOW_UPDATE[0]
    8 0.000161449    192.0.2.1192.0.2.254  HTTP2 103 HEADERS[1]: GET /
    9 0.000169075  192.0.2.254192.0.2.1    HTTP2 96 SETTINGS[0], SETTINGS[0]
   11 0.000190964    192.0.2.1192.0.2.254  HTTP2 75 SETTINGS[0]
   12 0.000850319  192.0.2.254192.0.2.1    HTTP2 321 HEADERS[1]: 200 OK, DATA[1] (text/html)

最初にクライアントから送られている Magic は、これから始める通信が Starting HTTP/2 with Prior Knowledge であることを伝えている。 この通信はコネクションプリフェイス (Connection Preface) と呼ばれる。

    4 0.000122686    192.0.2.1 → 192.0.2.254  HTTP2 90 Magic

具体的には PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n という文字列に対応するバイト列を送っている。 先に述べたとおり、理屈の上では最初に送られてくるのがコネクションプリフェイスかどうかで HTTP/2 と HTTP/1.1 の処理を分岐できる。 しかし HAProxy では、この機能を実装していない。

次の SETTINGS は、HTTP/2 で通信する上での様々なパラメータを設定したり確認応答 (ACK) を返すためのフレームである。

    5 0.000129571    192.0.2.1 → 192.0.2.254  HTTP2 93 SETTINGS[0]

フレームの右端にある [0] はストリームの番号 (識別子) を表している。 0 番のストリームは制御のために特別に利用される。

次の WINDOW_UPDATE は HTTP/2 のフロー制御に用いられるフレームである。 データの受信者は、コネクション全体か、あるいは特定のストリームで受信できるデータ量を、このフレームで相手に通知する。

    6 0.000132302    192.0.2.1 → 192.0.2.254  HTTP2 79 WINDOW_UPDATE[0]

上記はストリームの番号に 0 番が指定されているため、コネクション全体に対して通知している。

次の HEADERS は HTTP/1.1 のリクエストに相当する。 HTTP/1.1 ではテキストでやり取りしていたメソッドやパスなどの情報を、HTTP/2 では TLV (Type-Length-Value) 形式のフィールドとして表現している。

    8 0.000161449    192.0.2.1 → 192.0.2.254  HTTP2 103 HEADERS[1]: GET /

そして、最後の HEADERSDATA は HTTP/1.1 のレスポンスに相当する。 HTTP のステータスコードや Content-Type などの情報を HEADERS で返した上で、コンテンツを DATA で送っている。

   12 0.000850319  192.0.2.254 → 192.0.2.1    HTTP2 321 HEADERS[1]: 200 OK, DATA[1] (text/html)

Starting HTTP/2 for "https" URIs

続いては TLS が前提となって ALPN でプロトコルをネゴシエートする Starting HTTP/2 for "https" URIs を試す。

TLS を利用するためには証明書が必要になる。 そこで、まずは動作確認用の自己署名証明書を作成する。

まずは秘密鍵を作る。

$ openssl genpkey -algorithm ed25519 -out private.key

続いて証明書署名要求を作る。 サブジェクトの部分は適当。

$ openssl req -new -key private.key -out cert.csr -subj "/C=JP/ST=Tokyo/L=Chiyoda-ku/O=Example Corp./OU=Example Dept./CN=www.example.jp"

そして自己署名証明書を作る。

$ openssl x509 -signkey private.key -in cert.csr -req -days 365 -out cert.pem

HAProxy は証明書のフォーマットとして秘密鍵も必要とする。 そこで /var/tmp/haproxy/certs 以下に証明書と秘密鍵を連結したものを作成する。 あくまで動作確認用ではあるが、秘密鍵の取り扱いには注意することを示すためにディレクトリのパーミッションを 400 に変更している。

$ mkdir -p /var/tmp/haproxy/certs
$ cat cert.pem private.key > /var/tmp/haproxy/certs/haproxy.pem
$ sudo chmod -R 400 /var/tmp/haproxy/certs

HAProxy の設定ファイルを作成する。 ポイントは frontend web_proxybind :443 に対して証明書の場所と alpn h2,http/1.1 を指定しているところ。 特に後者の alpn h2,http/1.1 は、ALPN を使って HTTP/2 と HTTP/1.1 をネゴシエートすることを示している。

$ cat << 'EOF' > haproxy.cfg
global
  ssl-default-bind-options ssl-min-ver TLSv1.2

defaults
  mode http
  timeout client 1m
  timeout server 1m
  timeout connect 10s

frontend web_proxy
  bind :443 ssl crt /var/tmp/haproxy/certs/haproxy.pem alpn h2,http/1.1
  default_backend web_servers

backend web_servers
  server s1 198.51.100.1:80
EOF

ssl-default-bind-options ssl-min-ver TLSv1.2 は、TLS 1.0 ~ 1.1 が 2021 年に RFC 8996 で非推奨となったことに対応して入れている。

設定ファイルを元に HAProxy を起動する。

$ sudo ip netns exec proxy haproxy -f haproxy.cfg

自己署名証明書なので --insecure オプションをつけて curl(1) を実行する。 すると、HTTP/2 でリクエストが処理されることが確認できる。

$ sudo ip netns exec client curl --include --insecure https://192.0.2.254
HTTP/2 200 
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 09:56:13 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 06:49:15 GMT

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

上記は ALPN によって、使用するプロトコルが HTTP/2 と HTTP/1.x でネゴシエートされた結果である。 そのため、明示的にクライアント側で HTTP/1.x が使いたい旨を指定すれば、そのようにもなる。 たとえば curl(1) で --http1.1 オプションをつけてみよう。

$ sudo ip netns exec client curl --include --http1.1 --insecure https://192.0.2.254
HTTP/1.0 200 OK
server: SimpleHTTP/0.6 Python/3.10.12
date: Sun, 05 Nov 2023 10:25:07 GMT
content-type: text/html
content-length: 127
last-modified: Sun, 05 Nov 2023 06:49:15 GMT
connection: keep-alive

<!doctype html>
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>

上記から、HTTP/2 の代わりに HTTP/1.0 で結果が返ってくることが確認できる 4

まとめ

今回は Network Namespace で作成したネットワーク上で Haproxy を使って HTTP/2 を試してみた。

  • HTTP/2 と HTTP/1.x はアプリケーションレイヤーのプロキシでプロトコルを変換できる
  • HTTP/2 の通信を始めるには 2 つのやり方がある
    • Starting HTTP/2 for "https" URIs
      • クライアントは TLS の ALPN で HTTP/2 が利用したい旨を伝える
    • Starting HTTP/2 with Prior Knowledge
      • クライアントは Connection Preface と呼ばれるバイト列を送ることで HTTP/2 を利用する旨を伝える
      • 通信を平文でやり取りできる

参考文献

www.rfc-editor.org

www.rfc-editor.org

www.rfc-editor.org


  1. https://www.haproxy.org/
  2. この点は HTTP/3 の登場でも変わらない
  3. https://www.rfc-editor.org/rfc/rfc7540.html#section-3.2
  4. HTTP/1.1 でないのは Python の簡易 Web サーバの実装に依存している