解決Redisson無法連接Sentinel, Netty查找DNS失敗

前言

  1. 這裏 redisson 的版本爲 3.11.2, 對應 netty-all 的版本爲 4.1.38.Final
  2. 如果這篇描述的方法不能解決問題,可以參考另外一篇 Redisson-3.8 查找DNS異常的解決辦法
  3. redisson 其實沒問題,問題出在 netty 身上,而且神經得很。
  4. 之前的 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 服務器地址:

  1. 127.0.0.1 是安裝在本地的 DNS 服務器 bind9
  2. 202.xxx.xxx.xxx 是電信運營商自動生成的
  3. 163.15.15.15 是一個不存在 IP 地址,用於本次實驗

search 表示自動添加到域名後面的搜索域:

  1. xxx.yyy 是一個沒有記錄搜索域,用於本次實驗
  2. 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

綜上所述,可以得知:

  1. 使用域名 “redis” 時,會自動添加搜索域 “ostechnix.lan”
  2. 向 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”,會發現:

  1. 127.0.0.1 和 202.xxx.xxx.xxx 都沒有地址記錄
  2. 163.15.15.15 連接超時

因此,我們可以得到以下表格:
nameserver & search domain

Netty 源碼分析

因爲源碼內容較多,這裏直接給出主要的邏輯:

  1. 當滿足以下兩個條件時,DNS 查找過程將終止:
    • allowedQueries 遞減爲 0
    • nameserver 列表已經遍歷完
  2. allowedQueries 表示查找過程總共允許多少次查詢,每發起一次查詢就減 1。
  3. 查詢會一個接一個 nameserver 地進行嘗試,上一個 nameserver 查找失敗後(沒有記錄,超時,網絡問題),纔會往下一個 nameserver 發送查詢請求。
  4. 查詢的時候會把搜索域(search domain)添加到名稱的後面形成全路徑(如果名稱沒有以"."結尾)。搜索域也是一個接一個地進行嘗試,只要在上一個搜索域遍歷完所有 nameserver 後都查找失敗,纔有可能使用下一個搜索域。
  5. 查找過程會同時發起 “A” 和 “AAAA” 記錄的查詢請求。“A” 記錄表示 IPv4 的地址記錄,“AAAA” 表示 IPv6 的地址記錄。
  6. 如果所有 nameserver 查詢 “A” 或 “AAAA” 記錄都失敗,且滿足以下條件,則轉爲查詢 “CNAME” 記錄(“CNAME” 表示別名,相當於做多了一次命名映射):
    • allowedQueries 還沒有遞減到 0
    • 最後一次使用的 nameserver 沒有出現錯誤
  7. 查詢 “CNAME” 記錄時,將重新遍歷nameserver 列表,若 “CNAME” 有結果返回,則使用 “CNAME” 的結果再次查詢 “A” 或 “AAAA” 記錄。
  8. 如果在查找過程中,任一 nameserver 返回了記錄,則終止查找。如果出現了錯誤,則使用下一個 nameserver 繼續進行查詢。
  9. 當 allowedQueries 遞減到爲 0 時,如果還沒有找到 “A” 或 “AAAA” 記錄,則返回 UnknownHostException。
  10. 如果最後一次查詢出現了超時或者網絡錯誤,則把 UnknownHostException 的 cause 設置爲該錯誤。
  11. 檢查返回的結果,如果存在 UnknownHostException,則檢查它的 cause:
    • if 超時或者網絡錯誤,則最終判定爲查找失敗
    • else if 還有搜索域可用,則使用下一個搜索域,重複上述的邏輯,開始新一輪的查找,且 allowedQueries 會重置爲初始值
    • else 其他的邏輯(當前可以省略)

實驗

爲了更容易和準確地得到實驗結果,我稍微更改了一下 netty 的源碼,調整的內容有:

  1. allowedQueries 根據每個實驗進行設置
  2. 只查詢 “A” 記錄,不查詢 “AAAA” 記錄。如果兩個都進行查詢,會因爲異步調用而出現隨機性。
  3. 把查詢超時從 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

結果分析:

  1. 當 allowedQueries 爲 1~3 的時候,查詢 “redis.xxx.yyy” 都沒有記錄,因此返回了 UnknownHostException,但因爲沒有超時或網絡錯誤,且還有 “ostechnix.lan” 這個搜索域可用,所以用 “redis.ostechnix.lan” 開啓新一輪查找,最後在 127.0.0.1 上查詢到記錄。
  2. 當 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

結果分析:

  1. 只要 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

結果分析:

  1. 當 allowedQueries 爲 1~3 時,無論使用哪個搜索域,前 3 個 nameserver 都無法返回地址記錄(202.xxx.xxx.xxx 是沒有查找到記錄,而 163.15.15.15 會超時),因爲 allowedQueries 不夠次數使請求落到 127.0.0.1 上面,所以最終判定查找失敗。
  2. 當 allowedQueries == 4 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries 遞減到 0,且最後一個 nameserver 是 127.0.0.1,雖然沒有查詢到記錄,但也沒有出現超時或者網絡錯誤,於是使用 “redis.ostechnix.lan” 開啓新一輪查找。第二輪查找時,最終會在 127.0.0.1 上查詢到記錄。
  3. 當 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 上查詢到記錄。
  4. 當 allowedQueries == 7 時,第一輪搜索 “redis.xxx.yyy”,所有 nameserver 都查找失敗,這時 allowedQueries 遞減到 3,因爲最後一次查詢是在 127.0.0.1 上進行的,雖然無記錄但沒有出錯,於是從頭遍歷 nameserver 列表查找 “CNAME” 記錄。但是前 3 臺 nameserver 都查找失敗,這時 allowedQueries 遞減到 0,因爲最後一次查詢 “CNAME” 記錄是在 163.15.15.15 上進行的,出現了超時錯誤,所以最終判定查找失敗。
  5. 當 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 能成功,最好遵循以下準則:

  1. Netty 應用使用的搜索域,儘可能放在 /etc/resolv.conf 文件中 “search” 行的第一位
  2. 用於解析第1點中的搜索域的 nameserver,儘可能放在 /etc/resolv.conf 文件中 nameserver 列表的最上面
  3. 儘可能把網絡不可達的 nameserver 從 /etc/resolv.conf 文件中刪除

PS:

  1. Netty 對於 DNS 的默認查詢超時是 5s,查詢是一個接一個搜索域,一個接一個 nameserver 地串行進行的。如果存在 M 個無效的搜索域,N 個網絡不可達的 nameserver,那麼等待 DNS 查找結果的最差時間就會 >= (M * N * 5 * 2)s。(查找 “CNAME” 記錄和查找 “A” 或 “AAAA” 地址記錄一樣, 所以 *2 )
  2. Netty 默認的 allowedQueries 爲16,不要以爲很多,因爲是 “A” 和 “AAAA” 查詢共用的,再加上 “CNAME” 的查詢,能允許犯錯的 nameserver 節點其實很少。
  3. 在實際查詢過程中,“A” 和 “AAAA” 都是異步進行的,會存在運行順序和返回結果先後的不確定性,當 allowedQueries 遞減爲 0 時,是無法確定最後一次查詢使用哪個搜索域以及落在哪臺 nameserver 上的,因此不要依賴這種順序去配置 nameserver 的位置和搜索域的先後次序。
  4. 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	
發佈了22 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章