$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

Docker, Mesos, Sensu等を利用したBlue-Green Deploymentの仕組み

この辺に書いたとおり、id:wtatsuru, id:y_uuki, id:hagihala と一緒に、DockerやMesosなどを利用してBlue-Green Deploymentのプロトタイプのようなものを作っていた。この前は非常にざっくりと書いただけだったので、もう少し中身に突っ込んで書いてみる。かなり長くなったので時間があるときにでもどうぞ。


デプロイや運用の問題点

  • テストサーバと本番サーバが全然違って本番だけ動かない
  • デプロイを簡単に安全に高速に出来ない
    • 同じサーバにデプロイすると早いけど、安全か分からない
    • AMIコピーからproxy向き先の切替だと、安全度は高いけど少し時間がかかる
  • サーバを一から作り直せないと、保守できないサーバが出来上がる
  • 大量のサーバの最適なリソース配置を人が考えるのは難しい

これらの問題がDockerやMesosなどを利用して解決できるのかを試すために、プロトタイプとしてBlue-Green Deploymentの仕組みを作っていた。


どういうことができるようになったか

 どんなものが出来たかについては既に 開発合宿でDockerとMesosを使っていい感じにリソース提供とデプロイするやつを作ってた - wtatsuruの技術方面のブログDocker + Mesos + Marathon + Graphite + Fluentd + Sensuを組み合わせたデプロイ管理ツールの話 - ゆううきブログ に分かりやすくまとめられている。というわけで、ここでは全体の図とデプロイまでの流れを僕なりに書いておこうと思う。

 デプロイ管理ツールは便宜的にDeployerという名前をつけた。

f:id:shiba_yu36:20131222143012j:plain

 基本的に今回作った仕組みを利用すると以下の様な流れでデプロイが行われる

  1. masterに新機能をpush
  2. jenkinsでDocker Image差分ビルド + テスト
    • テストは本番と同じイメージで行われる
    • アプリケーションテスト、インフラテスト
    • テストが通ったらDeployerに通知
  3. 環境の作成
    • DeployerがMarathonに作りたい数とコマンドを渡す
    • Dockerコンテナが作られたらMarathonからDeployerに通知
    • MarathonやMesos側で自動的にリソース配置の最適化が行われる
  4. 環境準備完了
    • 通知により全てのコンテナの準備が完了が分かったら完了
    • ちなみにここまでがmasterへのpush後自動で行われる
  5. 本番前確認
    • 環境ごとにDNSが割り当てられ、切替を行う前に確認できる
  6. 本番にデプロイ
    • ボタンを押すとnginxの設定を書き換えて、作った環境に切替

 またデプロイされたDockerコンテナからはFluentdとSensuを利用して、アクセスログやメトリクスなどが転送される。

 本番前確認やデプロイのイメージは以下の画像の通り。

f:id:shiba_yu36:20131222143013p:plain:w400
f:id:shiba_yu36:20131222143014p:plain


実際にやったこと

 こういうプロトタイプを実現する上で僕が担当したのは

  • Dockerのイメージをどのようにビルドするか
  • 本番用Dockerイメージを使ってテストする
  • 環境の切替をどのように行うか

という部分をやった。なので、これから書くのはその部分が主体になると思う。

 それ以外にも実現するためにはいろいろやっていて、そのへんは別のチームメンバーがブログ記事なりに書くと思う。

  • DeployerとMarathonの連携
  • Marathon, mesos, mesos-dockerの扱い
  • 各環境をスケールアウトさせる
  • Dockerコンテナの中でどのようにプロセスを立ち上げるか
  • Dockerコンテナの中のログ転送
  • Dockerコンテナのメトリクス集計
  • 本番前確認環境をどうやって作るか
  • etc

