Perlはもう古い、これからはDocker

この記事は Perl Advent Calendar 2014 の19日目の記事です。 Plack/Carton で構築したモダンな Perl の Web アプリケーションの開発環境を Docker 化するための試行錯誤を紹介します。

普段は、Plack, Router::Simple, Text::Xslate, DBIx::Sunnyなどを組み合わせたフレームワークでアプリケーションを書く/運用することが多いですが、今回はサンプルとして Amon2 を使いました。 サンプルは GitHub に置いています。

Perl アプリケーションを Docker 化するメリット

まず、なぜ Docker 化するかについてですが、Perl にかぎらずアプリケーションが巨大であればあるほど環境構築と環境運用がどんどんめんどうになっていくからです。 だいたいいつも困っている例として以下の様なものがあります。

  • 開発環境ではスーパーバイザなどでデーモン化せずに、フォアグランドで起動しているが、本番環境では daemontools 用のスクリプトで起動している。
  • 各環境で何かのバージョンが違う。
  • 開発環境では carton install できるけど、CI 環境では失敗する。
  • 開発環境ではテスト通るけど、CI 環境ではテストが落ちる。
  • ImageMagick や RRDtool みたいなそもそもビルドが面倒なソフトウェアに依存している。 Dockerでffmpegもimagemagickも怖くないという話 - クックパッド開発者ブログ
  • 開発環境では、carton exec を使ってるけど、本番環境では carton exec を使っていない。
  • cpanfile.snapthot が気づいたら壊れている(carton install --deployment が失敗する)。
  • マイクロサービスのローカル環境構築が面倒。

これについて、Docker 化することにより、次のようなメリットがあります。

  • 各環境でバージョンの統一が簡単(バージョンというか実行環境そのものを統一)
  • 各環境の構築を docker pull してくるだけで終わらせられる。
  • CI 環境や本番環境を手元で簡単に再現できる。

