palindrome!

Dammit, I'm mad!

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しましょう

blog.manaten.net

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つコンテナが立ち上がります。

確認

  1. helloと挨拶すると、hubotがHiと返答
  2. hello.coffeeを適当に編集(Hi -> Konnichiwa!)
  3. @hubot updateで更新
  4. helloと挨拶すると、hubotがKonnichiwa!と返答!!

これで、hubot-hogeアダプタを追加して必要なenv_fileを用意するだけで、hubotをいろんなチームに送り込める体制が整いました。

その他

  • 運用については検討中、せっかくdockerで作った&試してみたいという気持ちから、AWSのECSで細々と動かせないかなと考えています。
  • foreverはもうメンテナンスが止まっているので別のものを検討したほうがいいかもしれないです。
    • ファイルの変更を感知して自動起動する--watchもなぜかうまく動かせなかった(ので、updateコマンドを用意しました)

自分の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:ListUsersActionを追加しているのは、ユーザーのリストを表示しないとアクセスキー変更の操作を行えないから。

{
    "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

うまくいっているように見える?でも findincrement!の間に競合状態がある。

訳注:ここでいう競合状態(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.findprocess2の内部でブロックしているからだ。

訳注:上記のコントローラーの実装でテストを走らせると、まず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

うまくいった!