spring-projects

Activity

Stars and mentions · Last 30 days, Oct 5 – Nov 3

GitHub stars509
Mentions18
08162432Oct 5Oct 19Nov 3
UTC days · repositories spring-projects owns6 repositories

Top repositories

spring-projects’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside spring-projects’s account where spring-projects opened or reviewed pull requests in the past 365 days.

Who noticed spring-projects

Everyone who starred or forked spring-projects’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

18posts
Nov 40 posts
  1. How a REST Client Should Handle 503 Service Unavailable: Retries with Backoff in Spring Your nightly job sends 50,000 articles to a partner's API, in batches of 1,000. At batch 23, the API answers 503 Service Unavailable. What should your client do? Give up, and the data is incomplete. Retry immediately, and you make an overloaded server even slower. This post shows how a REST client should handle a 503: which errors to retry, how long to wait, and how Spring Framework 7 does it with one annotation. What a 503 Means The HTTP status code 503 Service Unavailable says that the server can't handle the request right now, but probably will later. Common causes: - The server is down for scheduled maintenance. - The server is overloaded by too much traffic. - A service the server depends on, such as a database or another API, is failing. The server may add a Retry-After header that says how long to wait. The important word is temporarily: a 503 is an invitation to try again later, not a final answer. The Client Can't Control the Server Business-to-business (B2B) integrations are a typical case. One company's application is the client, the other company's API is the server, and the client has no say in how the server is built, deployed or scaled. ![A Spring web client, used by a person, sends HTTP requests to an external API](codersite.dev "A Spring web client, used by a person, sends HTTP requests to an external API") The first version of our client caught the exception, wrote it to the log and continued with the next batch. The job finished "successfully", and 1,000 articles were silently missing. Somebody had to read the logs, call the partner company, and send the batch again by hand. The opposite reaction is just as bad: retrying in a tight loop, three times in a row without a pause. A server that's overloaded receives even more requests, exactly when it can handle the fewest. If hundreds of clients do the same, they keep the server down. Which Errors Should You Retry? Retry only when the same request has a real chance of succeeding later: | Response | Retry? | Why | |---|---|---| | 503 Service Unavailable | Yes | Temporary by definition | | 429 Too Many Requests | Yes | Wait until your limit resets | | 502 Bad Gateway, 504 Gateway Timeout | Yes | Usually a temporary network or proxy problem | | Timeout, connection refused | Yes | The server may be restarting | | 400, 401, 403, 404, 422 | No | The same request will fail again | | 500 Internal Server Error | With care | Can be a bug, which a retry won't fix | <br/> Is the request safe to repeat? GET, PUT and DELETE are idempotent: sending them twice has the same effect as sending them once. Our request is a POST, but it's a search, so repeating it changes nothing. A POST that creates something, such as an order, is different: if the first attempt actually reached the server and only the response got lost, a retry creates a second order. APIs solve this with an idempotency key, a unique ID the client sends with the request, so the server can recognize a repeated request. A Retry Loop with Backoff A good retry waits longer after each failure (exponential backoff: 1, 2, 4 seconds...), adds a random part to each delay (jitter, so that many clients don't retry at the same moment), respects the server's Retry-After header, and gives up after a few attempts. Here it is with Spring's RestClient: public class ArticlesClient { private static final Logger logger = LoggerFactory.getLogger(ArticlesClient.class); private static final int MAX_ATTEMPTS = 4; private static final Duration INITIAL_DELAY = Duration.ofSeconds(1); private static final Duration MAX_DELAY = Duration.ofSeconds(30); private static final Set<Integer> RETRYABLE_STATUS_CODES = Set.of(429, 502, 503, 504); private final RestClient restClient; public ArticlesClient(RestClient restClient) { this.restClient = restClient; } public SearchResponse searchArticles(List<String> articleNumbers) { for (int attempt = 1; ; attempt++) { try { return restClient.post() .uri("/articles/search") .contentType(MediaType.APPLICATION_JSON) .body(new SearchRequest(articleNumbers)) .retrieve() .body(SearchResponse.class); } catch (RestClientException e) { if (attempt == MAX_ATTEMPTS || !isRetryable(e)) { throw e; } Duration wait = retryAfter(e).orElse(backoff(attempt)); if (wait.compareTo(MAX_DELAY) > 0) { throw e; // the server asks us to wait longer than we're willing to } logger.warn("Attempt {} of {} failed: {}. Retrying in {} ms", attempt, MAX_ATTEMPTS, e.getMessage(), wait.toMillis()); sleep(wait); } } } } The helper methods decide whether and how long to wait: private static boolean isRetryable(RestClientException e) { if (e instanceof RestClientResponseException responseError) { return RETRYABLE_STATUS_CODES.contains(responseError.getStatusCode().value()); } return e instanceof ResourceAccessException; // timeout, connection refused } // 1 s, 2 s, 4 s ... up to MAX_DELAY, each with random jitter between half and the full value private static Duration backoff(int attempt) { long cap = Math.min(MAX_DELAY.toMillis(), INITIAL_DELAY.toMillis() << (attempt - 1)); return Duration.ofMillis(ThreadLocalRandom.current().nextLong(cap / 2, cap + 1)); } // Retry-After is either a number of seconds or an HTTP date private static Optional<Duration> retryAfter(RestClientException e) { if (!(e instanceof RestClientResponseException responseError) || responseError.getResponseHeaders() == null) { return Optional.empty(); } String value = responseError.getResponseHeaders().getFirst(HttpHeaders.RETRY_AFTER); if (value == null) { return Optional.empty(); } try { return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); } catch (NumberFormatException notSeconds) { try { ZonedDateTime retryAt = ZonedDateTime.parse(value.trim(), DateTimeFormatter.RFC_1123_DATE_TIME); Duration wait = Duration.between(ZonedDateTime.now(), retryAt); return Optional.of(wait.isNegative() ? Duration.ZERO : wait); } catch (DateTimeParseException invalid) { return Optional.empty(); } } } private static void sleep(Duration duration) { try { Thread.sleep(duration); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted while waiting to retry", e); } } The request and response are records: public record SearchRequest(List<String> articleNumbers) {} public record SearchResponse(List<Article> articles) {} public record Article(String articleNumber, String name) {} Don't Lose the Batches That Still Fail Even with retries, a batch can fail, for example when the partner's API is down for an hour. Then the job must keep the data, not only a log line: public List<List<String>> searchAll(List<String> articleNumbers) { List<List<String>> failedBatches = new ArrayList<>(); for (int from = 0; from < articleNumbers.size(); from += BATCH_SIZE) { List<String> batch = articleNumbers.subList(from, Math.min(from + BATCH_SIZE, articleNumbers.size())); try { SearchResponse response = articlesClient.searchArticles(batch); save(response.articles()); } catch (RestClientException e) { logger.error("Batch starting at {} failed after all retries: {}", from, e.getMessage()); failedBatches.add(List.copyOf(batch)); // keep the data: retry later or report it } } return failedBatches; } The caller can store the failed batches in a table, send them again in the next run, or notify the team. In each case, nobody needs to search the logs. Try It I ran the client against a local test server that answers 503, then 503 with Retry-After: 2, then 200: server: call 1 -> 503 WARN ArticlesClient -- Attempt 1 of 4 failed: 503 Service Unavailable: [no body]. Retrying in 618 ms server: call 2 -> 503 (Retry-After: 2) WARN ArticlesClient -- Attempt 2 of 4 failed: 503 Service Unavailable: [no body]. Retrying in 2000 ms server: call 3 -> 200 result: SearchResponse[articles=[Article[articleNumber=4711, name=Potatoes]]] The first wait is a random value from the backoff, and the second is exactly the 2 seconds the server asked for. In the other test runs, a 400 Bad Request failed immediately without a retry, a server that always answered 503 got four attempts with growing delays (0.8 s, 1.6 s, 2.1 s) before the client gave up, and a Retry-After of 120 seconds made the client stop instead of waiting. To see a real 503 response, use the public test service httpbin.org: $ curl -i https://httpbin.org/status/503 HTTP/1.1 503 SERVICE UNAVAILABLE Content-Length: 0 The Spring Framework 7 Way: @Retryable Since Spring Framework 7 (Spring Boot 4), retries are built into Spring itself, with no extra library. Enable them once: @Configuration @EnableResilientMethods public class ResilienceConfig { } Then annotate the method: @Service public class ArticlesService { private final RestClient restClient; public ArticlesService(RestClient restClient) { this.restClient = restClient; } @Retryable( includes = { HttpServerErrorException.ServiceUnavailable.class, // 503 HttpServerErrorException.BadGateway.class, // 502 HttpServerErrorException.GatewayTimeout.class, // 504 HttpClientErrorException.TooManyRequests.class, // 429 ResourceAccessException.class }, // timeout, connection refused maxRetries = 3, delay = 1000, multiplier = 2, maxDelay = 30_000, jitter = 250) public SearchResponse searchArticles(List<String> articleNumbers) { return restClient.post() .uri("/articles/search") .body(new SearchRequest(articleNumbers)) .retrieve() .body(SearchResponse.class); } } In my test, this method also received 503, 503 and 200, with retries after about 1 and 2 seconds, and a 400 was not retried. The method itself contains no retry logic at all. Which one should you use? @Retryable is shorter and keeps the business code clean, and it's the right choice in most cases. It can't read the Retry-After header, though, because it only sees the exception type. When an API sends Retry-After, typically with 429 and 503, the loop above respects it. On Spring Boot 3, the Spring Retry library offers a similar @Retryable. Best Practices Checklist - Retry only errors that can succeed later: 429, 502, 503, 504 and timeouts. - Wait with exponential backoff and jitter, and respect Retry-After. - Limit the number of attempts and the longest wait. - Make sure a repeated request is safe; use idempotency keys for requests that create data. - Never lose data: keep failed requests for a later retry or a report. - On the server side, return Retry-After with your 503 and 429 responses. The rate limiting post shows how, and you can load-test it with concurrent clients. "What happens when the service you call is down?" is a standard question in backend and system design interviews. Practice with real interview questions: ![The Code Interview, book cover](codersite.dev) --- Originally published at codersite.dev.

  2. Reddit · · r/AIQuality · u/YodaOfJava · spring-projects/spring-ai

    Spring AI: recovering from invalid tool arguments with returnDirect=true I’ve submitted a Spring AI contribution addressing input-error recovery for method-backed tools using returnDirect=true. The issue: malformed JSON or invalid typed arguments can become the final response, preventing the model from correcting its input. Missing/null primitive arguments and top-level JSON null can also bypass the exception processor. The proposed fix distinguishes input parsing/binding failures before execution and sends processed error feedback back to the model. Successful corrected calls still return directly, while ordinary tool-body and result-conversion failures retain their existing behavior. Validation: - All 38 new regression cases passed, covering blocking/streaming, direct return on/off, and mixed-success batches. - Scoped clean package build: 2,030 tests reported, 10 skipped, zero failures or errors. - Negative control: the original code failed 20 of the initial 32 checks, including checks for the new exception classification; the patched code passed the same cases. No external LLM calls were needed. External-provider integration tests and the full repository build were not run. Developed with AI assistance and reviewed before submission. PR submitted for maintainer review: spring-ai#7292 Feedback on the input-error boundary and recovery behavior is welcome, especially from anyone using direct-return tools. #SpringAI #SpringFramework #Java #OpenSource #SoftwareTesting

    2 points · 1 comment
  3. 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 で、結果が変わらないことを確認する 業務ロジックをフレームワークから切り離すことでバージョンアップの範囲を局所化でき、ブラウザの試験を自動化することで工数を削減できます。一度整えておくと、次のバージョンアップにも効きます。

  4. Reddit · · r/SpringBoot · u/YodaOfJava · spring-projects/spring-ai

    Spring AI: recovering from invalid tool arguments with returnDirect=true I’ve submitted a Spring AI contribution addressing input-error recovery for method-backed tools using returnDirect=true. The issue: malformed JSON or invalid typed arguments can become the final response, preventing the model from correcting its input. Missing/null primitive arguments and top-level JSON null can also bypass the exception processor. The proposed fix distinguishes input parsing/binding failures before execution and sends processed error feedback back to the model. Successful corrected calls still return directly, while ordinary tool-body and result-conversion failures retain their existing behavior. Validation: - All 38 new regression cases passed, covering blocking/streaming, direct return on/off, and mixed-success batches. - Scoped clean package build: 2,030 tests reported, 10 skipped, zero failures or errors. - Negative control: the original code failed 20 of the initial 32 checks, including checks for the new exception classification; the patched code passed the same cases. No external LLM calls were needed. External-provider integration tests and the full repository build were not run. Developed with AI assistance and reviewed before submission. PR submitted for maintainer review: spring-ai#7292 Feedback on the input-error boundary and recovery behavior is welcome, especially from anyone using direct-return tools. #SpringAI #SpringFramework #Java #OpenSource #SoftwareTesting

    3 points
  5. 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.

  6. 我用 Eclipse 的核心技术,给 AI 写了一个 Java 启动器 最近做了一个开源工具,叫 joLink。 它通过 MCP 接入现有的 AI 编程助手,提供 Java 增量编译、测试、应用启动、热更新和调试能力。底层用到了 Eclipse JDT ,不过不需要打开 Eclipse ,也不需要让 AI 操作 IDE 界面。 做它的想法很简单:我们在 IDE 里已经习惯了“改完接着跑”,我想把这种开发体验也提供给 Agent 。 目标:让 AI 更快、更顺手地运行自己写的代码 让 Agent 修改 Java 代码,再启动项目、跑单元测试,已经不是新鲜事了。 我更关心的是后面这段过程: 修改代码 → 运行测试 → 发现问题 → 继续修改 → 再次运行 一个需求通常不止执行一次。第一次实现遗漏了边界,补一下;测试发现行为不对,再改一下;接口结果符合预期了,还要检查有没有影响其他功能。 AI 可以很快写完一段代码,但接下来仍然需要等待编译、启动和执行结果。如果每次只是小改动,却要重复做很多准备工作,那么几轮下来,等待就成了开发过程里很明显的一部分。 joLink 想做的,就是把这段反复“修改—运行—排查”的过程接起来,减少重复工作,让 Agent 尽快拿到下一步判断所需要的结果。 下面先看实际过程,再讲它是怎么实现的。 迅速:对比一次“测试—修改—再测试” 这次使用的是 CodeBuddy 客户端,模型为 DeepSeek-V4.1-Flash-1017 官方 api。 示例项目:[JTS Topology Suite]( locationtech/jts),固定版本 1.20.0,只运行 modules/core 模块中的 org.locationtech.jts.geom.EnvelopeTest。 固定提交:6e95fe82feb986a7aa657f4ffa406d8c290af509。 运行环境:macOS 、Zulu JDK 17.0.16 、Maven 3.9.11 joLink 0.1.0a10。。 这个模块本轮参与编译的源码包括 612 个生产文件和 369 个测试文件,共 981 个文件。 这次模拟一次小范围重构:先运行已有的 17 项测试,再把 Envelope.java 中的 getWidth() 做等价改写,保持功能不变,然后运行同一组测试。两轮之间只改了下面这一处,测试文件没有修改: public double getWidth() { - if (isNull()) { - return 0; - } - return maxx - minx; + return isNull() ? 0.0 : maxx - minx; } 两组使用相同的代码起点、修改和测试。一组通过 Maven 执行,另一组通过 joLink 执行。依赖提前准备好,首次执行所需的构建准备也计入时间。 先通过 Maven 执行 让 Agent 先运行目标测试,取得第一次结果,再应用上面的改动,执行同一条 Maven 命令。两轮之间没有执行 clean。 mvn -B -ntp -Dstyle.color=never \ -f modules/core/pom.xml -Dtest=EnvelopeTest test 第一次执行用了 8.472 秒,编译 612 个生产文件和 369 个测试文件,17 项测试全部通过。 ![图 1:Maven 首次执行,8.472 秒,编译 981 个源文件,17 项测试通过]( raw.githubusercontent.com) 修改一个生产文件后,再次执行用了 8.123 秒。日志仍然记录了 612 个生产文件和 369 个测试文件的重新编译,17 项测试全部通过,Checkstyle 也正常执行。 ![图 2:Maven 修改后再次执行,8.123 秒,重新编译 981 个源文件]( raw.githubusercontent.com) 再通过 joLink 执行 同样的测试和修改,这次由 Agent 调用 java_fast_test。 第一次执行需要准备项目模型和编译工作区;修改代码后,再运行同一组测试。 首次执行总耗时 6.268 秒,其中工具报告的编译耗时为 2.1433 秒,编译了 981 个源文件,17 项测试全部通过。首次工作区准备已经计入总耗时。 ![图 3:joLink 首次执行,6.268 秒,编译 981 个源文件,17 项测试通过]( raw.githubusercontent.com) 应用相同改动后,第二轮总耗时降到 546.1 毫秒,其中编译耗时 163.1 毫秒,只重新编译了 1 个源文件,17 项测试仍然全部通过。 ![图 4:joLink 修改后再次执行,546.1 毫秒,只重新编译 1 个源文件]( raw.githubusercontent.com) 把两轮结果放在一起: | 执行阶段 | Maven 总耗时 | joLink 总耗时 | |---|---:|---:| | 第一次执行,包含首次构建准备 | 8.472 秒 | 6.268 秒 | | 修改代码后的第二次执行 | 8.123 秒 | 0.5461 秒 | | 两轮合计 | 16.595 秒 | 6.8141 秒 | 第二轮的编译部分单独列出来: | 第二轮编译 | Maven | joLink | |---|---:|---:| | 实际重新编译的源文件数 | 981 (生产 612 +测试 369 ) | 1 | | 编译耗时 | 日志未提供纯编译耗时 | 163.1 毫秒 | | 测试结果 | 17 项全部通过 | 17 项全部通过 | 同样只修改一个生产文件,第二轮执行从 8.123 秒降到了 0.5461 秒,少了约 7.58 秒,执行耗时减少约 93%。 把首次准备也算上,两轮合计从约 16.6 秒降到了约 6.8 秒。 编译范围的区别很直接:Maven 这一轮仍然重编译了 981 个源文件,joLink 只重新编译了改动的 Envelope.java。测试没有省略,两边每轮都实际执行了 17 项测试。 > 时间取自这次截图中的工具自身报告:Maven 为 Total time,joLink 为 total_ms,不包含模型思考和客户端交互时间。Maven 两轮都保留了 Checkstyle ,因此这里比较的是两条定向测试执行路径的总耗时,不是纯编译器的速度比。 这里我最关心的不是“什么都不改,再跑一次”,而是代码真的变了以后,下一轮验证需要付出多少成本。 这就要说到 joLink 里面很重要的一部分:增量编译。 什么是增量编译,为什么值得专门讲? 我们平时用 IDEA 、Eclipse ,改完代码就点运行,很少专门关注:这一次,究竟编译了哪些文件? Java 源码修改以后,需要生成新的编译结果,程序和测试才能执行修改后的逻辑。 假设一个项目里有几千个 Java 文件,这次只改了 OrderService 中的一段金额计算逻辑。 如果这次修改触发了整个模块重新编译,那么大量没有变化、也没有受到影响的源码,就会再做一遍相同的工作。 增量编译的目的,就是避免这类重复劳动。 第一次构建时,建立编译产物和构建状态。下一次发生修改时,根据源码变化及其影响范围更新编译结果,其余仍然有效的产物继续使用。 可以这样理解: 全量编译: 修改一部分代码 → 重新编译全部源码 → 运行测试 增量编译: 修改一部分代码 → 判断哪些代码受到了影响 → 更新需要重新编译的部分 → 复用其他编译结果 → 运行测试 它也不是简单地“只编译你保存过的那个文件”。 比如,只修改一个方法内部的计算逻辑,影响范围可能很小;但如果修改的是一个接口的方法签名,依赖这个接口的其他代码也可能需要重新编译。 增量编译处理的是“这次修改影响到的范围”,不只是“这次手动改过的文件”。 joLink 怎样使用这套能力? joLink 先通过 Maven / Gradle 获取项目的源码目录、依赖、编译配置等信息,再建立一个持久的 Eclipse JDT 工作区。 后续修改时,由 JDT 的 Java Builder 跟踪源码变化和依赖影响,复用未变化的输出,再把编译结果交给测试或应用运行。工作区和构建状态保存在磁盘上,不只存在于某一轮对话或某一次命令里。 关键不只是留下 .class 文件,而是保留继续进行增量构建所需的状态。 因此,在同一个有效工作区里,后续运行另一个测试类,或者重新连接 MCP ,仍然可以复用已有编译状态,而不必把整个项目当成第一次见到。 这里复用的是编译结果,不是上一次“测试通过”的答案。测试仍然会在独立 JVM 中实际执行,代码变了,就检查新的执行结果。 这对 Agent 的开发过程有什么意义? 增量编译不是 AI 时代才需要的东西。恰恰相反,它是我们已经使用了很多年的开发能力。 只是 IDEA 和 Eclipse 把这部分做得太自然,我们习惯了改一点、跑一下,很少再去注意它在背后省了多少工作。 Agent 连续开发时,同样会经历这个过程: 调整一个条件 → 跑测试 → 发现边界不对 → 修改实现 → 再跑测试 → 补充一个场景 → 继续验证 一次重复编译,只是一段等待;连续多轮,等待就会不断累积。 我希望 Agent 在每一轮里,把时间花在新的修改和验证上,而不是反复处理已经完成过的准备工作。 这也是 joLink 使用持久编译工作区的原因。 优雅:项目跑起来以后,还能继续管理它 除了速度,我还想改善另一个体验:启动应用不应该是一次性的命令,而应该是一段可以继续操作的开发过程。 这里换成 [Spring Petclinic]( spring-projects/spring-petclinic) 演示应用启动和更新,固定提交为 818c4136ea971c21674525f9053de0d9c7ad8cfe。 这次应用监听本机 18093 端口,JDWP 端口为 15093。Agent 通过 java_application 发起启动,主要参数可以这样理解(项目路径为示意,省略本机环境配置): { "action": "launch", "project_path": "/path/to/petclinic", "main_class": "org.springframework.samples.petclinic.PetClinicApplication", "ready_port": 18093, "jdwp_port": 15093 } joLink 负责相关构建准备和进程启动,之后 Agent 可以通过 java_status 查看状态、按需读取日志。需要调查运行行为时,再使用调试工具获取实际的调用栈和变量。 这轮应用启动后的 PID 是 52032。Agent 随后通过 java_status 查看状态,再实际请求兽医列表:/vets.html?page=0 返回 HTTP 500 ,暴露了非正数页码的处理问题。 ![图 5:joLink 启动 Petclinic ,读取状态并实际请求兽医列表]( raw.githubusercontent.com) 更重要的是,修改代码以后,这个过程可以接着往下走。 对于已经由 joLink 管理的应用,Agent 可以调用: { "action": "restart" } joLink 会检测源码变化,进行增量编译,再应用新的编译结果。适合的修改优先使用 HotSwap ,不适合热替换的则重启 JVM ;需要重新执行应用初始化时,也可以明确选择重启。 这次修复只在 VetController 的分页入口增加了一行,把小于 1 的页码按第一页处理: page = Math.max(1, page); Agent 应用补丁后,通过 joLink 的更新流程让改动生效,再重新请求相关接口。 ![图 6:应用页码修复,通过 joLink 更新运行中的应用,再次请求接口]( raw.githubusercontent.com) 这一次实际返回的更新方式是 HotSwap,只编译了 1 个源文件,替换的是 VetController。更新前后 PID 都是 52032,没有重新启动 JVM 。 | 更新指标 | 实际结果 | |---|---| | 更新方式 apply_method | hotswap | | 编译类型 build_kind | INCREMENTAL | | 编译源文件数 | 1 | | 编译耗时 compile_ms | 123.4 毫秒 | | 应用修改耗时 apply_ms | 89.6 毫秒 | | 更新操作总耗时 total_ms | 261.5 毫秒 | | 进程 PID | 52032 ,更新前后保持一致 | 这个 261.5 毫秒是 joLink 更新操作本身的耗时,不包含模型编辑代码和后续 HTTP 检查。 更新后重新请求,结果也对应上了: | 请求 | 更新后的实际结果 | |---|---| | /vets.html?page=0 | HTTP 200 ,更新前为 500;返回第一页内容 | | /vets.html?page=-1 | HTTP 200 ,返回与第一页一致的内容 | | /vets.html?page=1 | HTTP 200 ,正常返回第一页的 5 位兽医 | | /vets.html?page=2 | HTTP 200 ,仍然返回第二页的 1 位兽医 | | /vets( JSON ) | HTTP 200 ,返回 6 位兽医,专长信息正常 | 其中 page=0、page=-1 与 page=1 的完整 HTML 的 MD5 一致。 ![图 7:261.5 毫秒完成 HotSwap ,PID 不变,page=0 从 HTTP 500 恢复为 200]( raw.githubusercontent.com) 这次不是只收到一个“更新成功”,而是在原来的 JVM 中应用了修改,再通过新的请求看到了修复后的结果。 这样,Agent 操作的是同一个有状态的运行环境: 启动应用 → 请求接口 → 发现异常 → 读取日志或运行时变量 → 修改代码 → 应用修改 → 再次请求 我想要的“优雅”,就是这些动作能够连起来。 编译失败了,拿到编译诊断;测试失败了,查看对应结果;应用还在启动,就继续等待;实际响应不符合预期,就沿着现有应用继续排查。 不用每一轮都重新组织一套运行过程,也不用为了知道当前发生了什么,把所有日志从头读一遍。 而且,跑单元测试与启动业务应用是两个独立入口。只需要验证某个测试类时,直接使用 Fast Test ,不必先把整个业务服务启动起来。 架构:规则、Skill 、MCP 和实际运行的部分 joLink 不只是几个工具名,它还提供配套的开发准则和 Skill 。 它们各自解决不同的问题。 全局规则负责表达工作原则。 比如“事上磨练”:先形成判断,通过实际执行取得证据;结果与判断不一致时,修正理解和实现,而不是不断给原来的猜测打补丁。核心是对改动的实际行为负责。 Skill 负责说明工具怎样使用。 例如,什么时候直接运行指定测试,怎样更新已经启动的应用,在哪里查看失败详情,以及如何继续一次尚未结束的操作。 MCP 是 Agent 调用这些能力的入口。 后面的 joLink Runtime 才实际维护编译工作区、执行测试、管理应用进程,并连接 JVM 调试能力。 可以简单理解成: agent 全局规则:怎样对待开发与验证 (大部分情况下会让 AI 更多的运行、测试项目,选择性安装) ↓ Skill:怎样使用这套工具 ↓ Coding Agent ↓ MCP joLink Runtime ↓ 增量编译 / 测试 / 应用运行 / JVM 调试 前面两部分帮助 Agent 组织工作,后面负责真正执行。joLink 的编译、测试、应用更新和调试机制,都由 joLink-Runtime 提供。 对 Agent 暴露的四个工具 | 工具 | 主要做什么 | |---|---| | java_fast_test | 编译并运行指定的 JUnit / TestNG 测试,读取执行结果。 | | java_application | 启动应用、应用代码修改,以及管理应用生命周期。 | | java_status | 查看当前执行状态,按需读取构建和应用日志。 | | java_debugger | 设置断点、观察异常、读取线程、调用栈和变量。 | 这四个入口分别对应测试、应用操作、状态观察和运行时调查,Agent 根据任务选择需要的能力。 目前的情况 joLink 是 MIT 协议的开源项目,目前还在 Alpha 阶段。我自己在工作项目里持续使用,也在用不同的开源项目检查兼容性。 目前主要面向 Maven / Gradle Java 项目,支持指定测试、普通 Java / Spring Boot 应用运行,以及运行中的更新和调试。日常修改验证交给 joLink ,完整打包和 CI 仍然保留项目原有的构建流程。 接下来想找几位平时用 AI 写 Java 的开发者,拿自己的项目试一试。安装是否顺利、项目能不能跑、连续修改时是否省事,这些实际反馈对我很重要。 项目和安装说明: https://github.com/L1ch404/jolink-runtime 再叠个甲,并不是说只有用了 joLink 才能让 AI 去启动、测试 Java 项目,只是 joLink 可以让这件事情变得更优雅、快捷 我们给自己配了用了很多年的 Java 开发环境,我也想给 Agent 配一套。让它改完以后,能顺手跑起来、看到结果,再接着改。

    5 comments
  7. 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 にしている。 ↩︎

  8. 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
  9. Иерархия контекстов в 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-канал. Там же можно задать вопрос напрямую команде. Всем успехов!

  10. Можно ли доверить ИИ миграцию 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‑канале.

Achievements

Awards earned by the repositories spring-projects owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.