ラベル docker の投稿を表示しています。 すべての投稿を表示
ラベル docker の投稿を表示しています。 すべての投稿を表示

2015年5月11日月曜日

[docker] DockerでSpark SQL

Spark SQL 1.3から以下の2つの機能が導入されています。

  • DataSourceとしてJDBCが使えるようになった
  • DataFrame

この2つの機能追加によってSpark SQLを汎用のバッチ処理基盤にできるのではないかというインスピレーションが湧きました。

この実現目的でSparkバッチをスタンドアロンで実行するためのDockerイメージspark-sql-scala-dockerを作ってみた、というのが今回のお話です。

Spark SQL

Spark SQLは、Sparkの分散計算処理をSQLで記述できるようにしたものです。SQLとSpark本来のmonadicなAPI(e.g. filter, map, flatMap)を併用して計算処理を記述することができます。

このプログラミングモデルは非常に強力で、大枠の絞り込みはSQLで行っておいて、アプリケーションに特化した検索ロジックをScalaで記述したUDF(User Defined Function)で補完するといった処理を、プログラミング言語的に簡潔に記述することができます。

Spark SQLの基本機能に加えて1.3から以下の機能も使えるようになりました。

DataSourceのJDBC対応

DataSourceとしてJDBCが使えるようになったことで、RedShift上にためた分析データなどから直接データを取得できるようになりました。MySQLやPostreSQLなどのデータを一旦S3に変換するといった準備タスクが不要になったので、ジョブ作成の手間が大きく低減すると思います。

小さな機能追加ですが、実運用上のインパクトは大きいのではないかと思います。

DataFrame

大きな機能追加としてはDataFrameが導入されました。

DataFrameは表形式の大規模データを抽象化したAPIで、元々はR/Pythonで実績のある機能のようです。

DataFrameは分析専用のAPIではなく、表形式データ操作の汎用APIとして使用できるのではないかと期待しています。計算結果を外部出力する際の汎用機能としても期待できます。

もちろんR/Pythonなどのデータ分析処理系との連携も期待できそうです。

Spark SQLの用途

Spark SQLの基本機能と上記の2つの機能追加によって、Sparkバッチを大規模(データ量/計算量)向けデータ処理基盤としてだけではなく、汎用のバッチ実行基盤として使えるようになるのではないかとインスピレーションが湧いたわけです。

データ集計用のバッチをSparkバッチとして作成して、データ量、計算量に応じてスタンドアロンジョブとSparkクラスタ上でのジョブのいずれかでジョブ実行するというユースケースです。

そのベースとして、Sparkクラスタを用いないスタンドアロンジョブとして実行するためのDockerイメージを作ってみました。

spark-sql-scala-docker

spark-sql-scala-dockerはSparkアプリケーションをスタンドアロンで実行するためのDockerイメージです。

GitHubにソースコードがありますので、詳細はこちらを参照して下さい。

以下では、実装上のポイントと使い方について説明します。

Dockerfile

spark-sql-scala-dockerのDockerfileは以下になります。

FROM sequenceiq/spark:1.3.0

RUN mkdir -p /opt/spark/lib
RUN cd /opt/spark/lib && curl -L 'http://dev.mysql.com/get/Downloads/Connector-J/mysql-connector-java-5.1.30.tar.gz' -o - | tar -xz --strip-components=1 mysql-connector-java-5.1.30/mysql-connector-java-5.1.30-bin.jar
RUN curl -L 'http://jdbc.postgresql.org/download/postgresql-9.2-1002.jdbc4.jar' -o /opt/spark/lib/postgresql-9.2-1002.jdbc4.jar

ENV SPARK_CLASSPATH /opt/spark/lib/mysql-connector-java-5.1.30-bin.jar:/opt/spark/lib/postgresql-9.2-1002.jdbc4.jar

RUN rpm -ivh http://ftp-srv2.kddilabs.jp/Linux/distributions/fedora/epel/6/x86_64/epel-release-6-8.noarch.rpm

RUN yum -y install redis --enablerepo=epel

COPY spark-defaults.conf /opt/spark-defaults.conf

COPY entrypoint.sh /opt/entrypoint.sh

ENV COMMAND_JAR_DIR /opt/command.d

ENV COMMAND_JAR_NAME command.jar

