Goの通信経路選択(net.LookupIP & net.Dial)

August 29, 2016 - golang

※2016-09-05追記: 本記事の対策では不備があるためRe: Goの通信経路選択(net.LookupIP & net.Dial)に続編書きました。

GoのHTTP通信経路選択の挙動について腑に落ちない点があり、net.LookupIP/net.Dialの仕様とそれに伴い生じる接続経路の選択問題、そしてその対策について調べてみました。

TL;DR

unix環境におけるGoの名前解決まわりの仕様

  • net.LookupIPはDNSから取得できたアドレスリストをそのまま返すわけではない
  • 内部ネットワーク間の接続であれば接続元と接続先がビット列的に近いものを優先したアドレスリストを返す
  • net.Dialnet.LookupIPがかえすアドレスリストの先頭から接続を試みる
  • その後、最初に接続が成功したものとのみ通信を行う
  • DNSラウンドロビン等で複数アドレスに分散したい場合、この仕様により経路が固定されてしまう可能性がある

前提条件などによって挙動がかわりますので、ここから深掘りしていきます。

前提とする環境

今回は以下の環境を想定としています。

  • unix系OSを利用している
  • Go1.5以降を利用している
  • IPv4を利用している
  • AWSのVPCなど内部ネットワークを構築している

Goのnet.LookupIPnet.Dialの仕様

net.LookupIPは以下のような仕様となっています。

  • 名前解決はgetaddrinfo相当をGoで実装
  • 解決した結果のアドレスリストをRFC6724にもとづいてソートを実施
  • ソート済みアドレスリストを呼び出し元に返す

このRFC6724に基づいたソート処理によってDNSが返すアドレスリストの順序が変更されてしまう可能性があり、 その可能性は接続元と接続先の組み合わせに依存しています。 (※ソート処理実装であるsortByRFC6724の定義はgo/addrselect.goを参照)

ちなみに、このRFC6724によるソートはgo-1.5以降のバージョンに含まれる(net: RFC 6724 address selection · golang/go)ので、それ以前のバージョンでは挙動が異なってくると思います。

net.Dialnet.LookupIPがかえすアドレスリストの先頭から接続を試みていき、最初に接続成功したアドレスを最終的な接続先とします。そのため、最終的なDial先IPはnet.LookupIPの結果に強く依存することになります。

sortByRFC6724内部のソート仕様

src/net/addrselect.goをみると、10ルールがソートにつかわれていることがわかります。

// Less reports whether i is a better destination address for this
// host than j.
//
// The algorithm and variable names comes from RFC 6724 section 6.
func (s *byRFC6724) Less(i, j int) bool {
        // Rule 1: Avoid unusable destinations.
        // Rule 2: Prefer matching scope.
        // Rule 3: Avoid deprecated addresses.
        // Rule 4: Prefer home addresses.
        // Rule 5: Prefer matching label.
        // Rule 6: Prefer higher precedence.
        // Rule 7: Prefer native transport.
        // Rule 8: Prefer smaller scope.
        // Rule 9: Use longest matching prefix.
        // Rule 10: Otherwise, leave the order unchanged.
}

このうち、Rule 9: Use longest matching prefix.が注目すべき処理となります。

Rule 9では接続元と接続先が特定のブロックに存在している場合に限り、アドレスのビット列の一致する長さをスコアとします。 そしてsortByRFC6724はこのスコアの高いものを優先してリストをソートします。

ソートのスコア算出処理の検証

このスコア算出処理はnetパッケージに閉じられていますが、なんとか動作を検証してみます。

AWSのVPCにinternalELBを立てた場合を例を以下に記します。10.0.0.0/16のVPC内に以下4つのサブネットが存在する環境を想定しています。

10.0.0.0/24
10.0.1.0/24
10.0.2.0/24
10.0.3.0/24

ここにinternal ELBを配置した結果、10.0.0.2のVPC用DNSにELBの名を問い合わせると以下4つのIPが取得できるようになったとします。

10.0.0.104
10.0.1.12
10.0.2.8
10.0.3.221

このELBの名前解決を行う際にsortByRFC6724でどのようにソートされるかをみるために、 以下に重要な関数のコピーして幾つかのパターンを試すサンプルを書きました。

これは接続元と接続先のIPの組み合わせにおいて各スコアを表示するプログラムであり、 検証する接続元と接続先IPの組み合わせは以下のとおりです。

// destination IPs
dsts := []net.IP{
    net.IP{10, 0, 0, 104}, // 10.0.0.0/24
    net.IP{10, 0, 1, 12},  // 10.0.1.0/24
    net.IP{10, 0, 2, 8},   // 10.0.2.0/24
    net.IP{10, 0, 3, 221}, // 10.0.3.0/24
}

// source IPs
srcs := []net.IP{
    net.IP{123, 4, 5, 6},
    net.IP{10, 0, 1, 3},
    net.IP{10, 0, 1, 199},
    net.IP{10, 0, 4, 211},
}

実行すると結果はこのようになります。

# case1: 外部から問い合わせた場合
src: 123.4.5.6
-> 10.0.0.104   : 1 (same block=false)
-> 10.0.1.12    : 1 (same block=false)
-> 10.0.2.8     : 1 (same block=false)
-> 10.0.3.221   : 1 (same block=false)

# case2: VPC内部の`10.0.1.0/24`から問い合わせた場合
src: 10.0.1.3
-> 10.0.0.104   : 23 (same block=true)
-> 10.0.1.12    : 28 (same block=true)
-> 10.0.2.8     : 22 (same block=true)
-> 10.0.3.221   : 22 (same block=true)

