jimble

サーバー設定#

server {
	host                 = ""         # 待ち受けるアドレス。空なら全部
	port                 = 9000
	max_request_size     = 10MiB      # リクエスト本文の上限
	max_header_size      = 16KiB      # ヘッダ全体の上限
	idle_timeout = 60s
	trust_proxy          = false      # X-Forwarded-* を信じるか
	compression          = true       # 文字の応答(1KB 以上)を gzip で返すか
	access_log           = true       # アクセスログを出すか([ログ](./log))
	bot_access_log       = true       # ボットのアクセスログを分けるか
	access_log_mask      = []         # アクセスログのクエリで値を伏せる名前([ログ](./log))
	strict_routes        = false      # 一生呼ばれないルートを例外にするか(CI では true に)

	shutdown_grace   = 0s      # 止め始めてから新規を断つまで(秒)
	shutdown_timeout = 15s     # 処理中を待つ上限(秒)

	backlog            = 1024  # 受け付け待ちの接続を OS に持たせる数
	write_queue_length = 0     # 応答を書き出す列の長さ。0/1 は「列を作らない」
	smart_async_writes = false # 列があるとき、空いていればその場で書く
	tcp_no_delay       = true  # TCP_NODELAY(Nagle を切り、小さく書いたものもすぐ送る)
}

接続の受け付けと書き出し#

backlog は、受け付けが追いつかないあいだ OS が代わりに持ってくれる接続の数です。 超えた分は OS が断ちます——アプリまで届かないので、ログには1行も出ません。 上げるのは「短時間にどっと来る」使い方(起動直後・キャンペーン・再接続の集中)で、 捌く速さは変わりません。詰まりを待たせるだけです。

補足

OS 側の上限にも頭を押さえられます(Linux の somaxconn)。 ここを大きくしても、そちらが小さければそちらで切られます。

write_queue_length は、応答を書き出す列の長さです。 0 か 1 なら列を作らず、その場で書き切ります(既定)。 2 以上にすると別のスレッドが書き出すので、遅い相手に書いているあいだ、処理のほうが先に進めます。 ただし列に積んだ分はメモリに載るので、大きくすれば速くなるものではありません。

smart_async_writes は、列があるときに 「いつも列に積む」か「空いていればその場で書き、混んできたら列に積む」かの切り替えです。

注意

smart_async_writes は単独では効きません。 write_queue_length が 2 以上でないと、helidon はこの値を読みません(列が無いからです)。 write_queue_length が 0 か 1 のまま smart_async_writes = true と書くと、起動時に落ちます—— 黙って無視すると、「効いているのに速くならない」としか見えなくなるためです。

tcp_no_delay は、受け付けた接続に TCP_NODELAY を付けるか(Nagle のアルゴリズムを切るか)です。既定は true です(helidon の既定は false)。

付けないと、outputStream() へ何回かに分けて書く応答(Transfer-Encoding: chunked。HTML・SSE など)が、 同じ接続の 2 回目から 1 回ごとに約 40ms 遅れます。 続く小さな書き込みが、前に送った分の ACK を待ち(Nagle)、それが相手の遅延 ACK と重なるためです。 nginx は upstream の keepalive で接続を使い回すので、プロキシの後ろでも起きます。 Content-Length を付けて1回で書く応答(json() など)は遅れません。

aqSell では、付けただけで次のようになりました(CPU 1 つ・同時 1 人・接続を使い回す・購入導線)。

付けない 付ける
HTML のページの中央値(nginx の後ろでも同じ) 44〜51ms 3〜9ms
1 分あたりの購入 136 件 395 件

小さな応答を多く返すと送る包みの数が増えますが、遅延 ACK の待ちは無くなります。 false にするのは、Nagle に頼りたい特別な場合だけにしてください。

いま何が選ばれたかは起動ログに出ます。

サーバー設定(接続): 接続待ち=1024 / 書き出し列=作らない(その場で書く) / TCP_NODELAY=true
サーバー設定(接続): 接続待ち=1024 / 書き出し列=32(空いていればその場で書く) / TCP_NODELAY=false

**書かなければ上の既定で動きます。**いま何が効いているかは起動ログに出ます。

jimble を起動しました: http://localhost:9000
サーバー設定: 待受=全部 / 本文上限=10485760byte / ヘッダ上限=16384byte / アイドル=60秒 / 圧縮=true / プロキシ信頼=false

ポート#

**ポートだけはシステムプロパティでも指定できます。**コンテナで「設定ファイルは触らずポートだけ変える」ためです。

java -Djimble.server.port=8080 -jar app.jar

優先順は -Djimble.server.port > server.port > 9000 です。

こつ

JimbleServer.start(app, 0) にすると空いているポートを自動で取ります。 テストはこれを使ってください(固定にすると、並べて走らせたときだけ落ちます)。 実際のポートは server.port() で取れます。

待ち受けるアドレス#