DockerイメージとImmutable

 まず最初に考えたこととして、Dockerイメージをどこまで厳密にImmutableにするかということである。一番厳密にするなら、毎デプロイのたびにdocker buildし直すということで可能であるが、その場合毎回変更が加わるたびにデプロイに30分以上の時間を要することがわかり、それだとさすがに許容できない。逆にDockerイメージに対し、差分を積み重ねすぎてしまうと、作りなおすことの出来ないイメージが出来上がってしまう。その辺りのバランスをどうするか。


 本質としてはいつどのレイヤーから作っても同じイメージが出来ることが重要なので、今回は三つのレイヤーに分けて考えることにした。

  • インフラレイヤーの構築
  • アプリケーションレイヤーの依存モジュール構築
  • アプリケーションレイヤーのコード反映


 インフラレイヤーは冪等性を担保するのがかなり大変である(Chefなどを見る限り)ので、もしその辺りのレイヤーが変わったらdocker buildをし直すことにした。このことによりインフラレイヤーは必ず一から作られるため、chefのような冪等性の仕組みは必要なくなる。

 アプリケーションレイヤーの依存モジュール構築はbundlerやcartonなどが存在することによって、インフラレイヤーより冪等性が保ちやすい。また、この層はインフラレイヤーの上に作られる。そこで、もしインフラレイヤーに変更がなく、しかしGemfileやcpanfileに変更があった場合は、docker buildし直すのではなく、依存モジュールが入っているディレクトリ(例えばlocal/)を消して、再度bundle install, carton installしなおせば良いと考えた。

 アプリケーションレイヤーのコード反映はさらに冪等性を考えるのが簡単で、rsyncやgit pullするだけでも基本的に冪等性は保たれる。そこでアプリケーション依存モジュールが変更されず、コードのみが変更された場合は、単にrsyncなどを行うだけで済むと考えた。


 まとめると

  • インフラレイヤーが変更された場合は、docker buildを一からする
  • アプリケーションレイヤーの依存モジュールが変更された場合は、依存モジュールが入っているディレクトリを消し、インストールし直す
  • アプリケーションレイヤーのコードしか変更されていない場合は、rsyncやgit pullするのみ

という考え方になった。これにより、どのレイヤーから構築し始めても同じイメージができると考えた。


Dockerイメージのfull buildと差分build

 上のような考え方をしたうえで今回僕は実際に差分buildの仕組みを作っていた。

やり方

色々あって以下のやり方に落ち着いた。full build = docker build .し直し。差分build = アプリケーションレイヤーの変更。

  • Dockerイメージの作成は全てJenkinsで行う
  • AUFS層(?)が42個までしか作れないので、
    • full buildにはweb-app-from-scratchという名前付けをする
    • 差分buildは直近のweb-app-from-scratchから初めてweb-appという名前を付ける
    • web-app-from-scratchは定期的に作りなおす(1日1回とか)
  • 差分buildで作ったweb-appにはgitのsha1をtagとしてつけた
    • 後に出るMarathonの連携のため
    • web-app:3074f4660986ea9cb2c12a193a9546e84827cbf9 などの名前を付けてpushする

これによりfull buildは30分かかるが、通常の差分buildなら1分程度でできるようになった。

full buildをできるようにする

 まず一からbuildできるようにDockerfileを用意した。例えば以下の様なもの。

FROM stackbrew/debian

ENV DEBIAN_FRONTEND noninteractive

# update/install packages
RUN apt-get -f update
RUN apt-get install -y openssh-server libgmp3-dev libssl-dev libexpat1-dev libxml2-dev libmysqlclient-dev shared-mime-info libmagickcore-dev git supervisor git

# perl-install
RUN wget -O /tmp/perl-install https://raw.github.com/tatsuru/xbuild/master/perl-install
RUN bash /tmp/perl-install 5.18.1 /opt/perl
ENV PATH /opt/perl/bin:/usr/sbin:/sbin:/usr/bin:/bin

# コード反映
RUN mkdir -p /opt/Web-App
ADD . /opt/Web-App/current

# carton install
RUN mkdir /opt/Web-App/shared
RUN cd /opt/Web-App/current && carton install --deployment --path /opt/Web-App/shared

EXPOSE 22 8000
CMD ["/usr/bin/supervisord", "-c", "/opt/Web-App/current/supervisor/supervisord.conf"]

 いろいろ辛い部分も残っているが、とりあえずこれでdocker build .できるようになった。

 あとはこれをjenkinsでbuildできるようにする。

以下のようにfull-build.shを作り、それをjenkinsに登録するだけで良い。

#!/bin/bash

docker build -t docker-registry-host:5000/web-app-from-scratch .
docker push docker-registry-host:5000/web-app-from-scratch