# case3: VPC内部の`10.0.1.0/24`から問い合わせた場合
src: 10.0.1.199
-> 10.0.0.104   : 23 (same block=true)
-> 10.0.1.12    : 24 (same block=true)
-> 10.0.2.8     : 22 (same block=true)
-> 10.0.3.221   : 22 (same block=true)

# case4: ELBが所属しない別サブネットから問い合わせた場合
src: 10.0.4.211
-> 10.0.0.104   : 21 (same block=true)
-> 10.0.1.12    : 21 (same block=true)
-> 10.0.2.8     : 21 (same block=true)
-> 10.0.3.221   : 21 (same block=true)

表示内容は

-> 10.0.1.12    : 24 (same block=true)

の場合、10.0.1.12に対するスコアが24であり、実際にそのスコアを採用するならsame block=trueになります。

ここで重要なのは、VPC外部のIPから問い合わせた場合はスコアはすべておなじであることと、同じサブネットから問い合わせてもスコアが異なる接続先IPがあることです。

同じ10.0.1.0/24から問い合わせても、case3の10.0.1.199よりcase2の10.0.1.3のほうが10.0.1.12に対するスコアが高くなっています。 これは、接続元IPと接続先IPの32bitのビット列から一致するビット長をもとにスコアを算出しているからです。 接続元IPが同じサブネットに所属しているかどうかはスコアに影響しません。

経路が固定化されないための対策

ここまでで書いたようにnet.Lookupが返すアドレスリストは特定の環境下で必ず同じ並びになってしまい、その結果net.Dialでの接続先が固定化されてしまうケースが発生します。 その対策として明示的にランダムなIPで接続する方法が使えます。

http.Clientを使ったHTTP接続の例だと、手順はこうなります。

  1. net.LookupIPでアドレスリストを取得
  2. アドレスリストからランダムに1つIPを選択
  3. URLにはホスト名ではなくIPを指定する
  4. Request.Hostに本来のホスト名(とポート)を指定する

簡単な実装例

何かと貧弱な実装ではありますが、一応ランダムにIPが採用されるようになります。

package main

import (
	"fmt"
	"math/rand"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strings"
	"time"
)

// random selection from ipaddrs
func pickupIP(host string) (string, error) {
	addrs, err := net.LookupIP(host)
	if err != nil {
		return "", err
	}
	rand.Seed(time.Now().UnixNano())
	idx := rand.Intn(len(addrs))
	ipaddr := addrs[idx].String()
	return ipaddr, nil

}

func do(req *http.Request) (*http.Response, error) {
	hostport := strings.Split(req.URL.Host, ":")
	ipaddr, err := pickupIP(hostport[0])
	if err != nil {
		return nil, err
	}

	urlstr := req.URL.Scheme + "://" + ipaddr
	if len(hostport) > 1 {
		urlstr = urlstr + ":" + hostport[1]
	}
	u, err := url.Parse(urlstr)
	if err != nil {
		return nil, err
	}

	// set ipaddr to req.Host and
	// keep the original `Host` header
	req.Host = req.URL.Host
	req.URL.Host = u.Host

	b, err := httputil.DumpRequest(req, true)
	if err != nil {
		return nil, err
	}
	fmt.Println(string(b))

	client := http.DefaultClient
	return client.Do(req)
}

func main() {
	urlstr := "http://internal-user-api-779196100.ap-northeast-1.elb.amazonaws.com:8888/ping"
	req, _ := NewRequest("GET", urlstr, nil)
	res, err := do(req)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(res.Status)
	}
}

urlに含まれるホスト名からpickupIPを使って1つのIPアドレスの文字列を取得しています。

func pickupIP(host string) (string, error)

この結果をurl.URL.Hostに上書きすることでhttp://10.0.1.104/のようなIPアドレスによる接続を行います。 さらに、接続先サービスがHostヘッダの値に依存しているケースもあるのでhttp.Request.Hostでオリジナルのホスト名を指定しています。

// set ipaddr to req.Host and
// keep the original `Host` header
req.Host = req.URL.Host
req.URL.Host = u.Host

これでランダムなIPによる接続が実現できますが、注意点があります。

HTTP/HTTPSリスナーとしてのELB名から引けるアドレスリストの並びは負荷状況も考慮されています。 しかし、この実装ではそこまで考慮しておらず、単純なランダム選択になります。

そもそも、net.LookupIPにRFC6724によるソートを回避できるオプションがあると嬉しいのですが…

Pure Goとcgoの2つの名前解決の実装

今回の主題からはそれますがsortByRFC6724するまでに行われるDNS問い合わせの実装はPure Goとcgoの2つから選択ができます。

デフォルトはPure Goであり、環境変数GODEBUG=netdns=cgoでcgoを指定できたりします。

By default the pure Go resolver is used, because a blocked DNS request consumes only a goroutine, while a blocked C call consumes an operating system thread.

リソース消費効率がPure Goのほうが良いからというのがPure Goがデフォルトである理由のようです。とくに理由がない限りはデフォルトのままでも良いのではないかなと思っていますが、明示的に指定しなければいけない事例があったら聞いてみたいです。

さいごに

以上がGoのnet.LookupIP/net.Dialの仕様とそれに伴い生じる接続経路の選択問題、そしてその対策になります。 実際のところELBの場合はよしなにやってくれることが多いのでクリティカルな問題にはつながらない場合がほとんどだとは思いますが、 この内部仕様を知らないことでドハマりすることもありますのでご注意を。私はこの仕様にたどり着くまでに結構手間取ってしまいました😢