OpenTelemetry agent 对 Spring Boot 应用的影响:一次 SPI 失效的调查

背景

前段时间公司领导让我排查一个关于在 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 地址返回域名。
代码语言:javascript
复制

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";
}

}


addresses = InetAddress.getAllByName("fedora");
// output: 127 127 10 1
</code></pre></div></div><p>这里我简单实现了一个对域名 fedora 的解析,会直接返回 <code>127.127.10.1</code></p><p>如果使用 IP 地址进行查询时:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">InetAddress byAddress = InetAddress.getByAddress(new byte[]{127, 127, 10, 1});

System.out.println(&#34;+++++&#34; + byAddress.getHostName());
// output: fedora
</code></pre></div></div><p>当然要要使得这个 SPI 生效的前提条件是我们需要新建一个文件:<code>META-INF/services/java.net.spi.InetAddressResolverProvider</code>里面的内容是我们自定义类的全限定名称:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">com.example.demo.MyAddressResolverProvider
</code></pre></div></div><p>这样一个完整的 SPI 就实现完成了。</p><figure class=""><hr/></figure><p>正常情况下我们将应用打包为一个 jar 之后运行:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">java -jar target/demo-0.0.1-SNAPSHOT.jar
</code></pre></div></div><p>是可以看到输出结果是符合预期的。</p><p>一旦我们使用配合上 spring boot 打包之后,也就是加上以下的依赖:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">&lt;parent&gt;  
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;  
  &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt;  
  &lt;version&gt;3.2.3&lt;/version&gt;  
  &lt;relativePath/&gt; &lt;!-- lookup parent from repository --&gt;  
&lt;/parent&gt;

&lt;build&gt;  
  &lt;plugins&gt;  
   &lt;plugin&gt;  
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;  
    &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;  
   &lt;/plugin&gt;  
  &lt;/plugins&gt;  
&lt;/build&gt;
</code></pre></div></div><p>再次执行其实也没啥问题,也能按照预期输出结果。</p><p>但我们加上 OpenTelemetry  agent 时:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">java  -javaagent:opentelemetry-javaagent.jar \
      -jar target/demo-0.0.1-SNAPSHOT.jar
</code></pre></div></div><p>就会发现在执行解析的时候抛出了 <code>java.net.UnknownHostException</code>异常。</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837586349194986.png" /></div></div></div></figure><p>从结果来看就是没有进入我们自定义的解析器。</p><h2 id="1egqh" name="SPI-%E5%8E%9F%E7%90%86"><strong>SPI 原理</strong></h2><p>在讲排查过程之前还是要先预习下关于 Java SPI 的原理以及应用场景。</p><p>以前写过一个 http 框架 cicada,其中有一个可拔插 IOC 容器的功能:</p><blockquote><p>就是可以自定义实现自己的 IOC 容器,将自己实现的 IOC 容器打包为一个第三方包加入到依赖中,cicada 框架就会自动使用自定义的 IOC 实现。</p></blockquote><p>要实现这个功能本质上就是要定义一个接口,然后根据依赖的不同实现创建接口的实例对象。</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">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 &lt;T&gt;
     * @return bean
     * @throws Exception
     */
    &lt;T&gt; T getBean(Class&lt;T&gt; clazz) throws Exception;

    /**
     * release all beans
     */
    void releaseBean() ;
}
</code></pre></div></div><p>获取具体的示例代码时就只需要使用 JDK 内置的 <code>ServiceLoader</code> 进行加载即可:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">public static CicadaBeanFactory getCicadaBeanFactory() {  
    ServiceLoader&lt;CicadaBeanFactory&gt; cicadaBeanFactories = ServiceLoader.load(CicadaBeanFactory.class);  
    if (cicadaBeanFactories.iterator().hasNext()){  
        return cicadaBeanFactories.iterator().next() ;  
    }  
    return new CicadaDefaultBean();  
}
</code></pre></div></div><p>代码也非常的简洁,和刚才提到的 <code>InetAddressResolverProvider</code> 一样我们需要新增一个 <code>META-INF/services/top.crossoverjie.cicada.base.bean.CicadaBeanFactory</code> 文件来配置我们的类名称。</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">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, &#34;Error locating configuration files&#34;, x);
        }
    }
    while ((pending == null) || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}
</code></pre></div></div><p> ServiceLoader 类中会会去查找 <code>META-INF/services</code> 的文件,然后解析其中的内容从而反射生成对应的接口对象。</p><p>这里还有一个关键是通常我们的代码都会打包为一个 JAR 包,类加载器需要加载这个  JAR 包,同时需要在这个 JAR 包里找到我们之前定义的那个 spi 文件,如果这里查不到文件那就认为没有定义 SPI</p><p>这个是本次问题的重点,会在后文分析原因的时候用到。</p><h2 id="1r6h7" name="%E6%8E%92%E6%9F%A5"><strong>排查</strong></h2><p>因为问题就出现在是否使用 opentelemetry-javaagent.jar 上,所以我需要知道在使用了 agent 之后有什么区别。</p><p>从刚才的对 SPI 的原理分析,加上 agent 出现异常,说明理论上就是没有读取到我们配置的文件: <code>java.net.spi.InetAddressResolverProvider</code></p><p>于是我便开始 debug,在 ServiceLoader 加载 jar 包的时候是可以看到具体使用的是什么 <code>classLoader</code> </p><p>这是不配置 agent 的时候使用的 classLoader</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837586836563624.png" /></div></div></div></figure><p>使用这个 loader 是可以通过文件路径在 jar 包中查找到我们配置的文件。</p><p>而配置上 agent 之后使用的 classLoader:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837587163132602.png" /></div></div></div></figure><p>却是一个 JarLoader,这样是无法加载到在 springboot 格式下的配置文件的,至于为什么加载不到,那就要提一下 maven 打包后的文件目录和 spring boot 打包后的文件目录的区别了。</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837587475310510.png" /></div></div></div></figure><p>这里我截图了同样的一份代码不同的打包方式:上面的是传统 maven,下图是 spring boot;其实主要的区别就是在 pom 中使用了一个构建插件:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">&lt;build&gt;  
  &lt;plugins&gt;  
   &lt;plugin&gt;  
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;  
    &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;  
   &lt;/plugin&gt;  
  &lt;/plugins&gt;  
