複数のデータベースをまとめてSELECTできるツール「mdq」を公開しました

こんにちは。
2016新卒でLobiのサーバーサイドエンジニアをやっているmorikuniです。

今回は、mdqというGo製のツールを作ったので、その紹介をします。 mdqは複数のデータベースに並列にクエリを投げて、結果をJSONで出力してくれるというものです。

github.com

mdqに近いコンセプトのツールとして、弊社の荒賀(@ken39arg)が作ったshard_promptがあります(#6「Shardingマジ怖い」tech.kayac.com Advent Calendar 2012)。 こちらはSQLを解析し、複数データベースにまたがる集約関数を一部サポートしていますが、MySQL専用かつ直列にクエリを投げるというものでした。 mdqでは複数データベースにまたがる集約関数はサポートしませんが、PostgreSQLなどMySQL以外のデータベースへのクエリや、並列処理による速度向上などを目指しました。

はじめに

LobiではMySQLへの書き込みをスケールさせるために、一部テーブルの水平分割を行っています。
水平分割によって書き込みには強くなりますが、「ターミナルからクエリを投げてデータを取得したい!」となったときには複数回コマンドを実行しなければいけなかったり、その度にホスト名などを変更しないといけなかったりと、オペレーションの手間が増えてしまうという側面もあります。

また、ログ解析にはRedshiftを使っており、ログのデータを見るときにはpsqlコマンド、サービスのデータを見るときにはmysqlコマンドと、データベースによってコマンドを使い分ける必要があります。

データベースにクエリを投げたいだけで、ホスト名とかコマンドとかをいちいち考えたくないよね、というモチベーションで作ったのがmdqです。

mdq

mdqを使えば、設定ファイルに接続先の情報とタグを書いておき、クエリを投げる際にはタグを指定するだけでよくなります。

説明するよりは例を出した方が分かりやすいと思うので、実際にMySQLとPostgreSQLのデータベースにクエリを投げてみます。(実際にMySQLとPostgreSQLに同じクエリを投げたいということはないと思いますが。)

まずはデータベースを用意します。 両方のデータベースにexampleというデータベースとhelloというテーブルを作成し、いくつかデータを挿入しています。

mysql.sql

CREATE DATABASE example;
USE example;
CREATE TABLE hello (
    id int,
    name text
);
INSERT INTO hello values (1, 'foo'), (2, 'bar');

postgres.sql

CREATE DATABASE example;
\c example;
CREATE TABLE hello (
    id int,
    name text
);
INSERT INTO hello values (3, 'hoge'), (4, 'fuga');
$ docker run --name mysql -e MYSQL_ROOT_PASSWORD=root --rm -d -p 33060:3306 mysql

$ mysql -u root -h 127.0.0.1 -P 33060 -p < mysql.sql

$ docker run --name postgres -e POSTGRES_PASSWORD=postgres --rm -d -p 54320:5432 postgres

$ psql -U postgres -h 127.0.0.1 -p 54320 < postgres.sql

次に接続先の情報を書いた設定ファイルを用意します。 PostgreSQLにはpostgres、MySQLにはmysqlというタグをつけておきました。

$ cat ~/.config/mdq/config.yaml
dbs:
  - name: "mysql_db"
    driver: "mysql"
    dsn: "root:root@tcp(127.0.0.1:33060)/example"
    tags: ["mysql"]
    
  - name: "postgres_db"
    driver: "postgres"
    dsn: "user=postgres password=postgres host=127.0.0.1 port=54320 sslmode=disable dbname=example"
    tags: ["postgres"]

ここまでで準備は出来たので、mdqでクエリを投げてみます。 mysqlpostgresの両方にクエリを投げた結果がJSONで出力されています。

$ mdq "select * from hello" | jq .
[
  {
    "Database": "postgres_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": 3,
        "name": "hoge"
      },
      {
        "id": 4,
        "name": "fuga"
      }
    ]
  },
  {
    "Database": "mysql_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": "1",
        "name": "foo"
      },
      {
        "id": "2",
        "name": "bar"
      }
    ]
  }
]