差分buildできるようにする

 次に差分buildできるようにする。最初に書いた分け方では、carton installする時とコードの反映のみで住む場合でやり方を変える必要があるが、今回はそこは省き、アプリケーションレイヤーの変更は全て差分buildするということにした。先ほどの方針通りならcpanfileが変更されてたら一度local/を消してcaron installし直し、コードのみだったらgit pullかrsyncするだけとかに分けるようにする(が今回はそこまでやらなかった)。

 ここで注意することがいくつかある。

  • AUFSの層は42が限界
  • docker commitを行うとENVやCMDなどの情報は消える
    • docker commit時に指定しておく必要がある
  • 差分buildするイメージにgitのsha1を含めないとMarathonにうまく渡せない
    • marathon(というよりmesos-docker)に渡すときにイメージの名前しか渡せない(?)ので、別々の名前にしておかないとdocker run時にpullしてくれない
    • これは完全にバッドノウハウ

 というわけで上のことに注意しながら差分buildの仕組みを作っていく。

 差分buildは以下の流れで行う。もちろん別の方法でもできると思うが、参考例として欲しい。

  1. docker runでweb-app-from-scratchからDockerコンテナを立てる
  2. そのコンテナに対して、ssh経由でコマンドを実行する
  3. その状態にgitのsha1を付けてcommitする
  4. docker pushする
  5. docker stopでコンテナを止める

 この流れをdelta-build.shとして作成する。ちなみにssh時にパスワード入力などを求められないように何らかの工夫をしておく必要がある。この辺りは頑張ればできることで、本質的ではないので今回は説明を省く。

#!/bin/bash

docker pull docker-registry-host:5000/web-app-from-scratch
CONTAINER_ID=$(docker run -d docker-registry-host:5000/web-app-from-scratch)
IPADDR=$(docker inspect -format "{{.NetworkSettings.IPAddress}}" $CONTAINER_ID) # コンテナのIP取得

# sshdが立ち上がるまでsleep
sleep 5

# コードの反映とcarton install
ssh -o "StrictHostKeyChecking no" root@${IPADDR} 'export PATH=/opt/perl/bin:$PATH && cd /opt/Web-App/current && git fetch && git checkout -q origin/master && carton install --deployment --path /opt/Web-App/shared'

# コンテナ内のrepositoryのsha1取得
GIT_SHA1=$(ssh -o "StrictHostKeyChecking no" root@${IPADDR} 'cd /opt/Web-App/current && git rev-parse HEAD')

# 正直二重管理になってしまっていて最悪だが、現状はこのように指定するしか無い
# https://github.com/dotcloud/docker/issues/1141 ができたら必要なくなるだろう
docker commit -run '
{
    "Cmd": [
        "/usr/bin/supervisord",
        "-c",
        "/opt/Web-App/current/supervisor/supervisord.conf"
    ],
    "Env": [
        "HOME=/",
        "PATH=/opt/ruby/bin:/opt/perl/bin:/usr/sbin:/sbin:/usr/bin:/bin",
        "DEBIAN_FRONTEND=noninteractive"
    ],
    "ExposedPorts": {
        "22/tcp": {},
        "8000/tcp": {}
    }
}' $CONTAINER_ID docker-registry-host:5000/web-app:$GIT_SHA1 # gitのsha1つけている
docker push docker-registry-host:5000/web-app
docker stop $CONTAINER_ID


 あとはこれをjenkinsに登録しておき、

  • full buildのjobからのhook
  • masterへのpush時のhook

で実行するようにしておけば良い。

 ここまででfull build、差分buildを使い分けられるようになった。


作成したイメージを使ってテストする

 続いて作成したイメージを使ってテストする。テストとしてはアプリケーションのテストとインフラのテストがあるが、今回はアプリケーションのテストのみ行った。

 まずjenkins-test.shを以下のように作った。

#!/bin/bash

docker pull docker-registry-host:5000/web-app
CONTAINER_ID=$(docker run -d docker-registry-host:5000/web-app)
IPADDR=$(docker inspect -format "{{.NetworkSettings.IPAddress}}" $CONTAINER_ID)

sleep 5

ssh -o "StrictHostKeyChecking no" root@${IPADDR} 'cd /opt/Web-App/current && ./script/test-in-docker.sh'
TEST_STATUS=$?

scp -o "StrictHostKeyChecking no" root@${IPADDR}:/opt/Web-App/current/junit_output.xml .

docker stop $CONTAINER_ID

 またtest-in-docker.shは以下のように作った。

export PATH=/opt/perl/bin:$PATH
export WEB_APP_ENV=jenkins
export PERL_CARTON_PATH=/opt/Web-App/shared

carton exec -- prove t/ -lvr --timer --harness TAP::Harness::JUnit --merge

 これでjenkins-test.shをjenkinsに登録しておけば、JUnit形式でテストが実行できることになる。インフラのテストもserverspecなどを利用すれば同じようにできるだろう。

