SRVレコードDialer

SRVレコードで Dial() できるヤツを作りました

Go2 Advent Calendar 2019 8日目の参加記事です。

TL;DR

  • SRVレコードDial() できる koron-go/dialsrv を作った
  • dialsrv.Dialernet.Dialer をラップする形で互換インタフェースを備えている
  • HTTP も gRPC も SRV レコード越しで接続できるようになる
  • nomad のようなオーケストレーションツールで動かすアプリで使う想定

SRV レコード Dialer

皆さんはDNSをひいてますか? いえこのページを見れているということはDNSをひけていることはわかってます。 ですがそれを普段から意識している人は少ないはずです。 まずはちょっとこのDNSを意識してみましょう。

DNSというのは名前をIPアドレスに変換するためのデータベースです。 ブラウザがこのDNSに www.kaoriya.net という名前を問い合わせて、 IPアドレスを得ているからこのブログポストを目にできています。 より正確にはDNSの「Aレコード」によりIPアドレスを知ることができています。 あいだに「CNAMEレコード」を挟むこともありますが、 最終的にAレコードによりIPアドレスが決定できることでウェブサーバーに繋がります。 このようにDNSには「レコード」の種類がいくつかあります。

インターネットの多くを支えているTCP/IPでは、 接続先を決定するのにIPアドレスだけでは情報が足りません。 わかってる人はわかってると思いますがポート番号も必要です。 Webサーバーであればこのポート番号にはHTTPであれば80番を、 HTTPSであれば443番を利用することになっています。 こういうサービスごとによく知られたポート番号をWell Known Portといいます。 なのでこのポート番号を意識することは普段はまずありません。 そう開発者を除いては。

本題のSRVレコードに入りましょう。 SRVレコードはDNSのレコードの1つなのですが、 Aレコードのように名前からIPアドレスだけではなくポート番号もあわせて引けます。 たとえば開発用にWebサーバーのようなコンポーネントを動かしたい、 でもWell Known Portでは動かしたくない(動かせない)というシーンはよくあります。 さらに一歩進んでdockerコンテナなどをオーケストレーションツールで管理した場合、 特定コンポーネントのIPアドレスだけでなくポート番号まで動的に変わりうるケースがあります。 そのような場合にコンポーネントのIPアドレスとポート番号を見つけられるようにするのがSRVレコードの役割です。

話をgolangに移しましょう。 golangにおいてTCP/IP接続を担うのは net.Dial() です。 この net.Dial() を次のように呼び出すことでTCP/IP接続が確立します。

c, err := net.Dial("tcp", "www.kaoriya.net:http")

net.Dial() は第2引数を解釈し、前半の www.kaoriya.net をDNSでAレコードを引いてIPアドレスを解決し、 後半の http からポート番号がWell Known Portの80番であると決め実際に接続しています。 また接続に伴う様々な情報や手続きをカプセル化したものとして net.Dialer 構造体があります。 この構造体のもつ Dial()DialContext() の2つのメソッドは net.Dial() とほぼ同じもので、 net/http など TCP/IP を前提とするパッケージの基本構造として君臨しています。

この Dial() の中のIPアドレスとポート番号を決める部分、 ここに介入してSRVレコードにより両方を決定するようにしたのが koron-go/dialsrv です。 いやーコレにたどり着くまで長かったですね。

dialsrv*net.Dialer をラップして *dialsrv.Dialer を作ります。 この *dialsrv.Dialer*net.Dialer と互換の Dial() 及び DialContext() を実装しているのでその代用として使えます。

以下のコードはSRVレコードを利用できる http.Client の実装例です。

// HTTPTransport is replacement for http.DefaultTransport
var HTTPTransport = &http.Transport{
	Proxy: http.ProxyFromEnvironment,
	DialContext: dialsrv.New(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
		DualStack: true,
	}).DialContext,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

// HTTPClient is replacement for http.DefaultClient
var HTTPClient = &http.Client{
	Transport: HTTPTransport,
}

少々わかりにくいですが *net.Dialerdialsrv.New*dialsrv.Dialer としてラップし、 その DialContext() メソッドを http.TransportDialContext フィールドに指定しています。 そのトランスポートを http.Client に設定すれば、ハイできあがり。

なおこの HTTPClient の定義は koron-go/dialsrv に含まれているので、 以下のようにお手軽にSRVレコードによるHTTP通信ができます。

r, err := dialsrv.HTTPClient.Get("http://srv+myservice+example.com/")

net.Dialer 互換であるため、 TCP/IPを使う通信であれば容易にSRVレコードに対応できます。 以下のコードはちょっとだけやることが多いですが gRPCサーバーへの接続にSRVレコードを使えるようにする例です。

import (
	"context"
	"net"
	"time"

	"github.com/koron-go/dialsrv"
	"google.golang.org/grpc"
)

func dial(adr string, to time.Duration) (net.Conn, error) {
	ctx, cancel := context.WithTimeout(context.Background(), to)
	defer cancel()
	d := dialsrv.New(nil)
	return d.DialContext(ctx, `tcp`, adr)
}

// ...(snip)...

	c, err := grpc.DialContext(ctx, name, grpc.WithDialer(dial))
	// TODO: work with `c *grpc.ClientConn`

この dialsrv にはSRVレコードとして受付可能なフォーマットが2種類あるのですが、 少しだけ特殊な独自記法を採用しています。 独自記法を採用した理由はこの *dialsrv.Dialer を使ったとしても 普通のAレコードを使ったDNS問い合わせもそのまま対応できるようにしたかったためです。 そのため上記のコードを用いてもサーバー名として www.kaoriya.net で問い合わせることは依然可能です。

SRV用のフォーマットの1つ目は srv+{service}+{hostname} です。 このときDNSには _{service}._{network}.{hostanme} という形での問い合わせが発生します。 {network} 部分には Dial() の第1引数が入りますので、通常ならば tcp となります。

2つ目のフォーマットは srv+{hostname} です。 この場合は {hostname} のSRVレコードを問い合わせます。

残念ながら現在は複数のSRVレコードが返された場合最初の1つしか利用していません。 後々問題を起こしそうなため対応したいところですが 今後改善したいところですが、まだ必要になっていないため、対応の目処はたっていません。

みなさんもgolangでSRVレコードが必要になったら koron-go/dialsrv を試してみてください。