kotas.tech

こたすの技術的なチラ裏 ( Twitter: @ksaito )

Docker で Web アプリを運用してみた

Docker してますか!

実は実験的に Docker で Web アプリを数ヶ月運用しており、色々と試行錯誤してきたので、少しずつアウトプットしていきます。

ちなみに Ruby 製のアプリで、AWS の EC2 上で運用している、小〜中規模ぐらいのものです。

2014-06-16 16:00: 追記あり

Docker イメージのビルドについて

Dockerfile を普通に書いてます。

今のところ、2層構造にしていて、

  • ベースとなるイメージ
  • デプロイされるイメージ (ベースイメージを元に作る)
    • git pull してソース更新
    • bundle install し直してベースにない gem を入れる
    • asset の precompile

という感じでやってます。

ベースイメージは1日1回、デプロイイメージは master への push を trigger にビルドしていて、ビルドは Jenkins でシェルスクリプトでゴリゴリやってます。

Jenkins のビルドID (日時) をタグとしてレジストリに push するようにしています。

レジストリについて

プライベートレジストリを自前でホストしてもいいんですが、運用面倒なので quay.io を使ってました。

quay.io は、UI が便利だったり、ボット用のトークンが簡単に払い出せたりできて便利です。

しかし、Docker Hub も発表されましたし、料金も安いので公式リポジトリ (index.docker.io) に移行中です。

(機能的には、push して pull するだけなら、どちらも大差ないので…)

イメージをサービスごとに細かく分割していくと、あっという間にレジストリ数の上限に引っかかるのが悩みどころ。妥協して supervisord などで1つにまとめていくのもアリだと思います。

ログ収集について

コンテナは使い捨てが基本なので、ログはどこかに退避させる必要があります。

docker run のオプションで -v--volumes-from を使うとホスト側ディレクトリをマウントできるので、そちら経由でホスト側に退避させる事もできるのですが、ホスト側も使い捨てたいので、fluentd を使っています。

構成はベーシックな fluentd + ElasticSearch + kibana で、それぞれコンテナ立てて動かしています。(kibana は nginx をサーバーに)

ホットデプロイについて

一番悩んだのがアプリのデプロイ。

実行については docker pull して docker run でいいとして、ダウンタイム無しで切り替えるのはどうしようか、という感じです。

どうせなら Blue-Green Deployment もしたいので、いくつか方法を試してみました。

  • Elastic Load Balancer で EC2 インスタンス抜き差し
    • デプロイごとに新しい EC2 インスタンスを立ち上げて、ELB のインスタンスを抜き差しする方法
    • 利点
      • ELB 内部で完結するので、確実に切り替えができる
      • デプロイごとに EC2 インスタンスを作るので security-update などが容易
    • 欠点
      • デプロイに時間が掛かる:EC2 インスタンスの立ち上げ + 環境セットアップ + docker pull + ELB 抜き差し
  • Elastic Beanstalk の Docker デプロイを利用
    • 最近 Beanstalk で Docker デプロイが出来るようになったので、試してみた
    • Beanstalk では DNS の CNAME レコード書き換えで Blue-Green ぽい事ができる
    • 利点
    • 欠点
      • DNS 書き換えでの切り替えなのでクライアントのキャッシュ依存
      • http://example.com/ のようなサブドメインの無い URL で運用できない(Route53 の Alias が Beanstalk の CNAME レコードをサポートしていないため ELB が使えない)
      • デプロイに時間が掛かる:Beanstalk のデプロイが EC2 インスタンスを1から立ち上げるため

で、両方とも結局 EC2 インスタンスの立ち上げが遅いという結論にいたり、どうせ docker 使っていてコンテナの immutability は確保できるので、ホストは使い回す事にしました。

nginx + mruby で backend 書き換え

良い方法はないかなーとググっていたところ、Docker, Mesos, Sensu等を利用したBlue-Green Deploymentの仕組み を見つけて パク 参考にさせて頂く事にしました。

結局のところ、リバースプロキシである nginx の後ろのアプリだけ切り替えられればいいので、nginx の設定を動的に書き換える事にしました。(immutable とは一体…うごごご)