タグを指定すると片方のデータベースにのみクエリを投げることも出来ます。

$ mdq --tag mysql "select * from hello" | jq .
[
  {
    "Database": "mysql_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": "1",
        "name": "foo"
      },
      {
        "id": "2",
        "name": "bar"
      }
    ]
  }
]

$ mdq --tag postgres "select * from hello" | jq .
[
  {
    "Database": "postgres_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": 3,
        "name": "hoge"
      },
      {
        "id": 4,
        "name": "fuga"
      }
    ]
  }
]

このように設定ファイルを用意しておけば、

  • 複数のデータベースにクエリを投げる
  • MySQLとPostgreSQLを同じコマンドで扱う

ということができるようになります。 出力形式もJSONなのでjqを使うなどして、ターミナル上である程度データの加工などもできます。

Goでの実装について

mdqはGoで作られていますが、どんな値が入っているかわからないデータベース上のデータを静的型付け言語であるGoのデータ構造に変換するには少し工夫が必要でした。 なので実装についても少し紹介しようと思います。

mdqでは標準のdatabase/sqlパッケージによってデータベースを扱っています。 全体の処理の概要としては、各データベースにDB.Queryを使ってクエリを投げ、Rows.Scanで値を読み込み、結果を結合するというものです。

Rows.ScanではScannerインターフェースに対応している型を受け取ることができるため、なんでも読み込めるScanner型を実装することにしました。 Scannerの定義を見てみると、以下のように、対応すべき型が定義されています。

type Scanner interface {
        // Scan assigns a value from a database driver.
        //
        // The src value will be of one of the following types:
        //
        //    int64
        //    float64
        //    bool
        //    []byte
        //    string
        //    time.Time
        //    nil - for NULL values
        //
        // An error should be returned if the value cannot be stored
        // without loss of information.
        Scan(src interface{}) error
}

[]byteだけはJSONに変換したときに望ましくない結果になる場合が多いのでstringに変換しますが、その他の型はそのまま使えそうです。 なので、基本的にはsrcの値をそのまま使って、[]byteの場合はstringに変換するという実装にしました。 以下が定義したAnyValue型です。

type AnyValue struct {
    Value interface{}
}

func (v *AnyValue) Scan(src interface{}) error {
    v.Value = src
    if bs, ok := src.([]byte); ok {
        v.Value = string(bs)
    }
    return nil
}

AnyValue型を使うことによって、任意のデータ型をデータベースから読み込めるようになります。 エラー処理は省きますが、使用例も載せておきます。

rows, _ := db.Query("select id, ....")
columns, _ := rows.Columns()
columnSize := len(columns)

var result [][]interface{}
for rows.Next() {
    values := make([]AnyValue, columnSize)
    valuePtrs := make([]interface{}, columnSize)
    for i := range columns {
        valuePtrs[i] = &values[i]
    }
    rows.Scan(valuePtrs...)

    row := make([]interface{}, columnSize)
    for i, val := range values {
        row[i] = val.Value
    }
    result = append(result, row)
}

最終的なresultには、レコード数×カラム数のデータが読み込まれます。 ポイントとなるのはvaluePtrsを定義しているところです。 Rows.Scanの引数は...[]interfaceなので、[]*AnyValueをそのまま渡すことはできません。 カラム数が固定の場合にはrows.Scan(&a, &b, &c)のように書けますが、そうでない場合は一度[]interface{}に変換する必要があります。

おわりに

mdqで使ったdatabase/sqlもそうですが、Goでは標準ライブラリで多くのインターフェースが提供されています。 オープンソースのライブラリもそれに対応している場合が多いので、同じインターフェースで様々なプロダクトを扱うことができます。 クロスビルドやシングルバイナリ生成といった特徴からも、今CLIツールを作るのに最もアツイ言語の1つだと思います。
弊社にはみんなのGo言語の著者のひとりであるfujiwara (id:sfujiwara)もいるので、Go言語に興味がある方はカヤックで働いてみるのはいかがでしょうか?

みんなのGo言語【現場で使える実践テクニック】