&lt;/build&gt;
</code></pre></div></div><blockquote><p>或者使用 <code>spring-boot</code> 命令再次打包的效果也是一样的。</p></blockquote><p>会发现 spring boot 打包后会多出一层 <code>BOOT-INF</code> 的文件夹,然后会在 <code>MANIFIST.MF</code> 文件中定义 <code>Main-Class</code>  <code>Start-Class</code>.</p><figure class=""><hr/></figure><p>通过上面的 debug 其实会发现 JarLoader 只能在加载 maven 打包后的文件,也就是说无法识别 BOOT-INF 这个目录。</p><p>正常情况下 spring boot 中会有一个额外的 <code>java.nio.file.spi.FileSystemProvider</code> 实现:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837588057988522.png" /></div></div></div></figure><p>通过这个类的实现可以直接从 JAR 包中加载资源,比如我们自定义的 SPI 资源等。</p><p>初步判断使用 <code>opentelemetry-javaagent.jar</code> agent 之后,它的类加载器优先于了 spring boot ,从而导致后续的加载失败。</p><h3 id="an9tc" name="%E8%BF%9C%E7%A8%8B-debug"><strong>远程 debug</strong></h3><p>这里穿插几个 debug 小技巧,其中一个是远程 debug,因为这里我是需要调试 javaagent,正常情况下是无法直接 debug 的。</p><p>所以我们可以使用以下命令启动应用:</p><div class="rno-markdown-code"><div class="rno-markdown-code-toolbar"><div class="rno-markdown-code-toolbar-info"><div class="rno-markdown-code-toolbar-item is-type"><span class="is-m-hidden">代码语言:</span>javascript</div></div><div class="rno-markdown-code-toolbar-opt"><div class="rno-markdown-code-toolbar-copy"><i class="icon-copy"></i><span class="is-m-hidden">复制</span></div></div></div><div class="developer-code-block"><pre class="prism-token token line-numbers language-javascript"><code class="language-javascript" style="margin-left:0">java -agentlib:jdwp=&#34;transport=dt_socket,server=y,suspend=y,address=5000&#34; -javaagent:opentelemetry-javaagent.jar \
      -jar target/demo-0.0.1-SNAPSHOT.jar
</code></pre></div></div><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837588585354071.png" /></div></div></div></figure><p>然后在 idea 中配置一个 remote 启动。</p><blockquote><p>注意这里的端口得和命令行中的保持一致。</p></blockquote><p>当应用启动之后便可以在 idea 中启动这个 remote 了,这样便可以正常 debug 了。</p><h3 id="5ebsq" name="%E6%9D%A1%E4%BB%B6%E6%96%AD%E7%82%B9"><strong>条件断点</strong></h3><p>第二个是条件断点也非常有用,有时候我们需要调试一个公共函数,调用的地方非常多。</p><p>而我们只需要关心某一类行为的调用,此时就可以对这个函数中的变量进行判断,当他们满足某些条件时再进入断点,这样可以极大的提高我们的调试效率:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:61.99%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837588889122845.png" /></div></div></div></figure><p>配置也很简单,只需要在断点上右键就可以编辑条件了。</p><h2 id="5lp7b" name="%E7%A4%BE%E5%8C%BA%E5%92%A8%E8%AF%A2"><strong>社区咨询</strong></h2><p>虽然我根据现象初步可以猜测下原因,但依然不确定如何调整才能解决这个问题,于是便去社区提了一个 issue</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837589159909981.png" /></div></div></div></figure><p>最后在社区大佬的帮助下发现我们需要禁用掉 OpenTelemetry agent 中的一个 resource 就可以了。</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837589471244886.png" /></div></div></div></figure><p>这个 resource 是由 agent 触发的,它优先于 spring boot 之前进行 SPI 的加载。目的是为了给 metric  trace 新增两个属性:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837589843860489.png" /></div></div></div></figure><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837590212617419.png" /></div></div></div></figure><p>加载的核心代码在这里,只要禁用掉之后就不会再加载了。</p><p>禁用前:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837590539206630.png" /></div></div></div></figure><p>禁用后:</p><figure class=""><div class="rno-markdown-img-url" style="text-align:center"><div class="rno-markdown-img-url-inner" style="width:100%"><div style="width:100%"><img src="https://cdn.static.attains.cn/app/developer-bbs/upload/1722837590951409601.png" /></div></div></div></figure><p>当我们禁用掉之后就不会存在这两个属性了,不过我们目前并没有使用这两个属性,所以为了使得 SPI 生效就只有先禁用掉了,后续再看看社区还有没有其他的方案。</p><p>想要复现 debug 的可以在这里尝试:https://github.com/crossoverJie/demo</p><p>参考连接:</p><ul class="ul-level-0"><li>https://github.com/TogetherOS/cicada</li><li>https://docs.spring.io/spring-boot/docs/current/maven-plugin/reference/htmlsingle/#packaging.repackage-goal</li><li>https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/10921</li><li>https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/instrumentation/resources/library/README.md#host</li></ul>