一方で、デメリットも当然あります。

  • Docker デーモン自体の運用をしなければならない
  • docker コマンドによるオペレーション、バッドノウハウを覚えなければならない
  • デバッグが面倒になることもある (環境の差異によるバグのデバッグは逆にやりやすいかもしれない

他にも、例えば Docker は手元とリモート環境で Dockerfile のビルドの成否が変わったりはします。 これは、一度作成した Docker image の動作のポータビリティはある程度保証されているが、Docker image の作成自体のポータビリティは一切保証されていないためです。 ビルド環境がインターネットに出られる環境でなければ、apt-get なんて当然絶対失敗しますよね。 したがって、Dockerfile ではなく、Docker image をなるべく使いまわしていくことが必要です。

Docker、気づいたらデメリットがメリットを上回っているなんてこともあると思うのでうまくメリットがでる用途や手法を確立していきたいですね。

Perl アプリケーションの Docker 化パターン

1年くらい Docker をやりつづけた知見を書きます。要点を以下に列挙します。

  • Perl, cpanm, Carton が入ったベースイメージを作る。DockerHub やプライベート Docker registry にアップしておいて、それをベースにする。
  • cpanfile は先に ADD(COPY) しておく。 carton install の結果をなるべくキャッシュできる。
  • fig を使う。アプリケーション、MySQL、memcached など実行プロセス単位で、コンテナを分ける。
  • 複数の実行コマンドがある場合はスクリプト化する。ローカル起動、プロダクション起動、テスト実行など、それぞれについてスクリプトを用意しておく。
  • CI も fig で実行する。
  • CI ではテスト成否だけでなく、ビルドした Docker image を docker push する。

Perl, cpanm, Carton が入ったベースイメージを作る

あちこちで使いまわすので、作っておくと楽です。 あまり、ONBUILD や ENTRYPOINT を使ってフックを作らないほうが、継承先のイメージビルドでハマらないかもしれません。 ベストプラクティスがたくさんあるので、適当に従いましょう。

例えば、自分の場合は、下記のような Dockerfile を書いて、DockerHub に Automate Build させてどこでもシュッと使えるようにしています。 サイズの小さめな debian イメージをベースにする、パッケージのミラーをCDNのものに指定する、パッケージのキャッシュは消してイメージサイズを抑えるなどの工夫などがあります。

https://github.com/y-uuki/dockerfiles/blob/master/perl/5.20.1/Dockerfile https://registry.hub.docker.com/u/yuuk1/perl/

FROM debian:wheezy
MAINTAINER y_uuki

ENV DEBIAN_FRONTEND noninteractive

RUN echo "deb http://cdn.debian.net/debian/ wheezy main contrib non-free" > /etc/apt/sources.list.d/mirror.jp.list
RUN echo "deb http://cdn.debian.net/debian/ wheezy-updates main contrib" >> /etc/apt/sources.list.d/mirror.jp.list
RUN rm /etc/apt/sources.list

RUN apt-get update && \
    apt-get install -yq --no-install-recommends build-essential curl ca-certificates tar bzip2 patch && \
    apt-get clean && \
    rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/*

ENV PERL_VERSION 5.20.1
ENV PATH /opt/perl-$PERL_VERSION/bin:$PATH
ENV CPAN_INSTALL_PATH /cpan

# Perl
RUN curl -sL https://raw.githubusercontent.com/tokuhirom/Perl-Build/master/perl-build > /usr/bin/perl-build
RUN perl -pi -e 's%^#!/usr/bin/env perl%#!/usr/bin/perl%g' /usr/bin/perl-build
RUN chmod +x /usr/bin/perl-build
RUN perl-build $PERL_VERSION /opt/perl-$PERL_VERSION
RUN curl -sL http://cpanmin.us/ | /opt/perl-$PERL_VERSION/bin/perl - --notest App::cpanminus Carton

もちろん、公式の言語スタック を使ってもよいですが、Carton が入っていなかったり、WORKDIRONBUILDENTRYPOINTなどが勝手に設定されていたりして、ハマりポイントになるかもしれないので、自分で作った素直なベースイメージを使うことを推奨します。

cpanfile は先に ADD(COPY) しておく。

Ruby の Bundler の場合の方法そのままです。How to Skip Bundle Install When Deploying a Rails App to Docker if the Gemfile Hasn’t Changed | I Like Stuff COPY ./ $APPROOT してから RUN carton install するとリポジトリのファイルをどれか変更するだけで、COPY ./ $APPROOTの行のキャッシュが切れてしまい、それ以降の cpanfile に変更がなくても carton install のフルインストールが走ってしまいます。 そこで、cpanfile を先に COPY しておくことで、cpanfile に変更がない場合は、その行はキャッシュされます。

FROM yuuk1/perl:5.20.1

RUN apt-get update && \
    apt-get install -yqq --no-install-recommends mysql-client-5.5 libmysqlclient-dev libssl-dev && \
    apt-get clean && \
    rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/*

ENV APPROOT /src/app
RUN mkdir -p $APPROOT
WORKDIR /src/app

COPY cpanfile $APPROOT/cpanfile
RUN carton install
COPY ./ $APPROOT

EXPOSE 5000
CMD ["script/app"]

cpanfile.snapshot をどうするかという問題があります。 Docker は、cpanfile.snapshot の CPAN モジュールバージョン固定の世界から Linux のユーザランドの固定の世界に拡張したものと捉えることができるので、Git管理せずに CI でビルドしたイメージをそのまま本番にデプロイすればあんまり意識しなくていいかなという気がしています。

もちろん、本番は Docker 化してないこともあると思うので、CI でビルドしたイメージから cpanfile.snapshot を docker cp で持ってきて、Git のリポジトリに自動で含めるようにするなどの工夫をすれば良い気がしています。 参考: Carton考2014 | おそらくはそれさえも平凡な日々

Fig を使う

MySQL や memcached などのミドルウェアをアプリケーションと同じコンテナに入れるのか別々のコンテナにいれるのがいいかを以前、id:papix さんに聞かれたことがありました。 Docker は複数のデーモンを立ち上げる綺麗な方法を基本的にサポートしていないので、1デーモン1コンテナな世界だと思っています。(supervisor を使うなどの方法は一応ある。https://docs.docker.com/articles/using_supervisord/ )

そもそも全部入りの Docker image を本番で使うことはまずないと思うので、開発環境とその他の環境で Dockerfile を分けることになり、二重管理するハメになりそうです。 ただ、あくまで複数ホスト構成が当たり前なWebアプリケーションを前提しているので、個人で作ったちょっとしたアプリケーションであれば、全部入りのほうがやりやすければそれでもよいような気はしています。

ただ、デーモン単位でコンテナをわけると、コンテナの管理が面倒ではあります。 そこで、Fig を使います。 Fig は Docker 社が開発している Docker 版の Foreman、Proclet みたいなものだと勝手に思っています。 要は、複数コンテナの立ち上げや停止の管理をサポートしてくれるいいやつです。

Fig で開発環境を作る方法は Fig のチュートリアルがわかりやすいです。

Getting started with Fig and Rails

今回は、Perl アプリケーションの例を紹介します。 Fig を使うにはまず、fig.yml を作る必要があります。 db, web などのロール名に対して、docker コマンドのオプションを定義していくやり方です(setup はスキーマを流すためのコマンドコンテナです)。

互いのロールの参照には、Docker のコンテナリンク を使います。 コンテナリンクを使うと、リンク先のホスト名と公開ポートなどがリンク元では環境変数として参照できます (https://github.com/y-uuki/dockerized-perl-app/blob/master/config/development.pl#L5-6 ) 。 本来は、links にはコンテナ名を指定しなければならないのですが、Fig ならロール名という抽象的な名称で指定できるので、名前指定の煩わしさがなくなっています。

db:
  image: mysql:5.5.40
  environment:
    - MYSQL_USER=nobody
    - MYSQL_PASSWORD=nobody
    - MYSQL_ROOT_PASSWORD=root
    - MYSQL_DATABASE=mydocker
  ports:
    - "3306"
setup:
  build: .
  command: script/db
  links:
    - db
web:
  build: .
  command: carton exec perl script/my-docker-server
  ports:
    - "5000:5000"
  volumes:
    - ./:/src/app
  links:
    - db

イメージのビルドは

$ fig build

するだけです。コンテナの起動方法はいくつかありますが、全コンテナをまとめて起動するなら

$ fig up

すればよいだけです。 コンテナを一つづつ起動したい場合は、fig run を使います。

$ fig run -d mysql
$ fig run setup
$ fig run web 

複数の実行コマンドがある場合はスクリプト化する

実行環境は web (linked with db) を使いたいが、Webサーバの起動以外のことをしたいときはよくあります。 このようなときには、各コマンドをスクリプト化しておくと便利です。 例えばテスト実行は

$ fig run web script/test

のようにできると手軽です。 他にも、スキーマの流しこみを

$ fig run web script/db # (fig up 時にもやってほしいので、fig run setup で実行できるようにしてはいる)

でできたりすると簡単です。

script/test は以下のような簡単なスクリプトですが、これを逐一実行するのは面倒です。

#!/bin/bash

DIR=$(dirname $0)
APPDIR=$DIR/../
carton exec -- prove -I$APPDIR/lib $APPDIR/t

CircleCI

Jenkins または Docker が使える CircleCI を使うのが今のところの CI サーバの選択肢かなと思っています。 CircleCI の環境では、Docker 1.2.0 が動いていて、古いのでいくつかのバグやサポートしていない機能があって真面目にやっていません。 雰囲気はだいたい以下の様な感じです。 deployment セクションで DockerHub か DockerRegistry にアップロードできれば完璧。

machine:
  services:
    - docker
  timezone: Asia/Tokyo

dependencies:
  cache_directories:
    - "~/docker"
  override:
    - mkdir -p ~/docker
    - sudo sh -c "curl -L https://github.com/docker/fig/releases/download/1.0.1/fig-Linux-x86_64 > /usr/local/bin/fig"; sudo chmod +x /usr/local/bin/fig
    - script/ci/load_images ~/docker; fig build; script/ci/save_images ~/docker
test:
  override:
    - fig run -d db
    - fig run --rm setup
    - fig run web script/test

ボツにしたやつ

ssh

supervisor で sshd も起動するようにするという方法。起動中のコンテナの中に入る手段は Docker 1.3 から入った docker exec -it <container> /bin/bash で確立されたといえるので、無理してやる必要はなさそうです。 何より鍵の管理とかが面倒すぎる。

差分ビルド

cpanfile を先に ADD するという発想がなかったため、cron かなにかで定期的に docker build してそのイメージを使うという面倒なことをやろうとしていた時期もありました。

Data-only Container and Runtime Container Pattern)

なるべく carton install のキャッシュを効かせるために、Docker の Volume 機能 を使うことを考えました。

具体的には、永続化したいデータ(carton installしたモジュール)を Data Volume Container という専用のコンテナに置き、それを Perl や Carton などの実行環境を積んだコンテナ(Runtime Container)からマウントするという方法です。 Data Volume Container はリポジトリのファイル群を ADD して、そのディレクトリを VOLUME として公開するだけで、carton installcarton exec は Runtime Container から実行する。

それなりにいいアイデアだと思ったものの、常にアプリケーションにつき 2種類の Docker image とコンテナを管理することになり、仕組みが煩雑になります。さらに、Data Volume Container に状態を持たせることになるので、クリーンな環境を作りやすい Docker を使っているメリットが薄くなってしまうという問題があります。 cpanfile.snapshot が壊れたりすることを考えると、cpanfile にモジュールを追加したときくらいは最初から carton install して作りなおしても悪くないと思います。

課題

まだやってないことです。

Git のブランチごとのイメージ管理

CI 環境でビルドした後に、DockerHub や Registry にブランチ名やSHA1をタグとして push すればよいと思っています。 ブランチが削除されたら Registry から削除するなどの仕組みの整理は結構面倒。

ホットデプロイ

Perl の世界では Server::Starter を使った無停止デプロイが有名ですが、Server::Starter と Docker 化したアプリケーションは相性が悪いと思っています。 例えば、アプリコンテナの起動時にコンテナの中で Server::Starter を起動するようにしたとしても、デプロイ前後で同じコンテナを使うことになり、せっかくクリーンなコンテナを使っているメリットがあまりありません。 Blue Green Deployment のような Docker コンテナごと入れ替える運用が推奨されていると思うので、ロードバランサの設定を動的に書き換えるなどの運用方法の確立が必須となりそうです。

他にもなんかいろいろある。

参考

どうでもいいけど、クジラの祖先がラクダであるとかいうめちゃくちゃ雑な情報をゲットしました。

明日は id:ar_tama さんです。よろしくお願いします!

はてなでは新プロダクトで Scala, Go などが採用されていますが、まだまだ Perl も現役なので、Perl エンジニアもとにかく募集しております。

株式会社はてなではインターネットで生活を楽しく豊かにしたいスタッフを募集しています
採用情報 - 株式会社はてな

Go言語によるCLIツール開発とUNIX哲学について

この記事ははてなエンジニアアドベントカレンダー2014の8日目です。

今回は、Go言語でサーバ管理ツール Mackerel のコマンドラインツールmkr を作るときに調べたこと、考えたこと、やったことについて紹介します。(mkr は現時点では開発版での提供になります。)

コマンドラインツールについて

コマンドラインツールを作るにあたって、@ さんの YAPC Asia 2014 での発表資料が非常に参考になります。 書籍 UNIXという考え方ーその思想と哲学 の内容をベースに、コマンドラインツールはどうあるべきかということが丁寧に説明されています。

上記資料から引用させていただくと、コマンドラインツールにおいて重要なポイントは以下の7つであるとされています。

  1. 1つのことに集中している
  2. 直感的に使える
  3. 他のツールと連携できる
  4. 利用を助けてくれる
  5. 適切なデフォルト値を持ち設定もできる
  6. 苦痛なくインストールできる
  7. すぐに改修できる

また、「いかに"良い"CLIツールを作り始めるか?」について、以下の4つの手順が示されています。

  1. どんなツールを作るか考える
  2. 言語を選ぶ
  3. README.mdを書く
  4. 高速にプロトタイプを作る

さらに、同じく@ さんのエントリ コマンドラインツールを作るときに参考にしている資料 | SOTA も参考になります。

Go言語におけるコマンドラインツール

Go言語は依存のないバイナリ生成とクロスコンパイルができるので、"苦痛なくインストールできる"という点で、コマンドラインツールを書くことに向いていると思います。 Goで書くとRubyなどと比べて基本的には動作が高速なため、Heroku や GitHub のコマンドラインツールがGoで書きなおされているというのも注目すべきポイントです。 hk は使ってないのでわかりませんが、hub は体感でもはっきりわかるほど動作が高速になりました。

余談ですが、Heroku ではGoでコマンドラインツールを書く仕事を募集していたようです。 Go/Golang job: Command Line Interface Developer @ Heroku

Go でコマンドラインツールを作る上で、コマンドラインツールのインタフェース部分の実装を助けてくれるcli というライブラリが非常に便利です。 これを使うとサブコマンドをもつインタフェースや、ヘルプ機能などを簡単に作ることができます。(Ruby でいうとこるの thor のライブラリ部分に近いかも) 使い方は motemen さんの ghqcli の作者のGoのコードをみるとよいと思います。

またコマンドラインツールとは関係ないですが、Go でのよいコードの書き方について、先日のGoConの資料が参考になります。 Goに入ってはGoに従え

Go については、以前便利リンク集をまとめました。 Go言語の便利情報 - ゆううきブログ

mkr

mkr は、サーバ管理ツール MackerelREST API を利用したコマンドラインツールです。

Mackerel のような管理ツール系のサービスは、ある操作を一括で行いたいなどの柔軟な操作性を求められますが、それをWebUIで表現することが難しいこともあると思います。 そんなときに、APIによる一括操作などができると、操作が自動化しやすいので一石二鳥といえます。 特にサーバ管理ツールの場合、他のツールのAPIなどの出力をサーバ管理ツールに反映したり、逆にサーバ管理ツールのAPIの出力を他のツールに渡したいなどの要求はよくあると思います。

例えば、Mackerel API と tssh を組み合わせて複数ホストに同時にログインするなどの応用があります。tmux + ssh + Mackerel API を組み合わせたとにかくモダンなサーバオペレーション - ゆううきブログ

サーバ管理ツールとAPIについては、昨年のYAPCで発表した資料が参考になるかもしれません。

はてなのサーバ管理ツールの話 // Speaker Deck

mkr は各種APIの操作とコマンドが対応しており、APIの入出力とコマンドラインでの入出力のパイプとしてだけ機能するように意識しています。 "1つのことに集中する"ことができるように、出力のフィルタリング/加工機能などは最小限の実装にしています。 代わりに、APIの出力を余分な情報を多少除いてそのままJSONで出力するようにして、jqで自在にフィルタリング/加工すれば"他のツールと連携できる"ことがしやすいと考えました。

例えば、特定ロールのホスト群のIPアドレス一覧を出力したいときには mkr の hosts サブコマンドに service と role オプションを付けて、jq で eth0 のみをフィルターします。

$ mkr hosts --service Mackerel --role proxy | jq -r -M ".[].ipAddresses.eth0"

同等のフィルタリングを mkr 側もしくはAPI側で実装しようと思うとかなり大変で、実装したとしても表現力が汎用フィルターの jq に劣る可能性が高いため、あらゆるツールと組み合わせるというわけにはいかなくなるかもしれません。

他のサービスのAPIと組み合わせとして例えば、EC2 と Mackerel 連携があります。 mkr retire <hostIds>aws-clidescrive-instancesを組み合わせると、EC2上で Terminated になったインスタンスリストを mkr retireコマンドに渡せば、一括で退役処理ができます。

さらに、mackerel-agent-pluginsmkr throwを組み合わせると、Sensu 形式のプラグインの出力をホストメトリックやサービスメトリックに投げ込めます。 cron で定期投稿させることで、mackerel-agentがなくてもメトリック投稿ができます。 ELB や RDS など、特定のインスタンスに紐付かないメトリックはこれを使うと楽かもしれません。

$ /usr/local/bin/mackerel-plugin-aws-elb | mkr throw --service <hostId>

mkr と Go

mkr を Go で実装した最大の理由は、mackerel-agent がインストールされたホスト上で、mkr status とうつとログインするホストの情報を簡単にみたり、ステータスを変更できるようにしたいというものです。 mackerel-agent が動作していれば、/etc/mackerel-agent/mackerel-agent.conf/var/lib/mackerel-agent/id にAPIキーやホストIDが書かれています。 指定がなければこれらを読むようにすることで、入力を省略できるようにします。 各ホストにインストールされている必要があるので、配布の簡単な Go で実装するのが適当だと思いました。

mkr 作成手順

下記の手順で作りました。

  1. インタフェースを決める
  2. READMEを書く
  3. go 版のAPIライブラリがなかったので作る https://github.com/mackerelio/mackerel-client-go
  4. ひな形を作る https://github.com/tcnksm/cli-init 高速にGo言語のCLIツールをつくるcli-initというツールをつくった | SOTA
  5. ghq を参考に実装。とにかく参考になる。
  6. go vet, golint を通す
  7. CI環境を整える (TravisCI)
  8. リリースフローを整える (goxc, travisci)
  9. Dockerfile を書いて、DockerHub で Automated Build https://registry.hub.docker.com/u/mackerel/mkr/
  10. Homebrew Formulaを書く https://github.com/y-uuki/homebrew-mkr (これも motemen さんの https://github.com/motemen/homebrew-ghq をそのまま)

インタフェース

mkr はインタフェースにこだわって作っています。 例えば、Ruby のCLIツールのインタフェースは https://github.com/mackerelio/mackerel-client-ruby#cli のようになっていますが、コマンド名 + リソース名 + 動詞 となっており、REST をそのまま表現しやすいですが、タイプ数が多くなってしまうという欠点があります。 (一応サブサブコマンド的なものもcliライブラリで実装できるようです。)

そこで、mkr はコマンド名 + 動詞で表現するようにして、タイプ数が短くなるようにしています。 代わりに操作したいリソースの表現力が落ちますが、実際には"service"(リソース)を"create"(動詞)するなどの操作は通常コマンドラインからは行いません。 このようにWebUIで十分事足りる操作については、コマンドラインでは表現しないもしくはタイプ数をあまり気にしないインタフェースにしています。 頻繁にコマンドラインで操作したいと思うのは"host"であるため、基本的には"host"(リソース)に対する操作を動詞としています。

また、APIでは異なるエンドポイントとして設計されていても、ユーザの直感では1コマンドとして表現されていたいと思うものもあります。 例えばAPIでは、ホスト情報の更新とホストのステータスの更新が分かれていますが、どちらもホスト情報の更新であるため、mkr では update コマンドにまとめてあります。

リリースフロー

今は GitHub の releasesにアップロードすることをリリースとしています。 "苦痛なくインストールできる"ことを考えると、yumリポジトリやaptリポジトリ、Homebrewで提供することを考えたほうがよりよいとは思います。

CI環境と合わせて GitHub の releases へのアップロードは TravisCI, CircleCI、wercker を使うと便利です。

Travis CI: GitHub Releases Uploading

さらに、複数環境の同時クロスコンパイルには gox または goxcが便利です。 toolchain の細かい validation や Zip や tar.gz へのアーカイブ、バージョニングなどができる点から goxc を使ってみました。 このあたりは、Makefiletravis.ymlを参照してください。

Dockerfile

モダン感を出すために、とりあえず置いておくとよいでしょう。 2行で済みます。(ビルドに make を使えなかったりするので、ちゃんとやろうとすると onbuild image 使わずにやるけどとりあえず動く)

FROM golang:1.3.3-onbuild
ENTRYPOINT ["/go/bin/app"]

Go でないツールで docker さえ入っていればコマンド一発でインストールできるので、Ruby や Python のコマンドラインツールでは有用かもしれません。

まとめ

UNIX哲学最高。Go最高。

UNIXという考え方―その設計思想と哲学

UNIXという考え方―その設計思想と哲学

次回は id:hatz48 さんです。よろしくお願いします!

はてなでは、Perlだけでなく、Scala, Goのエンジニア、さらに自社開発のツールでサーバ管理がしたいWebオペレーションエンジニアも募集しております。

株式会社はてなではインターネットで生活を楽しく豊かにしたいスタッフを募集しています
採用情報 - 株式会社はてな

GoとMySQLを用いたジョブキューシステムを作るときに考えたこと

この記事ははてなエンジニアアドベントカレンダー2014の4日目です。 前回は Mackerelで採用している技術一覧とその紹介 - Hatena Developer Blog でした。

今回は、社内の開発合宿でGo言語でジョブキューシステムを実装したときに考えたことのうち、主にサーバ運用視点でのアーキテクチャ設計について紹介します。 ジョブキュー(メッセージキュー)は、単なる非同期処理をしたいというだけでなく、今年流行したマイクロサービスアーキテクチャにおけるサービス間の連携などにもよく用いられているという点で、今後ホットになる話題だと思います。

社内ではジョブキューと呼んでいますが、ジョブのレスポンスを受け取らないため、厳密にはメッセージキューと呼ぶ気がします。ジョブキュー=メッセージキューとして読んでもらっても差し支えないと思います。

背景

社内では伝統的に、TheSchwartz + WorkerManager という MySQL をストレージとして用いたジョブキュー&ワーカーシステムを使用していました。 長らく安定稼働していたのですが、ジョブの投入と処理部分を基本的には Perl で書かなければならないため、Scala などの他言語から扱いにくいという問題があります。 その他にも例えば、CPU/メモリの使用量が多いまたは実行時間の長いジョブがワーカーノードのリソースを専有するため、比較的軽いジョブがキューに滞留してしまう問題があります。 さらに、キューに一定数以上のジョブが滞留したときに、ワーカーが処理するジョブを取得するクエリが遅くなるという問題があり、この場合ワーカーの並列数を増やしても結局ジョブ取得クエリがボトルネックになってワーカー数に対してジョブの処理スループットがスケールしません。

まとめると、以下の3つの問題があります。

  • 言語依存
  • 一部の重いジョブによるワーカーのリソース占有
  • キューにジョブが滞留したときのジョブ取得クエリの速度低下

これらの問題を解決できる既存の実装はないと考えて、自分たちでジョブキューシステムを実装することを考えました。

関連調査

実践ハイパフォーマンスMySQL 第3版 O'REILLY

p265-268の 6.8.1章 「MySQL でキューテーブルを作成する」にて、MySQL でのキュー作成についての記述があります。 MySQL をキューに使用する 5 つの微妙な方法とその落とし穴 (翻訳版) - Engine Yard Blog にもほぼ同じような内容が書かれています。

一般的なキューテーブルのパターンは、未処理の行、処理中の行、処理済みの行という3種類の行を含んだテーブルを作成します。 1つ以上のワーカープロセスが未処理の行を検索し、それらの行を更新して「claimed」マークを付け、処理が実行されたら「完了」マークを付けます。 この方法には、2つの問題点があります。

1つは、ワーカーの未処理の行取得にポーリングとロックが使用されるということです。ポーリングはサーバに負荷をかけ、ロックはワーカープロセス間に競合と直接をもたらします。 ポーリングの回避するには、ワーカープロセスに通知すればよいです。通知方法は、ワーカーが非常に長いSLEEP() 関数を使って待機しておいて、通知するときに、KILLするというやり方や、GET_LOCK()関数とRELEASE_LOCK関数を使ったり、別のメッセージングサービスを使うという方法があります。

もう1つの問題は、ワーカーが行のマーキングにSELECT FOR UPDATEを使う実装が多いということです。 そうすると、トランザクションが互いにブロックして待機するため、通常はスケーラビリティが大きく損なわれてしまうことになります。 ほとんどの場合はもっとよい方法があります。 単純なUPDATEを使って行をマークした後、SELECTすればよいだけです。 マークには、ジョブを処理しているのは誰かという点と、ジョブの状態の2種類あります。誰がジョブを処理しているかはownerカラムを用意して、MySQLのCONNECTION_ID()を格納します。ジョブの状態については、別途stateカラムを用意して、必要なジョブの状態を管理します。

最後に、マークされたものの、ワーカープロセスが異常終了したなどの理由で処理されなかった行をクリーンアップする必要があります。 これは簡単で、UPDATEを定期的に実行してそれらをリセットすればよいです。 SHOW PROCESSLISTで現在サーバに接続しているすべてのスレッドID以外のスレッドIDでマークされてる行は、もはや処理されることがないため、クリーンアップします。

より詳しい内容については書籍の方を参照してください。 クリーンアップについては、MySQLのイベントスケジューラを使えばMySQLだけで完結してできそうだなと思いました。

TheSchwartz について

TheSchwartz は奇しくも Go の生みの親である bradfitz のプロダクトです。 TheSchwartz のキュー実装も基本的には上述の手法に則っていますが、「claimed」マークを付けるといったことはせずに、各ワーカープロセスが50件ずつ取得してランダムに1つのジョブを選択することにより、他のワーカープロセスが同じジョブをある程度取得しないようにしています。雑なやり方ですが、一応ロックなしでジョブを取得できていることになります。

TheSchwartz のジョブテーブルは以下のようになっています。(https://github.com/saymedia/TheSchwartz/blob/master/doc/schema.sql#L7-20)

CREATE TABLE job (
        jobid           BIGINT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT,
        funcid          INT UNSIGNED NOT NULL,
        arg             MEDIUMBLOB,
        uniqkey         VARCHAR(255) NULL,
        insert_time     INTEGER UNSIGNED,
        run_after       INTEGER UNSIGNED NOT NULL,
        grabbed_until   INTEGER UNSIGNED NOT NULL,
        priority        SMALLINT UNSIGNED,
        coalesce        VARCHAR(255),
        INDEX (funcid, run_after),
        UNIQUE(funcid, uniqkey),
        INDEX (funcid, coalesce)
);

さらに、ジョブ取得クエリは以下のようになっており、funcid により現存するジョブの種類で絞り込んで、run_after, grabbed_until により実行時間が未来のものを除外して、 priority の高い順にソートしている。このあとランダムに1件取得し、ワーカーに処理させる。run_after が特に設定されていないようなジョブばかり投入された場合、インデックスをみる限り、funcid による絞り込みしか効かなさそうということがわかります。

SELECT * FROM job WHERE (job.funcid IN ('')) AND (job.run_after <= UNIX_TIMESTAMP()) AND (job.grabbed_until
 <= UNIX_TIMESTAMP()) ORDER BY priority DESC LIMIT 50

世の中のジョブキュー

TheSchwartz 以外のジョブキューとして、Rescue(JVM系言語 の場合は Jescue) 、QueRabbitMQQ4M などを検討しました。

まず、キューのストレージとして Redis を選択すると、オンメモリデータベースであるため、当然スループットの向上を期待できますが、フェイルオーバ時のデータの一貫性に問題があるためジョブをロストしてしまう可能性があります。 (厳密には、逐次書き込みモードにしておけば一貫性は向上するが、パフォーマンスは低下するというトレードオフがあるというのが正しい。また、ロストしてしまっても問題ないようなジョブの場合には問題ない。)

次に、RabbitMQ についてはメッセージ・キューとして、様々な機能を揃えており、開発も活発で、かなり大規模なサービスで運用されているという実績もあります。ただし、機能が多すぎて運用が大変そう、Erlangで書かれているのでいざというときコードを読めるか不安、Perl のクライアントがまともに使えるのか、そもそも AMQP が結構複雑ということもあって、採用を見送っています。

卜部昌平のあまりreblogしないtumblr - RabbitMQ と再送について

さらに、PostgreSQL を用いた Que は実践ハイパフォーマンスMySQLで書かれていたポイントを抑えられており、かなり筋がよさそうと思ったのですが、Ruby からでないと使えないので、実装を参考にする程度にしています。(PostgreSQLとジョブキューについては、後ほどでてきます)

最後に、Q4Mは、MySQLのストレージエンジンとしてキューを実装しており、キューとしてのパフォーマンスも最適化されているという点と信頼のMySQLという点でかなり魅力的な選択肢だったのですが、いざ問題が起きたときにMySQLのストレージエンジンのレイヤまで潜らなければならない点がネックでした。

その他、汎用的な実装ではないですが MogileFS 内部のジョブキュー実装も参考にしました(https://github.com/mogilefs/MogileFS-Server/blob/master/lib/MogileFS/Store.pm#L751-767) これもまた、bradfitz 作のプロダクトですね。

提案実装

上記を踏まえて、さいきょうのじょぶきゅーしすてむのアーキテクチャを考えました。

言語非依存なアーキテクチャ

ジョブキューを言語に依存させないために、以下の2つの工夫を考えました。

  • ジョブをHTTPにより投入できるようにする。
  • ジョブを処理するワーカー部分をWebアプリケーションのエンドポイント(POST /jobs/send-too-many-mails)として実装する。

ジョブ投入の際に、パラメータとしてワーカーのエンドポイントを含めておき、ジョブの dispatcher がそのエンドポイントに対してリクエストするという流れになります。 パラメータはJSONにしておき、キューにもJSONで格納されて、エンドポイントにもそのままJSONで渡されます。 TheSchwartz のようにキューテーブルでジョブの種類(funcid)を管理せずに、素朴にジョブに含まれるエンドポイントを叩きます。 ジョブの投入もWebアプリケーションサーバから行い、ジョブの処理もWebアプリケーションサーバが行うことになるので、一周しているように見えますが、ユーザのリクエスト処理にワーカーの処理が影響しないように、実際にはワーカー用のアプリケーションサーバを別途用意します。

信頼性のあるデータストレージ

ジョブをロストするリスクをできるかぎり小さくしたいという要求があります。 したがって、データ構造としてのキューを実装するには向いているとはいえないものの、実績のある RDBMS(MySQLやPostgreSQL)を使うのが自然だと思いました。 自分たちでジョブをディスクにストアする仕組みを実装する方針もありえなくはないですが、実装コストやメンテナンスコスト、さらに高可用性のための仕組みを考えると、既存のストレージに乗っかるのがどう考えても安心感があります。(高可用性については、DRBDを使えばなんとでもなる気がしますが、あまり低レイヤな仕組みに頼るといざ問題が起こった時の調査に困るという問題があります。)

Go 言語の採用

サーバ運用の観点だけに限定すると、以下の2つの理由で Go を採用しました。

まず、多数のワーカープロセスに、HTTPでコネクションを張り続けることを考えると、非同期的にリクエストを投げやすいものがよいという点です。(Go は非同期的にI/Oを実行するために、AIO や epoll(7)&ノンブロッキングI/Oではなく、スレッドを1つ作成してブロッキングI/Oさせるらしい(未確認)ので、コネクション数がどこまでスケールするかはわからない)

次に、どのサービスでもジョブキューシステムを使うことが多く、ローカルで環境構築をすることも多いので、依存がないかつクロスコンパイルができる Go がよいと思いました。

全体構成

以下に、全体構成を図示します。図の矢印はデータの流れを示しています。

f:id:y_uuki:20141204232859p:plain

アプリケーションサーバから HTTP の POST でジョブが投入されて、webでジョブを受けます。webは API サーバで、ジョブの投入を受け付けると、DB のキューテーブルにジョブを格納します。 dispatcherがキューテーブルに対して、定期的にポーリングをかけて、新規ジョブを取得します。取得したジョブの中のエンドポイントに対して、ジョブに含まれたパラメータを付加して、ワーカーアプリケーションサーバにHTTP POSTします。

単一の dispatcher による複数ジョブの同時取得

各ワーカープロセスが1件だけジョブを取得しているから遅いのであって、複数のジョブをまとめて取得して実行すれば効率がよいはずです。 さらに、TheSchwartz のようにキューテーブルでジョブの種類(funcid)を管理しない方針なので、funcid による絞り込みが不要になり、クエリの効率も多少上がると思います。 さらに性能面では、1つのキューに対して1つの dispatcher で十分となるため、基本的にはそもそもマークする必要がないと考えます。 キューのスループットを上げたい場合は、同時に取得するジョブ数を増やします。 実際には、dispatcher の異常終了のために、未処理の行をクリーンアップする必要などがあるため、ジョブ取得時に、owner カラムと status カラムの更新は必要です。 とはいえ、複数の dispatcher が互いにブロックすることがないので、安心感があります。

1つの dispatcher で十分とはいえ、異常終了する場合などを考えて dispatcher を冗長化する必要があります。 下の図に冗長化した場合の構成を示します。

f:id:y_uuki:20141204232909p:plain

マーキングをしているとはいえ、性能面で1つの dispatcher で問題ないなら、なるべく dispatcher 2 には仕事をさせたくありません。 そこで、MySQL の汎用ロックを使って、同時に1つの dispatcher しか動作しないようにします。 具体的には、dispatcher 1 が常にGET_LOCK でロックをとっておき、dispatcher 2 はIS_FREE_LOCK()でロックが解放されてないかポーリングでチェックします。 dispatcher 1 が何かの原因で終了すると、MySQLとのセッションが切れて、ロックが解放されるので、dispatcher 2 がGET_LOCKして仕事をし始めることになります。

イベント通知によるジョブ取得

基本的にポーリングで問題ないと考えていますが、イベント通知したい場合は、さきほどの図のwebdispatcherの部分を1プロセスにまとめて、それぞれ goroutine で実装するというやり方があります。 webがアプリケーションサーバからジョブの投入を受け付けて、MySQL に一旦格納したあと、channel を用いてdispatcherにイベント通知します。 このあたりのプロセス間通信的なやりとりが UNIX ドメインソケットを使う場合などと比べて、Go を使うと比較的簡単になるのがよいですね。

キューの分散によるジョブルーティング

一部の重いジョブによるワーカーのリソース占有という問題に対処するために、複数のキューを用意することを考えました。 複数のキューを用意する場合、いくつかの戦略があり、例えば優先度ごとにキューを用意したり、consistent-hashing でジョブ名に対してハッシュ値を計算して分散させたりなどがあります。

今回は、一部の重いジョブを専用のキューに押し込んで、そのキューを担当する dispatcher の最大ジョブ取得数を絞って、同時に重いジョブを処理するワーカー数を少なくすることができればよいはずです。 あらかじめ、アプリケーションを実装する場合に、どのジョブが問題になるかわかっていればよいですが、実際には動いてみないとわからない事が多いです。 アプリケーション側でジョブの処理優先度を付けるようにするなどの方法だと、問題が起きたときにわざわざアプリケーションをデプロイしなくてはならず面倒です。

そこで、なるべくインフラチームがその場で対応しやすいように、キューの増減およびジョブとキューのルーティング割り当てをAPIで管理出来るようにしたいと考えました。 具体的には、ジョブ名とキューのルーティング割り当てを専用のテーブルで管理しておき、APIで更新できるようにしておきます。 これにより、ジョブごとの処理時間のログを吐かせて、Fluentd & Kibana などで可視化しておけば、重いジョブが何かわかるため、そのときにAPIを叩いて、キューを増やして、重いジョブをそのキューに回すことができます。(一応、キューのスループットを上げるために、ルーティング登録の前段でconsitent-hashingで分散する案も考えてはいます。)

PostgreSQLの場合

実装はMySQLを使っていますが、PostgreSQLをバックエンドとした場合、どのようなメリットがあるかを紹介します。 PostgreSQL は社内では Mackerelで採用している技術一覧とその紹介 - Hatena Developer Blog に書かれているように、Mackerel で本番投入しています。

まず、行のマーキングをするために、Advisory Locks が使えます。 Advisory Locks は、MySQLにおけるGET_LOCK()などと同じ汎用ロックですが、1セッションあたり1つしかロックを作れないGET_LOCK()と違い、1セッションあたり複数の名前でロックをとれるので、MVCC的なロックとは別に各行に対して汎用ロックをとれます。(この辺、理解に少し自信がない) https://github.com/chanks/que/blob/master/lib/que/sql.rb#L6 行の state カラムの更新などがいらず、実際に書き込みを行わないため、マーキングが高速であるというメリットがあります。

Advisory Locks だとマーキングの状態がロックされている or されていないの2種類しかないので、2状態以上の状態がほしい、つまり state カラムや owner カラムが場合もあります。 そのときは、UPDATE文のRETURINGを使うと、更新された行を取得できるので、MySQLのようにマーキングにUPDATESELECT する必要がなく1クエリで済みます。

その他、ジョブのパラメータをJSONで表現しているため、PostgreSQL のJSON型を使うと、JSONの中身に対して条件を付加してクエリを実行できるので、問題調査に役立つかもしれません。

ポーリングとイベント通知の動的なモード変更

思いつき段階ですが、一応書きます。 ただし、仕組みが複雑になりすぎるので、実装が難しいかつメンテナンスが難しいまたは実際には必要ない可能性などを考慮して、実際には実装しないほうがよさそうだと思っています。

ポーリングは効率が悪く、イベント通知が効率がよく思えますが、それはワークロードが小さいときの話です。 今回のシステムではdispatcherが同時に複数のジョブを取得するという前提がありますので、1つ1つのジョブについてイベント通知が発生してしまい、却って性能が低下するということが考えられます。

ポーリングとイベント通知については、ネットワークパケットを受信するためのOSの仕組みに似たような課題があります。 通常、NICで受け取ったパケットは、NICからカーネルに割り込みをかけることにより、カーネルに渡されます。つまり、カーネルはイベントを待ち受けていて、NICがイベントを発生させます。 ところが、高パケットレート環境では、パケットが到着する度に、CPUが割り込みを受けることになるので、CPU利用率がボトルネックになります。 そこで、高パケットレート時にはNAPIという仕組みで、NICからイベントを通知するのではなく、カーネルからNICに対してポーリングをかけて複数のパケットを同時に受信するということをしています。(NICドライバ用対応)

ジョブキューにおいても、ジョブの投入レートをメモリに記憶させておいて、投入レートが閾値以上ならモードを切り替えることはできそうです。

NAPIについては、以下のドキュメントが詳しいです。 napi | The Linux Foundation

また、モード切り替えでなく、ジョブの投入レートから、ポーリング間隔を動的に決定することもできそうです。 今回のジョブキューの場合、ポーリング間隔とdispatcherの最大ジョブ取得数から最大スループットは決まりそうなので、投入レート=最大スループットとすると、ポーリング間隔が決定できそうな気はします。

NICドライバのポーリング頻度調整周りの実装については EC2のc3インスタンスでSR-IOVを使うときのNICドライバパラメータ検証 - ゆううきブログ に書いています。

参考資料

まとめ

Go と MySQL でジョブキューを実装するときに考えたことを主にサーバ運用視点で紹介しました。 ある程度動くものはできあがっているので、本番に投入してある程度実績を積んだらオープンソースにしたいと思っています。(僕は基本的にアーキテクチャ設計とかいろいろな調査とかベンチマーク環境の準備をしているだけですが) 個人的には、新しいと思っていた課題が、OSカーネルの伝統的な課題に置き換えたりできることがすごくおもしろいと思いました。 今回はパケット受信だけ紹介しましたが、他にスループットとレイテンシの最適化周りで、TCPのスライディングウィンドウとか、OSのプロセススケジューリングや、失敗ジョブの世代間ガベージコレクションなどなんとなく古典的な問題に置き換えられそうな問題がありました。 ジョブキュー/メッセージキューの実装について、よりよいアーキテクチャや最適化方法をご存知の方は教えていただけるとうれしいです。

次の担当は id:nanto_vi さんです。よろしくお願いします!

東京はもう古い、これからは京都

最近、京都市内に引っ越して生活クオリティがあがってる。家はオフィスのある烏丸御池からちょっと離れたところの、閑散としたところにある。 自炊活動を全て捨てていて、調理器具はおろか電子レンジもないし、お皿も箸もスプーンもない。 代わりに、隠れ家っぽいお店に行くのが好きなので、毎日いろんな店に行ってる。 そんなに高い店には行かないので、食費は思ったほどかかってない。

先週の休みは、昼過ぎに起きて、御所の近くまで散歩して、隠れ家っぽいカレーショップでさつまいもチキンカレー食べて、コンテッサチェアを観るために家具の店行って、隠れ家っぽいケーキ屋でアーモンドケーキとプリン買って帰って、部屋でプリン爆発させたりした。

コンテッサチェアについては以下のエントリが詳しい。

プリン、まれに爆発するので気をつける必要があるが、だいたい豊かな生活をしていると言える。

京都でおすすめの店

ランチ/ディナーで1000円前後のお店。飲み屋とラーメン屋は多すぎるので省いた。全部隠れ家っぽくて落ち着きのある店。

tomomii さんにすすめられたところ。京都らしく路地の奥まったところに立地してて、丁寧な感じの店の雰囲気がとてもよい。味も一口たべておっ、て思う感じ。

欧風カレーの有名な店。カレールーは土鍋に入って出てくる。インドカレーみたいなのがあまり好きじゃないので、こういうのが食べたかったと思うような味がする。

オフィスビルのすぐ裏にある。本が置いてある系のこじんまりした店。オーソドックスなチキンカレーがあって丁寧な味でおいしい。めっちゃ落ち着ける。

昭和っぽい喫茶店。見た目も味も特に変わってるところはないけど、なぜかおいしい。

swimy さんおすすめの店。町家そのままという感じで、こんなに落ち着きのある店と料理があるのかという感じ。とにかく丁寧。

冒頭に書いてたカレー屋さん。御所と裁判所が近くて、周辺の雰囲気がよい。さつまいものカレーとかがある。

とにかく分厚くてうまいタマゴサンドがある。ボリュームがあって、これでだいたい満腹になる。コロナのタマゴサンドとかいうやつで、わりとカジュアルに売り切れるっぽい。

1汁3菜形式で、健康的な定食が食べられる。めっちゃ静かというわけではなく、適度にざわついている感じ。

10時過ぎても空いてて便利。昔ながらの日本の洋食屋さんという感じで、どのメニューもボリュームが結構ある。デミグラスソースが美味しくて、たぶんコーヒーみたいなの入れて味の深みを出してる気がする。

Googleのオフィスの一角みたいな雰囲気の店。(Googleには行ったことがないのでなんとなくのイメージ)。たまごが乗っかったかわいい感じのカレーがでてくる。

ランチにたまに行ってる。牛バラ肉のワイン煮込みみたいなやつがとにかくうまい。

灯りを落としていて暗めな雰囲気。半個室風になっていて、ゆったりした気持ちになれる。多分、水出しコーヒーでいがらっぽくなくてスッキリした味。

おもちゃっぽい小物が大量においてある。吹き抜けの2階建てで、2階に陣取るとなんかよい。日替わりのオムライスがおいしい。超ミニサイズが選べるので、超小食な人にも安心。

京都でおすすめの仕事