フューチャー技術ブログ

ローカルでGoのHTTP/3サーバーを立てて接続テストを行う

Go 1.21ではcrypto/tlsパッケージでQUIC関連の更新が少しありましたが、QUICそのものは入りませんでした。QUICとかHTTP/3周りをどうするかはいろいろ議論があり、次のことが決定しています。

  • 最終的にはnet/quicが作られる
  • ただし、APIの安定化のために、まずは準標準パッケージとして golang.org/x/net/quicを作っていく
  • github.com/quic-go/quic-go という実装はあるが、それをそのまま取り込むことはしない

ということで、もう少ししばらくかかりそうです。

といっても、HTTP/3のリクエストをエンドのアプリケーションサーバーが直接受けることはおそらく稀で、CDNとか、最前面にたつエンドポイントがHTTP/2やHTTP/3をしゃべって、その裏はHTTP/1.1(非TLS)が多いと思いますし、より固くしようとしてもmTLSでHTTP/2じゃないですかね。もともとHTTP/3の強みはエラー率の高い回線でも速度が落ちにくいことなので、回線品質の安定したデータセンターとかクラウド内部はHTTP/3にしてもうまみがあまりないといえるので、そんな悲観的になることもないかな、と思います。

とはいえ、HTTP/3に触ってみたい方もいるかと思うので試してみました。

上記の議論の中でも出てきた github.com/quic-go/quic-go は、このパッケージの作者のMarten SeemannはIETFのQUICワーキンググループの初期からのメンバーでもあり、このパッケージ自身もIETFの他のQUICエージェントとの相互接続テストを行っており、品質はばっちりだと思われます。なのでこれを試してみます。

quic-goでのHTTPサーバー

Goの標準ライブラリのサーバー実装と似たようなAPIでサーバーをたてられます。ハンドラ周りはnet/httpのhttp.Handlerインタフェースそのものなので、いろんなフレームワークをそのまま上で動かせます。

package main

import (
"fmt"
"log"
"net/http"

"github.com/quic-go/quic-go/http3"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s", r.Proto)
})

log.Println("start at https://localhost:8443")
log.Println(http3.ListenAndServe("0.0.0.0:8443", "localhost.pem", "localhost-key.pem", mux))
}

Goを書いたことがある人にはおなじみですね? HTTP/2と同じく、TLS必須なので証明書を作成する必要があります。こちらは参考情報がいろいろあるので、お好きな文献などを参考に作ってみてください。Real World HTTPでも紹介しています。

localhostのホスト名で証明書を作ったので、localhost:8443でアクセスするとバッチリ表示されます。Safariでアクセスしてみると、すぐに表示されて案外簡単? と思いつつ、ここから深くなってきます。

スクリーンショット_2023-09-21_15.03.06.png

quic-goの内部ではサーバーが2つ起動する

http3パッケージにはListenAndServeQUIC()という関数もあります。どう違うのでしょうか? 最初はこちらで実装してみたのですが動かず、上記のListenAndServe()にしたら動きました。

こちらの方は内部的には、こんな感じのコードと同じような動きになります。よくよくみると、HTTP/2のサーバーも起動していますね。Alt-Svcフィールドを付与しています。

QUICはUDPですが、現在のブラウザはまずTCPでサーバーアクセスしに行きます。しかし、HTTP/3のみのサーバーがたっていても、そこにはTCPのサーバーはいません。そのため、HTTP/2のサーバーを裏でたてて、そのレスポンスの Alt-Svcフィールドを返し「こちらでHTTP/3のサーバーがいるよ」とブラウザをHTTP/3の方に誘導しているというわけです。

package main

import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"sync"

"github.com/quic-go/quic-go/http3"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Alt-Svc", `h3=":8443"; ma=2592000`)
fmt.Fprintf(w, "Hello via %s", r.Proto)
})

ctx, close := signal.NotifyContext(context.Background(), os.Interrupt)
defer close()

h2server := &http.Server{
Addr: "0.0.0.0:8443",
Handler: mux,
}
h3server := &http3.Server{
Addr: "0.0.0.0:8443",
Handler: mux,
}

wg := &sync.WaitGroup{}
wg.Add(2)

go func() {
log.Println("start at http/2 server at (TCP)https://localhost:8443")
log.Println(h2server.ListenAndServeTLS("localhost.pem", "localhost-key.pem"))
wg.Done()
}()

go func() {
log.Println("start at http/3 server at (UDP)https://localhost:8443")
log.Println(h3server.ListenAndServeTLS("localhost.pem", "localhost-key.pem"))
wg.Done()
}()

