亚马逊AWS官方博客

从 UnknownHostException 错误来分析 Java 的 DNS 解析和缓存机制

1. 概述

接连几个客户在直接使用 Amazon Java SDK 或者其他应用依赖 Amazon Java SDK 时,偶尔会遇到 API 端点无法解析的错误,也就是 Java 报出的 UnknownHostException 异常。这种问题偶尔发生,很难追踪和定位故障。本文从几个问题出发,希望能给与大家一个最合适和解决方案。为了方便快速阅读,先将问题和简单答案给出,后续再细细分析。

  1. -Amazon Java SDK 会在遇到这种 UnknownHostException 问题时会自动重试吗?
    -会,默认会重试 3 次。
  1. -为什么重试了还是出错?
    -大部分情况下 3 次重试都在数秒(甚至在一秒)内完成,而 JVM 默认针对失败的解析会有 10 秒的缓存(见下个问题),毫无疑问重试会继续失败。
  1. -JVM 的 DNS 缓存机制是什么?
    -主要由这两个配置参数来控制:networkaddress.cache.ttl 控制成功的解析缓存时间,如果没有启用 Java Security Manager,默认是 30 秒;如果启用了,则是 -1,表示永久。networkaddress.cache.negative.ttl 控制失败的解析缓存时间,默认是 10 秒。
  1. -JVM 的缓存 DNS 的时候会遵守 DNS 服务器返回的 TTL 吗?
    -不会,详见下文。
  1. -我该怎么办来避免 UnknownHostException?
    -设置 networkaddress.cache.ttl 为 30 秒,设置 networkaddress.cache.negative.ttl 为 0 秒。
    注意:该配置只是为了解决 DNS 服务器偶尔无法解析的情况。

接下来,我会通过一系列实验和源码解读来详细解释以上问题的答案。

2. 实验准备

我在 VPC 中布置了一个基于 BIND 的 DNS 服务器(172.31.21.120),另外一台 EC2 作为客户端(172.31.189.232)。

DNS 服务器的配置中,现在注释部分是为了模拟解析失败的问题,而配置用来接管 S3 地址的解析。下面 amazonaws.com.cn 匹配域名都直接转发到 VPC.2 的 Route53 解析服务。

/*
zone "s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn" {
        type master;
        file "s3.cn-north-1.zone";
};
*/
zone "amazonaws.com.cn" {
        type forward;
        forwarders { 172.31.0.2;};
};

客户端的 DNS 服务器设置为

[root@ip-172-31-189-232 ~]# cat /etc/resolv.conf
options timeout:2 attempts:5
; generated by /usr/sbin/dhclient-script
search cn-north-1.compute.internal
nameserver 172.31.21.120

Java JDK 版本选择了 1.8.0,Amazon Java SDK 版本为 1.11.563。

这里采用了一个简单的 S3 headBucket 请求来做测试,具体代码如下:

package org.beta.manages3;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.HeadBucketRequest;
import com.amazonaws.services.s3.model.HeadBucketResult;
import org.apache.log4j.Logger;
import sun.net.InetAddressCachePolicy;

/**
 * @author Beta Zhou
 */
public class DnsIssueTest {
    private static final Logger LOGGER = Logger.getLogger(DnsIssueTest.class);
    public static void main(String[] args) throws InterruptedException {
        int sleepInterval = 5;  // Default interval between each request.

        //get bucket and sleep interval from args
        if (args.length < 1) {
            System.out.println("Usage: DnsIssueTest <bucketName> <sleepInterval>");
            System.exit(1);
        } else if (args.length == 2) {
            sleepInterval = Integer.parseInt(args[1].trim());
        }
        String bucketName = args[0].trim();

        // Look up current security settings
        String cacheTtl = java.security.Security.getProperty("networkaddress.cache.ttl");
        LOGGER.error("networkaddress.cache.ttl = " + cacheTtl);
        String negativeTtl = java.security.Security.getProperty("networkaddress.cache.negative.ttl");
        LOGGER.error("networkaddress.cache.negative.ttl = " + negativeTtl);

        // Get current cache policy
        int cachePolicy  = InetAddressCachePolicy.get();
        LOGGER.error("cachePolicy = " + cachePolicy);
        int cacheNegativePolicy = InetAddressCachePolicy.getNegative();
        LOGGER.error("cacheNegativePolicy = " + cacheNegativePolicy);

        // S3 client
        AmazonS3 s3Client = AmazonS3ClientBuilder.standard().build();

        //head  a s3 bucket for 5 times
        for (int i = 0; i < 5 ; i++) {
            try {
                LOGGER.error("-------------------------------------");
                LOGGER.error("Test round: "+i);
                LOGGER.error("-------------------------------------");
                HeadBucketRequest headBucketRequest = new HeadBucketRequest(bucketName);
                HeadBucketResult headBucketResult = s3Client.headBucket(headBucketRequest);

                if (headBucketResult != null) {
                    LOGGER.info("Bucket is there: " + bucketName);
                }

            } catch(Exception e){
                    // The call was transmitted successfully, but Amazon S3 couldn't process
                    // it and returned an error response.
                    e.printStackTrace();
                    LOGGER.error("Error: " + e.getMessage());
            }
            //sleep sleepInterval seconds
            Thread.sleep(sleepInterval * 1000L);
        }
    }
}

