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

594a4a76a5b928ce5e9181758f3585c3.gif

01

概述

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

Q:Amazon Java SDK 会在遇到这种 UnknownHostException 问题时会自动重试吗?

A:会,默认会重试 3 次。

Q:为什么重试了还是出错?
A:大部分情况下 3 次重试都在数秒(甚至在一秒)内完成,而 JVM 默认针对失败的解析会有 10 秒的缓存(见下个问题),毫无疑问重试会继续失败。

Q:JVM 的 DNS 缓存机制是什么?
A:主要由这两个配置参数来控制:networkaddress.cache.ttl 控制成功的解析缓存时间,如果没有启用 Java Security Manager,默认是 30 秒;如果启用了,则是 -1,表示永久。networkaddress.cache.negative.ttl 控制失败的解析缓存时间,默认是 10 秒。

Q:JVM 的缓存 DNS 的时候会遵守 DNS 服务器返回的 TTL 吗?
A:不会,详见下文。

Q:我该如何避免 UnknownHostException?
A:设置 networkaddress.cache.ttl 为 30 秒,设置 networkaddress.cache.negative.ttl 为 0 秒。


注意:该配置只是为了解决 DNS 服务器偶尔无法解析的情况。

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

02

实验准备

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

f8cb35d54ed1f02f57034a84c4efc98a.png

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

/*
zone "s3.cn-north-1.amazonaws.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

03

测试过程

3.1 正常情况

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

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

7a3c90d7901153ad3a94a0b953f4e72c.png

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

ea276cac6d874d3e483f93b6978d2984.png

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

20ea2bb49ccb4a372f38ab7e0b3f6aed.png

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

  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.cn 域的解析,并不设置 betatest.s3.cn-north-1.amazonaws.com.cn 的 A 记录来完成。

zone "s3.cn-north-1.amazonaws.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.cn 的结果显示没有该域名记录:

59a3f8a6cb08eb0a298ff8a6ab5927d9.png

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

c13cec598be76fc45ecb0bacb425a01d.png

ad6632bd675ce21fc89175490af0b902.png

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

2ffb513c7bbf4c6e4db7c4cb2ad6cb85.png

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

  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 的结果确认。

7d3c28df6b3ed4a877f5f62de3d65eb7.png

a2e7b1ae617ef8a118b2ddc50e8e4675.png

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

775eb5666face84ad7408c5f75ebf0c6.png

总结来说看到以下几点:

  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 显示有四次查询,虽然每次都返回了无此域名的错误。

89682d0963f333b286bbaf69a6ecfa77.png

afcf35b7d4d7145fa56414d547dda8e4.png

总结来说看到以下:

  1. 当 networkaddress.cache.negative.ttl=0 时,失败的解析不再缓存,每次重试都会向 DNS 服务器方式请求。

  2. 针对偶尔 DNS 服务器出错的情况,这个配置可以让程序在重试时有机会成功。

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

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

450f8d6edc3b1d28825078d865f8a4da.png

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

0d365a50fd51bc8bdc4e6687db58eae4.png

总结来说看到以下:

  1. 当 networkaddress.cache.negative.ttl=0 时,失败的解析不再缓存,每次重试都会向 DNS 服务器方式请求。

  2. 由于 DNS 没有回复而导致了较长的重试时间,真可能会导致应用卡住,问题没有及时报告。

04

详细分析

4.1 缓存配置

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

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

72194c17041b67aee84418b606845010.png

首先针对 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 秒(https://docs.aws.amazon.com/zh_cn/sdk-for-java/latest/developer-guide/jvm-ttl-dns.html)。

其次针对 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 服务那边去找。

a3ef406f5d97cbe066bc4fde0a99efdc.png

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

c997aca442834f02345e7dc09f11ff03.png

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

1701fae3979f67ae1fd2924c0d61f45f.png

2bac059d12da0a00f0578c014c8dd641.png

4.3 重试机制

这里我们只讨论 Amazon Java SDK 的重试机制,其他语言的请参考相应文档。在 Java SDK 的文档中(https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/section-client-configuration.html)写明,默认的重试次数是 3 次,用户可以通过 ClientConfiguration.setMaxErrorRetry 方法来修改。重试是采用指数回退机制,来让重试变得更有效率,具体可以参考文档:https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html

05

总结

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

本篇作者

67be79f6726e4980bbb2c0d71a5f1598.jpeg

周平

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

fd351fadc9c999120244557880322485.gif

星标不迷路,开发更极速!

关注后记得星标「亚马逊云开发者」

8e9875eefd1a395784b39ac6af28b0c6.gif

听说,点完下面4个按钮

就不会碰到bug了!

1c094641d68f2ed1ff6488b778ff7679.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值