spring-boot

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

13posts · 30d
Nov 30 posts
  1. Zenn · · @carecraft

    Spring Boot 2系から4系へ — OpenRewrite、ArchUnit、Playwright で進めたバージョンアップ サポート期限と脆弱性への対応のため、Spring Boot を2系から3系へ、さらに3系から4系へバージョンアップしました。 メジャーバージョンアップは、依存の更新だけでは終わりません。今回特に効果があったのは次の3つです。 OpenRewrite で機械的な変更を任せる 業務ロジックをフレームワークに依存しない形にし、ArchUnit で担保する 回帰試験と Playwright の画面試験で、結果が変わらないことを確認する この記事は、Spring Boot のバージョンアップの経験がある人を想定しています。 ! コードは説明のために作った例です。実際の案件のコードではありません。バージョンは2026年11月時点の情報です。 各バージョンで確認したいこと バージョンアップ 主な確認事項 2系 → 3系 Java 17 以上が必要。javax から jakarta への移行 3系 → 4系 Spring Framework 7、Jakarta EE 11、Servlet 6.1 が前提 2系から3系へ移るときは、公式の移行ガイドでも、まず 2.7.x の最新版へ上げることが推奨されています。詳細は公式の移行ガイドを確認してください。 Spring Boot 3.0 Migration Guide Spring Boot 4.0 Migration Guide OpenRewrite で機械的な変更を任せる javax から jakarta へのパッケージの変更のように、機械的な修正は OpenRewrite に任せました。 Maven では、pom.xml を変更せずに次のようなコマンドで実行できます。 mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \ --define rewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \ --define rewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5 4系へは org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0 を指定します。実行に必要な設定は変わることがあるので、公式の手順を確認してください。 OpenRewrite の結果は、差分を確認してからコミットします。ツールで置き換えられるのは機械的な変更までです。 問題は業務ロジックの依存だった 作業で一番大きかったのは、業務ロジックが Spring Boot に依存していたことです。 業務ロジックがフレームワークに依存していると、フレームワークの変更の影響が業務ロジックまで広がります。そこで、業務ロジックをフレームワークに依存しない形にリファクタリングしました。 例えば、次のように業務ルールの計算だけを、フレームワークの型を使わないクラスへ切り出します。 // 変更前: 業務ルールがフレームワークの機能と一緒に書かれている @Service public class OrderService { @Transactional public Order confirm(long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); if (order.getAmount() > LIMIT) { throw new OrderLimitExceededException(); } order.confirm(); return order; } } // 変更後: 業務ルールは Spring に依存しない public class OrderPolicy { public void validate(Order order) { if (order.getAmount() > LIMIT) { throw new OrderLimitExceededException(); } } } フレームワークとのつなぎ込みは外側の層に残します。これで、バージョンアップの影響を外側の層に局所化できます。業務ルールの単体試験も、Spring を起動せずに実行できます。 ArchUnit で依存を試験にする リファクタリングしても、時間がたつと依存が戻ってしまうことがあります。そこで、ArchUnit で依存のルールを試験にしました。 import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; @AnalyzeClasses(packages = "com.example.app") public class ArchitectureTest { @ArchTest public static final ArchRule domainMustNotDependOnFramework = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAnyPackage("org.springframework..", "jakarta.."); } レビューで見つけるのではなく、試験で失敗させます。これで、次のバージョンアップのときも依存が局所化された状態を保てます。 回帰試験で結果が変わらないことを確認する バージョンアップの完了条件は、バージョンアップの前後で結果が変わらないことです。回帰試験を実施して確認しました。 画面の試験は Playwright で自動化しました。手作業の画面確認は工数が大きく、次のバージョンアップでも同じ工数がかかります。自動化しておけば、繰り返し実行できます。 生成AIの活用 作業には生成AIも活用しました。使ったのは次の3つの場面です。 影響範囲の確認 リファクタリング バージョンアップの作業 対応する箇所を洗い出して、修正案を作るところまで任せられると、人は判断に時間を使えます。出力は差分と試験の結果で確認します。 まとめ メジャーバージョンアップの負担を減らすには、作業そのものを速くするだけでなく、影響が広がらない作りにすることが重要です。 機械的な変更は OpenRewrite に任せる 業務ロジックをフレームワークから切り離し、ArchUnit で守る 回帰試験と Playwright で、結果が変わらないことを確認する 業務ロジックをフレームワークから切り離すことでバージョンアップの範囲を局所化でき、ブラウザの試験を自動化することで工数を削減できます。一度整えておくと、次のバージョンアップにも効きます。

  2. nginx upstream_connect_time comes in 1-second steps An nginx latency alert fires, yet the logs and metrics of the application server behind nginx can show the same requests finishing quickly. This happens when the application server stops entirely for a few seconds, for example during a full GC[^stop], and connections that arrive in the meantime are kept waiting. [^stop]: Ordinary GC pauses are not this long. The JVM's G1 collector has a default pause-time goal of 200 ms (G1Arguments::initialize() in JDK 25). Pauses of several seconds come from things like a full GC under heap pressure, which this article does not verify. sequenceDiagram participant N as nginx participant K as Application server's kernel participant U as Application server Note over U: Stopped by a full GC or similar N->>K: SYN (open a connection) Note over K: The accept queue is full, so the SYN is dropped The dropped SYN is resent every second and connects after the application server resumes. From nginx, these look like 200s that took about 1, 2, or 3 seconds to connect. The three lines below are from the nginx access log, which records $upstream_connect_time (the seconds nginx took to finish connecting to the application server) as uct. The columns are time, request ID, status, uct, $upstream_header_time (uht), $upstream_response_time (urt), and $request_time (rt). uht and urt are the seconds from when nginx starts connecting to the application server until it receives the start of the response, and until it has received the whole response. rt is the seconds from when nginx receives the request until it finishes sending the response. 1792402370.463 487aefe88a1328206a8e93c7c242399a 200 uct=1.002 uht=1.002 urt=1.002 rt=1.002 1792402370.464 cfdd11ceca67677e3601df796850a712 200 uct=2.067 uht=2.067 urt=2.067 rt=2.068 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 200 uct=3.054 uht=3.055 urt=3.055 rt=3.054 The application server receives the request ID that nginx assigned, and its log records time, request ID, and processing time in milliseconds. These are its lines for the same request IDs: 1792402370.463 487aefe88a1328206a8e93c7c242399a 0.092 1792402370.463 cfdd11ceca67677e3601df796850a712 0.104 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 0.084 Nearly all of the time went to connecting, and processing on the application server took about 0.1 ms. The application server starts timing only after it takes the connection off the queue, so the wait to connect does not appear in its logs or metrics. The logs above were taken from an application server that receives 200 requests per second, stopped for 3 seconds[^run]. [^run]: The results are here, and the code is in show-your-work. It ran on GitHub Actions ubuntu-24.04 (x86_64, kernel 6.17) with Docker 28.0.4. What is happening When the application server stops, the accept queue fills up. SYNs that arrive after that are dropped, and connections are made through retransmissions by the nginx-side kernel. The accept queue The kernel carries out the exchange that sets up a TCP connection (the handshake). The connecting side sends a SYN to request a connection, the receiving side replies with a SYN-ACK to accept it, and the handshake completes when the connecting side sends an ACK. A completed connection waits in the kernel's accept queue until the server process takes it with the accept system call. Even while the application server is stopped, the kernel on its side keeps completing handshakes and queuing the connections. Nothing takes connections off the queue during the stop, so the queue fills up. sequenceDiagram participant N as nginx participant NK as nginx-side kernel participant UK as Application server's kernel participant U as Application server Note over U: Stopped N->>NK: Connect to the application server NK->>UK: SYN UK-->>NK: SYN-ACK NK->>UK: ACK NK-->>N: Connected Note over UK: Puts the connection in the accept queue Note over U: On resuming, takes connections off the queue in order The maximum number of connections the queue can hold is the backlog. The application server passes the backlog to the kernel when it starts listening for connections (when it calls listen). The value comes from the runtime's or framework's default, or from configuration. SYNs are dropped when the queue is full When the number of connections in the queue exceeds the backlog, the application server's kernel drops newly arriving SYNs without replying[^drop]. [^drop]: In the Linux 6.17 source, tcp_conn_request() drops the SYN when the queue is full. sk_acceptq_is_full() decides whether it is full, and treats the queue as full when the number of connections in it is greater than (>) the backlog. Retransmissions get through When a SYN gets no reply, the nginx-side kernel waits and sends it again. This is SYN retransmission. If the nginx-side kernel is Linux 6.5 or later, the first few retransmissions go out at 1-second intervals[^linear]. [^linear]: The number of retransmissions sent at 1-second intervals is set by net.ipv4.tcp_syn_linear_timeouts (default 4; see the Linux 6.17 documentation). Retransmitted SYNs are dropped too while the queue is full. Once the application server resumes and the queue has room, the next retransmission gets a SYN-ACK and the connection is made. Depending on which retransmission gets through, $upstream_connect_time is about 1, 2, or 3 seconds. The three log lines at the top are these cases. sequenceDiagram participant N as nginx participant NK as nginx-side kernel participant UK as Application server's kernel participant U as Application server Note over U: Stopped Note over UK: The accept queue is full N->>NK: Connect to the application server NK->>UK: SYN Note over UK: Drops it without replying loop Every second while the queue is full NK->>UK: Resend the SYN Note over UK: Drops it again end Note over U: Resumes and takes connections off the queue NK->>UK: Next retransmission UK-->>NK: SYN-ACK NK->>UK: ACK NK-->>N: Connected ($upstream_connect_time is about 1, 2, or 3 seconds) Verification I stopped the application server for 3 seconds to confirm this sequence. Requests that arrived during the stop split into those that connected immediately and those that connected after waiting about 1, 2, or 3 seconds. Setup nginx and the application server ran as containers on the same Docker network, and nginx was sent 200 requests per second for 15 seconds. Five seconds in, the application server process was stopped from outside with SIGSTOP, then resumed with SIGCONT 3 seconds later. This mimics a full GC stopping the whole process. - nginx is 1.30.6, with a single application server in the upstream block. proxy_connect_timeout (how long nginx waits to finish connecting to the application server) is left at its default of 60 seconds - The application server is an HTTP server that returns 200 immediately for every request, and listens with a backlog of 100 Time to connect In the 3 seconds between the stop and the resume, 600 requests reached nginx, and all of them returned 200. The counts by $upstream_connect_time are: | $upstream_connect_time | Requests | |---|---| | 0 seconds (connected immediately) | 102 | | About 1 second | 204 | | About 2 seconds | 205 | | About 3 seconds | 89 | This holds beyond the three lines at the top: for all 600 requests, the processing time in the application server's log was under 1 ms. Which row a request falls into depends on when it arrived. The queue was full about 0.5 seconds after the stop. Connections that arrived before that went into the queue and connected immediately. Connections that arrived later had their SYNs dropped, the nginx-side kernel resent them every second, and they connected on the first retransmission after the application server resumed. The earlier a connection arrived, the more times its SYN was resent and the longer it waited. Spotting it in your own logs To tell whether the accept queue overflowed, match the nginx access log against the application server's log. The access log shows that SYNs were dropped and the connections were made by retransmission. The application server's log shows that the application server was stopped. Access log In the access log, look at $upstream_connect_time on the 200 lines from when the alert fired. If the values cluster around 1, 2, and 3 seconds, SYNs were dropped and the connections were made by retransmission. $upstream_connect_time is not part of the default combined format, so add it with log_format[^log-format]. The verification used this format: log_format lab '$msec $request_id $status uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time rt=$request_time'; [^log-format]: Adding variables with log_format is covered in nginx request_time is fast, but users say it's slow. Application server log In the application server's log, find the lines with the same IDs as the requests whose $upstream_connect_time came in 1-second steps, and look at how the lines are spaced around that time. If those requests finished quickly and the lines stop for a while around that time, the application server was stopped. The lines after the gap are bunched at the time it resumed. To match the two logs, pass the ID that nginx assigns to each request ($request_id) to the application server. On the nginx side, this takes one line: proxy_set_header X-Request-Id $request_id; On the application server side, its access log needs to be configured to include the received X-Request-Id header. Fix If the application server's backlog is set larger than the number of requests that arrive during a stop (requests per second × seconds stopped), the queue does not overflow[^somaxconn]. With a backlog of 4096, the 200s of about 1, 2, and 3 seconds no longer appear. The default backlogs of JVM servers, which can stop for a full GC, are: - Tomcat and Spring Boot: 100[^bl-tomcat] - Jetty: 50[^bl-jetty] [^somaxconn]: To go above 4096, also raise net.core.somaxconn in the application server's kernel. The kernel caps the backlog at somaxconn (__sys_listen_socket() in Linux 6.17), and its default is 4096 (preinit_net_sysctl(), documentation). [^bl-tomcat]: The Tomcat 11 documentation on HTTP connector configuration, and TomcatServerProperties in the Spring Boot 4.1.1 source. [^bl-jetty]: Jetty passes 0, and the JDK replaces it with 50. The default is 0 in ServerConnector in the Jetty 12.1.14 source, and ServerSocketChannelImpl in the JDK 25 source turns any value below 1 into 50. Even without an overflow, requests that arrive during a stop wait in the queue until the application server resumes. That wait shows up in $upstream_header_time, so 200s slowed by the length of the stop remain. How this varies by environment With a shortened connect timeout If proxy_connect_timeout is shorter than the stop, nginx gives up on connections whose retransmissions have not gotten through in time and returns 504. When the stop is not 3 seconds A stop of a different length should change the values $upstream_connect_time clusters around. This article tests a 3-second stop. Kernels older than 6.5 If the nginx-side kernel is older than 6.5, $upstream_connect_time clusters around 1 and 3 seconds, and about 2 seconds does not appear. This is because the retransmission interval doubles each time: after 1 second, then 2 seconds after that, then 4 seconds after that. The stock kernel of Ubuntu 24.04 (6.8) is 6.5 or later. Those of Ubuntu 22.04 (5.15), Debian 12 (6.1), and RHEL 9 (5.14) are older. Whether a distribution has backported this change into its own kernel has not been checked. With a proxy in front of the application server If a proxy that accepts connections first sits between nginx and the application server (Docker's userland-proxy, which handles published ports, or a sidecar proxy), that proxy answers the SYNs from nginx. Even if the application server's queue overflows, no retransmissions happen on the nginx side, so the method in this article should not apply. This article does not verify this case.

  3. nginxのupstream_connect_timeが1秒刻みになる nginx 側のレイテンシのアラートが鳴ったのに、nginx の後ろのアプリケーションサーバーのログやメトリクスでは、同じリクエストが速く終わっていることがある。アプリケーションサーバーが full GC などでプロセスごと数秒止まり1、その間に届いた接続が待たされると、こうなる。 捨てられた SYN は 1 秒ごとに送り直され、アプリケーションサーバーが動き出したあとにつながる。nginx からは、接続を張るのに約 1 秒、約 2 秒、約 3 秒かかった 200 に見える。下の3行は nginx のアクセスログで、$upstream_connect_time(nginx がアプリケーションサーバーへの接続を張り終えるまでにかかった秒数)を uct として出している。列は時刻、リクエスト ID、ステータス、uct、$upstream_header_time(uht)、$upstream_response_time(urt)、$request_time(rt)の順に並ぶ。uht と urt は、nginx がアプリケーションサーバーにつなぎ始めてから、応答の先頭を受け取るまでと、応答を受け取り終えるまでの秒数だ。rt は、nginx がリクエストを受けてから応答を返し終えるまでの秒数だ。 1792402370.463 487aefe88a1328206a8e93c7c242399a 200 uct=1.002 uht=1.002 urt=1.002 rt=1.002 1792402370.464 cfdd11ceca67677e3601df796850a712 200 uct=2.067 uht=2.067 urt=2.067 rt=2.068 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 200 uct=3.054 uht=3.055 urt=3.055 rt=3.054 アプリケーションサーバーには、nginx が付けたリクエスト ID を渡し、ログに時刻、リクエスト ID、処理時間(ミリ秒)の順に出している。上の3行と同じリクエスト ID の行は次のとおりだ。 1792402370.463 487aefe88a1328206a8e93c7c242399a 0.092 1792402370.463 cfdd11ceca67677e3601df796850a712 0.104 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 0.084 かかった時間のほとんどが接続を張るまでの時間で、アプリケーションサーバーでの処理は 0.1 ミリ秒ほどだ。アプリケーションサーバーが時間を測り始めるのは接続を取り出したあとなので、接続を張るまでの待ちは、そのログやメトリクスに出ない。 上のログは毎秒 200 件を受けるアプリケーションサーバーを 3 秒止めて取ったものだ2。 何が起きているか アプリケーションサーバーが止まると accept キューが埋まり、そのあと届いた SYN は捨てられ、nginx 側のカーネルの再送でつながる。 accept キュー TCP の接続を張るやりとり(ハンドシェイク)は、カーネルが行う。接続を張る側が接続を求める SYN を送り、受ける側が受け入れの返事の SYN-ACK を返し、張る側が ACK を返すと、ハンドシェイクが終わる。終わった接続は、サーバーのプロセスが accept というシステムコールで取り出すまで、カーネルの accept キューに並んで待つ。 アプリケーションサーバーが止まっていても、アプリケーションサーバー側のカーネルはハンドシェイクを進め、終わった接続をキューに積む。止まっている間は接続が取り出されないので、キューは埋まっていく。 キューに並べられる接続の数の上限が backlog で、アプリケーションサーバーが接続を待ち受けるとき(listen するとき)に backlog をカーネルに渡す。値はランタイムやフレームワークの既定値か設定で決まる。 満杯なら SYN を捨てる キューにある接続の数が backlog を超えると、アプリケーションサーバー側のカーネルは、新しく届いた SYN を返事をせずに捨てる3。 再送でつながる SYN に返事がないと、nginx 側のカーネルは時間をおいて SYN を送り直す。これを SYN の再送と呼ぶ。nginx 側のカーネルが Linux 6.5 以降なら、最初の数回を 1 秒間隔で送り直す4。 送り直した SYN も、キューが満杯のうちは捨てられる。アプリケーションサーバーが動き出してキューが空くと、その次の再送で SYN-ACK が返り、つながる。何回目の再送でつながったかで、$upstream_connect_time は約 1 秒、約 2 秒、約 3 秒になる。冒頭の3行がこれにあたる。 検証 アプリケーションサーバーを 3 秒止めて、この流れを確かめた。止まっている間に届いたリクエストは、すぐつながったものと、約 1 秒、約 2 秒、約 3 秒待ってつながったものに分かれた。 構成 nginx とアプリケーションサーバーを同じ Docker ネットワークのコンテナで動かし、nginx に毎秒 200 件のリクエストを 15 秒送った。送り始めて 5 秒後に、アプリケーションサーバーのプロセスに外から SIGSTOP を送って止め、3 秒後に SIGCONT で再開させた。full GC でプロセス全体が止まる状態を模している。 nginx は 1.30.6 で、upstream ブロックにアプリケーションサーバーを 1 台置いた。proxy_connect_timeout(nginx がアプリケーションサーバーへの接続を張り終えるのを待つ上限)は既定の 60 秒のまま アプリケーションサーバーは受けたリクエストにすぐ 200 を返す HTTP サーバーで、backlog 100 で listen する 接続にかかった時間 止めてから再開させるまでの 3 秒に nginx に届いたリクエストは 600 件で、すべて 200 だった。$upstream_connect_time ごとの件数は次のとおりだ。 $upstream_connect_time 件数 0 秒(すぐつながった) 102 件 約 1 秒 204 件 約 2 秒 205 件 約 3 秒 89 件 冒頭の3行に限らず、600 件とも、アプリケーションサーバーのログの処理時間は 1 ミリ秒未満だった。 表のどの値に入るかは、届いた時刻で決まる。止めてから 0.5 秒ほどでキューが満杯になり、それより前に届いた接続はキューに入って、すぐつながった。それより後に届いた接続は SYN を捨てられ、nginx 側のカーネルが 1 秒ごとに送り直して、アプリケーションサーバーが動き出したあとの次の再送でつながった。早く届いた接続ほど、送り直した回数が多く、待ちが長い。 自分のログで見分ける accept キューが溢れたかどうかは、nginx のアクセスログとアプリケーションサーバーのログを突き合わせて見分ける。アクセスログで SYN が捨てられて再送でつながったと分かり、アプリケーションサーバーのログで、アプリケーションサーバーが止まっていたと分かる。 アクセスログ アクセスログでは、アラートが鳴った時間帯の 200 の行の $upstream_connect_time を見る。値が約 1 秒、約 2 秒、約 3 秒に固まっていれば、SYN が捨てられ、再送でつながっている。 $upstream_connect_time は既定の combined 形式に入らないので、log_format に足す5。検証では次の形式を使った。 log_format lab '$msec $request_id $status uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time rt=$request_time'; アプリケーションサーバーのログ アプリケーションサーバーのログでは、アクセスログで $upstream_connect_time が 1 秒刻みの値になったリクエストと同じ ID の行と、その時間帯の行の並びを見る。同じ ID のリクエストが速く終わり、その時間帯に行が途切れていれば、アプリケーションサーバーは止まっていた。途切れたあとの行は、動き出した時刻にまとまって並ぶ。 突き合わせるには、nginx がリクエストごとに付ける ID($request_id)をアプリケーションサーバーに渡す。nginx の側は次の1行で渡せる。 proxy_set_header X-Request-Id $request_id; アプリケーションサーバーの側では受け取った X-Request-Id ヘッダをアクセスログに出す設定が要る。 対策 アプリケーションサーバーの backlog の設定を、止まっている間に届く件数(毎秒の件数 × 止まる秒数)より大きくすれば、溢れは起きない6。backlog を 4096 にすると、約 1 秒、約 2 秒、約 3 秒の 200 は出なくなる。 full GC で止まることのある JVM のサーバーの既定の backlog は、次のとおりだ。 Tomcat と Spring Boot:1007 Jetty:508 止まっている間に届いたリクエストは、溢れなくても、アプリケーションサーバーが動き出すまでキューで待たされる。この待ちは $upstream_header_time に出て、止まった時間の分だけ遅い 200 は残る。 環境による違い 接続のタイムアウトを縮めているとき proxy_connect_timeout を止まる時間より短くしていると、再送を待ちきれなかった接続は、nginx が打ち切って 504 を返す。 止まる時間が 3 秒でないとき 止まる時間が違えば、$upstream_connect_time が固まる値も変わるはずだ。この記事では 3 秒止めた場合を確かめている。 6.5 より前のカーネル nginx 側のカーネルが 6.5 より前だと、$upstream_connect_time は約 1 秒と約 3 秒に固まり、約 2 秒は出ない。再送の間隔が 1 秒後、その 2 秒後、その 4 秒後と倍々に伸びるからだ。 Ubuntu 24.04 の標準のカーネル(6.8)は 6.5 以降、Ubuntu 22.04(5.15)、Debian 12(6.1)、RHEL 9(5.14)は 6.5 より前だ。ディストリビューションがこの変更を自分のカーネルに取り込んでいるかは確かめていない。 アプリケーションサーバーの前にプロキシがあるとき nginx とアプリケーションサーバーの間に、先に接続を受けるプロキシ(Docker の公開ポートを受ける userland-proxy、サイドカーのプロキシ)があると、nginx からの SYN にはそのプロキシが返事をする。アプリケーションサーバーのキューが溢れても nginx 側では再送が起きないので、この記事の見分け方は使えないはずだ。この記事では確かめていない。 脚注 普段の GC ではここまで止まらない。JVM の G1 は停止時間の目標が既定で 200ms だ(JDK 25 の G1Arguments::initialize())。秒単位で止まるのは、ヒープが逼迫したときの full GC などで、この記事では確かめていない。 ↩︎ 検証結果はこちら、コードは show-your-work にある。GitHub Actions の ubuntu-24.04(x86_64、カーネル 6.17)で、Docker 28.0.4 を使った。 ↩︎ Linux 6.17 のソースの tcp_conn_request() で、キューが満杯なら SYN を捨てている。満杯かどうかは sk_acceptq_is_full() が決め、キューにある接続の数が backlog より大きい(>)ときに満杯とする。 ↩︎ 1 秒間隔で送り直す回数は net.ipv4.tcp_syn_linear_timeouts(既定 4)で決まる(Linux 6.17 の文書)。 ↩︎ log_format の足し方は nginxのrequest_timeは速いのにユーザーは遅いと言うで扱った。 ↩︎ 4096 より大きくするときは、アプリケーションサーバー側のカーネルの net.core.somaxconn も上げる。カーネルは backlog を somaxconn までに切り詰め(Linux 6.17 の __sys_listen_socket())、その既定は 4096 だ(preinit_net_sysctl()、文書)。 ↩︎ Tomcat 11 の文書の HTTP コネクタの設定と、Spring Boot 4.1.1 のソースの TomcatServerProperties。 ↩︎ Jetty は 0 を渡し、JDK が 50 に置き換える。Jetty 12.1.14 のソースの ServerConnector で既定が 0、JDK 25 のソースの ServerSocketChannelImpl で 1 未満を 50 にしている。 ↩︎

  4. Spring ApplicationContext Hierarchy and a High CVE in Axelix Introduction Hi everyone! My name is Mikhail Polivakha, and I am the Tech Lead of the Axelix project. If you haven't heard of us before, Axelix is a platform for managing and diagnosing Spring Boot applications. The core of the project is open source, so the source code, the change history and, as it turned out, our mistakes are open to everyone 😁. Recently, an external security researcher, Andrei Kotov (greatkaa), found a vulnerability in Axelix. In short: under a certain Spring Boot configuration, our Actuator endpoints could be called without a JWT. We have already fixed it in version 1.2.1 and described it in a Security Advisory. Andrei, I'll use this opportunity to say a big thank you. Let me say right away that this article is not about how the vulnerability could have been exploited. Honestly, that's not all that interesting. What's far more interesting is why it appeared at all. We didn't forget to add the filter, we didn't get the token signature check wrong, and we didn't mix up a condition in an if. The filter was there, and it worked perfectly. Just not where we thought it was. The cause lies in a Spring Framework mechanism that has existed for about twenty years, yet many modern Spring Boot developers never think about it at all. I'm talking about the ApplicationContext hierarchy. So here's the plan: first, we'll figure out what a context hierarchy is and who sees whom in it. Then we'll look at where Spring Boot itself creates one. And only after that will we return to Axelix and see how it all added up to one unpleasant story. Let's dig in. Where it all started Let's start with the symptom, because in my opinion it is very telling. The configuration was completely ordinary: server: port: 8080 management: server: port: 8081 So the business API lives on 8080, and Actuator is moved to a separate port, 8081. A lot of people do this, and it's good practice: the management port is easy to close off from the outside world at the network level. Axelix exposes its endpoints through Actuator (they are called axelix-*), and all of them are protected by a JWT filter. When Actuator lives on the main port, a request without a token gets the expected 401 Unauthorized. But on 8081, the very same request suddenly went through. Friends, note an important detail: the application started up normally. No NoSuchBeanDefinitionException, no warnings in the logs, all endpoints in place, dependencies injected. Everything looks great. These are exactly the most unpleasant kind of bugs. Everything seems to work fine. Why does simply changing a port number change the behavior so much? To answer that, we need to take a step back. One big context. Or not? ApplicationContext is Spring's central container. It holds bean definitions, creates beans, resolves dependencies, runs them through post-processors, publishes events, and manages the lifecycle. If you write Spring Boot code, you most likely have roughly this model in your head: > My application has one big Spring context, and all beans live inside it. And that's fine, because in the vast majority of cases that is exactly how it is. SpringApplication.run(...) creates a ServletWebServerApplicationContext, which starts embedded Tomcat itself, finds servlets and filters itself, and registers them itself (ServletWebServerApplicationContext Javadoc). One context is more than enough. But here's the thing: a context in Spring doesn't have to be the only one. ApplicationContext has a getParent() method, and the link is set via ConfigurableApplicationContext#setParent. In other words, contexts can form a tree. A bit of history. Root and DispatcherServlet Those who worked with Spring MVC before Spring Boot have almost certainly run into context hierarchies, even if they never thought about it specifically. The classic setup looked like this: root WebApplicationContext services repositories shared infrastructure | v DispatcherServlet WebApplicationContext controllers handler mappings web-specific beans The root context was created by ContextLoaderListener from web.xml, and each DispatcherServlet started its own (!) child WebApplicationContext. Services and repositories lived at the top and were shared, while the controllers and all the MVC infrastructure of a particular servlet lived at the bottom. Moreover, a single root could have several independent children (Spring MVC documentation). Then Spring Boot came along, everything collapsed into a single context, and most people happily forgot about the hierarchy. But the mechanism didn't go anywhere. A second context can still appear today: - from an explicit call to parent(...), child(...) or sibling(...) on SpringApplicationBuilder; - from a test with @ContextHierarchy; - from Spring Cloud, which starts its own child contexts, for example for each Feign client (surprise, dear friends!); - and, most importantly for us, from Spring Boot Actuator when a separate management port is used. We'll come back to that last item. For now, let's understand the main thing: how contexts in a hierarchy see each other. Who sees whom The rules here are fairly simple, but asymmetric: - a child sees the parent's beans; - a parent does not see (!) the child's beans; - sibling contexts, that is, children of the same parent, know nothing about each other; - if a child declares a bean with the same name as the parent's, the local (child's) definition overrides the parent's. This is stated explicitly in the ApplicationContext Javadoc. Let's look at it in code, it's clearer that way: @Configuration class ParentConfig { @Bean SharedService sharedService() { return new SharedService(); } } @Configuration class ChildConfig { @Bean ChildOnlyService childOnlyService(SharedService sharedService) { return new ChildOnlyService(sharedService); } } var parent = new AnnotationConfigApplicationContext(ParentConfig.class); var child = new AnnotationConfigApplicationContext(); child.setParent(parent); child.register(ChildConfig.class); child.refresh(); child.getBean(SharedService.class); // OK, found in parent child.containsBean("sharedService"); // true child.containsLocalBean("sharedService"); // false, not present locally parent.getBean(ChildOnlyService.class); // NoSuchBeanDefinitionException Note the difference between containsBean and containsLocalBean. The first takes the parent into account, the second looks only at the current level. This distinction will come in very handy later. So, roughly speaking, a child knows where to go upstairs if it can't find something at home. But a parent may not even suspect that a particular child exists. The word "parent" creates a pleasant feeling that the elder knows about all the younger ones. In Spring, it's exactly the other way around. Seeing a bean is not owning it And now the most important idea of this article. Let me highlight it: The fact that a child can get a bean from its parent does not mean that the child "uses" that bean as part of its own infrastructure. Every context in a hierarchy remains a full-fledged, separate container. It has its own bean definitions, its own post-processors, its own lifecycle, its own event multicaster. The hierarchy gives you just one thing: dependency lookup upwards. Everything else each context does on its own, and only with its own beans. Here are a few examples of how this shows up: - a BeanPostProcessor declared in the parent does not process beans created in the child; - getBeansOfType() lists only local beans. To walk the ancestors, you need BeanFactoryUtils.beansOfTypeIncludingAncestors(...); - events published in the child propagate up to the parent. But the parent's events do not flow down to the children on their own; - the parent has no universal API to iterate over all of its children. There's one more amusing nuance about events. If an @EventListener is declared in the parent, it will also receive the child's ContextRefreshedEvent. So if you do something "on application startup" on this event, then with a child context present your code may run twice. That's why it's useful to check which context the event actually came from. And finally, the example we're discussing all this for. An embedded web server (Tomcat, for instance) is started by a specific ServletWebServerApplicationContext, and at startup that context registers in the server the Servlets, Filters and ServletContextInitializers that are defined in that context itself. If your FilterRegistrationBean lives in the parent, but the server is started by the child, then the filter won't make it into that server. The child "sees" it, of course, but that has no effect on which filter chain it builds for its server. Remember this idea, we'll come back to it shortly. A separate Actuator port is a separate context So, what happens when you set management.server.port? Spring Boot distinguishes between two situations. If management.server.port is not set or matches server.port, Actuator simply lives in the main context. But if the ports differ, Boot creates a separate child management context, for which the main context is the parent (ManagementContextAutoConfiguration). Schematically, it looks like this: application parent server.port=8080 | web server :8080 | controllers, services | v application:management child management.server.port=8081 web server :8081 Actuator handler mappings its own servlet filter chain So management.server.port changes far, far more than just the socket number! A second ApplicationContext appears, a second embedded server, its own handler mappings and its own chain of servlet filters. The Spring Boot documentation, by the way, honestly warns about this: a management context on a different port does not use the same web infrastructure as the main application (Spring Boot Actuator 2.7 reference). But we don't read it, we vibe-code, as everyone knows. So, in essence, from the web layer's point of view, you now have two different applications. How a library gets into the management context A logical question: what if a library (for example, a starter like ours) needs to put something specifically into the management context? Spring Boot has a dedicated annotation for that, @ManagementContextConfiguration. It has a ManagementContextType parameter: - SAME means the configuration applies when Actuator lives in the main context; - CHILD means the configuration applies in a separate child management context; - ANY means the configuration should be applied in both cases. The annotation alone isn't enough, though. The class also has to be registered in a special file: META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports This is very similar to how regular auto-configurations are registered via AutoConfiguration.imports, except that Boot reads this file when it creates the management context. (Spring Boot 2.7 still also supported spring.factories for old times' sake, so if you're copying an example from older documentation, check the version. Selector in Boot 2.7, selector in Boot 3.5.) What happened in Axelix And now, I think, you can already guess what happened. A little context on how Axelix works. Our starter is added to the application (axelix-spring-boot-2-starter, -3-starter or -4-starter, depending on the Boot version). The starter exposes a set of Actuator endpoints through which Axelix collects information about the application: beans, configuration, loggers, caches, and so on. All of these endpoints are protected by JWT, and token validation is handled by our JwtAuthorizationFilter. Before version 1.2.1, this filter was registered in a regular auto-configuration, that is, via a FilterRegistrationBean in the main context (Axelix 1.2.0 code). When Actuator lives on the main port, everything is fine: single context web server :8080 Actuator mappings JwtAuthorizationFilter The request goes through the filter, there's no token, we get 401. But with different ports, the picture looks like this: main context web server :8080 JwtAuthorizationFilter <- the filter is here | v management child web server :8081 Actuator mappings <- but the endpoints are here no JwtAuthorizationFilter The management child sees all the beans it needs from the parent perfectly well, so the application starts without a single error. The endpoints work. But the server on 8081 builds its own filter chain, and our filter isn't in it. Exactly what we talked about in the "Seeing a bean is not owning it" section. The root cause in one sentence: we registered the JWT filter in the parent context, while Actuator on the separate port was served by a different server from the child context. The funniest part is that the JWT check itself worked flawlessly. The request simply never reached it. There was one more symptom that illustrates what was going on nicely. Some endpoints on 8081 responded not with 200, but with 500 and the message Security Context is expected to be present at this point. These endpoints expected the security context to already be populated, and it was precisely the missing filter that populated it. And even with a valid token it was the same: there was simply nobody to process the token. Honestly, looking back, the bug seems obvious. But at the time the code was written, we, like many others, had that very "one big context" model in our heads. And our tests only hit the main port, so they caught nothing. It's a good thing there are people like Andrei who look at code from a different angle. How we fixed it The solution suggests itself: the filter has to be registered where the server with Actuator is started. In 1.2.1, all three starters got a separate JwtAuthManagementContextConfiguration (the fix): @ManagementContextConfiguration( value = ManagementContextType.CHILD, proxyBeanMethods = false ) @ConditionalOnBean(WebIdentityAccessManager.class) public class JwtAuthManagementContextConfiguration { @Bean public FilterRegistrationBean<JwtAuthorizationFilter> jwtAuthorizationFilterRegistration( WebIdentityAccessManager webIdentityAccessManager, SecurityContextExecutor securityContextExecutor, WebEndpointProperties webEndpointProperties) { var filter = new JwtAuthorizationFilter( webIdentityAccessManager, securityContextExecutor, webEndpointProperties.getBasePath() ); var registration = new FilterRegistrationBean<>(filter); registration.setName("jwtAuthorizationFilter"); return registration; } } And, of course, the class is listed in each starter's ManagementContextConfiguration.imports. Look how everything we discussed above works here. WebIdentityAccessManager and SecurityContextExecutor still live in the parent, and the child gets them without any trouble via lookup. But the FilterRegistrationBean is now created locally in the management context, so it's the management server that includes the filter in its chain. The hierarchy is used exactly as intended: shared services at the top, the web infrastructure of a specific server at the bottom. If we phrase what we restored as a rule, it goes like this: the server that exposes the Axelix endpoints must itself have the JWT filter in its filter chain. And, of course, a test. Importantly, it checks the topology specifically, not the presence of a bean "somewhere in the container": @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "management.server.port=0" ) class JwtAuthorizationFilterSeparateManagementPortTest { @LocalManagementPort private int managementPort; @Test void shouldReturnUnauthorized_TokenIsMissing() { var response = restTemplate.getForEntity( "http://localhost:" + managementPort + "/actuator/axelix-env", String.class ); assertThat(response.getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } } management.server.port=0 makes Boot start the management context on a random port, and @LocalManagementPort gives us that port. The second test in the same class sends a valid token and expects 200 OK. The same tests exist for Boot 2, 3 and 4 (test in 1.2.1). A small off-topic note about the follow-up After the release, Andrei pointed out one more thing to us. The first patch left the filter registered both in the main context and in the child. Security didn't suffer from this, but with separate ports the filter on the main server was essentially redundant: Actuator isn't there anymore. In PR #1818, we removed the registration from the regular auto-configuration and changed ManagementContextType.CHILD to ANY. Now a single management configuration is responsible for the filter in both cases: when the port is shared and when it's separate. At the time of writing, this change is already in main but hasn't made it into a release yet. It's an architectural cleanup, not an additional security fix, so 1.2.1 closes the issue completely. How to look at the hierarchy in your own application If you've always assumed until now that you have just one context, the first useful step is very simple: stop guessing and take a look. @Component final class ContextTopologyLogger { @EventListener void onRefreshed(ContextRefreshedEvent event) { ApplicationContext context = event.getApplicationContext(); ApplicationContext parent = context.getParent(); System.out.printf( "context=%s class=%s parent=%s beans=%d%n", context.getId(), context.getClass().getName(), parent == null ? "<root>" : parent.getId(), context.getBeanDefinitionCount() ); } } Remember that events propagate from the child up to the parent? That's exactly why this listener, declared in the main context, will also see the management child's refresh. And that's exactly why it's important to take the context from the event itself here, rather than from an injected ApplicationContext. Run the application with management.server.port, and you'll see two lines in the logs instead of one. A few more useful tools: - context.getParent() returns the immediate parent; - containsLocalBean(name) tells you whether the bean is defined at the current level; - containsBean(name) also takes parents into account; - BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...) explicitly searches through all ancestors. The standard Actuator endpoint /actuator/beans also returns a contexts object, where a context can have a parentId (Actuator beans API). But there's a nuance: the endpoint describes the context it lives in and its ancestors. It isn't required to look for its descendants. So the result depends on which port you hit. And a piece of advice for those who write their own starters or simply hook something onto Actuator: if your code has to work with a separate management port, then test it specifically on a separate management port. A test that only hits @LocalServerPort doesn't check the management server at all. We learned this from our own experience. Summary Let's gather the key points: - An ApplicationContext in Spring doesn't have to be the only one. Contexts form a tree, and Spring Boot itself creates such a tree more often than it seems. - Visibility in the hierarchy works bottom-up: the child sees the parent, the parent doesn't see the child. - Seeing a bean doesn't make it part of another context's infrastructure. Post-processors, events, the filter chain and the web server are each context's own. - A management.server.port different from server.port means a separate child context and a separate web server. Treat it as a second web application. - If a library needs to put something into the management context, there's @ManagementContextConfiguration for that. For us, this story was a good reminder that even a very familiar framework can surprise you if you rely on a simplified mental model. And a simplified model works for years, right up until it stops working. Once again, thanks to Andrei for the find and for the careful review of the patch. And if you're curious about how Axelix works on the inside, welcome to GitHub, we're open. Good luck, everyone!

    1 reaction
  5. Habr · · @mipo256

    Иерархия контекстов в Spring. High CVE в Axelix Вступление Привет, Хабр! Меня зовут Михаил Поливаха, я Tech Lead проекта Axelix. Если Вы раньше про нас не слышали, то Axelix это платформа для управления и диагностики Spring Boot приложений. Ядро проекта доступно в Open Source, поэтому исходный код, история изменений и, как выяснилось, наши ошибки тоже открыты всем желающим 😁. Новости проекта, релизы и вот такие разборы мы публикуем в нашем Telegram-канале, заглядывайте. Недавно внешний security researcher Андрей Котов (greatkaa) нашёл в Axelix уязвимость. Если в двух словах: при определённой конфигурации Spring Boot наши Actuator endpoints можно было вызвать без JWT-токена. Мы её уже исправили в версии 1.2.1 и описали в Security Advisory. Андрею, пользуясь случаем, большое спасибо. Сразу скажу, что эта статья не про то, как этой уязвимостью можно было воспользоваться. Это, честно говоря, не так уж и интересно. Гораздо интереснее другое: почему она вообще появилась. Ведь мы не забыли поставить фильтр, не ошиблись в проверке подписи токена и не перепутали условие в if. Фильтр был, и он прекрасно работал. Просто не там, где мы думали. А причина кроется в механизме Spring Framework, который существует уже лет двадцать, но о котором многие современные Spring Boot разработчики вообще не задумываются. Речь про иерархию ApplicationContext. Поэтому план такой: сначала разберёмся, что такое иерархия контекстов и кто в ней кого видит, затем посмотрим, где её создаёт сам Spring Boot, и уже после этого вернёмся к Axelix и посмотрим, как всё это сложилось в одну неприятную историю. Давайте разбираться. С чего всё началось Начнём с симптома, потому что он, на мой взгляд, очень показательный. Конфигурация была совершенно обычной: server: port: 8080 management: server: port: 8081 sourcecraft.dev То есть бизнес-API живёт на 8080, а Actuator вынесен на отдельный порт 8081. Так делают очень многие, и это хорошая практика: management-порт удобно закрыть от внешнего мира на уровне сети. Axelix публикует свои endpoints через Actuator (они называются axelix-*), и все они закрыты JWT-фильтром. Когда Actuator живёт на основном порту, запрос без токена получает ожидаемый 401 Unauthorized. А вот на 8081 тот же самый запрос внезапно проходил. Друзья, обратите внимание на важную деталь: приложение при этом нормально стартовало. Никаких NoSuchBeanDefinitionException, никаких warning-ов в логах, все endpoints на месте, зависимости заинжектились. Всё выглядит прекрасно. Именно такие ошибки и являются самыми неприятными. Все как будто бы нормально работает. Почему простая смена номера порта так сильно меняет поведение? Чтобы ответить, придётся сделать шаг назад. Один большой контекст. Или нет? ApplicationContext это центральный контейнер Spring. Он хранит определения бинов, создаёт их, разрешает зависимости, прогоняет через post-processor-ы, публикует события и управляет lifecycle-ом. Если Вы пишете на Spring Boot, то, скорее всего, у Вас в голове живёт примерно такая модель: В моём приложении есть один большой Spring context, и все бины живут внутри него. И это нормально, потому что в подавляющем большинстве случаев так и есть. SpringApplication.run(...) создаёт ServletWebServerApplicationContext, тот сам поднимает embedded Tomcat, сам находит servlet-ы и filter-ы и сам их регистрирует (Javadoc ServletWebServerApplicationContext). Одного контекста хватает за глаза. Но вот в чём дело: контекст в Spring не обязан быть единственным. У ApplicationContext есть метод getParent(), а связь задаётся через ConfigurableApplicationContext#setParent. То есть контексты могут образовывать дерево. Немного истории. Root и DispatcherServlet Те, кто застал Spring MVC до Spring Boot, с иерархией контекстов наверняка сталкивались, даже если специально об этом не думали. Классическая схема выглядела так: root WebApplicationContext services repositories shared infrastructure | v DispatcherServlet WebApplicationContext controllers handler mappings web-specific beans sourcecraft.dev Root context создавал ContextLoaderListener из web.xml, а каждый DispatcherServlet поднимал свой собственный (!) дочерний WebApplicationContext. Сервисы и репозитории жили наверху и были общими, а контроллеры и вся MVC-инфраструктура конкретного servlet-а жили внизу. Причём у одного root могло быть несколько независимых детей (документация Spring MVC). Потом пришёл Spring Boot, всё схлопнулось в один контекст, и про иерархию большинство благополучно забыло. Но механизм никуда не делся. Второй контекст и сегодня может появиться: из явного вызова parent(...), child(...) или sibling(...) у SpringApplicationBuilder; из теста с @ContextHierarchy; из Spring Cloud, который поднимает свои дочерние контексты, например, для каждого Feign-клиента (сюрприз, дорогие друзья!); и, что для нас самое важное, из Spring Boot Actuator при отдельном management-порте. К последнему пункту мы ещё вернёмся. А сейчас давайте поймём главное: как контексты в иерархии видят друг друга. Кто кого видит Правила тут довольно простые, но несимметричные: child видит бины parent-а; parent не видит (!) бины child-а; sibling-контексты, то есть дети одного родителя, про друг-друга не знают; если child объявит бин с тем же именем, что и у parent-а, локальное (в child) определение перекроет родительское. Это прямо зафиксировано в Javadoc ApplicationContext. Давайте посмотрим на это в коде, так нагляднее: @Configuration class ParentConfig { @Bean SharedService sharedService() { return new SharedService(); } } @Configuration class ChildConfig { @Bean ChildOnlyService childOnlyService(SharedService sharedService) { return new ChildOnlyService(sharedService); } } sourcecraft.dev var parent = new AnnotationConfigApplicationContext(ParentConfig.class); var child = new AnnotationConfigApplicationContext(); child.setParent(parent); child.register(ChildConfig.class); child.refresh(); child.getBean(SharedService.class); // OK, найден в parent child.containsBean("sharedService"); // true child.containsLocalBean("sharedService"); // false, локально его нет parent.getBean(ChildOnlyService.class); // NoSuchBeanDefinitionException sourcecraft.dev Обратите внимание на разницу между containsBean и containsLocalBean. Первый учитывает parent-а, второй смотрит только на текущий уровень. Это различие нам ещё очень пригодится. То есть, грубо говоря, child знает, куда обратиться наверху, если чего-то не нашёл у себя. А вот parent о существовании конкретного child-а может вообще не подозревать. Слово «parent» создаёт приятное ощущение, будто старший знает обо всех младших. В Spring всё ровно наоборот. Видеть бин не значит им владеть А теперь самая главная мысль этой статьи. Я её выделю: То, что child может получить бин из parent-а, не означает, что child этот бин «использует» как часть своей инфраструктуры. Каждый контекст в иерархии остаётся полноценным отдельным контейнером. У него свои bean definitions, свои post-processor-ы, свой lifecycle, свой event multicaster. Иерархия даёт лишь одну вещь: lookup зависимостей наверх. Всё остальное каждый контекст делает сам и только со своими бинами. Вот несколько примеров того, как это проявляется: BeanPostProcessor, объявленный в parent-е, не обрабатывает бины, которые создаются в child-е; getBeansOfType() перечисляет только локальные бины. Чтобы пройтись по предкам, нужен BeanFactoryUtils.beansOfTypeIncludingAncestors(...); события, опубликованные в child-е, поднимаются к parent-у. А вот события parent-а вниз к детям сами по себе не идут; у parent-а нет универсального API, чтобы обойти всех своих детей. Про события есть ещё один забавный нюанс. Если @EventListener объявлен в parent-е, он получит и ContextRefreshedEvent от child-а. Так что если Вы что-то делаете «при старте приложения» по этому событию, то при наличии дочернего контекста Ваш код может отработать дважды. Поэтому полезно проверять, от какого именно контекста пришло событие. Ну и, наконец, пример, ради которого мы всё это обсуждаем. Embedded web server (тот же Tomcat) поднимается конкретным ServletWebServerApplicationContext, и при старте этот контекст регистрирует в сервере те Servlet-ы, Filter-ы и ServletContextInitializer-ы, которые определены в нём самом. Если Ваш FilterRegistrationBean лежит в parent-е, а сервер поднимает child, то фильтр в этот сервер не попадёт. Child его, конечно, «видит», но это никак не влияет на то, какую filter chain он соберёт для своего сервера. Запомните эту мысль, мы к ней сейчас вернёмся. Отдельный порт Actuator, это отдельный контекст Итак, что происходит, когда Вы указываете management.server.port? Spring Boot различает две ситуации. Если management.server.port не задан или совпадает с server.port, Actuator просто живёт в основном контексте. А вот если порты различаются, Boot создаёт отдельный дочерний management context, для которого основной контекст является parent-ом (ManagementContextAutoConfiguration). Схематично это выглядит так: application parent server.port=8080 | web server :8080 | controllers, services | v application:management child management.server.port=8081 web server :8081 Actuator handler mappings своя servlet filter chain sourcecraft.dev То есть management.server.port меняет далеко, далеко не только не номер сокета! Появляется второй ApplicationContext, второй embedded server, собственные handler mapping-и и собственная цепочка servlet-фильтров. Документация Spring Boot об этом, кстати, честно предупреждает: management context на другом порту не использует ту же web-инфраструктуру, что основное приложение (Spring Boot Actuator 2.7 reference). Но мы же ее читаем, а вайбкодим, как известно. То есть, по сути, с точки зрения web-слоя, у Вас теперь два разынх приложения. Как библиотеке попасть в management context Логичный вопрос: а если библиотеке (например, starter-у вроде нашего) нужно что-то положить именно в management context? Для этого в Spring Boot есть специальная аннотация @ManagementContextConfiguration. У неё есть параметр ManagementContextType: SAME означает, что конфигурация применяется, когда Actuator живёт в основном контексте; CHILD означает, что конфигурация применяется в отдельном дочернем management context; ANY означает, что конфигурацию нужно применить в обоих случаях. Одной аннотации при этом недостаточно. Класс нужно ещё и зарегистрировать в специальном файле: META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports sourcecraft.dev Это очень похоже на то, как регистрируются обычные auto-configuration-ы через AutoConfiguration.imports, только этот файл Boot читает при создании management context-а. (В Spring Boot 2.7 по старой памяти поддерживался ещё и spring.factories, так что если копируете пример из старой документации, проверьте версию. Selector в Boot 2.7, selector в Boot 3.5.) Что случилось в Axelix Ну и теперь, я думаю, Вы уже и сами догадываетесь, что произошло. Немного контекста про то, как устроен Axelix. В приложение подключается наш starter (axelix-spring-boot-2-starter, -3-starter или -4-starter, в зависимости от версии Boot). Starter публикует набор Actuator endpoints, через которые Axelix собирает информацию о приложении: бины, конфигурацию, логгеры, кэши и так далее. Все эти endpoints защищены JWT, и проверкой токена занимается наш JwtAuthorizationFilter. До версии 1.2.1 этот фильтр регистрировался в обычной auto-configuration, то есть через FilterRegistrationBean в основном контексте (код Axelix 1.2.0). Когда Actuator живёт на основном порту, всё прекрасно: один context web server :8080 Actuator mappings JwtAuthorizationFilter sourcecraft.dev Запрос идёт через фильтр, нет токена, получаем 401. А вот при разных портах картина такая: main context web server :8080 JwtAuthorizationFilter <- фильтр здесь | v management child web server :8081 Actuator mappings <- а endpoints здесь JwtAuthorizationFilter нет sourcecraft.dev Management child прекрасно видит все нужные ему бины из parent-а, поэтому приложение стартует без единой ошибки. Endpoints работают. Но сервер на 8081 собирает свою filter chain, и нашего фильтра в ней нет. Ровно то, о чём мы говорили в разделе «Видеть бин не значит им владеть». Root cause одним предложением: мы зарегистрировали JWT-фильтр в родительском контексте, а Actuator на отдельном порту обслуживался другим сервером из дочернего контекста. Самое смешное, что сама проверка JWT работала безупречно. До неё запрос просто не доходил. Был и ещё один симптом, который хорошо иллюстрирует происходящее. Часть endpoints на 8081 отвечала не 200, а 500 с сообщением Security Context is expected to be present at this point. Эти endpoints ожидали, что security context уже заполнен, а заполнял его как раз отсутствующий фильтр. Причём даже с валидным токеном было то же самое: обрабатывать токен было просто некому. Честно скажу, оглядываясь назад, ошибка выглядит очевидной. Но в момент написания кода у нас, как и у многих, в голове была та самая модель «один большой контекст». И тесты у нас ходили только на основной порт, так что ничего не ловили. Хорошо, что есть люди вроде Андрея, которые смотрят на код под другим углом. Как мы это починили Решение напрашивается само собой: фильтр нужно зарегистрировать там, где поднимается сервер с Actuator-ом. В 1.2.1 для всех трёх starter-ов появился отдельный JwtAuthManagementContextConfiguration (исправление): @ManagementContextConfiguration( value = ManagementContextType.CHILD, proxyBeanMethods = false ) @ConditionalOnBean(WebIdentityAccessManager.class) public class JwtAuthManagementContextConfiguration { @Bean public FilterRegistrationBean<JwtAuthorizationFilter> jwtAuthorizationFilterRegistration( WebIdentityAccessManager webIdentityAccessManager, SecurityContextExecutor securityContextExecutor, WebEndpointProperties webEndpointProperties) { var filter = new JwtAuthorizationFilter( webIdentityAccessManager, securityContextExecutor, webEndpointProperties.getBasePath() ); var registration = new FilterRegistrationBean<>(filter); registration.setName("jwtAuthorizationFilter"); return registration; } } sourcecraft.dev И, конечно, класс прописан в ManagementContextConfiguration.imports каждого starter-а. Посмотрите, как здесь работает всё, что мы обсудили выше. WebIdentityAccessManager и SecurityContextExecutor по-прежнему живут в parent-е, и child их спокойно получает через lookup. А вот FilterRegistrationBean теперь создаётся локально в management context, поэтому именно management server включает фильтр в свою цепочку. Иерархия используется ровно так, как она задумана: общие сервисы наверху, web-инфраструктура конкретного сервера внизу. Если сформулировать то, что мы восстановили, в виде правила, то получится так: сервер, который публикует Axelix endpoints, сам должен иметь JWT-фильтр в своей filter chain. Ну и, разумеется, тест. Важно, что он проверяет именно топологию, а не наличие бина «где-то в контейнере»: @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "management.server.port=0" ) class JwtAuthorizationFilterSeparateManagementPortTest { @LocalManagementPort private int managementPort; @Test void shouldReturnUnauthorized_TokenIsMissing() { var response = restTemplate.getForEntity( "localhost:" + managementPort - "/actuator/axelix-env", String.class ); assertThat(response.getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } } sourcecraft.dev management.server.port=0 заставляет Boot поднять management context на случайном порту, а @LocalManagementPort даёт нам этот порт. Второй тест в том же классе отправляет валидный токен и ожидает 200 OK. Такие же тесты есть для Boot 2, 3 и 4 (тест в 1.2.1). Небольшой Off-Top про follow-up После релиза Андрей обратил наше внимание ещё на одну вещь. Первый патч оставил регистрацию фильтра и в основном контексте, и в child-е. Безопасность от этого не страдала, но при раздельных портах фильтр на основном сервере был, по сути, лишним: Actuator-а там уже нет. В PR #1818 мы убрали регистрацию из обычной auto-configuration и поменяли ManagementContextType.CHILD на ANY. Теперь одна management-конфигурация отвечает за фильтр в обоих случаях: и когда порт общий, и когда отдельный. На момент написания статьи это изменение уже в main, но в релиз ещё не попало. Это архитектурная чистка, а не дополнительный security fix, так что 1.2.1 закрывает проблему полностью. Как посмотреть на иерархию в своём приложении Если Вы до сих пор всегда предполагали, что контекст у Вас один, то первый полезный шаг очень простой: перестать гадать и посмотреть. @Component final class ContextTopologyLogger { @EventListener void onRefreshed(ContextRefreshedEvent event) { ApplicationContext context = event.getApplicationContext(); ApplicationContext parent = context.getParent(); System.out.printf( "context=%s class=%s parent=%s beans=%d%n", context.getId(), context.getClass().getName(), parent == null ? "<root>" : parent.getId(), context.getBeanDefinitionCount() ); } } sourcecraft.dev Помните, что события поднимаются из child-а к parent-у? Как раз поэтому этот listener, объявленный в основном контексте, увидит refresh и management child-а тоже. И как раз поэтому контекст здесь важно брать из самого события, а не из заинжекченного ApplicationContext. Запустите приложение с management.server.port, и Вы увидите в логах две строчки вместо одной. Ещё несколько полезных инструментов: context.getParent() возвращает непосредственного parent-а; containsLocalBean(name) отвечает, определён ли бин на текущем уровне; containsBean(name) учитывает ещё и parent-ов; BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...) явно ищет по всем предкам. Стандартный Actuator endpoint /actuator/beans тоже возвращает объект contexts, где у контекста может быть parentId (Actuator beans API). Но тут есть нюанс: endpoint описывает тот контекст, в котором он живёт, и его предков. Своих потомков он искать не обязан. Так что результат зависит от того, на какой порт Вы пришли. И совет для тех, кто пишет свои starter-ы или просто вешает что-то на Actuator: если Ваш код должен работать при отдельном management-порте, то тестируйте его именно на отдельном management-порте. Тест, который ходит только на @LocalServerPort, management server вообще не проверяет. Мы в этом убедились на своём опыте. Итоги Давайте соберём главное: ApplicationContext в Spring не обязан быть один. Контексты образуют дерево, и Spring Boot сам создаёт такое дерево чаще, чем кажется. Видимость в иерархии работает снизу вверх: child видит parent-а, parent не видит child-а. Видимость бина не делает его частью инфраструктуры другого контекста. Post-processor-ы, события, filter chain и web server у каждого контекста свои. management.server.port, отличный от server.port, означает отдельный дочерний контекст и отдельный web server. Относитесь к нему как ко второму web-приложению. Если библиотеке нужно что-то положить в management context, для этого есть @ManagementContextConfiguration. Для нас эта история стала хорошим напоминанием, что даже очень знакомый фреймворк может удивить, если Вы опираетесь на упрощённую ментальную модель. Причём упрощённая модель работает годами, ровно до тех пор, пока не перестаёт. Ещё раз спасибо Андрею за находку и за внимательный review патча. А если Вам интересно, как устроен Axelix внутри, то добро пожаловать на GitHub, мы открыты. А чтобы не пропустить следующие релизы и разборы, подписывайтесь на наш Telegram-канал. Там же можно задать вопрос напрямую команде. Всем успехов!

  6. Habr · · @befayer

    Можно ли доверить ИИ миграцию Spring Boot 2.7 → 3.5? В проде у многих команд до сих пор крутится Spring Boot 2.7. Исправления, в том числе по безопасности, для этой версии закончились ещё в ноябре 2023 года, когда вышел 2.7.18, последний открытый релиз всей ветки 2.x. Но миграцию многие команды всё откладывают и откладывают. И нет, причина вовсе не в лени. Просто все понимают, что переход на третью версию затрагивает не только смену номера в build.gradle. Причём ошибки компиляции тут пугают меньше всего. Замену javax.* на jakarta.* или пропавший WebSecurityConfigurerAdapter видно сразу, потому что проект тупо не собирается. Гораздо неприятнее то, что ломается втихую. Код собрался, сервис поднялся, health-check зелёный, а клиенты вдруг начинают получать 404, 401 и 500 там, где всё работало. Казалось бы, это идеальная задача для ИИ-агента. Работа большая, нудная и хорошо описана в документации. Но на практике мало кто готов отдать агенту миграцию сервиса, который крутится в проде. И я эти опасения понимаю. Агент вполне может добиться зелёной сборки, поправить пару тестов под новое поведение и бодро отчитаться, что всё готово. А изменения, о которых я написал выше, как раз из тех, что легко пропустить и человеку, и модели. Поэтому я решил проверить это на деле. Я взял небольшой, но вполне жизненный сервис на Spring Boot 2.7 и попросил Kodacode перевести его на 3.5. Принимать работу на глаз не хотелось, поэтому критерием приёмки стал набор контрактных тестов, который агенту трогать запрещено. Если агенту можно доверять, он пройдёт эти тесты, не меняя в них ни строчки. Вот о чём пойдёт речь. как устроен подопытный сервис и почему главный риск в его клиентах; как сделать тесты, которые не мигрируют вместе с кодом; что меняется между 2.7 и 3.x и чего из этого не видит компилятор; как агент провёл миграцию и как сам доказал, что каждое его исправление нужно; где агенту можно доверять, а где без человека пока не обойтись; чеклист для вашего сервиса. Подопытный сервис Billing Service выставляет и принимает оплату счетов. Он умеет выставлять счёт, показывать список счетов и отдельный счёт, отменять оплату и отдавать плательщику публичную страницу по ссылке из письма. Метод Путь Доступ Назначение POST /api/invoices Basic Выставить счёт GET /api/invoices Basic Список счетов GET /api/invoices/{id} Basic Счёт по id POST /api/invoices/{id}/payments Basic Отметить оплату GET /api/public/pay/{token} публичный Страница оплаты для плательщика GET /actuator/health публичный Health check Стек такой, какой обычно бывает у сервиса, написанного несколько лет назад. Java 11, Spring Boot 2.7.18, Spring Data JPA с Hibernate 5.6, Flyway и PostgreSQL. Секурити через WebSecurityConfigurerAdapter и HTTP Basic, трассировка через Spring Cloud Sleuth. build.gradle.kts: Spring Boot 2.7.18 и Java 11 Зависимости сервиса: JPA, Security, Sleuth, Flyway Но код тут не самое интересное. Интереснее клиенты, потому что сломать миграция может именно их. мобильное приложение 3.x запрашивает списки со слэшем на конце, то есть /api/invoices/; API gateway передаёт контекст трассировки в заголовках B3 и подписанный контекст клиента в X-Client-Context размером до 16 КБ; плательщики открывают /api/public/pay/{token} из письма без авторизации, и если ссылка просрочена, они должны увидеть 404, а не форму входа. В типичных чеклистах миграции про такое не пишут, а в живых сервисах это встречается сплошь и рядом. Код демо лежит в befayer/spring-boot-27-migration-demo. Контрактные тесты вместо доверия на слово Перед миграцией первым делом хочется опереться на тесты, которые уже есть. Проблема в том, что unit- и интеграционные тесты мигрируют вместе с кодом. В них тоже придётся менять javax на jakarta, чинить @SpringBootTest, MockMvc и настройки Security. И если после этого они зелёные, это значит только то, что тесты и код договорились между собой. А вот того, что для клиентов ничего не поменялось, они не доказывают. Поэтому критерий приёмки я выношу наружу. Контрактные тесты здесь работают как внешний HTTP-клиент, который про Spring ничего не знает. запросы идут через java.net.http.HttpClient из JDK; состояние базы перед тестом сбрасывается обычным JDBC; тесты лежат в отдельном source set src/contractTest и стучатся в уже поднятый сервис по TARGET_BASE_URL. Контрактные тесты лежат рядом с main и test, но в отдельном source set Каждый тест описывает то, на что рассчитывает конкретный клиент. Вот, например, чего ждёт плательщик с просроченной ссылкой. @Test @DisplayName("Устаревшая ссылка на оплату — 404, а не просьба залогиниться") void expiredPaymentLinkIsNotFound() throws Exception { HttpResponse<String> response = api.send(api.request("/api/public/pay/expired-token").GET()); assertEquals(404, response.statusCode(), response.body()); assertEquals(404, json(response).path("status").asInt()); assertEquals("/api/public/pay/expired-token", json(response).path("path").asText()); } sourcecraft.dev Всего тестов 12. Они покрывают основной сценарий со счетами, доступ и публичная ссылка, совместимость с gateway. Скрипт scripts/contract-check.sh одной командой поднимает PostgreSQL и сервис из текущей ветки, гоняет тесты и гасит сервис. Так что один и тот же набор можно запустить и на 2.7, и на 3.5. Успех. Осталось запретить агенту подгонять тесты под новую реализацию. Это правило я записал не в промпт, а прямо в репозиторий, в AGENTS.md. Агент читает этот файл, когда начинает работу. AGENTS.md: тесты в src/contractTest менять нельзя, при конфликте — остановиться и сообщить Верить агенту на слово при этом не обязательно. Достаточно убедиться, что git diff boot-2.7 -- src/contractTest пустой. А ловят ли эти тесты вообще хоть что-нибудь? Прежде чем отдавать сервис агенту, я проверил это сам и сделал «наивную» миграцию. Поправил ровно то, на что ругался компилятор, и больше ничего. Проект собрался, сервис стартовал, health-check ответил UP, а контрактные тесты упали в 7 случаях из 12. Было 500 при выставлении счёта, 404 на списке, 401 на публичной ссылке и 400 на запросе от gateway. Наивную версию я потом выкинул, и агент получил исходный код на 2.7. Что меняется между 2.7 и 3.x Spring Boot 3.0 стал самым крупным релизом со времён второй версии. Ниже основные изменения, которые заденут типичный сервис. Самое важное здесь в последней колонке. Область Что меняется Видно при сборке? Java минимальная версия — 17 да Jakarta EE javax.* → jakarta.* да Spring Security 6 удалён WebSecurityConfigurerAdapter, antMatchers → requestMatchers, по умолчанию защищены все типы dispatch, включая ERROR частично Spring MVC 6 слэш на конце пути больше не совпадает с маршрутом без него нет Hibernate 6 другие имена sequence по умолчанию, изменения в типах и HQL нет Конфигурация часть свойств переименована или удалена; старые молча игнорируются нет Наблюдаемость Spring Cloud Sleuth → Micrometer Tracing да Flyway 10+ поддержка PostgreSQL вынесена в отдельный модуль при старте Все подробности есть в официальном Spring Boot 3.0 Migration Guide. Почему 3.5, а не сразу 4.x? Потому что переход 2.7 → 3.x самый тяжёлый. Jakarta, Security 6 и Hibernate 6 прилетают как раз здесь. Ветка 3.x заканчивается на 3.5, так что это логичная промежуточная остановка. Честно скажу, что и у 3.5 бесплатная поддержка уже закончилась в июне 2026 года, поэтому задерживаться на ней надолго не стоит. Про переход 3.5 → 4 расскажу в отдельной статье. Отдаю миграцию агенту Работал я в десктопном приложении Kodacode на модели Koda Pro с максимальным уровнем рассуждений. Проект открыл на ветке migration/boot-3.5, которую создал от тега boot-2.7, в режиме «Без подтверждений», где агент сам читает файлы, запускает команды и правит код. Проект открыт в Kodacode, выбрана модель koda-pro Но сначала, прежде чем давать задачу, я задал агенту один вопрос и попросил пока ничего не трогать. Что это за сервис и что в нём может сломаться при переходе на Spring Boot 3.5? Пока ничего не меняй. Мне хотелось понять, что агент разглядит в коде заранее, а потом сравнить его прогноз с тем, что получится на самом деле. Думал он почти восемь минут. За это время прочитал исходники, один раз запустил команду и дважды сходил в интернет за документацией. Ответ агента: что это за сервис и какие изменения видны при компиляции Риски он поделил на две кучки. В первую попали очевидные, на которых падает компиляция, вроде javax → jakarta, пропавшего WebSecurityConfigurerAdapter и Sleuth. Во вторую то, что он назвал «неочевидные рантайм-ловушки — именно они валят контракт», причём для каждой указал конкретный тест, который упадёт. Таблица рантайм-ловушек: причина и тест, который упадёт И попал в точку, назвав все четыре ловушки, которые я заложил в сервис. Hibernate 6 ищет invoices_seq вместо hibernate_sequence → любой POST /api/invoicesдаст 500; слэш на конце пути → /api/invoices/ даст 404; Security 6 проверяет доступ и к /error → просроченная ссылка на оплату даст 401 вместо 404; server.max-http-header-size молча игнорируется → заголовок 16 КБ даст 400. Без промаха тоже не обошлось. Агент решил, что сломается B3-трассировка. Но об этом позже. Варианты фикса для sequence и что пострадать не должно Но угадать ещё не значит смигрировать. Знать про ловушку и аккуратно обойти её в живом коде совсем не одно и то же. Так что дальше смотрю, дотянет ли агент до зелёных тестов. Промпт получился коротким. Все требования к миграции лежат в репозитории, а не в нём. Правила работы описаны в AGENTS.md, объём и критерии приёмки в docs/MIGRATION.md. Промпт просто собирает их в задачу. Переведи этот сервис со Spring Boot 2.7 на Spring Boot 3.5. Сначала прочитай AGENTS.md и docs/MIGRATION.md, затем изучи код и контрактные тесты в src/contractTest. Порядок работы: 1. Запусти scripts/contract-check.sh на текущей версии и зафиксируй исходный результат. 2. Составь план миграции и перечисли риски. 3. Выполни миграцию. 4. Запусти ./gw build и scripts/contract-check.sh. 5. Если контрактные тесты падают, найди причину и исправь код приложения. Тесты и их ожидания не меняй. 6. Повторяй, пока все критерии приёмки из docs/MIGRATION.md не выполнены. В конце дай отчёт в формате из docs/MIGRATION.md. sourcecraft.dev Основной промпт отправлен в тот же чат Исходный прогон и план Первым делом агент сделал то, что просили в первом пункте, и прогнал контрактные тесты на нетронутой 2.7. И тут же упёрся в реальность. Docker в окружении агента не нашёлся, потому что на машине стоит Rancher Desktop, и его docker не попал в PATH. Останавливаться и звать на помощь он не стал, а вместо этого нашёл PostgreSQL 16, установленный через Homebrew, завёл в нём роль и базу billing (и предупредил, что это меняет только локальный PostgreSQL), а ещё подсунул временную заглушку docker за пределами репозитория, чтобы скрипт проверки отработал как есть. Docker недоступен: агент поднимает локальный PostgreSQL и фиксирует исходный результат На 2.7 получилось 12 из 12. Дальше он набросал план из шести шагов и показал его чек-листом. План миграции: шесть шагов, четвёртый — рантайм-совместимость Здесь интересен четвёртый шаг «Исправить рантайм-совместимость: trailing slash, B3, размер заголовков, ERROR-dispatch, sequence Hibernate 6». То есть прогноз из первого вопроса никуда не делся. Агент сделал из него отдельный пункт плана ещё до того, как увидел хоть один красный тест. Сборка и код Минут через десять после старта агент добрался до build.gradle.kts. Было так. До: Spring Boot 2.7.18, dependency-management 1.0.15, Java 11 А стало так. После: Spring Boot 3.5.16, dependency-management 1.1.7, Java 21 Из зависимостей он выкинул BOM Spring Cloud 2021.0.9, потому что кроме Sleuth ничего из Spring Cloud в проекте не было. Место Sleuth занял Micrometer Tracing с провайдером Brave, именно туда переехало ядро Sleuth, а ещё появился модуль flyway-database-postgresql, без которого Flyway 10+ PostgreSQL просто не узнаёт. Это прямо написано в документации Spring Boot 3.5. Всё неочевидное агент подписал комментариями прямо в коде. Зависимости после миграции: Micrometer Tracing вместо Sleuth и отдельный модуль Flyway Потом пошла рутина. javax.* → jakarta.* в пяти файлах, SecurityFilterChain вместо WebSecurityConfigurerAdapter, новый Tracer в фильтре, который отдаёт trace id. Заодно он сразу поставил четыре защиты от рантайм-ловушек. К ним я вернусь чуть ниже, когда дойду до проверки. Первый прогон на 3.5 зелёный, и это подозрительно Через 14 минут после старта Kodacode собрал проект и запустил контрактные тесты на Spring Boot 3.5.16. Первый прогон на 3.5: все 12 контрактных тестов зелёные 12 из 12 с первого раза. Вроде бы всё, пиши отчёт. Только зелёный прогон сам по себе мало о чём говорит. Может, все исправления и правда нужны. А может, половина из них лишняя, а тесты проверяют совсем не то, что я думаю. Агент, похоже, подумал о том же и решил проверить. Всё зелёное с первого раза. Чтобы отчёт содержал достоверные причины падений, проверю каждую защиту по отдельности — временно откатывая её и прогоняя контракт. Я его об этом не просил. В промпте было «если тесты падают, найди причину». Тесты не упали, и агент сам себе устроил контрольный эксперимент. Агент проверяет каждую защиту Дальше он по очереди снимал по одной защите, прогонял контракт, записывал, какой тест упал и как, и ставил защиту на место. В отчёте из этого получилась таблица. Таблица абляций из отчёта агента: какую защиту убрали, какой тест упал и почему Пройдусь по каждой строке и расскажу, что сломалось, почему компилятор этого не заметил и как агент это починил. Hibernate 6 и последовательность ID Если убрать явный @SequenceGenerator и оставить голый @GeneratedValue, падают все четыре теста, которые выставляют счёт. Они получают 500, а в логе relation "invoices_seq" does not exist. Компилятор тут бессилен, ведь аннотация @GeneratedValue осталась ровно той же. Поменялось поведение самого Hibernate. Для стратегии AUTO Hibernate 5 брал общую последовательность hibernate_sequence, а Hibernate 6, как сказано в его руководстве по миграции, создаёт отдельную последовательность <имя сущности>_seq на каждую иерархию сущностей, причём с шагом 50, как у @SequenceGenerator по умолчанию. Схему здесь создаёт Flyway, и там есть только hibernate_sequence. При ddl-auto: none сервис спокойно стартует и падает на первой же записи. Тот же гайд предлагает для обратной совместимости глобальную настройку hibernate.id.db_structure_naming_strategy=legacy. Агент выбрал более точечный путь. Он не стал городить новую миграцию, а просто явно указал существующую последовательность и шаг 1. Явный @SequenceGenerator на существующую hibernate_sequence Потом он отдельно проверил это решение по критерию приёмки «существующая база продолжает работать». Вставил в базу строку «как из 2.7» с id=900 и убедился, что счёт читается как INV-000900, а новый получает id=901. Значит, последовательность продолжается, а не начинается с нуля. Слэш на конце пути Если убрать явные пути со слэшем, GET /api/invoices/ от мобильного клиента отвечает 405 Method Not Allowed. Дело в том, что в Spring MVC 6 сопоставление со слэшем на конце отключили по умолчанию, а саму настройку объявили устаревшей. В официальном гайде описан результат 404, и при моей наивной миграции этот же запрос действительно давал 404, а при откате у агента 405. Всё потому, что POST у него уже висел и на "/". Spring находит маршрут, но не для этого метода. Причина одна, а коды ответа разные. Поэтому в контрактном тесте стоит проверять конкретный статус, а не просто «не 200». Агент прописал пути со слэшем явно, причём на всех эндпоинтах контроллера, а не только на списке, на который жаловался тест. Так поведение 2.7 сохраняется полностью. Явные пути "" и "/" на всех эндпоинтах контроллера Security 6 и страница ошибки Если убрать разрешение для ERROR-dispatch, плательщик с просроченной ссылкой получает 401 вместо 404. Самое неочевидное место. permitAll для /api/public/** никуда не делся. Но контроллер бросает ResponseStatusException, контейнер внутри перенаправляет запрос на /error, а Security 6 авторизует каждый тип dispatch, включая ERROR. Об этом же говорит и гайд по миграции Boot. Анонимного пользователя до /error не пускают, и он получает 401. В 2.7 внутренний переход повторно не проверялся. Сам приём с dispatcherTypeMatchers(DispatcherType.ERROR).permitAll() есть в документации Spring Security. Починилось одной строкой dispatcherTypeMatchers(DispatcherType.ERROR).permitAll(), и агент оставил рядом комментарий, зачем она нужна. SecurityFilterChain: ERROR-dispatch разрешён, иначе 404 превращается в 401 Размер заголовка Если убрать новое свойство с размером заголовка, запрос от gateway с 16 КБ в X-Client-Context получает 400 ещё от Tomcat, до приложения он даже не доходит. В Boot 3.0 свойство server.max-http-header-size объявили устаревшим в пользу server.max-http-request-header-size, а к 3.5 оно перестало поддерживаться совсем. Старое имя в YAML никто не проверяет. Spring молча пропускает неизвестное свойство, и Tomcat возвращается к стандартным 8 КБ. Фикс простой, это новое имя свойства. В том же файле видна и настройка трассировки, о ней чуть позже. application.yml: новое имя свойства и явная B3-пропагация Flyway и PostgreSQL Пятая защита не тихая, но агент внёс в таблицу и её. Без модуля flyway-database-postgresqlсервис просто не стартует и падает с FlywayException: Unsupported Database: PostgreSQL 16.6. Такое замечаешь сразу, но понять по одной этой строке, в чём дело, получается не всегда. Итог Закончил агент отчётом в том формате, который просил docs/MIGRATION.md. В нём есть что изменилось, какие тесты падали и почему, результаты и что проверить не удалось. Результаты из отчёта агента На слово я отчёту не поверил и прогнал ключевые проверки сам. $ ./gw clean build BUILD SUCCESSFUL $ ./gw contractTest 12 tests completed, 12 passed BUILD SUCCESSFUL $ git diff --stat boot-2.7 -- src/contractTest (пусто) $ git diff --stat boot-2.7 8 files changed, 68 insertions(+), 61 deletions(-) sourcecraft.dev В логе старта нет ни одной строки уровня WARN или ERROR. Параметр Значение Модель Koda Pro, максимальный уровень рассуждений Анализ рисков (первый вопрос) 8 минут Миграция от промпта до отчёта 24 минуты До первого зелёного прогона на 3.5 14 минут Изменено файлов 8, +68 / −61 строк Контрактные тесты 12 из 12, ожидания не менялись Вмешательств человека во время миграции 0 Что пошло не так Агент трогает не только код, но и окружение. Когда Docker не нашёлся, он завёл роль и базу в локальном PostgreSQL и положил временную заглушку docker в /tmp. Он об этом предупредил, и всё это за пределами репозитория. Но учтите, что в режиме без подтверждений агент решает такие проблемы сам, и после сессии ваш локальный PostgreSQL будет уже не таким, как до неё. С настоящим Docker скрипт contract-check.sh он так и не проверил, о чём честно написал в отчёте. Промах с B3. Отвечая на первый вопрос, Kodacode уверенно заявил, что Micrometer Tracing по умолчанию понимает только W3C и B3-заголовки от gateway потеряются. Это не так. В Boot 3.5 свойство management.tracing.propagation.consume по умолчанию равно [W3C, B3, B3_MULTI], то есть входящие B3-заголовки принимаются и без настройки. Хорошо, что агент сам в этом разобрался, когда откатывал защиты. Тест прошёл и без настройки, и в итоговом отчёте он прямо написал, что B3-пропагация не подтверждена падением. Хуже то, что одно промежуточное сообщение («все пять защит подтверждены реальными падениями») было неточным, и правильная картина появилась только в отчёте. Так что читайте отчёт, а не только сообщения по ходу работы. Саму настройку propagation.type: B3_MULTI агент оставил «как декларацию требования gateway». Совсем бесполезной её не назовёшь, так сервис и дальше отдаёт B3-заголовки, как это делал Sleuth в 2.7. Но если в системе появятся клиенты с W3C traceparent, настройку придётся пересмотреть. Что осталось на человеке. Агент ничего не закоммитил, изменения ждут ревью. А ещё он отдельно предупредил, что продовые конфиги вне репозитория не проверялись. Если spring.sleuth.* или старое server.max-http-header-size заданы в переменных окружения или ConfigMap, их надо поправить при выкатке. Иначе история с заголовком повторится, только уже в проде. Чеклист для вашего сервиса Сначала контракт, потом миграция. Зафиксируйте поведение API внешними тестами, которые не зависят от Spring и не мигрируют вместе с кодом. Пропишите правила для агента в репозитории. Запрет трогать тесты должен лежать рядом с кодом, а не теряться в промпте. А соблюдается ли он, легко проверить через git diff. Не верьте первому зелёному прогону. Откатывайте исправления по одному и смотрите, какой тест упадёт. Если ни один, значит либо исправление лишнее, либо контракт его не покрывает. На время миграции подключите spring-boot-properties-migrator. Он подсветит переименованные и удалённые свойства, которые иначе молча игнорируются. И не забудьте про конфиги вне репозитория. Отдельно проверьте тихие изменения. Это пути со слэшем на конце, ответы публичных эндпоинтов при ошибках, генерацию идентификаторов в JPA. Прогоните миграцию на копии продовой схемы. Особенно если у вас sequence и ddl-auto: none. Вместо заключения Я думал, что увижу, как агент спотыкается о тихие изменения Spring Boot 3 и выбирается по красным тестам. А получилось по-другому. Kodacode нашёл все ловушки ещё до миграции, закрыл их до первого прогона, а потом сам ломал каждое исправление, чтобы доказать, что оно нужно. И за всё это время в контракте не поменялось ни строчки. Так можно ли доверить ИИ миграцию? Отдать и не смотреть точно нельзя. Агент меняет окружение, иногда уверенно ошибается и понятия не имеет, что лежит в ваших продовых конфигах. Но доверять ему на слово и не нужно. Когда есть внешний контракт, правила в репозитории и агент, который сам себя проверяет, доверие строится на проверках, а страшная миграция становится обычной задачей с понятным критерием готовности. Демо-репозиторий с исходным сервисом, контрактными тестами и веткой после миграции лежит на befayer/spring-boot-27-migration-demo. Что почитать Spring Boot 3.0 Migration Guide, главный документ по переходу. Большинство ловушек из статьи описаны в нём. Spring Security, раздел про авторизацию HTTP-запросов, где объясняется, как работают типы dispatch. Hibernate ORM 6.0 Migration Guide, где описаны изменения в генерации идентификаторов. Spring Cloud Sleuth 3.1 Migration Guide, про переход со Sleuth на Micrometer Tracing. Spring Boot 3.5, свойства приложения, где видны актуальные имена и значения по умолчанию. В следующей части переведу этот же сервис с 3.5 на Spring Boot 4. Попробовать Kodacode можно тут: скачать для IDE, CLI и Desktop. Анонсы обновлений, практические разборы и новости продукта в Telegram‑канале.

  7. DEV · · @krishna_y

    Spring Boot 4.1's SSRF Mitigation Is Opt-In: How to Actually Close the Hole !krishnakky.com Open the webhook sender in almost any payments service and you will find a method that reads a URL out of a database row and calls it. A merchant registered the address during onboarding. A partner bank supplied a callback host. A KYC vendor returned a link to a scanned document. The call is a single line, restClient.get().uri(url).retrieve(), and it is also the line where an attacker can make your service fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/ for them. Spring Boot 4.1 ships a first-party defence for this. I went through the 4.1.0 RC1 release notes and then looked at how the feature behaves in a service with hand-built HTTP clients. Two things are easy to miss. The filter does nothing until a bean exists for it, and it only covers the clients Boot itself builds. Below I cover both gaps, show a resolver-level guard for clients Boot cannot see, and write the tests that show the hole is shut. I also treat the code filter as the last of several layers, since egress control belongs underneath it. Then I turn to the other 4.1 change that matters for microservice teams: gRPC server and client auto-configuration now lives inside Boot, which makes mutual TLS between internal services a configuration exercise. Why payment backends get hit A service in a private network can reach what the public internet cannot: the cloud instance metadata endpoint, the Kubernetes API server, an admin port bound to the pod network, a Redis instance left without a password because only cluster traffic reaches it. If an attacker controls where one outbound request goes, the service's network position becomes theirs. Payment systems have more of these attacker-influenced destinations than most. In the services I have worked on, they come from four places: - Webhook targets that merchants type into a dashboard. - Callback and token endpoints belonging to partner banks and processors, stored in configuration or onboarding records. - Document links (statements, logos, KYC scans) submitted through forms. - Location headers on responses from third parties, if the client follows them. On a node where the metadata service still accepts unauthenticated GETs, the first request to 169.254.169.254 can return temporary cloud credentials, and everything after that depends on how broad the instance role is. My time on the OpenBanking PSD2 side of fintech shaped how I read partner-supplied hosts. The whole model there is calling other organisations' endpoints, and a hostname being on the partner's onboarding sheet says who signed a contract. It says nothing about where the name resolves today. Partner infrastructure gets compromised, lapsed domains get registered by someone new, and a name that pointed at a public address last month can be repointed at 10.0.4.17 by whoever controls its DNS zone. What 4.1 gives you The new type is InetAddressFilter. It is evaluated after DNS resolution, against the InetAddress the client is about to connect to. That placement is the reason it works better than URL inspection. A string check for 169.254 in the host loses to a decimal-encoded IP, an IPv4-mapped IPv6 literal, a trailing dot, or simply a domain the attacker owns whose A record is the metadata address. Looking at the address that will actually receive the SYN makes the spelling of the URL irrelevant. InetAddressFilter.externalAddresses() is the built-in factory. As I read the release notes, it refuses loopback, private-range, multicast and other special-purpose addresses, including the link-local block that holds the metadata endpoint. Check the exact set against the Javadoc for your patch version rather than relying on my summary. Once a filter bean exists, Boot attaches it to the clients it auto-configures: RestClient, RestTemplate and WebClient. The opt-in is one bean: @Configuration(proxyBeanMethods = false) class OutboundSecurityConfig { // No bean, no filtering. Boot 4.1 does not install this on its own. @Bean InetAddressFilter outboundAddressFilter() { return InetAddressFilter.externalAddresses(); } } Most write-ups on the feature stop at this snippet. The next two sections are about what it leaves open. Gap one: no bean, no protection Take a project on 4.1 with no InetAddressFilter bean and point a test at a WireMock instance on 127.0.0.1. My prediction, which follows directly from the opt-in design and which you can confirm in five minutes, is that the call succeeds. Nothing is installed, so nothing blocks. Here is the version of this that worries me. A platform team sees "Spring Boot 4.1 mitigates SSRF" in the release notes, raises the parent POM version across its services, and closes the ticket. I am describing a scenario, not a specific company. The upgrade changed no behaviour for SSRF. Every service is as exposed as before, and the tracker says otherwise. What I do about it is put the bean in a shared internal starter and add a startup check beside it. If a service has a RestClient.Builder bean but no InetAddressFilter, it refuses to boot outside local and test profiles: @Component @Profile("!local & !test") class OutboundFilterGuard implements SmartInitializingSingleton { private final ObjectProvider<InetAddressFilter> filter; OutboundFilterGuard(ObjectProvider<InetAddressFilter> filter) { this.filter = filter; } @Override public void afterSingletonsInstantiated() { if (filter.getIfAvailable() == null) { throw new IllegalStateException( "No InetAddressFilter bean declared, so outbound SSRF protection is OFF. " + "Import OutboundSecurityConfig, or record an explicit exception for this service."); } } } The !local & !test profile expression is a trade-off. Local runs and integration tests talk to WireMock, Testcontainers and embedded servers, all of which live on loopback, and an enforcing filter would break them. The cost is that the guard never runs in those profiles, so the tests described later in this post, which exercise the real resolver explicitly, are what keep the protection covered in CI. If you want the filter active in test profiles too, you will need an allow exception for loopback that exists only there, and that exception must never ship in a production profile. Gap two: clients you build yourself Boot's filter reaches clients through its own auto-configuration. Define your own HttpClient, CloseableHttpClient or OkHttpClient bean and wrap it in a request factory, and Boot's customisation never touches it. As far as I can tell nothing logs a warning either, so you find out by testing, not by reading startup output. If you have verified different behaviour on a specific 4.1.x build, put your finding next to mine: I haven't tested every client type. Real payment code ends up here constantly, because partner integrations ask for things the defaults can't do: - Mutual TLS with a keystore dedicated to one partner. - A pool sized to a processor's cap on concurrent connections. - Timeouts that differ per acquirer, so a slow one cannot starve the others. - An OkHttp interceptor that produces the detached JWS signature a bank requires. Each requirement leads to a hand-built client, and a hand-built client is outside Boot's reach. The remedy is to attach the address check to the client yourself. For Apache HttpClient 5 the narrowest point is the DnsResolver, because every connection attempt passes through it. This version uses only JDK address classification, so it does not depend on Boot internals, and it takes its delegate as a constructor argument so tests can feed it canned answers: public final class PublicOnlyDnsResolver implements DnsResolver { private final DnsResolver delegate; public PublicOnlyDnsResolver() { this(SystemDefaultDnsResolver.INSTANCE); } PublicOnlyDnsResolver(DnsResolver delegate) { this.delegate = delegate; } @Override public InetAddress[] resolve(String host) throws UnknownHostException { InetAddress[] resolved = delegate.resolve(host); for (InetAddress address : resolved) { if (isForbidden(address)) { // Host and address only. The full URL can carry tokens. throw new BlockedDestinationException( "Blocked outbound connection: " + host + " resolved to " + address.getHostAddress()); } } return resolved; } @Override public String resolveCanonicalHostname(String host) throws UnknownHostException { return delegate.resolveCanonicalHostname(host); } static boolean isForbidden(InetAddress address) { if (address.isLoopbackAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress() || address.isMulticastAddress()) { return true; } byte[] raw = address.getAddress(); if (raw.length == 4) { int first = raw[0] & 0xFF; int second = raw[1] & 0xFF; // 100.64.0.0/10 (carrier-grade NAT) and 198.18.0.0/15 (benchmarking) // are not covered by the JDK helpers above. return (first == 100 && (second & 0xC0) == 0x40) || (first == 198 && (second & 0xFE) == 18); } // fc00::/7, IPv6 unique-local return raw.length == 16 && (raw[0] & 0xFE) == 0xFC; } /** UnknownHostException subtype so retry policies can tell policy from network failure. */ public static final class BlockedDestinationException extends UnknownHostException { public BlockedDestinationException(String message) { super(message); } } } On IPv6: InetAddress hands back an Inet4Address for IPv4-mapped literals such as ::ffff:10.0.0.1, so the checks above see the embedded IPv4 value. Other transition formats (NAT64 prefixes, 6to4) are not normalised that way, which is one more reason to run this behind an egress policy that does not care how an address is spelled. Wire it into a pooled connection manager and give the client to a request factory: @Bean RestClient acquirerRestClient(PartnerTlsProperties tls) { var connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .setDnsResolver(new PublicOnlyDnsResolver()) .setSSLSocketFactory(tls.mutualTlsSocketFactory()) .setMaxConnPerRoute(20) .build(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .disableRedirectHandling() // a redirect is a new destination chosen by someone else .build(); return RestClient.builder() .baseUrl(tls.acquirerBaseUrl()) .requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)) .build(); } Redirects I disable automatic redirect handling on any client whose target a tenant or partner can influence. A host you are entitled to call can reply 302 Location: http://169.254.169.254/.... With the resolver installed, the redirected hop goes through the same pooled manager and gets checked, so the guard would likely catch it. Even so, I would rather a human-written branch decide whether a redirect is acceptable than let the client follow one silently. Payment APIs seldom need redirects (a 3-D Secure challenge URL is sent to the browser, not followed by your server). In the few cases that do, write explicit code that validates the new target against the same rules and caps the hop count. The resolver and DNS rebinding Checking at resolve time, inside the same call that returns the addresses the connection manager then uses, avoids the classic time-of-check/time-of-use trap where a validator resolves a name once and the client resolves it again and gets a different answer. That holds only because the check and the connect use the same resolver result. If you add a separate pre-flight "is this URL safe" step that does its own lookup, you have reintroduced the race. For OkHttp the equivalent hook is OkHttpClient.Builder.dns(Dns), and isForbidden can be reused unchanged. I keep that method in one shared library class so a fix to a range lands in every client together. Allowlists, and the first support ticket An address filter is a denylist by category. Where destinations form a closed set, use an allowlist instead: the hosts of your partner banks are known, so store them, compare against them, and treat everything else as an onboarding exception needing review. Hold the address filter for the open-ended cases such as merchant webhooks. Expect friction once merchants hit it. The first ticket is predictable: a merchant has a legitimate endpoint on a private address, perhaps a VPN-fronted ERP or a staging box on an internal range, and every delivery to it is now blocked. Decide the policy in advance. Mine is that there is no per-tenant bypass in the shared library. A merchant that needs private connectivity gets a dedicated delivery path, with its own network route and its own review, and not a flag in the filter. Proving it with tests Security code I haven't watched reject something is code I do not trust. Three tests cover the guard. The first two need no network; they check classification and resolution using canned answers: class PublicOnlyDnsResolverTest { private static DnsResolver resolvingTo(String... literals) { return new DnsResolver() { @Override public InetAddress[] resolve(String host) throws UnknownHostException { InetAddress[] out = new InetAddress[literals.length]; for (int i = 0; i < literals.length; i++) { out[i] = InetAddress.getByName(literals[i]); } return out; } @Override public String resolveCanonicalHostname(String host) { return host; } }; } @ParameterizedTest @ValueSource(strings = { "169.254.169.254", // cloud metadata "127.0.0.1", "10.0.4.17", // a "public-looking" name pointing at a private range "172.16.8.9", "192.168.1.20", "100.64.3.4", // carrier-grade NAT "::1", "fd00::1", // IPv6 unique-local "::ffff:10.0.0.1" // IPv4-mapped IPv6 }) void rejectsInternalAddressesNoMatterWhatTheNameLooksLike(String literal) { var resolver = new PublicOnlyDnsResolver(resolvingTo(literal)); assertThatThrownBy(() -> resolver.resolve("partner.example.com")) .isInstanceOf(PublicOnlyDnsResolver.BlockedDestinationException.class) .hasMessageContaining("Blocked outbound connection"); } @Test void rejectsWhenOnlyOneOfSeveralRecordsIsInternal() { // Round-robin DNS can mix addresses; one bad record must poison the lookup. var resolver = new PublicOnlyDnsResolver(resolvingTo("93.184.216.34", "10.0.0.8")); assertThatThrownBy(() -> resolver.resolve("mixed.example.com")) .isInstanceOf(PublicOnlyDnsResolver.BlockedDestinationException.class); } @Test void allowsOrdinaryPublicAddresses() throws Exception { var resolver = new PublicOnlyDnsResolver(resolvingTo("93.184.216.34")); assertThat(resolver.resolve("partner.example.com")).hasSize(1); } } The third goes through the real client bean, so it exercises the wiring and not only the helper. It relies on a wildcard DNS service resolving to loopback, which means it needs outbound DNS from your CI runners; if yours do not have that, replace the hostname with one your internal test resolver maps to 127.0.0.1: @Test void clientBeanRefusesNameThatResolvesToLoopback() throws Exception { try (var server = new MockWebServer()) { server.enqueue(new MockResponse().setBody("secrets")); server.start(InetAddress.getLoopbackAddress(), 0); // localtest.me resolves to 127.0.0.1: the name looks external, the address is not. String url = "http://localtest.me:" + server.getPort() + "/latest/meta-data/"; assertThatThrownBy(() -> acquirerRestClient.get().uri(url).retrieve().body(String.class)) .hasRootCauseInstanceOf(PublicOnlyDnsResolver.BlockedDestinationException.class); assertThat(server.getRequestCount()).isZero(); } } The last assertion carries the weight. A request count of zero on the listener shows that the connection was refused before any bytes left the process, which is a stronger statement than "an exception was thrown". Write one test like this per client bean you own. If the codebase has eleven client beans and the test class has six cases, the five missing ones are your list of unprotected paths. I have not pasted run output here because the numbers would depend on your build and Boot patch release. Run these against your own services and attach the result to the change that introduces the guard; that is the artefact a reviewer or auditor will ask for. Defence in depth: the code filter is the last layer Everything above lives in the application. The application is also the layer that gets forgotten, misconfigured and bypassed, so treat it as the final wall and not the only one: 1. Egress at the network layer. A Kubernetes NetworkPolicy, security group or egress firewall that denies the link-local range and RFC 1918 space for pods that only need to reach the internet is the real fix. If application code fails, the packet still goes nowhere. For services that must call arbitrary tenant URLs, route them through an egress proxy or a small webhook-dispatcher service in an isolated network segment, so only that one component holds the risk. 2. IMDSv2 with a hop limit of 1 on AWS. Version 2 of the metadata service wants a session token obtained with a PUT, which the usual blind-GET SSRF primitive cannot produce, and a hop limit of one keeps container-originated traffic from reaching it. For the metadata attack in particular, this is the highest-value infrastructure change I know of. 3. Allowlists wherever the destination set is closed, as described above. 4. Short timeouts and response caps. SSRF probing often relies on timing differences to map a network. A two-second connect timeout and a one-megabyte body limit remove most of that signal. 5. Egress observability. Tag outbound spans with the resolved address if you already trace requests. I wrote about wiring that up in distributed tracing with OpenTelemetry on Spring Boot. A counter of blocked connections per tenant is a cheap detector, and a tenant whose count suddenly climbs deserves a look. The other half: gRPC between your own services Closing the outbound door covers traffic leaving your estate. Traffic between your own services needs the same attention, and Boot 4.1 changes the setup cost here too. The Spring gRPC project (version 1.1.0 at the time of writing) has been brought into Boot, so server and channel configuration arrive through ordinary auto-configuration and properties and not through a separately versioned starter. For a ledger, risk and settlement split, gRPC gives typed contracts and low overhead. It also gives you one obvious place to enforce transport security, which plain HTTP between pods rarely gets in practice. I count plaintext inside the cluster as a finding. A ledger service exposing an endpoint looks like this: @Service class LedgerGrpcService extends LedgerServiceGrpc.LedgerServiceImplBase { private final PostingEngine postingEngine; LedgerGrpcService(PostingEngine postingEngine) { this.postingEngine = postingEngine; } @Override public void postEntry(PostEntryRequest request, StreamObserver<PostEntryReply> reply) { // The idempotency key travels in the message body, not in transport metadata, // so a retry from any hop deduplicates against the same value. PostingResult result = postingEngine.post(request.getIdempotencyKey(), request.getDebitAccount(), request.getCreditAccount(), request.getAmountMinor()); reply.onNext(PostEntryReply.newBuilder().setPostingId(result.id()).build()); reply.onCompleted(); } } With auto-configuration, that BindableService bean is discovered and registered without hand-assembling a ServerBuilder. Transport security then becomes configuration. The property names below follow the Spring gRPC reference as I understand it, so confirm them against the 4.1.x documentation for your exact patch version before copying: spring: ssl: bundle: pem: ledger-server: keystore: certificate: file:/etc/tls/ledger/tls.crt private-key: file:/etc/tls/ledger/tls.key truststore: certificate: file:/etc/tls/ca/internal-ca.crt grpc: server: port: 9090 ssl: bundle: ledger-server client-auth: need # mutual TLS: a caller without a certificate never reaches your code client-auth: need is the line that matters. A caller without a certificate chained to your internal CA fails during the handshake, before any of your code executes. With cert-manager issuing short-lived workload certificates, the day-to-day cost is small. The calling side reuses the SSL bundle mechanism, and the channel factory resolves named channels from configuration: @Configuration(proxyBeanMethods = false) class LedgerClientConfig { @Bean LedgerServiceGrpc.LedgerServiceBlockingStub ledgerStub(GrpcChannelFactory channels) { // "ledger" maps to spring.grpc.client.channels.ledger.* in application.yml, // including its SSL bundle and target address. ManagedChannel channel = channels.createChannel("ledger"); return LedgerServiceGrpc.newBlockingStub(channel) .withDeadlineAfter(800, TimeUnit.MILLISECONDS); } } The two halves cover different trust boundaries. Channels to ledger.payments.svc.cluster.local are internal and authenticated by mutual TLS. The InetAddressFilter and the custom DnsResolver belong to the HTTP clients that reach out to the world. Keep them visibly apart in configuration. One line is worth adding to the code review checklist: channel targets come from configuration, never from request data. If anyone writes ManagedChannelBuilder.forTarget(valueFromARequest), they have rebuilt the SSRF hole one protocol over, and the HTTP-layer controls will not see it because gRPC does not travel through those clients. Behaviour under failure A hardened path still has to fail sensibly. Two changes are worth making before the first load test, not after: - Blocked calls must not retry. Many resilience configurations treat UnknownHostException as transient and retry it with backoff. A blocked address is a policy decision, so retrying it three times only triples the log volume and delays the alert. That is why BlockedDestinationException is its own subtype above: exclude it from the retry predicate. - Webhook delivery needs a terminal state for rejected destinations. When the filter rejects a merchant's endpoint, the event cannot silently disappear. Park it and flag the merchant record for review. If you deliver through an outbox, as in the transactional outbox and idempotent consumers post, a blocked destination becomes a terminal status on the outbox row and not an endless retry loop. Also plan for false positives from things that legitimately call loopback or private addresses: health checks, sidecar proxies, Actuator scrapes and calls to a local agent. Those should not use the clients you are guarding. If they do, keep them on a separate client bean with a narrow, documented purpose and no tenant-controlled input. Order of work for an existing estate If I were given a fintech microservice estate on 4.1 and a quarter to make it safer, this is the order I would go in: 1. Ship the shared starter that declares InetAddressFilter.externalAddresses(), along with the startup guard that fails a deploy when the bean is missing. 2. Search for HttpClient, CloseableHttpClient, OkHttpClient and Feign builders. Every hit is a client the Boot filter cannot see. Give each the resolution-time guard and a test like the ones above. 3. Turn off automatic redirects on anything that calls a tenant-controlled URL. 4. Enforce IMDSv2 with hop limit one on every node, and add egress policy that blocks the link-local and private ranges for workloads that have no reason to reach them. 5. Move internal gRPC to mutual TLS through SSL bundles, and ban request-derived channel targets in review. None of this is exotic. It removes a whole class of incident from the risk register, and the framework now gives you a starting point where there used to be a blank file. If you suspect your outbound clients are less covered than the dashboards claim, my background across airline-scale traffic and fintech platforms is a reasonable place to begin a conversation. Send the list of HTTP client beans in your busiest service through the contact section, and I will tell you which of them the Boot filter never touches. Related Articles - OpenBanking PSD2 API Development: the partner-integration side of fintech, where every outbound call needs a threat model. - Distributed Tracing with OpenTelemetry on Spring Boot in Production: how to make blocked and suspicious egress visible. - Transactional Outbox and Idempotent Consumers for Spring Boot Payment Systems: reliable webhook delivery, including what to do when a destination is rejected.

  8. X · · @nagise

    @madonomori SpringBoot 4.1.2 がリリースされているので、Tomcatの脆弱性が影響するようなシステムの場合は速やかにアップデートを! github.com/spring-projects/spring-boot/releases/t…

    1 like · 1 repost · 183 views
  9. Java serviceにproduction向けの共通機能を足す。 Spring BootはSpring製app/serviceのframework。Java applicationをstand-alone JARまたはWARで起動し、embedded server、security、metrics、health checks、外部設定にも対応します。 spring-projects/spring-boot

    25 views
  10. Milestone of the Spring Boot 4.1.2 is 100%. "Due by October 22, 2026" "Closed 2 days ago" 4.1.2 · Milestone #432 · spring-projects/spring-boot github.com/spring-projects/spring-boot/milestone/…

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

No packages found.

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
spring-projects/spring-boot mentions in the last 30 days