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.Dial
はnet.LookupIP
がかえすアドレスリストの先頭から接続を試みる- その後、最初に接続が成功したものとのみ通信を行う
- DNSラウンドロビン等で複数アドレスに分散したい場合、この仕様により経路が固定されてしまう可能性がある
前提条件などによって挙動がかわりますので、ここから深掘りしていきます。
前提とする環境
今回は以下の環境を想定としています。
- unix系OSを利用している
- Go1.5以降を利用している
- IPv4を利用している
- AWSのVPCなど内部ネットワークを構築している
Goのnet.LookupIP
とnet.Dial
の仕様
net.LookupIP
は以下のような仕様となっています。
- 名前解決は
getaddrinfo
相当をGoで実装 - 解決した結果のアドレスリストをRFC6724にもとづいてソートを実施
- ソート済みアドレスリストを呼び出し元に返す
このRFC6724に基づいたソート処理によってDNSが返すアドレスリストの順序が変更されてしまう可能性があり、
その可能性は接続元と接続先の組み合わせに依存しています。
(※ソート処理実装であるsortByRFC6724
の定義はgo/addrselect.goを参照)
ちなみに、このRFC6724によるソートはgo-1.5以降のバージョンに含まれる(net: RFC 6724 address selection · golang/go)ので、それ以前のバージョンでは挙動が異なってくると思います。
net.Dial
はnet.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接続の例だと、手順はこうなります。
net.LookupIP
でアドレスリストを取得- アドレスリストからランダムに1つIPを選択
- URLにはホスト名ではなくIPを指定する
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の場合はよしなにやってくれることが多いのでクリティカルな問題にはつながらない場合がほとんどだとは思いますが、
この内部仕様を知らないことでドハマりすることもありますのでご注意を。私はこの仕様にたどり着くまでに結構手間取ってしまいました😢