問題点

 さてここまででアプリケーションレイヤーのテストを本番のイメージを使ってできるようになった。が、以下の様な問題が存在する。

  • 本番イメージには必要ないが、テスト時には必要なものが存在する
    • Test::mysqldやTest::TCPなどテスト時にmysqldやmemcachedなどを立てている場合、mysql-serverやmemcachedなどが追加で必要

 これを何とかするにはlocalでこのようなものを使わずにテストするということが考えられるが、本番のイメージのことを考えてアプリケーションのテストが書きにくくなると、また別の問題が発生する。

 なので現実的には以下のようになるのかなと思った。

  • 本番イメージに対し実行するのは外部テスト(中身をブラックボックスとして振る舞いをテストする)に限定する
    • serverspec
    • アプリケーションにrequest送りresponseをテストする
    • etc
  • アプリケーションの全体のテストは本番イメージとほぼ同等だが、追加で必要なパッケージを入れたものを利用する

作成したイメージを使って環境を作成する

 あとはMarathonなどと連携してこのイメージ使い環境を作成する。この辺りはid:y_uukiがやっていたので Docker + Mesos + Marathon + Graphite + Fluentd + Sensuを組み合わせたデプロイ管理ツールの話 - ゆううきブログ に少し書いてあるし、もっと詳しく書いてくれると思う。

 ただ、ここに渡せるのがrunを実行するためのイメージ名だけっぽい感じだったので、バッドノウハウとしてgitのsha1をイメージのtagとして付けるということをしていた。

  • docker runは手元に同じ名前が無ければpullしてから実行する
  • 逆に同じ名前があったらpullしない
  • ので変更があるのに同じ名前がついていた場合は、更新されない

本番環境の環境を入れ替える

 いろいろな方法があると思うが、今回はnginxを利用してやることにした。以下の様な設定をしておいて、includeしたファイルを置き換えてreloadするということをした。

include /etc/nginx/conf.d/backend.conf;
server {
    listen 80;
    server_name sample-app.example.com;
    location / {
        proxy_pass http://backend;
    }
}

 このbackend.confというのには以下の様なものが書いてあり、DeployerのAPI経由でこの設定を取れるようにしていた。
backend.conf

upstream backend {
      server dockerhost02:31999;
      server dockerhost03:31000;
      server dockerhost01:31625;
}

 あとは

  1. curlでDeployerのAPI経由でweb server一覧を取得
  2. backend.confを更新
  3. nginx reload

で、本番のサーバ入れ替えができるようになった。

 このへんは結構適当にやったので、もっといい方法あると思う。


課題

本番環境のBlue-Green Deploymentの仕組みのプロトタイプを作っていた - $shibayu36->blog; に書いたとおりだが再掲する。まだまだ本番で利用するには危険が多すぎる。ただ、テスト環境や社内用には利用できるところまで来ているかもしれない。

  • Dockerがまだまだ安定していない
    • commitすると前のimageが持っていたCMDやEXPOSEなどの情報が消える https://github.com/dotcloud/docker/issues/1141
    • AUFSの層を42以上作れない https://github.com/dotcloud/docker/issues/1171
      • DockerfileでRUNなどを42回以上使えない
    • Dockerを動かしているサーバのkernel processが無駄に増えていた?
    • dockerのimageをおいているストレージの容量がかなり大きくなる
    • docker-registryにpingが通らなくなる時がある(loopで何回かやってるとつながる)
  • アプリケーションレイヤーのテストを本番と同等の環境でやるのが難しい
    • 外にテスト用mysqlサーバなどがあり、それに接続するだけなら難しくない
    • Test::mysqldやTest::TCPなどを使ってテストごとにlocalにmysqldサーバを立てるとかしていると出来ない
      • 本番用imageは普通は内部にmysql-serverやmemcached-serverを持っていないため
  • リソースの最適な配置を決めてくれるツールがまだ安定していない
    • marathon, mesos, mesos-dockerなど
    • 普通にうまく動かない時があり、コードを読んでドキュメントを作ったり、軽いパッチを当ててなんとか動いたという感じ

まとめ

 今回はDockerやMesos、Sensuなどを利用したBlue-Green Deploymentの仕組みの中で自分が担当した

  • Dockerのイメージをどのようにビルドするか
  • 本番用Dockerイメージを使ってテストする
  • 環境の切替をどのように行うか

という部分について詳しく解説した。

 もうちょっと細かいDockerの挙動で気づいたこととかは適当に書こうと思う。

 その他の実際にMarathonなどと連携する部分、リソースの自動制御部分、コンテナの状況のグラフ化部分などは他のメンバーが書いてくれると思う。