同时,为了看到 SDK 的重试情况,创建了 log4j.properties,内容如下:

log4j.rootLogger=WARN, A1
log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c -  %m%n
# Log all HTTP content (headers, parameters, content, etc)  for
# all requests and responses. Use caution with this since it can
# be very expensive to log such verbose data!
#log4j.logger.org.apache.http.wire=DEBUG
log4j.logger.com.amazonaws.request=DEBUG

3. 测试过程

3.1 正常情况

首先进行一个正常情况下的测试,这里用了 tcpdump 来抓包。

程序运行日志(由于篇幅,只截取了 4 次运行结果),一开始为 cache 的相关配置:

以下为 tcpdump 结果,抓取了与 DNS 服务器之间的通讯信息:

以下为 dig betatest---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn 的结果,通过多次执行,发现 TTL 最大为 4 秒。

从上面截图上可以看到以下几点:

  1. networkaddress.cache.ttl 默认是没有配置的,而生效 cache 时间是 30 秒。
  2. networkaddress.cache.negative.ttl 默认是 10 秒。
  3. 运行的几次尝试中,只有一开始有抓到 DNS 的数据包。说明 Java 是缓存了 DNS 的,后续请求无需再次解析。
  4. DNS 返回的 S3 地址的 TTL 是 4 秒,每次执行间隔为 5 秒,所以显然 Java 在缓存的时候没有考虑该 TTL,还是按照 cachePolicy 的 30 秒来缓存。

3.2 模拟无法解析情况

该情况下,DNS 服务器将返回该域名不存在。具体是通过接管 s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn 域的解析,并不设置 betatest---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn 的 A 记录来完成。

zone "s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn" {
        type master;
        file "s3.cn-north-1.zone";
};
zone "amazonaws.com.cn" {
        type forward;
        forwarders { 172.31.0.2;};
};

dig betatest---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn 的结果显示没有该域名记录:

日志只截取了四次执行结果,都是以 UnknownHostException 为错误结束的。

下面的 tcpdump 只有包含 3 次请求,而非 5 次,和上面的请求日志并非完全匹配,说明存在了缓存机制。这里提一下在客户端/etc/resolve.conf 中配置了 search domain,所以在查询 betatest---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn 失败后,又尝试了 betatest---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn.cn-north-1.computer.internal 域名。

总结来说,可以看到以下几点:

  1. S3 SDK 在失败的时候自动执行了 3 次重试,每次重试间隔在几到几百毫秒之间,逐步扩大。
  2. 在每次重试时,并没有再次发生 DNS 请求,说明失败的解析也被缓存了。这就是通过 networkaddress.cache.negative.ttl 来控制的,默认 10 秒。
  3. 通过 tcpdump,三次请求的间隔为 11 秒,正好印证了上面设置。而中间 33 秒时候的请求就没有再发送 DNS 请求。

3.3 模拟 DNS 服务器没有响应

我们通过 iptables -A INPUT -s 172.31.189.232 -p udp –dport 53 -j DROP 命令来阻断所有来自客户端的 DNS 请求来模拟 DNS 服务器没有响应的情况。从下图可以看到第一次请求到重试之间花了 20 秒,这是由于在/etc/resolve.conf 设置了重试 5 次,每次 2 秒,并且存在 search domain 多做一轮,所以总共 DNS 花了 20 秒在尝试查询,这个可以从 tcpdump 的结果确认。

下面的 tcpdump 我用红绿框表示了两次查询,实际上还有一次,发生在 14:39:29,限于篇幅就不放更多截图了。

总结来说看到以下几点:

  1. 当 DNS 服务器没有响应时,请求会有较长时间在尝试解析,这和客户端 DNS 解析配置和有关。
  2. DNS 解析失败同样会被缓存,在有效时间内不再向 DNS 服务器解析,直到过期后才会继续尝试。这点和测试 3.2 结果类似。

3.4 模拟无法解析情况时不缓存