せっかくなので、面白そうだった ngx_mruby (組み込み用 Ruby である mruby を nginx で使えるようにする拡張) を使って、アプリと別ポートで設定書き換える API を作り、デプロイ時に叩くようにしました。

server {
  listen              8888;
  server_name         ctrl;

  location = /backends {
    mruby_content_handler /etc/nginx/mruby/backends.rb;
  }
}
BACKEND_CONF = '/etc/nginx/backend.conf'

r = Nginx::Request.new

if r.method == 'POST'
  json = r.args.gsub(/%[0-9a-fA-F]{2}/) { |s| s.slice(1..-1).to_i(16).chr }

  begin
    backends = JSON.parse(json)
  rescue ArgumentError => e
    Nginx.errlogger Nginx::LOG_ERR, "Invalid json received: #{e.message}"
    Nginx.return Nginx::HTTP_BAD_REQUEST
    return
  end

  conf = "# Generated at #{Time.now}\n"
  conf << "upstream backend {\n"
  backends.each { |backend| conf << "  server #{backend} fail_timeout=0;\n" }
  conf << "}\n"

  File.open(BACKEND_CONF, 'w') { |f| f.write(conf) }

  Nginx.errlogger Nginx::LOG_INFO, "Updated backend conf to: #{json}"
else
  if File.exist?(BACKEND_CONF)
    conf = IO.read(BACKEND_CONF)
  else
    conf = ''
  end
end

r.content_type = 'text/plain'
Nginx.echo conf

これはひどい

そして、この API をデプロイ時に叩きます。

# アプリコンテナのプライベート IP 取得
CONTAINER_IP=$(docker inspect -f '{{ .NetworkSettings.IPAddress }}' $APP_CONTAINER_NAME)

# API 叩いて backend.conf を書き換え
BACKENDS=$(ruby -rjson -ruri -e 'puts URI.encode_www_form_component(ARGV.to_json)' "$CONTAINER_IP:$APP_PORT")
curl -X POST 'http://localhost:8888/backends?'$BACKENDS

# HUP 送って設定反映
docker kill -s HUP app-nginx

これはひどい

しかし動くんだよなぁ、これが。しかも切り替えは一瞬で済むという…。

これにより、デプロイに掛かる時間はほぼ docker pull 分だけで済むようになりました。

欠点としては、同じ EC2 インスタンスを使い回すのでメンテナンスが必要になる事ですね…

Docker の安定性について

Docker 自体は今のところ安定して稼働できています。

Docker 1.0 もリリースされましたし、まだ気になるところもあるのですが(docker pull 中に Ctrl-C すると docker pull できなくなる事があるとか)、そろそろ実用いけるんじゃないでしょうか。

もうちょっと運用してみたいと思います。

追記: 2014-06-16 16:00

ngx_mruby の開発者である id:matsumoto_r さんに反応していただき、違う構成のエントリも書かれていたのでご紹介がてら、twitter 上でのやりとりを引用します。

Dockerとmrubyで迅速かつ容易にnginxとapacheの柔軟なリバースプロキシ構成を構築する

Docker の --link を使って、コンテナ間接続をして nginx → アプリ を構成する例をご紹介されています。

私も、この構成は試していたのですが、Docker の --link が仕様上、起動時に静的にコンテナ間の接続を決定してしまうため、デプロイ毎に nginx コンテナを再起動する必要があり、ダウンタイムが発生するため、見送った経緯がありました。上記の ELB のインスタンス抜き差しなどインスタンス単位でのダウンタイムを別レイヤで吸収できるのであれば、Docker の本流というべき綺麗な構成にできると思います!

こちらについて、コメントしたところ、twitter 上で反応いただけました。

相変わらずボケが発動しておりますが orz matsumoto-r さん、ありがとうございました!

ngx_mruby 使うと Ruby であんなことやこんなことができて楽しので、皆使うべきそうすべき!

https://github.com/matsumoto-r/ngx_mruby

Ruby + Padrino で DDD (1)

長年、仕事では PHPer + JSer + α だったのですが、今年に入って Rubyist に入門しています。

Web アプリを1から作るにあたって、自分の中では DDD が熱かったので、ActiveModel を回避するために Rails 以外の選択肢を模索して、Padrino に行き着きました。

