OpenTelemetry agent 對 Spring Boot 應用的影響:一次 SPI 失效的案例

crossoverJie發表於2024-06-04

背景

前段時間公司領導讓我排查一個關於在 JDK21 環境中使用 Spring Boot 配合一個 JDK18 新增的一個 SPI(java.net.spi.InetAddressResolverProvider) 不生效的問題。

但這個不生效的前置條件有點多:

  • JDK 的版本得在 18+
  • SpringBoot3.x
  • 還在額外再配合使用 -javaagent:opentelemetry-javaagent.jar 使用,也就是 OpenTelemetry 提供的 agent。

才會導致自定義的 InetAddressResolverProvider 無法正常工作。


在復現這個問題之前先簡單介紹下 java.net.spi.InetAddressResolverProvider 這個 SPI;它是在 JDK18 之後才提供的,在這之前我們使用 InetAddress 的內建解析器來解析主機名和 IP 地址,但這個解析器之前是不可以自定義的。

在某些場景下會不太方便,比如我們需要請求 order.service 這個域名時希望可以請求到某一個具體 IP 地址上,我們可以自己配置 host ,或者使用服務發現機制來實現。

但現在透過 InetAddressResolverProvider 就可以定義在請求這個域名的時候返回一個我們預期的 IP 地址。

同時由於它是一個 SPI,所以我們只需要編寫一個第三方包,任何專案依賴它之後在發起網路請求時都會按照我們預期的 IP 進行請求。

復現

要使用它也很簡單,主要是兩個類:

  • InetAddressResolverProvider:這是一個抽象類,我們可以繼承它之後重寫它的 get 函式返回一個 InetAddressResolver 物件
  • InetAddressResolver:一個介面,主要提供了兩個函式;一個用於傳入域名返回 IP 地址,另一個反之:傳入 IP 地址返回域名。

public class MyAddressResolverProvider extends InetAddressResolverProvider {
    @Override
    public InetAddressResolver get(Configuration configuration) {
        return new MyAddressResolver();
    }
    @Override
    public String name() {
        return "MyAddressResolverProvider Internet Address Resolver Provider";
    }
}

public class MyAddressResolver implements InetAddressResolver {

    public MyAddressResolver() {
        System.out.println("=====MyAddressResolver");
    }

    @Override
    public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
            throws UnknownHostException {
        if (host.equals("fedora")) {
            return Stream.of(InetAddress.getByAddress(new byte[] {127, 127, 10, 1}));
        }
        return Stream.of(InetAddress.getByAddress(new byte[] {127, 0, 0, 1}));
    }
    @Override
    public String lookupByAddress(byte[] addr) {
        System.out.println("++++++" + addr[0] + " " + addr[1] + " " + addr[2] + " " + addr[3]);
        return  "fedora";
    }
}

---

