前言
- 這裏 redisson 的版本爲 3.11.2, 對應 netty-all 的版本爲 4.1.38.Final
- 如果這篇描述的方法不能解決問題,可以參考另外一篇 Redisson-3.8 查找DNS異常的解決辦法
- redisson 其實沒問題,問題出在 netty 身上,而且神經得很。
- 之前的 netty 查找 DNS 失敗,是因爲解析 /etc/resolv.conf 文件有 bug,而這個版本的 netty 則是代碼有 bug。
現象
使用 redisson 的應用程序運行時出錯,報錯信息:
Exception in thread "main" org.redisson.client.RedisConnectionException: At least two sentinels should be defined in Redis configuration! SENTINEL SENTINELS command returns empty result!
at org.redisson.connection.SentinelConnectionManager.<init>(SentinelConnectionManager.java:188)
at org.redisson.config.ConfigSupport.createConnectionManager(ConfigSupport.java:197)
at org.redisson.Redisson.<init>(Redisson.java:120)
at org.redisson.Redisson.create(Redisson.java:160)
at com.ericsson.jee.iam.common.redisson.util.RedissonFactory.initRedissonObject(RedissonFactory.java:27)
at test.Test.main(Test.java:17)
連接使用的 URL:sentinel://redis:26379?. . .
sentinel URL 的說明
你可能會覺得奇怪,報錯明明說了至少需要2個 sentinel,但是 URL 卻只寫了一個 (redis:26379),是不是這裏出的問題?
其實不然,redisson 在啓動的時候,會通過 URL 中配置的 sentinel 信息,訪問 sentinel 節點,然後從 sentinel 節點獲取同一集羣中其他 sentinel 節點的信息,因此 URL 中只需要一個 sentinel 的信息就可以了。很多時候我們在 URL 中填寫多個 sentinel 的連接信息,是出於容錯的考慮,如果使用第一個 sentinel 的信息連接不上,那就使用第二個,依次類推。
DNS 環境和配置描述
從 URL 中可以看出,sentinel 連接信息使用的是域名 redis,而不是 IP 地址,因此需要先確認 DNS 的可用。本次實驗環境在本地安裝了 bind9 用以提供 DNS 服務。
helowken@helowken-mint ~ $ ping redis
PING redis.ostechnix.lan (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.016 ms
64 bytes from localhost (127.0.0.1): icmp_seq=2 ttl=64 time=0.031 ms
64 bytes from localhost (127.0.0.1): icmp_seq=3 ttl=64 time=0.031 ms
/etc/resolv.conf 內容:
helowken@helowken-mint ~ $ cat /etc/resolv.conf
nameserver 127.0.0.1
nameserver 202.96.128.166
nameserver 202.96.134.133
nameserver 163.15.15.15
search xxx.yyy ostechnix.lan
nameserver 表示 DNS 服務器地址:
- 127.0.0.1 是安裝在本地的 DNS 服務器 bind9
- 202.xxx.xxx.xxx 是電信運營商自動生成的
- 163.15.15.15 是一個不存在 IP 地址,用於本次實驗
search 表示自動添加到域名後面的搜索域:
- xxx.yyy 是一個沒有記錄搜索域,用於本次實驗
- ostechnix.lan 存在於本地 bind9 的配置中,可以被 bind9 正確解析
使用 nslookup 可以再次確認 DNS 的可用:
helowken@helowken-mint ~ $ nslookup redis 127.0.0.1
Server: 127.0.0.1
Address: 127.0.0.1#53
Name: redis.ostechnix.lan
Address: 127.0.0.1
使用 dig 查詢完整的 DNS 記錄信息
helowken@helowken-mint ~ $ dig @127.0.0.1 redis.ostechnix.lan
; <<>> DiG 9.10.3-P4-Ubuntu <<>> @127.0.0.1 redis.ostechnix.lan
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5064
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 2, ADDITIONAL: 3
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;redis.ostechnix.lan. IN A
;; ANSWER SECTION:
redis.ostechnix.lan. 86400 IN A 127.0.0.1
;; AUTHORITY SECTION:
ostechnix.lan. 86400 IN NS pri.ostechnix.lan.
ostechnix.lan. 86400 IN NS sec.ostechnix.lan.
;; ADDITIONAL SECTION:
pri.ostechnix.lan. 86400 IN A 192.168.1.200
sec.ostechnix.lan. 86400 IN A 192.168.1.201
;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Sat Nov 02 02:28:49 CST 2019
;; MSG SIZE rcvd: 132
從 dig 的輸出中可以看到返回了地址記錄:
;; ANSWER SECTION:
redis.ostechnix.lan. 86400 IN A 127.0.0.1
綜上所述,可以得知:
- 使用域名 “redis” 時,會自動添加搜索域 “ostechnix.lan”
- 向 nameserver 127.0.0.1 (bind9) 發送 DNS 查詢請求 “redis.ostechnix.lan”,會得到實際的地址 127.0.0.1
使用 redis-cli 確認 sentinel 是可以連接的:
helowken@helowken-mint ~/redis-2.8.21 $ src/redis-cli -h redis -p 26379
redis:26379> sentinel get-master-addr-by-name mymaster
1) "127.0.0.1"
2) "6379"
接下來使用 dig 嘗試往 “202.96.128.166” 發送查詢請求:
helowken@helowken-mint ~ $ dig @202.96.128.166 redis.ostechnix.lan
; <<>> DiG 9.10.3-P4-Ubuntu <<>> @202.96.128.166 redis.ostechnix.lan
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 18859
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0
;; QUESTION SECTION:
;redis.ostechnix.lan. IN A
;; AUTHORITY SECTION:
. 1800 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2019110101 1800 900 604800 86400
;; Query time: 8 msec
;; SERVER: 202.96.128.166#53(202.96.128.166)
;; WHEN: Sat Nov 02 02:40:34 CST 2019
;; MSG SIZE rcvd: 112
沒有看到 “ANSWER SECTION”,也就是說沒有查找到地址記錄。(202.96.134.133 結果類似)
再嘗試 dig “163.15.15.15”:
helowken@helowken-mint ~ $ dig @163.15.15.15 redis.ostechnix.lan
; <<>> DiG 9.10.3-P4-Ubuntu <<>> @163.15.15.15 redis.ostechnix.lan
; (1 server found)
;; global options: +cmd
;; connection timed out; no servers could be reached
發現連接超時。
如果依次往所有 nameserver 查詢 “redis.xxx.yyy”,會發現:
- 127.0.0.1 和 202.xxx.xxx.xxx 都沒有地址記錄
- 163.15.15.15 連接超時
因此,我們可以得到以下表格:
Netty 源碼分析
因爲源碼內容較多,這裏直接給出主要的邏輯:
- 當滿足以下兩個條件時,DNS 查找過程將終止:
- allowedQueries 遞減爲 0
- nameserver 列表已經遍歷完
- allowedQueries 表示查找過程總共允許多少次查詢,每發起一次查詢就減 1。
- 查詢會一個接一個 nameserver 地進行嘗試,上一個 nameserver 查找失敗後(沒有記錄,超時,網絡問題),纔會往下一個 nameserver 發送查詢請求。
- 查詢的時候會把搜索域(search domain)添加到名稱的後面形成全路徑(如果名稱沒有以"."結尾)。搜索域也是一個接一個地進行嘗試,只要在上一個搜索域遍歷完所有 nameserver 後都查找失敗,纔有可能使用下一個搜索域。
- 查找過程會同時發起 “A” 和 “AAAA” 記錄的查詢請求。“A” 記錄表示 IPv4 的地址記錄,“AAAA” 表示 IPv6 的地址記錄。
- 如果所有 nameserver 查詢 “A” 或 “AAAA” 記錄都失敗,且滿足以下條件,則轉爲查詢 “CNAME” 記錄(“CNAME” 表示別名,相當於做多了一次命名映射):
- allowedQueries 還沒有遞減到 0
- 最後一次使用的 nameserver 沒有出現錯誤
- 查詢 “CNAME” 記錄時,將重新遍歷nameserver 列表,若 “CNAME” 有結果返回,則使用 “CNAME” 的結果再次查詢 “A” 或 “AAAA” 記錄。
- 如果在查找過程中,任一 nameserver 返回了記錄,則終止查找。如果出現了錯誤,則使用下一個 nameserver 繼續進行查詢。
- 當 allowedQueries 遞減到爲 0 時,如果還沒有找到 “A” 或 “AAAA” 記錄,則返回 UnknownHostException。
- 如果最後一次查詢出現了超時或者網絡錯誤,則把 UnknownHostException 的 cause 設置爲該錯誤。
- 檢查返回的結果,如果存在 UnknownHostException,則檢查它的 cause:
- if 超時或者網絡錯誤,則最終判定爲查找失敗
- else if 還有搜索域可用,則使用下一個搜索域,重複上述的邏輯,開始新一輪的查找,且 allowedQueries 會重置爲初始值
- else 其他的邏輯(當前可以省略)
實驗
爲了更容易和準確地得到實驗結果,我稍微更改了一下 netty 的源碼,調整的內容有:
- allowedQueries 根據每個實驗進行設置
- 只查詢 “A” 記錄,不查詢 “AAAA” 記錄。如果兩個都進行查詢,會因爲異步調用而出現隨機性。
- 把查詢超時從 5s 修改爲 1s,這是爲了快速得到結果。
本實驗環節爲了簡單,不配置 CNAME 記錄。
實驗1
nameserver 127.0.0.1
nameserver 202.96.128.166
nameserver 202.96.134.133
nameserver 163.15.15.15
search xxx.yyy ostechnix.lan
allowedQueries | Result |
---|---|
1 ~ 3 | Pass |
>= 4 | Failed |
結果分析:
- 當 allowedQueries 爲 1~3 的時候,查詢 “redis.xxx.yyy” 都沒有記錄,因此返回了 UnknownHostException,但因爲沒有超時或網絡錯誤,且還有 “ostechnix.lan” 這個搜索域可用,所以用 “redis.ostechnix.lan” 開啓新一輪查找,最後在 127.0.0.1 上查詢到記錄。
- 當 allowedQueries >= 4 時,第一輪查詢的最後一個 nameserver 是 163.15.15.15,由於網絡不可達,返回了超時錯誤,按照上面源碼分析的結果,最終判定查找失敗。(即使這時 allowedQueries > 0 也不會查詢 “CNAME”,因爲最後一次查詢出了錯)
實驗2
nameserver 127.0.0.1
nameserver 202.96.128.166
nameserver 202.96.134.133
nameserver 163.15.15.15
search ostechnix.lan xxx.yyy
allowedQueries | Result |
---|---|
N (N >= 1) | Pass |
結果分析:
- 只要 allowedQueries >= 1,在第一輪查找時,都會在 127.0.0.1 上查詢到 “redis.ostechnix.lan” 的地址記錄,所以 100% pass。
實驗3
nameserver 202.96.128.166
nameserver 202.96.134.133
nameserver 163.15.15.15
nameserver 127.0.0.1
search xxx.yyy ostechnix.lan
allowedQueries | Result |
---|---|
1 ~ 3 | Failed |
4 ~ 6 | Pass |
7 | Failed |
>= 8 | Pass |
結果分析:
- 當 allowedQueries 爲 1~3 時,無論使用哪個搜索域,前 3 個 nameserver 都無法返回地址記錄(202.xxx.xxx.xxx 是沒有查找到記錄,而 163.15.15.15 會超時),因爲 allowedQueries 不夠次數使請求落到 127.0.0.1 上面,所以最終判定查找失敗。
- 當 allowedQueries == 4 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries 遞減到 0,且最後一個 nameserver 是 127.0.0.1,雖然沒有查詢到記錄,但也沒有出現超時或者網絡錯誤,於是使用 “redis.ostechnix.lan” 開啓新一輪查找。第二輪查找時,最終會在 127.0.0.1 上查詢到記錄。
- 當 allowedQueries == [5, 6] 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries 遞減到 2,因爲最後一次查詢是在 127.0.0.1 上進行的,雖然無記錄但沒有出錯,於是從頭遍歷 nameserver 列表查找 “CNAME” 記錄。但是兩臺 202.xxx.xxx.xxx 都無法查詢到記錄,這時 allowedQueries 遞減到 0,因爲最後一次查詢 "CNAME"記錄時雖然無記錄,但也沒有出現超時或者網絡錯誤,於是使用 “redis.ostechnix.lan” 開啓新一輪查找,最終在 127.0.0.1 上查詢到記錄。
- 當 allowedQueries == 7 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries 遞減到 3,因爲最後一次查詢是在 127.0.0.1 上進行的,雖然無記錄但沒有出錯,於是從頭遍歷 nameserver 列表查找 “CNAME” 記錄。但是前 3 臺 nameserver 都查找失敗,這時 allowedQueries 遞減到 0,因爲最後一次查詢 “CNAME” 記錄是在 163.15.15.15 上進行的,出現了超時錯誤,所以最終判定查找失敗。
- 當 allowedQueries >= 8 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries >= 4,因爲最後一次查詢是在 127.0.0.1 上進行的,雖然無記錄但沒有出錯,於是從頭遍歷 nameserver 列表查找 “CNAME” 記錄。雖然最後所有 nameserver 都查找失敗,但是因爲最後的查詢請求是落在 127.0.0.1 上的,沒有超時或網絡錯誤,所以使用 “redis.ostechnix.lan” 開啓新一輪查找,最後在 127.0.0.1 上查詢到記錄。
PS:你還可以進行更多的實驗,根據上面的邏輯進行分析。
結論
解決辦法
爲了保證 Netty 查找 DNS 能成功,最好遵循以下準則:
- Netty 應用使用的搜索域,儘可能放在 /etc/resolv.conf 文件中 “search” 行的第一位
- 用於解析第1點中的搜索域的 nameserver,儘可能放在 /etc/resolv.conf 文件中 nameserver 列表的最上面
- 儘可能把網絡不可達的 nameserver 從 /etc/resolv.conf 文件中刪除
PS:
- Netty 對於 DNS 的默認查詢超時是 5s,查詢是一個接一個搜索域,一個接一個 nameserver 地串行進行的。如果存在 M 個無效的搜索域,N 個網絡不可達的 nameserver,那麼等待 DNS 查找結果的最差時間就會 >= (M * N * 5 * 2)s。(查找 “CNAME” 記錄和查找 “A” 或 “AAAA” 地址記錄一樣, 所以 *2 )
- Netty 默認的 allowedQueries 爲16,不要以爲很多,因爲是 “A” 和 “AAAA” 查詢共用的,再加上 “CNAME” 的查詢,能允許犯錯的 nameserver 節點其實很少。
- 在實際查詢過程中,“A” 和 “AAAA” 都是異步進行的,會存在運行順序和返回結果先後的不確定性,當 allowedQueries 遞減爲 0 時,是無法確定最後一次查詢使用哪個搜索域以及落在哪臺 nameserver 上的,因此不要依賴這種順序去配置 nameserver 的位置和搜索域的先後次序。
- Netty 查找 DNS 的代碼,使用的全是 async 模式,性能雖然好,但是不容易閱讀和維護,而且調試中發現不少邏輯存在問題,譬如:
- allowedQueries 遞減到 0 了,還繼續查詢,查詢又不斷遞歸,最後直到遍歷完所有 nameserver 才終止,但這個過程中其實什麼都沒幹,因爲每次進行真正的查詢之前都需要檢驗 allowedQueries > 0
- 當最後一次查詢出現錯誤時,就停止嘗試剩餘的搜索域
實驗涉及到的 Netty 源碼文件
查找 DNS:
io.netty.resolver.dns.DnsResolveContext
設置查詢超時和 allowedQueries:io.netty.resolver.dns.DnsNameResolverBuilder
額外的測試代碼
以下是模擬 Netty 發送 DNS 請求的代碼,根據 Netty 的源碼得來。
package io.netty.handler.codec.dns;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.util.internal.PlatformDependent;
import io.netty.util.internal.StringUtil;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
public class DnsClient {
private static final InetSocketAddress address = new InetSocketAddress("localhost", 53);
private static final DnsRecordDecoder recordDecoder = DnsRecordDecoder.DEFAULT;
private static final int BUF_LENGTH = 1024;
public static void main(String[] args) throws Exception {
int id = PlatformDependent.threadLocalRandom().nextInt(65536 - 1) + 1;
DnsQuestion question = new DefaultDnsQuestion(
"redis.ostechnix.lan.",
new DnsRecordType(1, "A")
);
DnsQuery query = new DatagramDnsQuery(null, address, id);
query.setRecursionDesired(true);
query.addRecord(DnsSection.QUESTION, question);
DnsRecord optResource = new AbstractDnsOptPseudoRrRecord(4096, 0, 0) {
};
query.addRecord(DnsSection.ADDITIONAL, optResource);
ByteBuf buf = new PooledByteBufAllocator().ioBuffer(BUF_LENGTH);
new DnsQueryEncoder(DnsRecordEncoder.DEFAULT).encode(query, buf);
byte[] bs = new byte[buf.readableBytes()];
buf.getBytes(0, bs);
DnsQuery dnsQuery = sendAndReceive(bs);
print(dnsQuery);
}
private static void print(DnsQuery msg) {
System.out.println(msg);
System.out.println("===========================");
StringBuilder sb = new StringBuilder();
printSection(sb, msg, DnsSection.ADDITIONAL);
printSection(sb, msg, DnsSection.ANSWER);
System.out.println(sb);
}
private static void printSection(StringBuilder sb, DnsQuery message, DnsSection section) {
final int count = message.count(section);
for (int i = 0; i < count; i++) {
DnsRecord record = message.recordAt(section, i);
if (record instanceof DefaultDnsRawRecord)
printRecord((DefaultDnsRawRecord) record, sb);
}
}
private static void printRecord(DefaultDnsRawRecord record, StringBuilder buf) {
final DnsRecordType type = record.type();
if (type == DnsRecordType.OPT)
return;
buf.append(StringUtil.NEWLINE).append(StringUtil.TAB);
buf.append(record.name().isEmpty() ? "<root>" : record.name()).append(" => ");
ByteBuf byteBuf = record.content();
if (type.intValue() == DnsRecordType.A.intValue()) {
byte[] bs = new byte[byteBuf.readableBytes()];
byteBuf.readBytes(bs);
try {
buf.append(
InetAddress.getByAddress(bs)
);
} catch (Exception e) {
e.printStackTrace();
}
} else {
buf.append(byteBuf.readableBytes()).append("B)");
}
}
private static DnsQuery sendAndReceive(byte[] bs) throws Exception {
DatagramSocket socket = new DatagramSocket();
DatagramPacket packet = new DatagramPacket(bs, bs.length, address);
socket.send(packet);
packet = new DatagramPacket(new byte[BUF_LENGTH], BUF_LENGTH);
socket.receive(packet);
ByteBuf buf = new PooledByteBufAllocator().ioBuffer(BUF_LENGTH);
buf.writeBytes(packet.getData(), 0, packet.getLength());
return decode(buf);
}
private static DnsQuery decode(ByteBuf buf) throws Exception {
final DnsQuery query = newQuery(buf);
final int questionCount = buf.readUnsignedShort();
final int answerCount = buf.readUnsignedShort();
final int authorityRecordCount = buf.readUnsignedShort();
final int additionalRecordCount = buf.readUnsignedShort();
decodeQuestions(query, buf, questionCount);
decodeRecords(query, DnsSection.ANSWER, buf, answerCount);
decodeRecords(query, DnsSection.AUTHORITY, buf, authorityRecordCount);
decodeRecords(query, DnsSection.ADDITIONAL, buf, additionalRecordCount);
return query;
}
private static DnsQuery newQuery(ByteBuf buf) {
final int id = buf.readUnsignedShort();
final int flags = buf.readUnsignedShort();
// if (flags >> 15 == 1) {
// throw new CorruptedFrameException("not a query");
// }
final DnsQuery query =
new DatagramDnsQuery(
null,
address,
id,
DnsOpCode.valueOf((byte) (flags >> 11 & 0xf)));
query.setRecursionDesired((flags >> 8 & 1) == 1);
query.setZ(flags >> 4 & 0x7);
return query;
}
private static void decodeQuestions(DnsQuery query, ByteBuf buf, int questionCount) throws Exception {
for (int i = questionCount; i > 0; i--) {
query.addRecord(DnsSection.QUESTION, recordDecoder.decodeQuestion(buf));
}
}
private static void decodeRecords(
DnsQuery query, DnsSection section, ByteBuf buf, int count) throws Exception {
for (int i = count; i > 0; i--) {
final DnsRecord r = recordDecoder.decodeRecord(buf);
if (r == null) {
break;
}
query.addRecord(section, r);
}
}
}
以下是 本實驗中 bind9 的 DNS 配置文件:
helowken@helowken-mint ~ $ cat /etc/bind/for.ostechnix.lan
$TTL 86400
@ IN SOA pri.ostechnix.lan. root.ostechnix.lan. (
2011071001 ;Serial
3600 ;Refresh
1800 ;Retry
604800 ;Expire
86400 ;Minimum TTL
)
$ORIGIN ostechnix.lan.
@ IN NS pri.ostechnix.lan.
@ IN NS sec.ostechnix.lan.
@ IN A 192.168.1.200
@ IN A 192.168.1.201
@ IN A 192.168.1.202
pri IN A 192.168.1.200
sec IN A 192.168.1.201
client IN A 192.168.1.202
redis IN A 127.0.0.1
redis IN AAAA ::1