みんなのGo言語【現場で使える実践テクニック】

Lobiスピンオフ!トーナメント開催サービスをDocker+CircleCIで開発したはなし

 はじめまして(?)。taiyohと申します。
 幾つか事業部を渡り歩いた後、現在はLobiにて新規機能の開発などを行っております。
 皆様は3/3発売のNintendo Switchの予約はお済みでしょうか。僕は早速1/21の午前中に近所の電器店に駆け込んで予約をしました。ゼルダ、Splatoon2、ゼノブレイド2と、既にやりたいソフトがいくつかあるので大変楽しみです。2017年も何とか生きていけそうです。どうぞ宜しくお願いします。

 さて、昨年の2016年末ですが、Lobiアカウントを利用したトーナメントサイトというものをリリースしましたので、そのちょっと技術っぽい観点についてお話させていただきます。なお、技術っぽいと書きながら実装やインフラ成分は低めです。その点予めご了承ください。

 Lobiトーナメントはゲーム大会をより簡単に開催・参加できるようにしたサービスです。大会のエントリーから大会終了まで、スマートフォンさえあればすぐに使うことができます。トーナメント表、対戦の組み合わせ、相手とのチャットコミュニケーションなど、オンライン、オフラインに限らず、ゲーム大会に必要なさまざまな機能が揃っています。1/28の土曜には12月に次いでハースストーンの第二回大会も行われ、今回も進行不可能になる状況もなく、参加者の方々には引き続きお楽しみいただけたようです。

vs.lobi.co

f:id:sun-basix:20170126182707p:plain

第一回ハースストーンLobi杯 - トーナメント表 - Hearthstone - Lobiトーナメント

目次:

トーナメントの作り方について

 トーナメントといえば、1回戦から徐々に勝ち上がり、決勝戦で勝つと優勝という、あのトーナメント表が真っ先に浮かぶと思います。

(こちらの写真はイメージです)
 実はこのサイトの実装に入る前に、どこかにトーナメント表をプログラムとして作った人がいないか、Googleで検索するところから始めたのですが、自分の求めるサイトや記事が見つかりませんでした。なのでこのエントリでは、作り方というか、データ構造の考え方について多少多めに文字数を割きたいと思います。
 さて、このトーナメント表をデータとしてどう表現するかといいますと、まず考えるべきは木構造です。ただ、安易に木構造をDBに保存することを考えてしまうと、SQLアンチパターンの第二章に記述されている「ナイーブツリー」に該当してしまいます。
 SQLアンチパターンに記載されている解決方法は、以下のことが考慮されているはずです。

  • 任意のノードに新たにノードが追加・削除されることを想定する
  • 任意のノードの親や子のノードをリストアップできる
  • 任意のノードから見て別のノードの深さを調べることができる

ただ、トーナメントの木構造は以下の特徴があります。

  • 木構造の中でも二分木に該当する
  • あとからノードが追加・削除されることがない
  • 条件が揃うと完全二分木になる

 完全二分木であれば、SQLアンチパターンに記載された方法でなくても、配列でデータを保持しながら考慮すべき点を全てクリアすることができます。というのも、配列のインデックス番号を使えば、任意の2つのノード間に親子関係があるかどうかや、同じ深さのノードかどうかも簡単な計算で算出することができるからです。設計がシンプルにできるだけでなく、データ量も大幅に抑えることができます。これを使わない手はないと思いました。
 ただ、一つ問題があり、トーナメントの参加者が完全二分木を構成できる人数になるとは限らないということです。参加者を2n人にするために抽選で落とすなどしたくはありません。
 とはいえ、完全二分木を採用することのメリットはとても大きいので、今回このトーナメントサイトでは、ダミーのプレイヤーを大会毎に紛れ込ませておいて、足りない分の人数はそのダミーのプレイヤーで埋めることで、必ず完全二分木の構造となるように調整することにしました。ダミーのプレイヤー同士の対戦は表示する側で調整すればよく、シードも同様に表示方法を工夫してもらうことで、この問題を乗り切ることにしました。