```java
addresses = InetAddress.getAllByName("fedora");
// output: 127 127 10 1

這裡我簡單實現了一個對域名 fedora 的解析,會直接返回 127.127.10.1

如果使用 IP 地址進行查詢時:

InetAddress byAddress = InetAddress.getByAddress(new byte[]{127, 127, 10, 1});

System.out.println("+++++" + byAddress.getHostName());
// output: fedora

當然要要使得這個 SPI 生效的前提條件是我們需要新建一個檔案:
META-INF/services/java.net.spi.InetAddressResolverProvider
裡面的內容是我們自定義類的全限定名稱:

com.example.demo.MyAddressResolverProvider

這樣一個完整的 SPI 就實現完成了。


正常情況下我們將應用打包為一個 jar 之後執行:

java -jar target/demo-0.0.1-SNAPSHOT.jar

是可以看到輸出結果是符合預期的。

一旦我們使用配合上 spring boot 打包之後,也就是加上以下的依賴:

<parent>  
  <groupId>org.springframework.boot</groupId>  
  <artifactId>spring-boot-starter-parent</artifactId>  
  <version>3.2.3</version>  
  <relativePath/> <!-- lookup parent from repository -->  
</parent>

<build>  
  <plugins>  
   <plugin>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-maven-plugin</artifactId>  
   </plugin>  
  </plugins>  
</build>

再次執行其實也沒啥問題,也能按照預期輸出結果。

但我們加上 OpenTelemetry 的 agent 時:

java  -javaagent:opentelemetry-javaagent.jar \
      -jar target/demo-0.0.1-SNAPSHOT.jar

就會發現在執行解析的時候丟擲了 java.net.UnknownHostException異常。


從結果來看就是沒有進入我們自定義的解析器。

SPI 原理

在講排查過程之前還是要先預習下關於 Java SPI 的原理以及應用場景。

以前寫過一個 http 框架 cicada,其中有一個可拔插 IOC 容器的功能:

就是可以自定義實現自己的 IOC 容器,將自己實現的 IOC 容器打包為一個第三方包加入到依賴中,cicada 框架就會自動使用自定義的 IOC 實現。

要實現這個功能本質上就是要定義一個介面,然後根據依賴的不同實現建立介面的例項物件。

public interface CicadaBeanFactory {

    /**
     * Register into bean Factory
     * @param object
     */
    void register(Object object);

    /**
     * Get bean from bean Factory
     * @param name
     * @return
     * @throws Exception
     */
    Object getBean(String name) throws Exception;

    /**
     * get bean by class type
     * @param clazz
     * @param <T>
     * @return bean
     * @throws Exception
     */
    <T> T getBean(Class<T> clazz) throws Exception;

    /**
     * release all beans
     */
    void releaseBean() ;
}

獲取具體的示例程式碼時就只需要使用 JDK 內建的 ServiceLoader 進行載入即可:

public static CicadaBeanFactory getCicadaBeanFactory() {  
    ServiceLoader<CicadaBeanFactory> cicadaBeanFactories = ServiceLoader.load(CicadaBeanFactory.class);  
    if (cicadaBeanFactories.iterator().hasNext()){  
        return cicadaBeanFactories.iterator().next() ;  
    }  
    return new CicadaDefaultBean();  
}

程式碼也非常的簡潔,和剛才提到的 InetAddressResolverProvider 一樣我們需要新增一個 META-INF/services/top.crossoverjie.cicada.base.bean.CicadaBeanFactory 檔案來配置我們的類名稱。

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        try {
        	// PREFIX = META-INF/services/
            String fullName = PREFIX + service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                configs = loader.getResources(fullName);
        } catch (IOException x) {
            fail(service, "Error locating configuration files", x);
        }
    }
    while ((pending == null) || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}

在 ServiceLoader 類中會會去查詢 META-INF/services 的檔案,然後解析其中的內容從而反射生成對應的介面物件。

這裡還有一個關鍵是通常我們的程式碼都會打包為一個 JAR 包,類載入器需要載入這個 JAR 包,同時需要在這個 JAR 包裡找到我們之前定義的那個 spi 檔案,如果這裡查不到檔案那就認為沒有定義 SPI。

這個是本次問題的重點,會在後文分析原因的時候用到。

排查

因為問題就出現在是否使用 opentelemetry-javaagent.jar 上,所以我需要知道在使用了 agent 之後有什麼區別。

從剛才的對 SPI 的原理分析,加上 agent 出現異常,說明理論上就是沒有讀取到我們配置的檔案: java.net.spi.InetAddressResolverProvider

於是我便開始 debug,在 ServiceLoader 載入 jar 包的時候是可以看到具體使用的是什麼 classLoader

這是不配置 agent 的時候使用的 classLoader:

使用這個 loader 是可以透過檔案路徑在 jar 包中查詢到我們配置的檔案。

而配置上 agent 之後使用的 classLoader:

卻是一個 JarLoader,這樣是無法載入到在 springboot 格式下的配置檔案的,至於為什麼載入不到,那就要提一下 maven 打包後的檔案目錄和 spring boot 打包後的檔案目錄的區別了。


這裡我截圖了同樣的一份程式碼不同的打包方式:
上面的是傳統 maven,下圖是 spring boot;其實主要的區別就是在 pom 中使用了一個構建外掛:

<build>  
  <plugins>  
   <plugin>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-maven-plugin</artifactId>  
   </plugin>  
  </plugins>  
</build>

或者使用 spring-boot 命令再次打包的效果也是一樣的。

會發現 spring boot 打包後會多出一層 BOOT-INF 的資料夾,然後會在 MANIFIST.MF 檔案中定義 Main-ClassStart-Class.


透過上面的 debug 其實會發現 JarLoader 只能在載入 maven 打包後的檔案,也就是說無法識別 BOOT-INF 這個目錄。

正常情況下 spring boot 中會有一個額外的 java.nio.file.spi.FileSystemProvider 實現:

透過這個類的實現可以直接從 JAR 包中載入資源,比如我們自定義的 SPI 資源等。

初步判斷使用 opentelemetry-javaagent.jar的 agent 之後,它的類載入器優先於了 spring boot ,從而導致後續的載入失敗。

遠端 debug

這裡穿插幾個 debug 小技巧,其中一個是遠端 debug,因為這裡我是需要除錯 javaagent,正常情況下是無法直接 debug 的。

所以我們可以使用以下命令啟動應用:

java -agentlib:jdwp="transport=dt_socket,server=y,suspend=y,address=5000" -javaagent:opentelemetry-javaagent.jar \
      -jar target/demo-0.0.1-SNAPSHOT.jar

然後在 idea 中配置一個 remote 啟動。

注意這裡的埠得和命令列中的保持一致。

當應用啟動之後便可以在 idea 中啟動這個 remote 了,這樣便可以正常 debug 了。

條件斷點

第二個是條件斷點也非常有用,有時候我們需要除錯一個公共函式,呼叫的地方非常多。

而我們只需要關心某一類行為的呼叫,此時就可以對這個函式中的變數進行判斷,當他們滿足某些條件時再進入斷點,這樣可以極大的提高我們的除錯效率:

配置也很簡單,只需要在斷點上右鍵就可以編輯條件了。

社群諮詢

雖然我根據現象初步可以猜測下原因,但依然不確定如何調整才能解決這個問題,於是便去社群提了一個 issue


最後在社群大佬的幫助下發現我們需要禁用掉 OpenTelemetry agent 中的一個 resource 就可以了。


這個 resource 是由 agent 觸發的,它優先於 spring boot 之前進行 SPI 的載入。
目的是為了給 metric 和 trace 新增兩個屬性:


載入的核心程式碼在這裡,只要禁用掉之後就不會再載入了。

禁用前:

禁用後:

當我們禁用掉之後就不會存在這兩個屬性了,不過我們目前並沒有使用這兩個屬性,所以為了使得 SPI 生效就只有先禁用掉了,後續再看看社群還有沒有其他的方案。

想要復現 debug 的可以在這裡嘗試:
https://github.com/crossoverJie/demo

參考連線:

  • https://github.com/TogetherOS/cicada
  • https://docs.spring.io/spring-boot/docs/current/maven-plugin/reference/htmlsingle/#packaging.repackage-goal
  • https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/10921
  • https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/instrumentation/resources/library/README.md#host

相關文章