从上面看到,因为有 negative cache 的存在,有时 DNS 服务器短暂故障而无法解析,会导致后续重试时继续失败。这次我们将 java.security 里面的配置改为 networkaddress.cache.negative.ttl=0,其他配置还是按照 3.2 的方式。

下图可以看到生效的 cacheNegativePolicy 确定为 0 秒。在四次请求的 1 秒内,tcpdump 显示有四次查询,虽然每次都返回了无此域名的错误。

总结来说看到以下:

  1. 当 networkaddress.cache.negative.ttl=0 时,失败的解析不再缓存,每次重试都会向 DNS 服务器方式请求。
  2. 针对偶尔 DNS 服务器出错的情况,这个配置可以让程序在重试时有机会成功。

3.5 模拟 DNS 服务器没有响应时不缓存

我们设置了 networkaddress.cache.negative.ttl=0,其他按照 3.3 的方式。下图可以看到每次重试时间间隔达到了 20 秒。

Tcpdump 也显示了每次重试都需要经过多次尝试。

总结来说看到以下:

  1. 当 networkaddress.cache.negative.ttl=0 时,失败的解析不再缓存,每次重试都会向 DNS 服务器方式请求。
  2. 由于 DNS 没有回复而导致了较长的重试时间,真可能会导致应用卡住,问题没有及时报告。

4. 详细分析

4.1 缓存配置

Sun Java 官方网站(Link 1)对于 DNS 缓存的两次参数描述还是很简单的:

首先针对 networkaddress.cache.ttl,在有没有启用 Security Manager 功能情况下结果是不同的。在 Security Manager 启用时为 -1,表示永久缓存。这是 Java 为了避免遭受 DNS spoofing(也叫 DNS cache poisoning)攻击而设置的,这样可以避免通过黑客篡改 DNS 来攻击。而 Amazon 服务的 API 终端节点对应的 IP 会经常变化,不能把 DNS 永久缓存,而应该把这个 TTL 的值设置的小一点。而没有启用 Security Manager 的情况下,JDK 版本 1.8 及以上的缺省值都是 30 秒。 Amazon 官方文档也建议该参数要小于 60 秒(Link 2)。

其次针对 networkaddress.cache.negative.ttl,无论是否启用 Security Manager,缺省都是 10 秒,这个可以在 JDK 目录的 java.security 配置文件中找到,也可以做修改。其中 -1 表示永久缓存,0 表示不缓存。从上面的测试来看,设置为 0 的利大于弊。

4.2 源码探究

在写本文之前,我一直以为 Java 会遵循 DNS 的 TTL,测试的结果确实出乎意料,虽然网上也有些资料说明这点,但最好的办法来确认这个还是看代码。

首先找到 java/net/InetAddress.java中getAllByName0 这个方法,跳过 SecurityManager 部分,可以看到 getCachedAddresses,说明会先去 Cache 里面找,如果找不到,才会到 DNS 服务那边去找。

我们在从 Cache 里面取结果的部分,会判断这个结果是否已经过期。那这个过期时间是否和 DNS 服务器返回的 TTL 有关呢?

答案是:非也。下面给 Cache 里面加内容的代码中,expiration 只是根据 cachePolicy 来算的,也就是上面提到的配置参数 networkaddress.cache.ttl。

4.3 重试机制

这里我们只讨论 Amazon Java SDK 的重试机制,其他语言的请参考相应文档。在 Java SDK 的文档中(Link 3)写明,默认的重试次数是 3 次,用户可以通过 ClientConfiguration.setMaxErrorRetry 方法来修改。重试是采用指数回退机制,来让重试变得更有效率,具体可以参考文档(Link 4)。

5. 总结

DNS 服务可以说是网络世界中最重要的一个服务了,本文从 Java 程序遇到的 DNS 解析问题出发,通过各种测试以及阅读源码,颠覆了之前认知,学习了 Amazon Java SDK 和 JDK 之间合作依赖关系,为解决偶发的 DNS 解析故障,即 UnknownHostException 错误给出了一个可行建议,供大家参考:

设置 networkaddress.cache.ttl 为 30 秒,
设置 networkaddress.cache.negative.ttl 为 0 秒。

参考链接

[1] https://docs.oracle.com/javase/8/docs/technotes/guides/net/properties.html

[2] https://docs.aws.amazon.com/zh_cn/sdk-for-java/latest/developer-guide/jvm-ttl-dns.html

[3] https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/section-client-configuration.html

[4] https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html

本篇作者

周平

西云数据高级技术客户经理,致力于大数据技术的研究和落地,为亚马逊云科技中国客户提供企业级架构和技术支持。