<-ctx.Done()
h2server.Shutdown(ctx)
h3server.Close()
}

プロトコル選択

HTTP/3ではいくつかの箇所でプロトコル選択をさせるようになっています。

  • DNSのHTTPSレコード
  • TLSのハンドシェイクのALPN(Application-Layer Protocol Negotiation)
  • 上記で紹介したAlt-Svcフィールド

上から順番に実行されます。DNSであれば、最初のサーバーアクセスの前に情報を得ることができます。TLSのハンドシェイクはTCP/UDPを決めた後に行えるため、最初のTCPのアクセスの空撃ちは必要です。Alt-Svcフィールドのタイミングはさらに遅く、一度HTTP/2のサーバーがリクエストを処理するまで、ブラウザはHTTP/3のサーバーの存在を知ることはできません。

個人的には103 Early HintsでAlt-Svcフィールドを返せば僅かばかりAlt-Svcをレスポンスで返すよりも効率的かな、と思いましたが、103 Early Hintsはサンプル見てもプリロードの用途しか見ませんね。

CoreDNSをたててみた

さて、上記のHTTP/3サーバーですが、Safariからはばっちり3の方につながるのですが、ChromeとFirefoxは2ばかり。たまに開発者ツールを開いてリロードしたタイミングだけ3でつながったりといまいち安定しません。そんな中、Twitter(X)で、まえかわさんという方から、HTTPSレコードでばっちりいけるというお話を伺い、ちょっと試してみることにしました。

ちなみに、現在出ているSoftware Design 2023年10月号がたまたまHTTP/3特集でしたが、HTTPSレコードはCDN上の設定で付与していました。

今回あつかったCoreDNSは、CNCFの傘下にいるプロジェクトで、Kubernetesでもよく使われています。もっとも、その理由がetcdでエントリーの管理ができて、そちらの情報を元に情報を返せるため管理が楽、というところがあるのだと思いますが、今回はetcdは使いません。

こちらをみながらmacOSでやってみた例になりますが、サービスの起動部分以外はポータブルなはずです。hnakamurさんがWindowsでサービス化するラッパーを作られているのでこちらに変えればWindowsでも動くかと思います。

# インストール
$ brew install coredns

# 起動
$ sudo brew services start coredns

http3.testというホスト名だけ特別扱いしてHTTPSレコードをつけてあげたいので、/opt/homebrew/etc/coredns/Corefileファイルを次のようにしました

/opt/homebrew/etc/coredns/Corefile
. {
forward . 8.8.8.8
log
errors
}

http3.test {
file /Users/shibu/.config/coredns/test
log
errors
}

上記の設定からインポートされる自分のホーム以下に追加の設定ファイル(/Users/shibu/.config/coredns/test)をおきます。こんな感じにしてみました。

$ORIGIN test.
$TTL 1m

@ IN SOA ns.test. admin.test. (
2020010510 ; Serial
1m ; Refresh
2m ; Retry
4m ; Expire
1m) ; Minimum TTL
@ IN A 127.0.0.1
@ IN NS test.
ns IN CNAME @


http3 IN A 127.0.0.1
IN AAAA ::1
IN HTTPS 1 . alpn=h3 port=8443
# 再起動
$ sudo brew services restart coredns

最後にネットワーク設定でDNSに127.0.0.1を追加してあげれば完了です。

これでSafariでアクセスしてみると、無事に https://http3.testというURLでアクセスできました。HTTPSレコードはプロトコル以外にもポートを設定できるのでポート番号を省略できるようになります。いいですね。

しかし、実はChromeとFirefox、EdgeはこのDNSサーバーを見に行ってくれませんでした。昔はDNS設定があったと思いますが、いまはDNS over HTTPSのみです。ChromeでHTTP/3に繋ぎたくてCoreDNSを入れてみたのですが、ここはうまく行っていません。DNS over HTTPSをCoreDNSで建ててみてもそこにアクセスしてくれなかったり、設定を拒否されたり。ここはぼちぼちやっていこうと思います。dnsmasqを入れた時はアクセスはしてくれたものの、HTTPSレコードの追加が分からずCoreDNSでやりましたが、別の方法も試そうかと。

まとめ

というわけで、HTTP/3のサーバーを起動してブラウザでアクセスしてみました。Safariからはうまくつながりました。しかし、実際に試す時間の90%はDNS周りを操作する時間だったりして、ちょっと敷居が高い気がしました。

将来的にはもう開発体験がちょっと改善されたらいいな、と思いました。