VOLUME [$COMMAND_JAR_DIR"]

ENTRYPOINT ["/opt/entrypoint.sh"]

Dockerイメージsequenceiq/spark:1.3.0をベースにしていて以下の調整だけ行っています。

  • MySQLとPostgreSQLのJDBCドライバのインストール
  • Sparkアプリケーションの登録処理
entrypoint.sh

spark-sql-scala-dockerのentrypoint.shは以下になります。

#! /bin/bash

# WAIT_CONTAINER_TIMER
# WAIT_CONTAINER_FILE
# WAIT_CONTAINER_KEY

# set -x

set -e

echo MySQL host: ${MYSQL_SERVER_HOST:=$MYSQL_PORT_3306_TCP_ADDR}
echo MySQL port: ${MYSQL_SERVER_PORT:=$MYSQL_PORT_3306_TCP_PORT}
echo PostgreSQL host: ${POSTGRESQL_SERVER_HOST:=$POSTGRESQL_PORT_5432_TCP_ADDR}
echo PostgreSQL port: ${POSTGRESQL_SERVER_PORT:=$POSTGRESQL_PORT_5432_TCP_PORT}
echo Redis host: ${REDIS_SERVER_HOST:=$REDIS_PORT_6379_TCP_ADDR}
echo Redis port: ${REDIS_SERVER_PORT:=$REDIS_PORT_6379_TCP_PORT}
export MYSQL_SERVER_HOST
export MYSQL_SERVER_PORT
export POSTGRESQL_SERVER_HOST
export POSTGRESQL_SERVER_PORT
export REDIS_SERVER_HOST
export REDIS_SERVER_PORT

function wait_container {
    if [ -n "$REDIS_SERVER_HOST" ]; then
 wait_container_redis
    elif [ -n "$WAIT_CONTAINER_FILE" ]; then
 wait_container_file
    fi
}

function wait_container_redis {
    result=1
    for i in $(seq 1 ${WAIT_CONTAINER_TIMER:-100})
    do
 sleep 1s
 result=0
 if [ $(redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT GET $WAIT_CONTAINER_KEY)'' = "up" ]; then
     break
 fi
 echo spark-sql-scala-docker wait: $REDIS_SERVER_HOST
 result=1
    done
    if [ $result = 1 ]; then
 exit 1
    fi
}

function wait_container_file {
    result=1
    for i in $(seq 1 ${WAIT_CONTAINER_TIMER:-100})
    do
 sleep 1s
 result=0
 if [ -e $WAIT_CONTAINER_FILE ]; then
     break
 fi
 echo spark-sql-scala-docker wait: $WAIT_CONTAINER_FILE
 result=1
    done
    if [ $result = 1 ]; then
 exit 1
    fi
}

COMMAND_JAR=$COMMAND_JAR_DIR/$COMMAND_JAR_NAME

wait_container

sed -i "s!hdfs://.*:9000!file:\/\/\/tmp!g" /usr/local/hadoop/etc/hadoop/core-site.xml

spark-submit --properties-file /opt/spark-defaults.conf $COMMAND_JAR

基本的にはspark-submitでSparkアプリケーションのジョブをサブミットしているだけですが、以下の2つの調整を行っています。

  • Redisを使って他のコンテナの待ち合わせ
  • 中間データのローディング先をHDFSではなくローカルファイルに変更する
コンテナの待ち合わせ

Sparkアプリケーションを動作させる前の準備を他のコンテナで進める場合は、コンテナの待ち合わせが必要になります。この待ち合わせをmysql-java-embulk-dockerと同様にRedisを用いて実現しています。

典型的な使用例は、Sparkアプリケーションのテスト実行時でのテストDBの準備です。この実例は後ほどサンプルで説明します。

中間データのローディング先

core-site.xmlの変更処理です。

sed -i "s!hdfs://.*:9000!file:\/\/\/tmp!g" /usr/local/hadoop/etc/hadoop/core-site.xml

ここの設定を変更しないとDocker環境内でスタンドアロンでは動かなかったので設定変更しています。

設定変更の方法としてはHDFSを動くようにするという方式もあるのですが、スタンドアプリケーションなのでここではローカルのファイルを使う方式で対応しています。

Docker Hub

mysql-java-embulk-dockerと同様にspark-sql-scala-dockerもDocker Hubの自動ビルドの設定を行っているので、以下の場所にDockerイメージが自動ビルドされます。

このイメージは「asami/spark-sql-scala-docker」という名前で利用することができます。

サンプル

Dockerイメージ「asami/spark-sql-scala-docker」を利用してテストデータの投入を行うサンプルを作ってみます。

手元の環境上でテスト目的で動作させるためmysql-java-embulk-dockerを併用してテストデータの投入を行っています。

サンプルのコードはGitHubのspark-sql-scala-dockerのsampleディレクトリにあるので、詳細はこちらを参照して下さい。

SimpleApp.scala

サンプルのSparkバッチであるSimpleAppのプログラムは以下になります。

import org.apache.spark.SparkContext
import org.apache.spark.SparkContext._
import org.apache.spark.SparkConf
import org.apache.spark.sql.{SQLContext, DataFrame}

object SimpleApp extends App {
  val batting = SparkSqlUtils.createMysqlDataFrame("Simple Application", "batting")
  val count = batting.count()
  println(s"count = ${batting.count()}")
}

object SparkSqlUtils {
  def createSqlContext(name: String): SQLContext = {
    val conf = new SparkConf().setAppName(name)
    val sc = new SparkContext(conf)
    new SQLContext(sc)
  }

  def createMysqlDataFrame(name: String, table: String): DataFrame = {
    val sqlc = createSqlContext(name)
    createMysqlDataFrame(sqlc, table)
  }

  def createMysqlDataFrame(sqlc: SQLContext, table: String): DataFrame = {
    val host = System.getenv("MYSQL_SERVER_HOST")
    val port = System.getenv("MYSQL_SERVER_PORT")
    val user = System.getenv("MYSQL_SERVER_USER")
    val password = System.getenv("MYSQL_SERVER_PASSWORD")
    sqlc.load("jdbc", Map(
      "url" -> s"jdbc:mysql://$host:$port/baseball?user=$user&password=$password",
      "dbtable" -> table
    ))
  }
}

SparkSqlUtilsにDataFrame取得処理をまとめています。ここは汎用ライブラリ化できるところです。

この処理を除いた以下の処理がSparkバッチの本体です。

val batting = SparkSqlUtils.createMysqlDataFrame("Simple Application", "batting")
  val count = batting.count()
  println(s"count = ${batting.count()}")
アプリケーションロジック

指定したテーブル"batting"に対応したDataFrameを取得し、countメソッドでレコード総数を取得し、その結果をコンソールに出力しています。とても簡単ですね。

この部分を以下の機能を用いて記述することで高度なバッチ処理を簡単に記述できます。

  • DataFrameによる表データ操作
  • DataFrameから変換したRDDを用いてSpark計算処理

前述したように「大枠の絞り込みはSQLで行っておいて、アプリケーションに特化した検索ロジックをScalaで記述したUDF(User Defined Function)で補完するといった処理を、プログラミング言語的に簡潔に記述することができます。」

移入・移出

テーブル"batting"をDataFrameとしてローディングしているのは、前述のSpark 1.3の機能追加「DataSourceとしてJDBCが使えるようになった」によるものです。

また、ここでは外部出力をコンソール出力にしていますが、RDDのsaveAsTextFileメソッドやDataFrameを用いることで、S3やデータベースなどに集計結果を簡単に出力することができます。

データベースなどへの外部出力が簡単に行えるのもSpark 1.3の機能追加「DataFrame」の効果です。

ここからも分かるように、Spark SQL 1.3で導入された「DataSourceとしてJDBCが使えるようになった」と「DataFrame」により、Sparkバッチ処理の難題であったデータの移入/移出処理が極めて簡単に記述できるようになったわけです。

SBTの設定

SBTによるScalaプログラムのビルドの設定は以下になります。

Spark本体とSpark SQLを依存ライブラリとして設定している、ごくオーソドックスな設定です。

Sparkバッチ用にすべての依存ライブラリをまとめたJARファイルを作る必要があるので、sbt-assemblyの設定を行ってます。

ポイントとしては、Sparkバッチの実行環境にScalaの基本ライブラリとSpark本体/Spark SQLのライブラリが用意されているので、sbt-assemblyでまとめるJARファイルから排除する設定を行っています。

  • Spark本体とSpark SQLの依存ライブラリの設定を"provided"にしてリンク対象から外す
  • sbt-assemblyの設定で"includeScala = false"としてScala基本ライブラリをリンク対象から外す

これらの設定はなくても動作しますが、JARファイルが巨大になってしまいます。

name := "simple"

version := "1.0"

scalaVersion := "2.10.4"

libraryDependencies += "org.apache.spark" %% "spark-core" % "1.3.1" % "provided"

libraryDependencies += "org.apache.spark" %% "spark-sql" % "1.3.1" % "provided"

assemblyOption in assembly := (assemblyOption in assembly).value.copy(includeScala = false)

sbt-assemblyプラグインが必要なのでproject/assembly.sbtに以下の設定をしておきます。

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.13.0")
docker-compose.yml

サンプルプログラムのdocker-compose.ymlは以下になります。

spark:
  image: asami/spark-sql-scala-docker
  links:
    - mysql
    - redis
  volumes:
    - target/scala-2.10:/opt/command.d
  environment:
    COMMAND_JAR_NAME: simple-assembly-1.0.jar
    WAIT_CONTAINER_KEY: mysql-java-embulk-docker
    MYSQL_SERVER_USER: baseball
    MYSQL_SERVER_PASSWORD: baseball
mysql:
  image: asami/mysql-java-embulk-docker
  links:
    - redis
  ports:
    - ":3306"
  volumes:
    - setup.d:/opt/setup.d
  environment:
    MYSQL_USER: baseball
    MYSQL_PASSWORD: baseball
    MYSQL_ROOT_PASSWORD: baseball
    MYSQL_DATABASE: baseball
redis:
  image: redis
  ports:
    - ":6379"

自前ではDockerイメージを作らず、以下の3つの汎用Dockerイメージを再利用しています。

  • asami/spark-sql-scala-docker
  • asami/mysql-java-embulk-docker
  • redis
asami/spark-sql-scala-docker

ボリュームと環境変数の記述で、targetscala-2.10simple-assembly-1.0.jarがSparkバッチプログラムとして認識されるようにしています。

simple-assembly-1.0.jarはsbt-assemblyで作成した「全部入り(SparkとScala以外)」のJARファイルです。

それ以外は、mysql-java-embulk-dockerと同期をとるためのおまじないです。

asami/mysql-java-embulk-docker

前回の記事「Docker Composeでデータ投入」と同じ設定です。テスト用のMySQLデータベースにテストデータを投入しています。

Batting.csvは以下のサイトからデータを取得しました。

redis

asami/mysql-java-embulk-dockerによるテストデータ投入の待ち合わせにredisを用いています。

ビルド

Sparkバッチのビルドはsbtで行います。

$ sbt assembly

テスト環境はdocker-composeのbuildコマンドでビルドします。

$ docker-compose build
実行

docker-composeのupコマンドで実行します。

$ docker-compose up

動作過程がコンソールに出力されますが、最後の方で以下のような出力があります。

spark_1 | count = 99846

無事Sparkバッチでデータ集計ができました。

まとめ

Spark SQLを汎用のバッチ処理基盤として運用する目的でSparkバッチをスタンドアロンで実行するためのDockerイメージspark-sql-scala-dockerを作ってみましたが、無事動作しました。

このことによってspark-sql-scala-dockerとmysql-java-embulk-dockerを使って手元で簡単にSparkバッチをテストできるようになりました。

汎用Dockerイメージをdocker-composeで組み合わせるだけなので運用的にも大変、楽だと思います。

今回は試していませんが、spark-sql-scala-dockerを使ってSparkバッチをECS(EC2 Container Service)などのDocker環境上でスタンドアロンバッチとして実行するという運用も可能ではないかと考えています。

もちろん、SparkバッチのJARファイルをspark-submitコマンドによるジョブ投入により直接Sparkクラスタ上で実行することでSpark本来の大規模(データ量/計算量)処理を行うことができます。

いずれの場合も、基本的に開発するのは、Scalaによる通常のSparkバッチプログラムだけです。テストやDocker環境上でのスタンドアロンバッチのいずれも汎用Dockerイメージを活用することで、簡単な設定のみで運用することができそうです。

今回の作業で上記の3つのユースケースを同時に満たせることの目処が立ちました。この成果をベースにSpark SQLを汎用のバッチ処理基盤として利用するためのノウハウの積み上げをしてきたいと思います。

諸元

  • Mac OS 10.7.5
  • docker 1.6
  • docker-compose 1.2.0
  • Spark SQL 1.3

2015年5月7日木曜日

[docker] Docker Composeでデータ投入

Docker ComposeでMySQLを使う」ではDocker Composeを使ってJobSchedulerからMySQLをそれぞれ別のDockerコンテナ上で動作させ連携して使用しました。

テストなどでMySQL公式Dockerコンテナを使う際の問題点として、データ投入があります。事前に用意したデータをMySQLに投入後に、テスト対象のアプリケーションが起動されると理想的なのですが、MySQL公式Dockerイメージではデータ投入する機能は提供されていません。

また、MySQL公式DockerイメージではMySQLの起動完了を待ち合わせる機能を持っていないことも問題です。

これらの問題に対応するためMySQL公式Dockerイメージを元に、マイDockerイメージであるmysql-java-embulk-dockerを作ってみました。

mysql-java-embulk-docker

mysql-java-embulk-dockerはdocker-composeを使ってアプリケーションDockerイメージのテストを行う際に、事前にデータ投入したMySQLデータベースを提供するためのDockerイメージです。

GitHubにソースコードがありますので、詳細はこちらを参照して下さい。

以下では、実装上のポイントと使い方について説明します。

Dockerfile

mysql-java-embulk-dockerのDockerfileは以下になります。

FROM mysql:5.6
MAINTAINER asami

RUN apt-get update && apt-get -y install wget curl

# Install JDK 1.7
RUN cd /opt; wget --no-cookies --no-check-certificate --header "Cookie: oraclelicense=accept-securebackup-cookie" "http://download.oracle.com/otn-pub/java/jdk/7u51-b13/jdk-7u51-linux-x64.tar.gz" -O /opt/jdk-7-linux-x64.tar.gz

# Install in /usr/java/jdk1.7.0_51 
RUN mkdir /usr/java && (cd /usr/java; tar xzf /opt/jdk-7-linux-x64.tar.gz)
RUN rm /opt/jdk-7-linux-x64.tar.gz
RUN update-alternatives --install /usr/bin/java java /usr/java/jdk1.7.0_51/jre/bin/java 20000; update-alternatives --install /usr/bin/jar jar /usr/java/jdk1.7.0_51/bin/jar 20000; update-alternatives --install /usr/bin/javac javac /usr/java/jdk1.7.0_51/bin/javac 20000; update-alternatives --install /usr/bin/javaws javaws /usr/java/jdk1.7.0_51/jre/bin/javaws 20000; update-alternatives --set java /usr/java/jdk1.7.0_51/jre/bin/java; update-alternatives --set javaws /usr/java/jdk1.7.0_51/jre/bin/javaws; update-alternatives --set javac /usr/java/jdk1.7.0_51/bin/javac; update-alternatives --set jar /usr/java/jdk1.7.0_51/bin/jar;

RUN curl --create-dirs -o /opt/embulk -L "http://dl.embulk.org/embulk-latest.jar" && chmod +x /opt/embulk

RUN /opt/embulk gem install embulk-output-mysql

RUN apt-get -y install redis-server

COPY charset.cnf /etc/mysql/conf.d/charset.cnf
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh

VOLUME ["/var/lib/mysql", "/etc/mysql/conf.d", "/opt/setup.d"]

ENTRYPOINT ["/opt/entrypoint.sh"]

CMD ["mysqld"]
Embulk

Embulkはビッグデータスケールのデータローダです。

データ投入にEmbulkを利用できると大変便利なので組み込んでみました。

EmbulkはJava VM上で動作するので、Embulk用にJDKをインストールしています。

また、MySQLにデータ投入するので、Embulkにembulk-output-mysqlプラグインを追加しています。

Redis

Docker Compose上でmysql-java-embulk-dockerコンテナの起動時にデータ投入をする際に問題点として、mysql-java-embulk-dockerコンテナの起動とアプリケーションコンテナの起動の同期が行われないというものがあります。

mysql-java-embulk-dockerコンテナの起動とアプリケーションコンテナの起動が同時に行われてしまうために、mysql-java-embulk-dockerコンテナの起動時に行われるデータ投入が完了する前に、アプリケーションコンテナが動き出してしまい、想定したデータがない状態なので誤動作する、という問題です。

現段階ではDocker Composeにはこの問題を解決するための機能は提供されていないようなので、Redisを使って対応することにしました。

この目的でRedisをインストールしています。

本来はRedisのクライアントのみがインストールできればよいのですが、簡単にできるよい方法がみつからなかったのでRedisをまるごとインストールしています。

charset.cnf

MySQLで日本語を使うための設定として、サーバーのコード系をUTF-8に設定します。

この目的で以下のchaset.cnfを用意して、Dockerコンテナの/etc/mysql/conf.d/charset.cnfにCOPYします。

[mysqld]
character-set-server = utf8
entrypoint.sh

mysql-java-embulk-dockerのentrypoint.shは以下になります。

#!/bin/bash

# WAIT_DB_TIMER
# WAIT_CONTAINER_KEY

# set -x

set -e

echo Wait contaner key: ${WAIT_CONTAINER_KEY:=mysql-java-embulk-docker}
echo Redis host: ${REDIS_SERVER_HOST:=$REDIS_PORT_6379_TCP_ADDR}
echo Redis port: ${REDIS_SERVER_PORT:=$REDIS_PORT_6379_TCP_PORT}

function check_db {
    if [ "$MYSQL_ROOT_PASSWORD" ]; then
 mysql -u root -p"$MYSQL_ROOT_PASSWORD" -e "status"
    elif [ "$MYSQL_ALLOW_EMPTY_PASSWORD" ]; then
 mysql -e "status"
    else
 exit 1
    fi
}

function wait_db {
    result=1
    for i in $(seq 1 ${WAIT_DB_TIMER:-10})
    do
 sleep 1s
 result=0
 check_db && break
 result=1
    done
    if [ $result = 1 ]; then
 exit 1
    fi
}

if [ "${1:0:1}" = '-' ]; then
    set -- mysqld "$@"
fi

is_install=false

if [ "$1" = 'mysqld' ]; then
    # read DATADIR from the MySQL config
    DATADIR="$("$@" --verbose --help 2>/dev/null | awk '$1 == "datadir" { print $2; exit }')"

    if [ ! -d "$DATADIR/mysql" ]; then
        if [ -z "$MYSQL_ROOT_PASSWORD" -a -z "$MYSQL_ALLOW_EMPTY_PASSWORD" ]; then
            echo >&2 'error: database is uninitialized and MYSQL_ROOT_PASSWORD not set'
            echo >&2 '  Did you forget to add -e MYSQL_ROOT_PASSWORD=... ?'
            exit 1
        fi

 is_install=true

        echo 'Running mysql_install_db ...'
        mysql_install_db --datadir="$DATADIR"
        echo 'Finished mysql_install_db'

        # These statements _must_ be on individual lines, and _must_ end with
        # semicolons (no line breaks or comments are permitted).
        # TODO proper SQL escaping on ALL the things D:

        tempSqlFile='/tmp/mysql-first-time.sql'
        cat > "$tempSqlFile" <<-EOSQL
            DELETE FROM mysql.user ;
            CREATE USER 'root'@'%' IDENTIFIED BY '${MYSQL_ROOT_PASSWORD}' ;
            GRANT ALL ON *.* TO 'root'@'%' WITH GRANT OPTION ;
            DROP DATABASE IF EXISTS test ;
EOSQL

        if [ "$MYSQL_DATABASE" ]; then
            echo "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` ;" >> "$tempSqlFile"
        fi

        if [ "$MYSQL_USER" -a "$MYSQL_PASSWORD" ]; then
            echo "CREATE USER '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASSWORD' ;" >> "$tempSqlFile"

            if [ "$MYSQL_DATABASE" ]; then
                echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* TO '$MYSQL_USER'@'%' ;" >> "$tempSqlFile"
            fi
        fi

        echo 'FLUSH PRIVILEGES ;' >> "$tempSqlFile"

 # http://qiita.com/toritori0318/items/242274d4f5794e2f68e5
        # setup
        echo "use $MYSQL_DATABASE;" >> "$tempSqlFile"
 if [ -e "/opt/setup.d/setup.sql"]; then
            cat /opt/setup.d/setup.sql >> "$tempSqlFile"
 fi
        # start mysql
        set -- "$@" --init-file="$tempSqlFile"
    fi

    chown -R mysql:mysql "$DATADIR"
fi

exec "$@" &

wait_db

if [ -e "/opt/setup.d/setup.yml" ]; then
    if [ $is_install=true ]; then
 echo "embulk run setup.yml"
 cd /opt/setup.d && /opt/embulk run setup.yml
    fi
fi

if [ -n "$REDIS_SERVER_HOST" ]; then
    redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT SET $WAIT_CONTAINER_KEY up
fi

sleep infinity

MySQL公式をベースに、データ投入用SQLおよびEmbulkでデータ投入するように拡張したものです。

setup.sql

/opt/setup.d/setup.sqlとしてデータ投入用SQLが存在する場合は、MySQLの初期起動スクリプトにこの内容を追加することで、起動時にデータ投入されるようになっています。

/opt/setup.dはDockerfileでVolumeなっていて、外部からディレクトリをマウントして使用することを想定しています。

データ投入用SQLは「Docker公式のmysqlイメージを使いつつ初期データも投入する」の記事を参考にしました。

setup.yml

/opt/setup.d/setup.ymlとしてデータ投入用Embulk記述ファイルが存在する場合は、Embulkを使ってデータ投入するようになっています。

ただし、MySQLの起動が完了した後でないとデータ投入ができないのでmysqlコマンドを使って待ち合わせ処理を行っています。

Redisによる同期

mysql-java-embulk-dockerコンテナの起動終了の待ち合わせのためRedisを使用します。

具体的には以下のように、外部コンテナでRedisが起動されている場合に、redis-cliコマンドを使って環境変数WAIT_CONTAINER_KEYで指定されたスロットに「up」という文字列を設定しています。

if [ -n "$REDIS_SERVER_HOST" ]; then
    redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT SET $WAIT_CONTAINER_KEY up
fi

アプリケーション側は、Redisのこのスロットがupになるまでポーリングで待ち合わせることで同期を取ることになります。

終了抑止

最後にDockerでサービスを記述する時のお約束としてsleepコマンドで終了抑止を行っています。

Docker Hub

Docker HubはGitHubやBitBucketと連動した自動ビルド機能を提供しています。

mysql-java-embulk-dockerもこの設定を行っているので、以下の場所にDockerイメージが自動ビルドされます。

このイメージは「asami/mysql-java-embulk-docker」という名前で利用することができます。

サンプル

Dockerイメージ「asami/mysql-java-embulk-docker」を利用してテストデータの投入を行うサンプルを作ってみます。

サンプルのコードはGitHubのmysql-java-embulk-dockerのsampleディレクトリにあるので、詳細はこちらを参照して下さい。

docker-compose.yml

サンプルプログラムのdocker-compose.ymlは以下になります。

app:
  build: .
  links:
    - mysql
    - redis
  environment:
    WAIT_CONTAINER_KEY: mysql-java-embulk-docker
    MYSQL_SERVER_USER: baseball
    MYSQL_SERVER_PASSWORD: baseball
mysql:
  image: asami/mysql-java-embulk-docker
  links:
    - redis
  ports:
    - ":3306"
  volumes:
    - setup.d:/opt/setup.d
  environment:
    MYSQL_USER: baseball
    MYSQL_PASSWORD: baseball
    MYSQL_ROOT_PASSWORD: baseball
    MYSQL_DATABASE: baseball
redis:
  image: redis
  ports:
    - ":6379"

setup.dをコンテナの/opt/setup.dにマウントしています。

setup.dには後述のsetup.ymlとデータファイルBatting.csvが格納されています。

Batting.csvは以下のサイトからデータを取得しました。

setup.yml

setup.ymlはembulkで移入するデータの情報を記述したものです。

in:
  type: file
  path_prefix: Batting.csv
  parser:
    charset: UTF-8
    newline: CRLF
    type: csv
    delimiter: ','
    quote: '"'
    escape: ''
    skip_header_lines: 1
    columns:
    - {name: playerID, type: string}
    - {name: yearID, type: long}
    - {name: stint, type: long}
    - {name: teamID, type: string}
    - {name: lgID, type: string}
    - {name: G, type: long}
    - {name: AB, type: long}
    - {name: R, type: long}
    - {name: H, type: long}
    - {name: 2B, type: long}
    - {name: 3B, type: long}
    - {name: HR, type: long}
    - {name: RBI, type: long}
    - {name: SB, type: long}
    - {name: CS, type: long}
    - {name: BB, type: long}
    - {name: SO, type: long}
    - {name: IBB, type: long}
    - {name: HBP, type: long}
    - {name: SH, type: long}
    - {name: SF, type: long}
    - {name: GIDP, type: long}
exec: {}
out:
  type: mysql
  host: localhost
  database: baseball
  user: baseball
  password: baseball
  table: batting
  mode: insert

CSVファイルから入力したデータをMySQLに投入する際の標準的な指定と思います。

Dockerfile

サンプルアプリケーションのDockerfileは以下になります。

FROM mysql
MAINTAINER asami

ENV MYSQL_ALLOW_EMPTY_PASSWORD true

RUN apt-get update && apt-get -y install redis-server

COPY app.sh /opt/app.sh
RUN chmod +x /opt/app.sh
ADD https://raw.githubusercontent.com/asami/mysql-java-embulk-docker/master/lib/mysql-java-embulk-docker-lib.sh /opt/mysql-java-embulk-docker-lib.sh

ENTRYPOINT /opt/app.sh

アプリケーションでmysqlコマンドを使うので、MySQL公式Dockerイメージをベースにしました。

mysql-java-embulk-dockerコンテナとの同期にRedisを使うのでRedisをインストールしています。

また、アプリケーション起動シェルの共通ライブラリmysql-java-embulk-docker-lib.shをGitHubからコンテナ内にコピーしています。

アプリケーション起動の動きは大きく以下の3つの部分に分かれます。

  • パラメタの取り込み
  • データ投入の待ち合わせ
  • アプリケーションロジック

この中の「パラメタの取り込み」と「データ投入の待ち合わせ」をmysql-java-embulk-docker-lib.shが行います。

app.sh

サンプルアプリケーションの実行スクリプトapp.shは以下になります。

#! /bin/bash

# set -x

set -e

DIR="${BASH_SOURCE%/*}"
if [[ ! -d "$DIR" ]]; then DIR="$PWD"; fi
source $DIR/mysql-java-embulk-docker-lib.sh

mysql -u $MYSQL_SERVER_USER -p$MYSQL_SERVER_PASSWORD --host=$MYSQL_SERVER_HOST --port=$MYSQL_SERVER_PORT -e "select count(*) from baseball.batting"

まず、共通ライブラリmysql-java-embulk-docker-lib.shをsourceで取り込んでいます。

この中でRedisを使った同期が行われ、Embulkによるデータ投入が完了した状態でアプリケーションロジックに入ってきます。

今回のアプリケーションロジックは非常に単純で以下の処理を行います。

  • baseball.battingテーブルの総レコード数を取得する

この問合せ処理をmysqlコマンドを使って行っています。

実行

docker-composeのbuildコマンドでビルドします。

$ docker-compose build

docker-composeのupコマンドでビルドします。

$ docker-compose up

動作過程がコンソールに出力されますが、最後の方で以下のような出力があります。

app_1   | count(*)
app_1   | 99846

無事、Batting.csvをMySQLのbaseball.battingテーブルに投入した後、baseball.battingテーブルの総レコード数を取得することができました。 

まとめ

SQLとEmbulkを使ってデータ投入できるMySQL用のDockerコンテナを作ってみました。

アプリケーション開発では、テスト用データベースの準備とデータ投入が大きな手間であり、テスト自動化の障壁にもなっていたので、今回開発したDockerイメージをアプリケーション開発に適用していきたいと思います。

それにしても、Dockerのイメージ開発はシェルスクリプトプログラミングということを実感しました。相当錆び付いていましたが、なんとか動くものができました。

諸元

  • Mac OS 10.7.5
  • docker 1.6
  • docker-compose 1.2.0

2015年5月4日月曜日

[docker] Docker ComposeでMySQLを使う

製品の評価や、開発時のテストなどDBをカジュアルに作成して消したいシチュエーションは多々あります。

開発マシンにDBを入れてデータ投入する運用だと、開発マシン環境が汚れてきたり、復数の設定が共存して収集がつかなくなったり、DBやライブラリのバージョンに依存する場合の切り替えに困ったりということになりがちです。

そこで以前だとVMを使ったり、AWSのようなクラウド上にDBを立てたりしていたわけですが、設定はそのものが難しくはないものの、毎回環境を構築する作業が必要になったりと、まだまだ手間のかかる作業でした。

言うまでもなく、このような問題がDockerで一気に解決したのは大変画期的なことです。

今回はDockerを使ってDBをカジュアルに構築する方法について考えてみます。

SoS JobScheduler

今回はジョブ管理製品のSoS JobSchedulerを使ってみました。

JobSchedulerはバックエンドにDBを使っているので、このDBをDocker上でどのようにして構築して接続するのかという点がポイントです。

またJobSchedulerはapt-getやrpmといったインストーラに対応しておらずインストールが手作業になります。このような製品の配布方式はけっこうありますが、この部分をDockerfile側、起動スクリプト側双方のシェルスクリプトで対応することになります。

MySQL公式Dockerイメージ

MySQLをインストールしたマイDockerイメージを作る方法も有力ですが、MySQLの公式Dockerイメージが機能豊富で結構便利だったので使ってみました。

また、JobSchedulerとMySQLを同じDockerイメージにインストールする方法もありますが、JobScheduler用Dockerイメージとしての汎用性が損なわれるので、避けたほうがよいでしょう。

このため、今回使用するDockerイメージはMySQL公式イメージとJobSchedulerをインストールしたマイイメージの2つになります。このように復数のイメージを接続する場合、dockerコマンドののパラメタを設定する方法もありますが、かなり煩雑です。

この問題に対応するため今回はDocker Composeを使ってみました。

Docker Compose

Docker Compose(旧fig)は復数のDockerイメージを連携動作させるための機能です。

定義ファイルdocker-compose.ymlの設定に従って、復数のDockerコンテナを同時に立ち上げ、Dockerのlinking systemを用いて各Dockerコンテナをリンクで接続する処理を行います。

設定

それでは、Docker Composeを用いた設定を行います。設定結果の全体はGitHubにありますので必要に応じて参照して下さい。

docker-compose.yml

docker-compose.ymlの設定は以下になります。

jobscheduler:
  build: .
  links:
    - db
  ports:
    - "4444:4444"
db:
  image: mysql
  ports:
    - ":3306"
  volumes:
    - conf.d/etc.mysql.conf.d:/etc/mysql/conf.d
  environment:
    MYSQL_USER: jobscheduler
    MYSQL_PASSWORD: jobscheduler
    MYSQL_ROOT_PASSWORD: jobscheduler
    MYSQL_DATABASE: jobscheduler

jobschedulerとdbの2つのDockerコンテナの定義をしています。

jobschedulerコンテナは自前のDockerfileを使ったコンテナです。linksでdbコンテナをリンクする設定を行っています。

dbコンテナはMySQL公式イメージをそのまま使っています。ポイントとなるのはenvironmentで指定している4つの環境変数です。これらの環境変数を設定することで、自動的に必要な初期設定をしてくれるようになっています。

多くのケースで、MySQL公式イメージが提供している環境変数で目的が足りると思われるので、MySQL公式イメージはかなり使い出がありそうです。

Dockerfile

JobSchedulerを実行するDockerfileの設定は以下になります。

FROM dockerfile/java:oracle-java8
MAINTAINER asami

ENV JOBSCHEDULER_VERSION 1.9.0

RUN mkdir -p /opt/jobscheduler && cd /opt/jobscheduler; curl -L http://freefr.dl.sourceforge.net/project/jobscheduler/jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.tar.gz -o - | tar -xz --strip-components=1

# SSH, API/HTTP, API/HTTPS, JOC
EXPOSE 22 44440 8443 4444

USER root

COPY scheduler_install.xml /opt/jobscheduler/scheduler_install.xml

# Set the default command to run when starting the container
COPY startup-jobscheduler.sh /opt/startup-jobscheduler.sh
CMD ["/opt/startup-jobscheduler.sh"]

JobSchedulerがJava 8依存なので、基盤イメージとしてjava8版のJava公式イメージを指定しています。

wgetはインストールが必要なので用いずcurlを使っています。curlとtarを連動させている以下の行は:

RUN mkdir -p /opt/jobscheduler && cd /opt/jobscheduler; curl -L http://freefr.dl.sourceforge.net/project/jobscheduler/jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.tar.gz -o - | tar -xz --strip-components=1
  • 中間ファイルを残さない。
  • 配布アーカイブにあるディレクトリ"jobscheduler.1.9.0"を"jobscheduler"に付け替え。こうすることで、後続の処理でバージョン番号を意識する処理を減らすことができます。

という意図です。このようなケースのイディオム的なスクリプトです。

startup-jobscheduler.sh

dockerの定義で比較的難しいのは起動スクリプトのところです。

基本的には、ターゲットのプログラムを起動するだけなのですが、環境との整合性を取るための処理を色々と書く必要があります。

今回の起動スクリプトはstartup-jobscheduler.shで、DockerfileのCMDで指定しています。

#! /bin/bash

sleep 10s

sed -i -e "s/{{DB_PORT_3306_TCP_ADDR}}/$DB_PORT_3306_TCP_ADDR/g" /opt/jobscheduler/scheduler_install.xml
sed -i -e "s/{{DB_PORT_3306_TCP_PORT}}/$DB_PORT_3306_TCP_PORT/g" /opt/jobscheduler/scheduler_install.xml

(cd /opt/jobscheduler;/usr/bin/java -jar jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.jar scheduler_install.xml)

sleep infinity

DBの起動を待ち合わせるためsleepコマンドで10秒ウエイト入れています。

scheduler_install.xmlの設定を、Docker実行時の環境に適合するようにsedコマンドで書き換えています。環境変数DB_PORT_3306_TCP_ADDRとDB_PORT_3306_TCP_PORTはDockerのlinking systemが設定してくる環境情報です。これらの情報の取り込みを起動スクリプトで対応する必要があります。

JobSchedulerは起動後、自動的にバックグラウンドになってしまうため、そのままstartup-jobscheduler.shが終わるとそのままDockerも終わってしまいます。そこでsleepコマンドで永久にウエイトするようにしています。

実行

Docker Composeを使ってJobSchedulerを立ち上げてみましょう。

設定の取得

GitHubから設定一式を取得します。

$ git clone https://github.com/asami/SoS-JobScheduler-docker.git
ビルド

docker-composeコマンドのbuildを実行します。

$ docker-compose build
実行

docker-composeコマンドのupを実行します。

$ docker-compose up

起動は以上で終了です。

以下のアドレスにアクセスするとJobSchedulerの管理画面が表示されます。(Macの場合)

まとめ

Docker Composeを使って、自前のJobSchedulerコンテナとMySQL公式イメージを連動させ、JobSchedulerの実行環境を作ってみました。

MySQL公式イメージの機能が結構豊富なので、DB側は設定だけで使用することができました。

Dockerも便利ですが、Docker Composeもかなり便利で、アイデア次第で色々と応用がありそうです。

諸元

Mac OS 10.7.5 docker 1.6 docker-compose 1.2.0

2015年4月27日月曜日

Finagle+Karaf+Dockerでmicroservices

流行りなのでmicroservicesという用語を使ってみましたが、意図としては(microservicesも含む)マルチサーバー構成によるクラウド・システムを構成するサービス群がターゲットです。

この実行基盤としてFinagle、Karaf、Dockerの組合せが有力ではないかということで、試しにサンプルプロジェクトを作ってみました。このサンプルプロジェクトは、新しいクラウド・システム内サービスを作る時の雛形を想定しています。

このサンプルプロジェクトを実際に作ってみることで、Karafがどのぐらいの手間で使えるのか、ScalaやFinagleとの相性はどうなのか、といったことを試してみるのが目的です。この手のプロジェクト構築ではライブラリ間の依存関係の解決が難しいのですが、Finagleをリンクしても大丈夫な設定を発見できたので、この問題はクリアできていると思います。

以下ではクラウド・システム内サービスをサービスの粒度によらずmicroserviceと呼ぶことにします。

Apache Karaf

Apache Karafは軽量OSGiコンテナです。

Karafを導入する目的は大きく以下の3つです。

  • microserviceの環境設定共有
  • dependency hell対策
  • CamelによるEIP(Enterprise Integration Patterns)
環境設定共有

第一の目的はクラウド・システムを構成するmicroservice群の環境設定を共通化することです。

microserviceをmainメソッドを作るなどして自前でデーモン化すると各種設定もすべて自前で行わなければならなくなります。

たとえば、SLF4J+fluentedなどによるロギング、JMX(Java Management Extensions)による運用監視、JNDI(Java Naming and Directory Interface)によるディレクトリ管理といった各種設定をサービス毎に設定する必要があります。

これは(1)設定そのものが大変な手間、(2)クラウド・システム内の共通設定情報の共有の手間、(3)設定漏れの危険、(4)ハードコーディングによるカスタマイザビリティの欠如、といった問題があります。

microserviceをコンテナ内で動作させることで、各種設定はコンテナに対する設定として共通化する上記の問題を解消することができます。

この目的で導入するコンテナとして、軽量OSGiコンテナであるKarafがよいのではないか考えています。

dependency hell対策

Java VM上での開発における未解決問題の一つにdependency hellがあります。

この問題はJava 9でModule機能として解決される見込みですが、当面の対策としてはOSGiコンテナを使用するのが現実解となっています。

最近はちょっとしたライブラリをリンクしても、その裏でApache Commons、Spring、JBossといった巨大なライブラリがついてくることが多いので、思ったより切実な問題です。

CamelによるEIP

Karafを採用したボーナスのようなものですが、KarafからはCamelを簡単に使えるようになっています。(KarafはESB(Enterprise Service Bus)のServiceMixのOSGiコンテナ部を切り出して製品化したものです。)

Camelを使うと、EIPのパターンを使用して様々な通信プロトコルを使って外部サービスと連携する処理を簡単に作成することができます。またCamelにはScala DSLも用意されています。

Twitter Finagle

Twitter Finagleはmicroservices指向のRPCシステムです。

FinagleはFutureモナドによる非同期実行、耐故障性の抽象化を行っており、Monadic Programmingとの相性がよいのもFunctional Reactive Programming指向のmicroservicesの通信基盤として魅力です。

準備

Karafを使うためのベースとしてKarafの実行環境をDockerイメージ化しました。

Karaf Docker Image

Dockerイメージを作成するプロジェクトは以下になります。

Dockerfileだけの簡単なプロジェクトです。

実際にmicroserviceをインストールして使うことを想定しているので、余分な設定は行わずプレインな簡単なものにしています。

Docker Hub

このDockerイメージをDocker Hubに登録しました。

以下のようにして利用できます。

$ docker pull asami/karaf-docker

サンプル・プロジェクト

サンプル・プロジェクトは以下のGitHubプロジェクトとして作成しました。

サンプル・サーバー

Finagleによるサンプル・サーバーは以下のものです。

package sample

import com.twitter.finagle.{Http, Service, ListeningServer}
import com.twitter.util.{Await, Future}
import java.net.InetSocketAddress
import org.jboss.netty.handler.codec.http._

object Server {
  val service = new Service[HttpRequest, HttpResponse] {
    def apply(req: HttpRequest): Future[HttpResponse] =
      Future.value(new DefaultHttpResponse(
        req.getProtocolVersion, HttpResponseStatus.OK))
  }

  def start(): ListeningServer = {
    Http.serve(":8080", service)
  }

  def stop(server: ListeningServer) {
    server.close()
  }

  def main(args: Array[String]) {
    val server = start()
    Await.ready(server)
  }
}
ビルド

ビルドは以下の手順で行います。

  • Karaf KAR
  • Dockerfile
  • Dockerイメージ
Karaf KAR

Karafに配備するKARファイルの作成にはkarafタスクを使用します。

$ sbt karaf

karafタスクはbuild.sbtで定義しています。

Dockerfile

KARファイルを配備したKaraf実行環境のDockerイメージを作成するためのDockerfileの作成にはdockerタスクを使用します。

$ sbt docker

dockerタスクはbuild.sbtで定義しています。

Dockerイメージ

Dockerイメージの作成は以下になります。

$ docker build -t sample-finagle-karaf-docker .

dockerタスクを実行するとDockerfileが作成されるので、このDockerfileでDockerイメージをビルドします。この時、前述したDockerイメージasami/karaf-dockerを内部的に使用します。

実行

作成したDockerイメージsample-finagle-karaf-dockerを実行してみましょう。

$ docker run -t -i --rm -p 1099:1099 -p 8101:8101 -p 44444:44444 -p 8080:8080 sample-finagle-karaf-docker

ポートは1099, 8101, 44444, 8080の4ポートを使用します。

ポート1099, 8101, 44444はKarafが使用するポートです。

ポート用途
1099JMX RMI registry
8101SSHコンソール
44444JMX RMI server

ポート8080はサンプルのFinagleサービスが使用するポートです。

確認

Dockeイメージを実行するとKarafコンテナ内で自動的にサンプルのFinagleサービスが起動されます。

curlコマンドでサンプルのFinagleサービスにアクセスすると以下の結果が得られます。無事動作していることが確認できました。

$ curl http://192.168.59.103:8080 -v
* Rebuilt URL to: http://192.168.59.103:8080/
* Hostname was NOT found in DNS cache
*   Trying 192.168.59.103...
* Connected to 192.168.59.103 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.37.1
> Host: 192.168.59.103:8080
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Length: 0
< 
* Connection #0 to host 192.168.59.103 left intact

感想

現時点だとSBTからKaraf向けのKARファイルの作成のための設定が大変なので、まだまだKarafは気軽には使えないような感じです。

ただAPPREL CLOUD級の規模になってくると設定の大変さよりも運用管理上のメリットの方が大きくなるのでそろそろ導入を考えてもよさそうです。

諸元

  • Scala 2.10.5
  • Finagle 6.25.0
  • Karaf 4.0.0.M2
  • Docker 1.3.2
  • sbt 0.13.7