苦しんでいる人をちらほら見たので解決策を示しておく。
tl;dr
このように、http4sでサーバを書くとき、バックエンド実装がEmber(デフォルト)の場合はwithDefaultTimeout()
を使って短いタイムアウトを指定するようにしてください。
import scala.concurrent.duration._ // (略) def run(args: List[String]): IO[ExitCode] = EmberServerBuilder .default[IO] .withHost(ipv4"0.0.0.0") .withPort(port"8080") .withHttpApp(helloWorldService) // デフォルトだと30秒待ってしまうので速攻で終了させる。 // 開発環境では即座に、本番環境では長めに待つといった構成をすると丁寧 .withShutdownTimeout(Duration(1, "second")) // ↑↑↑↑↑↑↑↑↑ .build .use(_ => IO.never) .as(ExitCode.Success)
サンプルコードはこちらです。
あらすじ
このような記事を読んだ。
念のため説明しておくとhttp4sとはScalaのHTTPサーバ/クライアントライブラリであり、sbt-revolverはこうしたサーバの再起動・リロードを行ってくれる、開発時に便利なsbtのプラグインだ。
たとえば、~reStart
というコマンドをsbt上で実行すると、ソースコードの変更を検知して自動的に修正部分だけコンパイルしてサーバを再起動してくれる。サーバはバックグラウンドで動作するので、その間は好きにsbtのコマンドを入力できる。かなり便利だ。
sbt:http4s-revolver-fast> ~reStart [info] Application root not yet started [info] Starting application root in the background ... root Starting Main.main() [success] Total time: 4 s, completed 2023/06/17 0:23:51 [info] 1. Monitoring source files for root/reStart... [info] Press <enter> to interrupt or '?' for more options. [info] Build triggered by /home/windymelt/src/github.com/windymelt/http4s-revolver-fast/src/main/scala/Main.scala. Running 'reStart'. [info] compiling 1 Scala source to /home/windymelt/src/github.com/windymelt/http4s-revolver-fast/target/scala-3.3.0/classes ... [info] Stopping application root (by killing the forked JVM) ... [info] Starting application root in the background ...
ちなみに~
はソースコードの監視を行うためのsbt標準のコマンドであり、reStart
はサーバを再起動させるだけのコマンドである。これらを組み合わせることで、ソースコードを監視して適宜再起動するという動作になるのだ。すごいね。
そんな中、追試をしたところ同様に再起動が遅いという報告を発見した。
http4sを使った簡単なサーバをホットリロードしたくてこの記事を参考にやってみたけどリロードに60sくらいかかってつらかった
— 藤棚 (@fujidana_wt) 2023年6月15日
(ハードのスペックも悪くないしsbt compile自体は10sくらいで終わる)
millのホットリロードっぽい機能も試そうとしたけどport Already usedみたいなので動かなくて諦めた https://t.co/JlZYm3leC6
簡単なプログラムなのでそんなに時間がかかるのはおかしいし、複数人で同じような状況になっているのは奇妙だと考えたため、自分で原因を探ってみることにした。
サーバの再起動が遅い
結論から言うとサーバの再起動が遅い。以前自分はサーバがなかなかシャットダウンしなくて困る、というトラブルに遭遇しており、その時にはサーバのデフォルト設定とブラウザの挙動の問題が噛み合ってシャットダウンが遅くなっていたことを突き止めていた。
自分も簡単なサーバを書いてsbt-revolver
の~reStart
を試してみたが、確かに30秒ほど待たされる結果となった。
そこで、デフォルトタイムアウトが長くてシャットダウンが遅れているのではないかと仮説を立て、タイムアウトを設定してみたところ、すぐに再起動するようになった:
def run(args: List[String]): IO[ExitCode] = EmberServerBuilder .default[IO] .withHost(ipv4"0.0.0.0") .withPort(port"8080") .withHttpApp(helloWorldService) .withShutdownTimeout(Duration(1, "second")) // ここ!!!!!!!!!!!!!!!!!!!!!!!!! .build .use(_ => IO.never) .as(ExitCode.Success)
処理に時間がかかるリクエストを実行中ではないはずなのに、なぜシャットダウンしないのか?その理由はKeep-aliveにある。
多くのブラウザは、あるページに接続した際にTCP接続をKeep-aliveして使い回そうとする。最近のサイトはAPIやリソースの取得のために同一のサーバに何度もリクエストを行うことが多く、リクエストのたびにTCP 3-way handshakeやHTTPSのhandshakeを行うのではオーバーヘッドが大きい*1からだ。
そういうわけでブラウザはタブを開いている間中コネクションを開けっぱなしにしているから、この場合サーバからは接続が切れていないように見えてしまい(見えてしまいというか、切れていないのだ)、そのタブが閉じられるまでずっとタイムアウト待ちになるのだ。
この証左として、すぐに接続を閉じるcurl
でリクエストを行ったり、一度もブラウザでそのページを開かずにリロードさせると瞬時に切り替わる。
対応
以下のような対応をすることで、http4sで快適に開発できるようになるだろう:
- 本番環境では長めの、開発環境ではとても短めの
shutdownTimeout
を指定する - 他のサーババックエンドを利用する(シャットダウンタイムアウトが速いかどうかはちゃんと確認する必要がある)
- Keep-Aliveしないようにブラウザに指示する
- ヘッダーで調整可能?
*1:サーバが遠距離にある場合、往復にとんでもない時間が積み重なる