f:id:sun-basix:20170126171654p:plain

 こちらが、出来上がったテーブル構成の一部です。試合テーブルの「シーケンス番号」カラムは、決勝戦(=根ノード)を1として、幅優先の順序で番号を振っています。また、対戦相手1と対戦相手2がそれぞれダミーのプレイヤーだった場合は試合のステータスは「未決定」となり、片方がダミーのプレイヤーだった場合は「シード」というステータスとなります。
 Wikipediaのトーナメントのページを見てみますと、よく見知ったトーナメントだけでなく、実は様々な方式があることがわかります。これらの方式を全て網羅するようなトーナメントサイトを作ることはできません。まずはPO(プロダクトオーナー及びプロジェクトオーナー)達とよく相談をして、どういうトーナメント方式にするのかを決めるということが何より重要です。今回の場合「勝ち残り方式」で「シングルイリミネーション」であったからこそ、ここまでの設計上の割り切りができました。

テスト環境について

 CIはCircleCIを使用しております。現在弊社にて稼働中のプロジェクトの多くはJenkinsを使用しており、実績もありますが、規模がそこまで大きいサイトではないので、試験的な意味も兼ねてCircleCIを選択することにしました。ここでは、ちょっとハマったポイントについて共有したいと思います。
 本番のRDBはAmazon Auroraを使用することにしていたので、できればクライアント側のDBのドライバはバージョン5.6としてビルドしたもので統一したいという気持ちがありました。

Amazon Aurora は MySQL 5.6 と互換性を持つように設計されている

ただ、調べてみたところ、このエントリの執筆時点でCircleCIで提供しているMySQLは5.7のみとなっています。検索してみて解決方法を書いている方が何人かいましたが、基本的には「既にあるものをアンインストールして必要なものをインストールし直せ」というものです。ただ、自分の場合、どうにも食い合わせが悪かったのか、1日2日では解決しそうにありませんでした。
 散々ハマった挙句最終的に採用したのが、dockerのMySQL5.6イメージをCircleCI上でdocker pullしておき、テスト実行時にそのコンテナを起動して接続する、という方法でした。諸事情で今回のサイトはPerlを使って構築したのですが、PerlであればTest::Docker::MySQLというCPANモジュールがあるので、dockerでMySQLコンテナを立ち上げる際の障壁は低かったです。ただ、これを案件にフィットする形で一部改修を加えて使用しています。
 実際に使用しているcircle.ymlの中から、テスト実行前の依存モジュール等のセットアップ部分を切り出しました。

dependencies:
  cache_directories:
    - ~/perl-5.24
    - ~/docker
    - local
    - assets/node_modules
  pre:
    - |
      sudo apt-get remove -y 'mysql-*'
      sudo apt-get autoremove -y
      sudo apt-add-repository -y 'deb http://ppa.launchpad.net/ondrej/mysql-experimental/ubuntu precise main'
      sudo apt-get update
      sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libmysqlclient-dev
    - |
      mkdir -p ~/docker
      if [ -e ~/docker/mysql-56.tar ]; then
        docker load < ~/docker/mysql-56.tar
      else
        docker pull mysql:5.6
        docker save mysql:5.6 > ~/docker/mysql-56.tar
      fi
    - |
      mkdir -p ~/perl-5.24
      if [ -z "$(ls -A ~/perl-5.24/)" ]; then
        wget https://raw.githubusercontent.com/tagomoris/xbuild/master/perl-install -O ~/perl-install
        chmod +x ~/perl-install
        ~/perl-install 5.24.0 ~/perl-5.24
      fi
      carton install --deployment

 CircleCIはまっさらなコンテナから都度環境を作成するので、データやツール等が次のテストに持ち越されることがないので、依存が少なく済むのでとてもよいですね。ただこれは諸刃の刃で、テストの度に構築処理が走るので、テスト自体は一瞬で終わるのに構築に数分かかる、ということもあります。そこが気になる方は、テスト用のイメージを作成しておけばその分のオーバーヘッドは減るかと思います。ちなみに今回のプロジェクトではそこまでは行っておりません。現状、proveによるテストはおよそ3分半ほどに対して、上記YAMLによる環境構築も同じくらいかかっています。

