docker-composeで複数のhubotインスタンスを立ち上げる
やりたいこと
slackチームA用hubot、slackチームB用hubot、chatwork用hubot...という感じで、複数サービス/アカウントに向けて一気にhubotを動かしたくなりました。とりあえず開発環境として動く、というところまでなので、実際の運用についてはまた別途考えることにします。
開発中はホストOSでscirpts/*.coffeeを編集→各コンテナに反映、という流れが理想。
構成
ホストOS:OSX
- docker: Docker version 1.11.0, build 4dc5990
- docker-compose: docker-compose version 1.7.0, build 0d7bf73
コンテナ:
- hubot-brain用のredis
- hubot_slack
- hubot_chatwork
hubotの応答ロジックを格納しているscriptsディレクトリにはホストOSのscriptsディレクトリをマウントし、ホスト側で編集した複数のcoffeescriptファイルを読み込ませます。
Dockerfile
まず、各hubotコンテナのベースとなるDockerイメージを作るためのDockerfileを用意します。
- dockerユーザーを作ってrootで作業しないようにしてます
- npmモジュールはnpm installコマンドで逐次インストールしていますが、package.jsonで管理したほうが綺麗なのかもしれない
- adapterとしてhubot-slackとhubot-chatworkをインストールしていますが、必要に応じて他のadapterも入れられます
- 開発中にスクリプト編集→hubotに再起動するよう命令、という流れで変更を反映したいので、foreverでhubotをデーモン化しています。このブログ記事を参考にさせてもらいました。
- external-scriptsを読み込みたければ、COPYしましょう
FROM node:latest RUN apt-get update RUN apt-get -y install sudo RUN useradd -m -d /home/docker -s /bin/bash docker && echo "docker:docker" | chpasswd && adduser docker sudo RUN echo "docker ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers WORKDIR /home/docker USER docker # workaround for https://github.com/npm/npm/issues/9863 RUN cd $(npm root -g)/npm \ && sudo npm install fs-extra \ && sudo sed -i -e s/graceful-fs/fs-extra/ -e s/fs.rename/fs.move/ ./lib/utils/rename.js # npm installs RUN npm install hubot coffee-script RUN npm install hubot-slack hubot-chatwork RUN npm install yo generator-hubot RUN npm install forever # ENV for node ENV NODE_PATH=/usr/local/lib/node_modules:./node_modules \ PATH=$PATH:./node_modules/.bin RUN yes | yo hubot --defaults # need to run "chown" because COPY command doesn't care USER directive COPY scripts/hello.coffee ./scripts/ RUN sudo chown docker:docker ./scripts # COPY external-scripts.json ./ COPY hubot-scripts.json ./ # need to create an empty file for forever RUN touch ./.foreverignore CMD forever -c coffee node_modules/.bin/hubot -a ${HUBOT_ADAPTER}
docker-compose.yml
redisコンテナと別々の2つのhubotコンテナを管理するためのdocker-compose用設定ファイルを用意します。
- redisのvolumesは適当に指定してあります
- hubotコンテナのvolumesでホスト側のscriptsをマウントするように設定してあります。
redis: image: redis:latest restart: always command: redis-server --appendonly yes ports: - '6379:6379' volumes: - /tmp hubot_slack: restart: always build: . volumes: - $path_to_scripts:/home/docker/scripts ports: - '9999:9999' env_file: .env_hubot_slack environment: TZ: Asia/Tokyo links: - redis hubot_chatwork: restart: always build: . volumes: - $path_to_scripts:/home/docker/scripts ports: - '9998:9998' env_file: .env_hubot_chatwork environment: TZ: Asia/Tokyo links: - redis
env_file
docker-compose.yml
で指定したenv_fileを用意します。
env_fileに各サービスで必要になる環境変数を設定しておくと、dockerがコンテナ内で使える環境変数として読み込んでくれるので便利です。
- TODO:redisのURLは動的に取得したい
.env_hubot_slack
HUBOT_ADAPTER=slack HUBOT_NAME=hubot-chan HUBOT_SLACK_BOTNAME=hubot-chan HUBOT_SLACK_TOKEN=xxxxxxxxxxxxx HUBOT_SLACK_CHANNELS=test HUBOT_SLACK_CHANNNELMODE=blacklist REDIS_URL=redis://192.168.99.100:6379
.env_hubot_chatwork
HUBOT_ADAPTER=chatwork HUBOT_NAME=hubot-chan HUBOT_CHATWORK_TOKEN=xxxxxxxxxxxxx HUBOT_CHATWORK_ROOMS=123 HUBOT_CHATWORK_API_RATE=350 REDIS_URL=redis://192.168.99.100:6379
scripts/hello.coffee
適当なscriptを用意します。
# Description: # Test # # Commands: # hubot hello - Say "Hi" module.exports = (robot) -> robot.hear /HELLO$/i, (msg) -> msg.send "Hi"
update.coffee
hubotに向かってupdate
と話しかけると、自殺します。
foreverが監視しているので起動してくれます。
# Description: # Test # # Commands: # hubot update - hubot will suicide because it is supposed to be re-launched by forever child_process = require 'child_process' module.exports = (robot) -> robot.respond /update/, (msg) -> process.exit()
hubot-scripts
redisをbrainとして使います。
["redis-brain.coffee"]
起動
docker-compose up -d
コマンドで3つコンテナが立ち上がります。
確認
- helloと挨拶すると、hubotがHiと返答
- hello.coffeeを適当に編集(Hi -> Konnichiwa!)
@hubot update
で更新- helloと挨拶すると、hubotがKonnichiwa!と返答!!
これで、hubot-hogeアダプタを追加して必要なenv_fileを用意するだけで、hubotをいろんなチームに送り込める体制が整いました。
その他
自分のAccess keyとSecret keyを操作できるようにするIAMポリシーの設定
例えば、外部の開発会社にAWSアカウントを渡す際に、Access keyとSecret keyは彼ら自身で管理できるようにしたいケースなんかありますよね。
PoweruserポリシーでもIAM系のアクションはnot allowになっているので個別で許可する必要があります。
Resourceの書き方とかすぐ忘れるし、今後何回か使いそうなのでメモ。
ポリシー名はなんでもよいが、ManageYourOwnAccesskey
など。
[your account number]
の部分を自分のアカウント番号に置き換えてください。
アカウント番号の確認方法は、コンソール右上のSupport->Support Center。参考リンク
${aws:username}
とすることで自分のアクセスキーだけを変更できるようになってます。
iam:ListUsers
Actionを追加しているのは、ユーザーのリストを表示しないとアクセスキー変更の操作を行えないから。
{ "Version": "2012-10-17", "Statement": [ { "Action": [ "iam:*AccessKey*" ], "Effect": "Allow", "Resource": [ "arn:aws:iam::[your account number]:user/${aws:username}" ] }, { "Action": [ "iam:ListUsers" ], "Effect": "Allow", "Resource": [ "arn:aws:iam::[your account number]:user/" ] } ] }
ついでに各ユーザーにパスワード変更を許可するポリシー。
{ "Version": "2012-10-17", "Statement": [ { "Sid": "Stmt1450162323000", "Effect": "Allow", "Action": [ "iam:ChangePassword" ], "Resource": [ "*" ] } ] }
Railsのテストで悲観的ロックを再現する方法(翻訳)
Railsアプリでデータベースのロックをテストしたいなと思って探していたらよいブログポスト(英語)が見つかったので自分の理解も兼ねてざっと翻訳。
ポイントは、テストの中でサブプロセスをforkし、ロックが期待される状況を再現しているところ。
コードにすると少し複雑だが、ブレイクポイントでfind
の実行タイミングを制御することでうまく再現している。
アプリケーション的に大事なロジックなどはこれでテストするとよさそう。
以下翻訳。
並列処理を正しくやるのはなかなか難しい。それをテストするのも同じように難しい。以下のシンプルなコントローラーの例から始める。
app/controllers/counters_controller.rb
class CountersController < ApplicationController def increment counter = Counter.find(params[:id]) counter.increment!(:value) render text: counter.value end end
うまくいっているように見える?でも find
と increment!
の間に競合状態がある。
訳注:ここでいう競合状態(race condition)とは、findのタイミングによっては必ずしもincrementされるわけではないということ。後述のテストの例がわかりやすい。
修正する前に、テストを書いて失敗させてみることにする。そのテストには、fork_break
というgemを使う。そのgemを使えば、ブレイクポイントを使っている親プロセスとサブプロセスを同期させることができる。
ここで、テストがデータベーストランザクション内部で実行されていないようにしておく必要がある。
spec/spec_helper.rb
Rspec.config do |config| ... # trueだとトランザクションが実行されてしまう config.use_transactional_fixtures = false ... end
以下が失敗用のテストケース。 spec/controllers/counters_controller_spec.rb
require 'spec_helper' describe CountersController do it "works for concurrent increments" do counter = Counter.create! # postgresql用にフォークする前にdisconnect、他のdbには影響なし ActiveRecord::Base.connection.disconnect! process1, process2 = 2.times.map do ForkBreak::Process.new do |breakpoints| # フォークした後に再接続する必要あり ActiveRecord::Base.establish_connection # findが実行された後にbreakpointを設置 original_find = Counter.method(:find) Counter.stub!(:find) do |*args| counter = original_find.call(*args) breakpoints << :after_find counter end get :increment, :id => counter.id end end process1.run_until(:after_find).wait process2.run_until(:after_find).wait process1.finish.wait process2.finish.wait # 親プロセスもあたらしいコネクションを張る必要あり ActiveRecord::Base.establish_connection counter.reload.value.should == 2 end end
テスト実行結果は以下の通り。
$ rspec spec/controllers/counters_controller_spec.rb
CountersController
works for concurrent increments (FAILED - 1)
素晴らしい、期待どおりテストは失敗した。
訳注:テストが失敗するのは、process1とprocess2のfindがともに同じ値を読み込んでおり、インクリメントが実質1回しか呼ばれていないからである。これが競合状態の具体例。
ではコントローラーをどうやって修正するか?例えば、悲観ロックを使ってみよう。
app/controllers/counters_controller.rb
class CountersController < ApplicationController def increment counter = Counter.find(params[:id], lock: true) # 変更点 counter.increment!(:value) render text: counter.value end end
しかし、specテストを実行しても、途中で実行が止まってしまう。(最終的にはデータベース接続がタイムアウトしてエラーになる)
Counter.find
がprocess2
の内部でブロックしているからだ。
訳注:上記のコントローラーの実装でテストを走らせると、まずprocess1のfindがselect ~ for updateの形で実行(process1.run_until(:after_find).wait
)され、次にprocess2も同じようにselect ~ for updateを発行するので、ロック待ちの状態になってしまう。
これを修正するために、上記のテストを少し修正する。
spec/controllers/counters_controller_spec.rb
require 'spec_helper' describe CountersController do it "works for concurrent increments" do counter = Counter.create! # postgresql用にフォークする前にdisconnect、他のdbには影響なし ActiveRecord::Base.connection.disconnect! process1, process2 = 2.times.map do ForkBreak::Process.new do |breakpoints| # フォークした後に再接続する必要あり ActiveRecord::Base.establish_connection # findが実行された後にbreakpointを設置 original_find = Counter.method(:find) Counter.stub!(:find) do |*args| breakpoints << :before_find counter = original_find.call(*args) breakpoints << :after_find counter end get :increment, :id => counter.id end end process1.run_until(:after_find).wait # process2がfind内部で確実にブロックするようにsleepする process2.run_until(:before_find).wait process2.run_until(:after_find) && sleep(0.1) # ここでprocess1を終了させ、process2を待つ process1.finish.wait process2.finish.wait # 親プロセスもあたらしいコネクションを張る必要あり ActiveRecord::Base.establish_connection counter.reload.value.should == 2 end end
テスト実行結果
$ rspec spec/controllers/counters_controller_spec.rb
CountersController
works for concurrent increments
うまくいった!