🧪

nginxとGoでEarly Hintsを手軽に試す

に公開

Early Hintsを趣味サイトでちょっとだけ試してみました。

https://www.catatsuy.org/

ちなみにこのサイトは自分の自己紹介サイトと見せかけて、実際には自分の実験場として運用されています。最近のインターネットは正当なTLS証明書を持っていないと動かない機能が多すぎるので、こういうサイトは1つ持っていると便利です。特殊な設定を色々しているので、見てみるとおもしろいかもしれません。

少し前に自分がよく使うGoもnginxもEarly Hintsに対応したので、簡単に使ってみます。ざっくりした方針は以下です。

  • すごくシンプルなEarly Hintsを返すGoのHTTPサーバを書いて、systemdで立ち上げる
  • nginxのearly_hintsディレクティブでいい感じに103 Early Hintsを返す

今回は高速化というよりも「挙動確認用の最小構成」という感じで進めていきます。

GoのHTTPサーバ(easerver)

まずはGo側。/に来たリクエストに対してEarly Hints用のLinkヘッダを付けつつ、最終的にはindex.htmlをそのまま返すだけのサーバです。

main.go
package main

import (
	"io"
	"net/http"
	"os"
	"path/filepath"
)

func earlyHintsHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Add("Link", "</css/ea.css>; rel=preload; as=style")
	w.Header().Add("Link", "</js/ea.js>; rel=preload; as=script")

	w.WriteHeader(http.StatusEarlyHints)

	workDir := os.Getenv("WORKDIR")
	if workDir == "" {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "private")

	w.WriteHeader(http.StatusOK)
	f, err := os.Open(filepath.Join(workDir, "index.html"))
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	defer f.Close()

	io.Copy(w, f)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", earlyHintsHandler)
	http.ListenAndServe("127.0.0.1:8000", mux)
}

ポイントはこのあたりです。

  • Linkヘッダで/css/ea.css/js/ea.jsをpreloadする
  • WORKDIR環境変数を見て、その配下のindex.htmlを返す
  • Cache-Control: privateだけ軽く付けている

w.WriteHeader(http.StatusEarlyHints)が特殊な動きをするようになっていて、status codeを書き込むだけでなく、一旦レスポンスを返してくれます。Early Hintsの仕様に即した、少しマジカルな動きをしているので気をつけてください。詳しくは以下の記事でも解説されています。

https://future-architect.github.io/articles/20220804a/

systemdサービスとして起動する

Linux側ではsystemdサービスにして常駐させました。

/etc/systemd/system/easerver.service
[Unit]
Description=Simple Go HTTP Server (easerver)
After=network.target

[Service]
Type=simple
User=nobody
Group=nogroup

Environment=WORKDIR=/var/www/test

ExecStart=/usr/local/bin/easerver_linux
Restart=on-failure

[Install]
WantedBy=multi-user.target
  • ユーザーはnobody:nogroup
  • WORKDIRindex.htmlのあるディレクトリを指定
  • バイナリは/usr/local/bin/easerver_linuxに配置

登録する。

sudo systemctl daemon-reload
sudo systemctl enable easerver.service

で起動します。

nginx側の設定

肝心のnginx側はearly_hintsディレクティブを有効にして、Goサーバにproxyするだけです。このディレクティブはnginx 1.29でないと使えないので、気をつけてください。ちなみに自分がメンテナンスしているcubicdaiya/nginx-buildを使うと簡単にコンパイルできるので、自分はこちらを利用しています。

Early Hintsを有効にするのはHTTP/2とHTTP/3の時のみが推奨されているのでそうします。なおGoのコードを素直に使うとこの設定はできません。

https://developer.chrome.com/docs/web-platform/early-hints

だいたいこんなイメージで設定しています。

upstream easerver {
  server 127.0.0.1:8000;
}

server {
  listen 443 ssl http2;

  server_name www.example.com;

  location = / {
    early_hints $http2$http3;
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_pass http://easerver;
  }
}

Early HintsはHTTP/1.0には対応していない(1.1自体は対応しているが、対応していないクライアントが多いので返さないことが推奨)ので、バックエンドとの通信は必ずHTTP/1.1になるようにしましょう。

Sec-Fetch-Modeヘッダーを使う

ちなみにnginxのblogでは以下の設定が推奨されています。

map $http_sec_fetch_mode $early_hints {
  navigate $http2$http3;
}
server  {
  ...
  location / {
       early_hints $early_hints;
       proxy_pass http://example.com;
  }
}