**空なら全部のアドレスで待ちます。**外から見えてはいけないものは絞ってください。

server { host = "127.0.0.1" }

こつ

手元で動かす MCP サーバーはこれが要ります(MCP)。 ファイアウォールに頼ると、設定を忘れたときに黙って公開されます。

上限#

上限 超えたら
リクエスト本文 server.max_request_size(10MiB) 413
ヘッダ全体 server.max_header_size(16KiB) 接続ごと切られる
何もしない接続 server.idle_timeout(60秒) 閉じる

アップロードにはこれとは別の上限があります(ファイルアップロード)。

注意

upload.max_total_size が server.max_request_size を超えていると起動時に落ちます。 本文は先にこちらで切られるので、超えた分には届かないためです。 大きいものを受けるなら、両方を上げてください(既定はどちらも 10MiB)。

読み取り/書き込みのタイムアウトはありません。

プロキシの後ろに置く#

**既定では X-Forwarded-* を信じません。**信じるのは、前段のプロキシを必ず通ると分かってからです。

server {
	trust_proxy      = true
	trusted_proxies  = ["10.0.0.0/8"]   # 中継の IP / CIDR。書けば、ここから来たときだけヘッダを見る
	client_ip_header = ""                # "CF-Connecting-IP" / "X-Real-IP" など。書いたときだけ信じる
}

true にすると proxyAddress() が次の順で送信元を返します。

  1. trusted_proxies を書いていて、接続元がそこに入っていなければ、接続元(ヘッダは名乗りにすぎない)
  2. client_ip_header を書いていれば、そのヘッダの値
  3. X-Forwarded-For を右から見て、trusted_proxies に入っていない最初のもの(書いていなければ右端)
  4. どれも無ければ接続元

落とし穴

X-Forwarded-For の左端は、クライアントが好きに名乗れます。 nginx・ALB・jimble の ReverseProxy は、クライアントが送ってきた値の後ろに足すからです。 2.2.2 までは左端と、CF-Connecting-IP / X-Real-IP を無条件に信じていたので、 名乗る値を変えるだけで IP ごとのレート制限をすり抜けられました。 中継が2段以上(CDN → ロードバランサ など)なら、trusted_proxies に中継の範囲を書いてください。

落とし穴

trust_proxy を true にしても address() は変わりません。 address() は常にTCP の接続元(=ロードバランサ)です。 送信元で判断するところは proxyAddress() を使ってください。

落とし穴

ボット判定(isBotAccess())は address() を見ます。 プロキシの後ろでは、判定に使う IP がロードバランサのものになります。

true にすると、scheme() / isSecure() も X-Forwarded-Proto を見ます(2.5.3 から)。 TLS を前段で終端していても、クライアントが https で来たことが分かります(HSTS のヘッダもこれで出ます)。

  • trusted_proxies を書いていれば、接続元がそこに入っているときだけ見ます
  • 値が複数なら右端(いちばん近い中継が付けたもの)を使います。左はクライアントが名乗れます
  • http / https 以外の値は無視して、接続の Scheme を使います

補足

**前段が X-Forwarded-Proto を付けるようにしてください。**nginx なら proxy_set_header X-Forwarded-Proto $scheme;。 ALB や Cloud Run などは付けてくれます。

注意

直接叩ける状態で true にしないでください。 ヘッダは誰でも付けられるので、送信元をいくらでも偽れます。 信頼する upstream を IP で絞る仕組みはありません(真偽値1つです)。

圧縮#