Ruby + Padrino で DDD をやるにあたって、どうやっていったか記録しておこうと思います。いくつかに分けて書く予定。

Padrino のモデル層について

Padrino は、SinatraRails ライクな Helper や Mailer やリロード機構やら、色々便利な皮をかぶせたもので、モデル層は ORM 使ってもいいし、使わなくてもいいよ、みたいなスタンスのフレームワークです。

今回は DDD やりたかったので ORM は使わない事にしました。(※かなり茨の道なので、あまりオススメできません)

ValueObject と Entity

DDD における ValueObject といえば、何かの値を表現するために用いられ、値のみで識別されるオブジェクト。Entity といえば、ライフサイクルを通して一貫した ID を持ち、ID で識別されるオブジェクト。

識別については、素直に ==hash をオーバーライドして、それぞれ attribute を使ったり、id を使ったりしました。

VO の識別に使われる attribute については、attr_reader をオーバーライドして定義された getter を記録するようにしました。

実際に使われてるコードはもっと手を加えてますが、イメージとしてはこんな感じです。

module DDD
  class Object
    class << self
      def attr_reader(*names)
        attributes.concat(names)
        super(*names)
      end

      def attributes
        @attributes ||= []
      end
    end
  end

  class ValueObject < Object
    def ==(other)
      self.class.attributes.all? do |attr|
        send(attr) == other.send(attr)
      end
    end
    alias_method :eql?, :==

    def hash
      self.class.attributes.map { |attr| send(attr) }.hash
    end
  end
end

class Color < DDD::ValueObject
  attr_reader :r, :g, :b

  def initialize(r, g, b)
    @r, @g, @b = r, g, b
  end
end

red1 = Color.new(255, 0, 0)
red2 = Color.new(255, 0, 0)
blue = Color.new(0, 0, 255)

p red1 == red2  # true
p red1 == blue  # false

Entity の ID

ID の一意性をどう担保するか、という問題と、リポジトリ実装の都合上 DB の AUTO_INCREMENT に頼るのは避けたかったので、ID 生成は自前で実装する事にしました。

UUID を使っても良かったんですが、データサイズ的にやはりつらさがあるので、unsigned 64bit int で TwitterSnowflake を参考に自作してみました。

64bit を分割して

  • タイムスタンプ (41ビット): 2014-01-01 00:00:00 +00:00 からの経過ミリ秒数
  • シャード番号 (12ビット): サーバープロセスごとに変更する
  • シーケンス番号 (11ビット): ID生成の度インクリメントされ、毎ミリ秒ごとリセットされる

という構造の ID を作っています。

外部に ID 生成用サーバーを持たなくてもローカルで何とか出来るため安上がりで済み、毎ミリ秒 2048 個の ID を作成できて、しかもプロセスごとに重複しないため、ユニーク性をそれなりに保証できるなど便利です。

タイムスタンプがミリ秒で 41 ビットと聞くと、一見心許なく感じますが、約70年間は枯渇しません。

((1 << 41) - 1) / 1000 / 60 / 60 / 24 / 365 = 69

Immutability

Ruby で immutable というのは、あんまり見かけないんですが setter 定義を縛る事で一応可能です。

immutable にするメリットは色々あると思いますが、やはり大きいのは気軽に引数で引き渡し回せるようになる事と、テストのしやすさだと思いました。

Entity を immutable にするかは迷ったんですが、ものは試しでやってみる事にしました。

状態を変えたい時のために、以下のようなメソッドを基底クラスに定義していて

def transform(&block)
  cloned = dup
  cloned.instance_eval(&block)
  cloned
end

状態の変更は以下のように行えるようにしました。

def pay
  transform do
    @paid = true
    @paid_at = Time.now
  end
end

以下のように更新後のインスタンスが返ってきます。

ticket.paid # false
paid_ticket = ticket.pay
paid_ticket.paid  # true
ticket.paid # false

dup するのでややコストがありますが、やはり状態が変わると別インスタンスになるというのは、状態毎に適切な名前を付けられますし、一度作ったインスタンスの状態変化を気にしなくて済むのでありがたいですね。

つづく

(あれ、ここまで Padrino ほとんど関係ないな…)