Go言語で踏み台サーバー越しにDBからデータを取得する

# なにこれ
表題の通り`sshした踏み台サーバーからprivateなネットワークにあるDBに対し、クエリを投げたい` という要件をgoで実践してみたのでまとめます。

# あらまし
* 「DBから情報を集計して統計取ったり活用したい！」
* 「けどre:dashみたいなイカした集計ツールも入れてないんだよね・・・。」
* 「でも会計の関係ですぐにでも情報が欲しいんだ！」
* 「いつものようにパパッと頼むね☆」

・・・なんて言われた日には「ほう？」ぐらいしか返す言葉が思いつかないです。
でもやらないとね。仕方ないね。
で、

* 特に制約さえなければre:dash等の集計ツールを入れてしまえばOK
* 目的を達成するだけなら踏み台から手作業をすればOK

なのですが、折角なので既にある構成から試してみたかったことをやってみました。
~~決して踏み台入って手作業とか失敗する未来しか見えなかったので嫌だったし、新しくツールを導入するのに諸々制約があってダルかったとかではないです。~~

# ネットワーク設定
本ケースでのネットワーク設定は以下のような感じです。
 <img src="https://qiita-image-store.s3.amazonaws.com/0/287363/444dc726-9ef3-a4c1-e1cf-f20146cfd5a5.png" width="400">

詳細としては

* アプリケーションが動いているサブネットに踏み台がある
* 踏み台からRDSへのリクエストはできる
* 踏み台への接続は社内IPからのみ

となっています。
今回のゴールは `bastion server` となっている踏み台からRDSへリクエストを投げて結果を取得することです。


# プログラム
今回利用したプログラムは以下のような形です。
厳密には参照するカラムなどは別です。

```go
package main

import (
	"database/sql"
	"fmt"
	"io/ioutil"
	"net"

	"github.com/go-sql-driver/mysql"
	"golang.org/x/crypto/ssh"
)

type DB struct {
	Host     string
	Port     string
	User     string
	Password string
	DBName   string
}

type SSH struct {
	Key  string
	Host string
	Port string
	User string
}

// newSSH はsshクライアントを生成する
func newSSH(conf *SSH) (*ssh.Client, error) {
	// ssh_keyファイルを取得
	sshKey, err := ioutil.ReadFile(conf.Key)
	if err != nil {
		return nil, err
	}
	signer, err := ssh.ParsePrivateKey(sshKey)
	if err != nil {
		return nil, err
	}
	hostKeyCallbackFunc := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
		return nil
	}
	sshConf := &ssh.ClientConfig{
		User: conf.User,
		Auth: []ssh.AuthMethod{
			ssh.PublicKeys(signer),
		},
		HostKeyCallback: hostKeyCallbackFunc,
	}
	return ssh.Dial("tcp", conf.Host+conf.Port, sshConf)
}

// newDB はmysqlクライアントを生成する
func newDB(conf *DB, sshc *ssh.Client) (*sql.DB, error) {
	// MySQLで使うプロトコルは一旦tcpで設定
	mysqlNet := "tcp"
	if sshc != nil {
		// MySQLはプロトコルを更新
		mysqlNet = "mysql+tcp"
		dialFunc := func(addr string) (net.Conn, error) {
			// dialはtcpでやりとり
			return sshc.Dial("tcp", addr)
		}
		mysql.RegisterDial(mysqlNet, dialFunc)
	}
	dbConf := &mysql.Config{
		User:                 conf.User,
		Passwd:               conf.Password,
		Addr:                 conf.Host + conf.Port,
		Net:                  mysqlNet,
		DBName:               conf.DBName,
		ParseTime:            true,
		AllowNativePasswords: true,
	}
	return sql.Open("mysql", dbConf.FormatDSN())
}

func main() {
	// init config
	// 本当はtomlとかで取得した方が良い
	dbConf := &DB{
		Host:     "your.mysql.host.domain",
		Port:     ":3306",
		User:     "user_name",
		Password: "p@ssW0rd",
		DBName:   "db_name",
	}
	sshConf := &SSH{
		Key:  "/Users/user-name/.ssh/ssh_key",
		Host: "bastion_host_ip",
		Port: ":22",
		User: "user_name",
	}

	// init ssh client
	sshClient, err := newSSH(sshConf)
	if err != nil {
		fmt.Println("err in new ssh client. reason : " + err.Error())
	} else {
		defer sshClient.Close()
	}
	// init db client
	dbClient, err := newDB(dbConf, sshClient)
	if err != nil {
		fmt.Printf("erro in new db client. reason : %v\n", err)
		panic(err)
	}
	defer dbClient.Close()

	// select from mysql
	rows, err := dbClient.Query("SELECT * FROM stores")
	if err != nil {
		panic(err.Error())
	}
	// 結果が見たい
	columns, err := rows.Columns()
	if err != nil {
		panic(err.Error())
	}
	values := make([]sql.RawBytes, len(columns))
	args := make([]interface{}, len(values))
	for i := range values {
		args[i] = &values[i]
	}
	for rows.Next() {
		err = rows.Scan(args...)
		if err != nil {
			panic(err.Error())
		}
		var value string
		for i, val := range values {
			if val == nil {
				value = "NULL"
			} else {
				value = string(val)
			}
			fmt.Println(columns[i], ": ", value)
		}
		fmt.Println("-----------------------------------")
	}
}

```

仮置きで店舗情報が入っている `stores` テーブルがあるものとしています。
~~ファイル一個で済ませるためベタ書きになっているのが気がかり。~~

## ポイント
1. `ssh.Dial`でssh Clientを生成
2. `mysql.RegisterDial` を利用し踏み台経由でリクエストを送るように設定
  * この時mysql側で指定するプロトコルは`"mysql+tcp"`
  * 指定するdialFuncは `func(addr string) (net.Conn, error)` を満たすもの
  * このサンプルではssh Clientに対し `Dial("tcp", addr)` を指定

これだけでOKなので楽チン。

## 余談
一個のファイルにベタ書きとかではなく、真面目にパッケージ分離した場合は[こんな感じ](https://github.com/mochisuna/ssh_mysql_sample)。  
まずないとは思うけど、こんな風にやれば `連携チームの踏み台にsshで入ってSQLを叩き、結果を利用するAPI` とか作れます。~~まぁそんな要件自体がそもそもあまりないとは思うけど。~~