server.compression(既定 true)で on / off します。 クライアントが Accept-Encoding で受け取れると言ったときに、文字の応答(text/*・JSON・JavaScript・XML・SVG)で 1KB 以上のものだけを圧縮します(2.5.2 から)。

  • 画像・動画・zip・ダウンロードのファイルは圧縮しません(縮まないのに CPU を使い、Content-Length も消えるため)
  • 小さい応答も圧縮しません(圧縮器を作る手間のほうが大きいため)
  • SSE(text/event-stream)は圧縮しません(溜めてから送ることになり、届くのが遅れるため)

補足

**圧縮レベルや、種類を足す設定はありません。**細かく制御したいときは前段でやってください。

ボットを弾く#

判定だけならアクセスログの分離で足ります(ログ)。弾くなら宣言します。

before(BotBlocker.forbidden());                          // 403 を返す
before(BotBlocker.of(context -> context.response().redirect("/")));   // 好きに返す

補足

**既定の「弾く」動作は用意していません。**検索エンジンまで弾くと困るので、 何を返すかはアプリが決める形にしてあります。

無いもの#

TLS / HTTPS 前段(ロードバランサ・nginx)で終端してください
HTTP/2 依存に入れていません
ヘルスチェックのルート jimble は用意しません(下の「止める」を参照)

止める#

いきなり止めません。stop() はこの順に進みます。

  1. 「止め始めた」ことにする — Shutdown.isStopping() が true になる。普通のリクエストはまだ受ける
  2. server.shutdown_grace 待つ(既定 0)
  3. 新しいリクエストを断つ(503)
  4. 処理中のものが終わるのを server.shutdown_timeout(既定 15 秒)まで待つ
  5. サーバーを止める

SIGTERM を受けたら自動で走ります(コンテナはこれを送って待ちます)。

ヘルスチェックを先に落とす#

jimble はヘルスチェックのルートを用意しません。こう書いてください。

get("/health_check", context ->
	context.response().send(Shutdown.isStopping() ? 503 : 200));

こつ

ロードバランサがこの台を外すまでには時間がかかります。 その間に来たリクエストを 503 にすると、外から見たらエラーです。 だから 1 と 3 の間に猶予を置けるようにしてあります。 ヘルスチェックの間隔 × 失敗回数ぶん(例:2秒 × 3回 → shutdown_grace = 10s)を入れてください。

注意

待ちきれなかったら、残ったまま止めます。 そのときは 処理中のリクエストが N 件残ったまま停止します が warn に出ます。 止まらないほうが困る(コンテナに強制終了される)ためです。

補足

自分で立てたスレッドも一緒に止めたいなら Shutdown.add(...) に預けてください (実行モデル)。

動いている印(RUNNING_PID_{ポート})#

待ち受けを始めると、アプリの jar があるディレクトリに RUNNING_PID_8080 のような名前のファイルを置きます。中身はプロセス ID です。 stop() が済むと消えます(SIGTERM でも消えます)。

kill $(cat /opt/app/RUNNING_PID_8080)   # 止める
test -f /opt/app/RUNNING_PID_8080       # 動いているか
  • 名前にポート番号が入るので、同じディレクトリから複数立てても互いの印を消しません
  • jar から動いていないとき(jimbleRun・テスト)は置きません。置く場所が「jar のディレクトリ」なので、無いものの隣には置けません
  • 前回の印が残っていたら(kill -9 や電源断)warn を出して上書きします。起動は止めません
  • 書けなくても(ディレクトリが読み取り専用など)warn を出すだけで、待ち受けは続けます

jimble をプロキシにする#

逆に、jimble から別のサーバーへ流すこともできます。

install(() -> ReverseProxy.mount("/api", "http://backend:8080"));

X-Forwarded-For は既存の値に足します(上書きしません)。 タイムアウトは proxy.connect_timeout(5秒)と proxy.request_timeout(30秒)で、 転送に失敗したら 502 を返します。

待ちの上限
繋ぐまで connect_timeout
リクエストを送るあいだ request_timeout のあいだ1バイトも進まなければ切る
送り終えてから、応答のヘッダが届くまで 全体で request_timeout(2.2.4 から。それまでは1回の読み込みごとだったので、少しずつ返されると終わらなかった)
応答の本文 1回の読み込みごとに request_timeout。全体は proxy.body_timeout(既定 0 = 上限なし)か、ReverseProxy#bodyTimeout

本文の全体を既定で切らないのは、大きなダウンロードや SSE を途中で切らないためです。 パスはセグメントごとにエンコードし直して送り、. / .. を含むパスは 400 で断ります (ベース URL のパスの外へ出させないため)。

nginx の proxy_set_header などに当たるもの#

設定したプロキシを mount に渡します。設定は mount の前に済ませてください(リクエストを受けたあとに変えると例外です)。

install(() -> ReverseProxy.mount("/shop", new ReverseProxy("http://shop:9000")
	.preserveHost()                                     // proxy_set_header Host $http_host
	.setHeader("X-App", "front")                        // proxy_set_header X-App front
	.setHeader("X-User", context -> userId(context))    // 値をリクエストから作る(null なら送らない)
	.removeHeader("Cookie")                             // proxy_set_header Cookie ""
	.hideResponseHeader("X-Powered-By")                 // proxy_hide_header X-Powered-By
	.redirect("http://shop.internal/", "/shop/")        // proxy_redirect http://shop.internal/ /shop/
	.cookieDomain("shop.internal", "example.com")       // proxy_cookie_domain shop.internal example.com
	.cookiePath("/", "/shop/")));                       // proxy_cookie_path / /shop/
nginx と同じ既定
Host 転送先のものを送ります(proxy_set_header Host $proxy_host)。元の Host は X-Forwarded-Host / X-Forwarded-Port で渡します。転送先が Host で振り分けるなら preserveHost() を付けてください
Location / Refresh 転送先の URL で始まるものを、ブラウザから見えるパスに書き換えます(proxy_redirect default。http://backend:8080/moved → /api/moved)。相対の値(/moved)はそのままです。切るなら noRedirectRewrite()
転送先との接続 1回ごとに切ります(keepalive は持ちません)。https:// の転送先は証明書のホスト名を確かめます
圧縮 転送先が gzip で返した本文は、そのまま返します(もう一度圧縮しません)