https://blog.nginx.org/blog/nginx-introduces-support-103-early-hints

こちらだとブラウザが付与するsec-fetch-mode: navigateがついていて、かつHTTP/2かHTTP/3が有効になっているリクエストでEarly Hintsが有効になります。

もちろんこれでもいいのですが、curlだとsec-fetch-modeヘッダーがデフォルトだと付与されていないので、curlで動作確認するのが少し面倒になります。適当に広い設定するならこちらの方が変なレスポンスを返さずに済むと思います。

今回は特定のエンドポイントのみで設定したので、不要と判断しました。

動作確認

ChromeでDevToolsのNetworkタブを開いてリロードすると、Early Hintsが飛んでいるかどうかが分かります。

最初は試すために中身が空のCSS/JSをpreloadしていたら、Chromeに

The resource https://example.com/css/ea.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate as value and it is preloaded intentionally.

みたいな警告を出されました。preloadしたコンテンツは実際に使うようにしましょう。

curlを使うと以下のようになります。

% curl -I -X GET https://www.catatsuy.org/
HTTP/2 103
link: </css/ea.css>; rel=preload; as=style
link: </js/ea.js>; rel=preload; as=script

HTTP/2 200
server: nginx
date: Sat, 22 Nov 2025 09:57:36 GMT
content-type: text/html; charset=utf-8
vary: Accept-Encoding
cache-control: private
link: </css/ea.css>; rel=preload; as=style
link: </js/ea.js>; rel=preload; as=script
...(省略)

この設定で本当にいいのか

今のnginxのEarly Hintsサポートはバックエンドが対応している場合にのみ利用でき、nginxだけでEarly Hintsを返すことはできません。

そもそもEarly Hintsが有効なパターンは「バックエンドがレスポンスを返すのに時間がかかるので、レスポンスを構築している間に必要になる静的ファイルをダウンロードしておいてほしい」という場合です。メリットが大きいパターンは最近だと大きく2通りあると思います。

  1. 例えばOriginが日本にあって、海外からのリクエストで海外のCDNのEdgeとクライアントは通信を確立しており、実際のコンテンツが返ってくるまで時間がかかる
  2. 例えばSSRなど、Origin側でコンテンツを作るのに時間がかかる

2のケースなら、アプリケーション側で最初に必要な静的ファイルのリンクを返却してからコンテンツを生成するみたいなロジックにすることが考えられます。この処理のメリットは以下です。

  • アプリケーション側が必要になる静的ファイルを返せば良いので、返却したURLはその後確実に利用される
  • アプリケーションと一体化しているので、デプロイが容易

しかし1の場合はどうでしょうか。アプリケーションからの返却を待っている時点である程度時間がかかりますし、処理に時間があまりかからない場合、Early Hintsを返した直後に実際のレスポンスを返せるので、うれしいことは特にありません。

よってこのケースではEdgeと通信を確立できた時点で、Early Hintsを返さないとパフォーマンスに寄与できません。この方法の問題点は以下です。

  • アプリケーションのデプロイタイミングとCDNの設定変更タイミングに時間差があるので、無駄なEarly Hintsを返してしまう可能性がある

これについては別システムのため完全な回避策はありません。

設定変更が高速なFastlyなら現実的に問題にならないと思う人がいるかもしれません。例えばFastlyだとEdge DictionariesがAPIで動的に設定を変更できるので、設定をするならそれを使うことになると思いますが、Edge DictionariesはEdgeへの反映に数分かかります。数分間無駄なEarly Hintsを返してしまう問題を回避できないので、気にしないという判断をすることになると思います。

ということでアプリケーションに依存せず、nginx単体でEarly Hintsを返せるようにならないと高速化への寄与が少ないケースもあるのではと思います。

という内容をGitHubのissueにもコメントをしておきました。

https://github.com/nginx/nginx/issues/779

まとめ

今回の構成では、以下のような形でEarly Hintsを試しました。

  • GoのHTTPサーバで103 Early HintsLinkヘッダを返す
  • nginx 1.29のearly_hintsディレクティブを使い、HTTP/2とHTTP/3のときだけEarly Hintsを有効化する

どのレイヤーでEarly Hintsを返すか、どのタイミングで返すかによって、パフォーマンスへの影響も変わります。特にCDNとOriginを組み合わせた構成では、アプリケーション側だけでEarly Hintsを返しても効果が限定的なケースがあるので、今後の動向にも注目していきたいです。

Discussion