開発環境について(docker周り)

 テスト環境でdockerを使い始めたので(CircleCIが元々dockerベースなのはおいといて)、開発環境も手を入れようと思い、docker-composeで環境を組んでみることにしました。Docker Hubperlのイメージが存在していたので、これを導入して開発用のコンテナを作成しています。開発スピードがだいぶ優先されていたので、Dockerfileにはモジュールのインストール周りの記述はせず、docker-composeでの起動時に volumes を使ってgit cloneしたディレクトリをそのままマウントする形にしています。
 以下に、プロジェクトで使用しているdocker-compose.ymlを当たり障りのない範囲で載せます。

version: "2"
volumes:
  mysql:
  redis:
services:
  mysql:
    image: mysql:5.6
    ports:
      - 3306:3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 1
    volumes:
      - mysql:/var/lib/mysql
  redis:
    image: redis:3.2
    ports:
      - 6379:6379
    volumes:
      - redis:/data
  node:
    image: node:6.9.1
    working_dir: /app
    volumes:
      - .:/app
  web: &app
    build: .
    image: hoge
    working_dir: /app # /app 以下にリポジトリを設置している
    ports:
      - 5050:5000
    volumes:
      - .:/app
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - mysql
      - redis
    links:
      - "mysql:hoge-mysql"
      - "redis:hoge-redis"
    stdin_open: true
    command: "plackup ****"
  admin:
    <<: *app
    ports:
      - 5000:5000
    command: "plackup ****"

 docker周りで個人的に一番驚いたのは volumes の挙動です。散々調べた結果、これはホストマシンのパスだけでなく、Linux環境の元イメージのパスも共有できるということでした。Windows上でdocker-composeを動かした時、 /var/run/docker.sock がちゃんと共有されて内部からdockerコマンドが問題なく動いたのを見た時は、目を疑ってしまいました。当然、Windows環境には /var/run/docker.sock は存在しておりません。知らないと怖いですね。(「Dockerでホストを乗っ取られた」という恐ろしい投稿もあるので、取扱に注意が必要です)
 docker-composeを組んだ副産物として2点ほどよかったことがあります。

  • フロントエンドエンジニアの使うnodejsのバージョンもdocker-composeに収めることができた
  • Docker for Windows上でだいたい動く

 前者については、フロントエンドを担当しているエンジニアがgulpによってCSSやJSのビルドをを行っている都合でnode.jsの環境構築が必要だったのですが、nodeのイメージをdocker-compose.ymlに入れておいたので、ローカルの環境を変更することなくビルドの作業ができるようになりました。
 後者は、Windows10だとPro版でないと動作しません。また、「だいたい動く」と書いたのは、「 docker-compose run が動かない」という問題があり、仕方なくコンテナを起動しっぱなしにしておいて、 docker exec で中に入る必要があります。ただ、どうやらこれはdocker-composeのバージョンが1.8までの話で、1.9では解消するらしいという話をこのエントリを書いている最中に見つけました。

qiita.com

 Docker for Windowsユーザはご参考までに。
 現状では開発環境しかDocker化されていないですが、ゆくゆくはECSに置き換えていきたいと考えている今日このごろです。

まとめ、というかおまけ

  • トーナメント表を実装する際は、仕様を適切にシュリンクさせないと死亡するので注意しましょう
  • CircleCIは環境構築の時間が長いのでなんとか頑張りましょう(?)
  • Docker for Windowsは思った以上にイケる

 最後に、昨年の12/23と今年は1/28にこれまで2回大会が行われたのですが、大会開催中は特に不正もなく、ユーザからシステム不具合や運営方法でクレームをつけられることなく、和やかに終了しました。終了後アンケートをとってみたところ、「また参加したい」という反応が多く、サイトのシステムに対しても「クレーム」ではなく「改善案」を挙げてもらえるという、とても貴重な体験ができました。この場を借りて、改めて参加者の方々に御礼申し上げます。