dockerでrails5環境構築

## dockerでrails環境構築

`rails5` + `mysql`の開発環境が必要になったので、dockerコンテナとして構築します。
`docker-machine`で作業するにあたってものすごくハマったのでその作業記録も。

### 作業環境
`rails`と`mysql`はそれぞれ独立したコンテナにしたかったので`docker-compose`でまとめます。
またホストマシンはmacなので`docker-machine`で`docker`の環境を作っています。

- docker-machine v0.7.0
- docker v1.11.0
- docker-compose v1.7.0

### railsコンテナ作成
#### 方針

  - ホスト側に`ruby`がインストールされていなくても開発出来るようにしたい。
  - railsのアプリケーションのフォルダ自体はボリュームでマウントして、ファイルの編集自体はホスト環境で行えるようにしたい。

#### railsコンテナの構成

  - ruby 2.3.0
  - rails5 beta3

  の環境で作成します。

##### Dockerfile
  - ディレクトリ構成
  
  ```sh
   % tree
   .
   ├── app               # railsアプリケーションディレクトリ
   └── docker            # dockerの関連ディレクトリ
       ├── bin            #   docker-compose用のディレクトリ
       ├── db             #   mysql用のdockerディレクトリ
       └── web            #   rails用のdockerディレクトリ
  ```

  以下作業のカレントディレクトリは上記の`.`の位置で行います。

  - mysql用のdockerファイル `docker/db/Dockerfile`

  今回はrails側のコンテナ構築がメインなのでとりあえずデータの永続化はしません。

  ```sh:docker/db/Dockerfile
  FROM mysql:5.7
  ```

  - rails用のdockerファイル `docker/web/Dockerfile`

  起動時に`app`フォルダをマウント出来るようにwebコンテナの`/var/myapp`を`VOLUME`にしておきます。

  ```docker:docker/web/Dockerfile
  FROM ruby:2.3

  ############################################################
  # ruby setting bundlerだけはglobalに入れておく
  RUN gem install bundler

  #####################################################
  # misc settings
  RUN usermod -u 1000 www-data
  RUN \cp -fp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

  VOLUME /var/myapp
  WORKDIR /var/myapp
  
  EXPOSE 3000

  CMD ["bash"]
  ```

  - `docker-compose`用ファイル

    `docker-compose.yml`は中で環境変数が展開出来るので、各Dockerディレクトリへのパスを環境変数で定義しておくために`docker-compose`を実行するためのラッパースクリプトを作って`docker-compose`を実行します。

    - `docker/bin/run-docker-compose.sh`

      ```sh:docker/bin/run-docker-compose.sh
      #!/bin/sh

      SCRIPT_DIR=$(cd $(dirname $0) && pwd)

      export DB_DOCKERFILE_DIR=${SCRIPT_DIR}/../db
      export WEB_DOCKERFILE_DIR=${SCRIPT_DIR}/../web
      export WEB_APP_ROOT_DIR=${SCRIPT_DIR}/../../app

      export MYSQL_DATABASE=mydb
      export MYSQL_USER=dbuser
      export MYSQL_PASSWORD=dbuser_pass
      export MYSQL_ROOT_PASSWORD=root

      YML_FILE=$SCRIPT_DIR/docker-compose.yml

      docker-compose -f $YML_FILE $*
      ```

    - `docker/bin/docker-compose.sh`

      `web`から`db`を参照出来るように`links`の設定とホスト側の`rails`ディレクトリをマウントするための`volumes`設定をしておきます。

      ```docker:docker/bin/docker-compose.sh
      db:
        container_name: db
        build: ${DB_DOCKERFILE_DIR}
        ports:
          - "3306:3306"
        environment:
          MYSQL_DATABASE: ${MYSQL_DATABASE}
          MYSQL_USER: ${MYSQL_USER}
          MYSQL_PASSWORD: ${MYSQL_PASSWORD}
          MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      
      web:
        container_name: web
        build: ${WEB_DOCKERFILE_DIR}
        ports:
          - "3000:3000"
        links:
          - db:db
        volumes:
          - ${WEB_APP_ROOT_DIR}:/var/myapp
        environment:
          DATABASE_NAME: ${MYSQL_DATABASE}
          DATABASE_USER: ${MYSQL_USER}
          DATABASE_PASS: ${MYSQL_PASSWORD}
          DATABASE_HOST: db
        command: sh -c "bundle install --path=vendor/bundle && rm tmp/pids/server.pid; bin/rails s -b 0.0.0.0"
      ```

##### コンテナ作成

   - `docker build` & `rails`ディレクトリ作成

     これでコンテナが起動できるようになったので、`docker-compose build`してイメージ作成します。

     ```sh
     docker/bin/run-docker-compose.sh build
     ```  

     次にwebコンテナに入っている`ruby`で`rails`のアプリケーションを初期化します。

     ```sh
     docker run --rm -it -v $(pwd)/app:/var/myapp bin_web bash  #docker-composeでイメージを作成するとbin_${yamlの定義名}になります。

     # 以降このコードブロックの終わりまでコンテナ内での作業

     # Gemfile初期化
     bundle init

     # この記事を作成中の最新の5.0.0.beta3を指定します。
     echo "gem 'rails', '5.0.0.beta3'" >> Gemfile

     # プロジェクトローカルにrails5をインストール
     bundle install --path=vendor/bundle

     # railsのアプリケーションを作成します。
     # とりあえず雛形のGemfileを作りたいだけなのであとでこのアプリケーションは全部削除します。
     bundle exec rails new sample_app --skip-bundle
     #できたGemfileを持ってきます。
     mv sample_app/Gemfile .
     #sample_appはもういらないので削除します。
     rm -rf sample_app

     # 次にrailsのアプリケーションを作っていきますがファイルの編集はホストの方がやりやすいので一旦コンテナから出ます。
     exit
     ```

   - Gemfile設定
      
     ホスト側でGemfileを変更します。ここでは`sqlite`の替わりに`mysql2`に変えたのと`therubyracer`のコメントを外しただけで後は全部`rails new`が作成してくれたままです。
     
     ```ruby
     source 'https://rubygems.org'
     
     
     # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
     gem 'rails', '>= 5.0.0.beta3', '< 5.1'
     # Use sqlite3 as the database for Active Record
     gem 'mysql2'
     # Use Puma as the app server
     gem 'puma'
     # Use SCSS for stylesheets
     gem 'sass-rails', '~> 5.0'
     # Use Uglifier as compressor for JavaScript assets
     gem 'uglifier', '>= 1.3.0'
     # Use CoffeeScript for .coffee assets and views
     gem 'coffee-rails', '~> 4.1.0'
     # See https://github.com/rails/execjs#readme for more supported runtimes
     gem 'therubyracer', platforms: :ruby
     
     # Use jquery as the JavaScript library
     gem 'jquery-rails'
     # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
     gem 'turbolinks', '~> 5.x'
     # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
     gem 'jbuilder', '~> 2.0'
     # Use Redis adapter to run Action Cable in production
     # gem 'redis', '~> 3.0'
     # Use ActiveModel has_secure_password
     # gem 'bcrypt', '~> 3.1.7'
     
     # Use Capistrano for deployment
     # gem 'capistrano-rails', group: :development
     
     group :development, :test do
       # Call 'byebug' anywhere in the code to stop execution and get a debugger console
       gem 'byebug'
     end
     
     group :development do
       # Access an IRB console on exception pages or by using <%= console %> in views
       gem 'web-console', '~> 3.0'
       gem 'listen', '~> 3.0.5'
       # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
       gem 'spring'
       gem 'spring-watcher-listen', '~> 2.0.0'
     end
     
     # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
     gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
     ```
     
   - 再度コンテナで`bundle install`して`rails`アプリケーション作成

     ```sh
     docker run --rm -it -v $(pwd)/app:/var/myapp bin_web bash

     # 以降このコードブロックの終わりまでコンテナ内での作業
     # dbmsはmysqlにする。
     bundle install && bundle exec rails new . -d mysql
     ```

     このとき`Overwrite /var/myapp/Gemfile? (enter "h" for help) [Ynaqdh]`と、Gemfileを上書きするかと聞いてきますが、先ほど設定したので`n`にしました。

   - database設定

     ホストに戻って `docker-compose`でlinkする`mysql`に対しての接続設定をしておきます。
     database.ymlは`rails`がロードする前に`erb`で処理するので任意の`erb`の式が使えるため、そこでコンテナ起動時に渡している環境変数から接続情報を設定します。

     ```app/config/database.yml
     <%
     database_host = ENV['DATABASE_HOST'] || 'localhost'
     database_port= ENV['DATABASE_PORT'] || 3306
     %>
     default: &default
       adapter: mysql2
       encoding: utf8
       pool: 5
       username: <%= ENV['DATABASE_USER'] %>
       password: <%= ENV['DATABASE_PASS'] %>
       port: <%= database_port %>
       host: <%= database_host %>

     development:
       <<: *default
       database: <%= ENV['DATABASE_NAME'] %>

     # testとproductionは省略
     ```

   - コンテナの起動
     これで設定ができたので、`docker-compose`で`up`します。(バックグラウンドにするなら`up -d`)

     ```
     docker/bin/run-docker-compose.sh up
     ```

   `db`と`web`のコンテナが正常に起動した後、`docker-machine ip`で確認したipの3000番portにアクセスしてrailsのwelcomeページが表示されれば一旦設定完了です。

![スクリーンショット 2016-04-20 0.04.10.png](https://qiita-image-store.s3.amazonaws.com/0/34692/8def393b-59f4-206f-bc4d-28e0eba53922.png)



### git 登録
  ここまでで`git`で管理出来るようになったので`git init`したいですが、`app/vendor`以下と`app/tmp`以下はgitに入れたくないので、`.gitignore`の設定を追加します。
  
  app/.gitignore
  
  ```
  app/tmp/*
  app/vendor/*  
  ```
  
  で、`git`に登録します。
  
  ```
  git init
  git add -A
  git commit -m "initial commit"
  ```
  
### 困った点

  上記までで一旦railsの起動までいったんですが、ここからいざ開発しようとしたらいろいろ問題があって、主に`docker-machine`(というか`virtual box`などの`shared folder`でソースをホストと共有するがゆえの)問題だったので結構ハマりました。

#### リロードされない問題

   これで環境ができたので適当にコントローラを作ってみます。
   `docker exec -it web bash`でアタッチします。
   
   ```rb
   # hello コントローラ作成
   bundle exec bin/rails g controller hello
   ```
  
   コントローラとビューを作成

   app/controllers/hello_controller.rb
      
   ```rb
   class HelloController < ApplicationController
     def hello
       @msg = "world"
     end
   end
   ```
   
   app/views/hello.html.erb
   
   ```
   Hello, <%= @msg %>
   ```
   
   ルーティングも1つ追加します。`app/config/routes.rb` 

   ```rb
   Rails.application.routes.draw do
    # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
 
    # Serve websocket cable requests in-process
    # mount ActionCable.server => '/cable'
    get 'hello' => 'hello#hello'
  end
  ```
  
  一応`docker/bin/run-docker-compose.sh down; docker/bin/run-docker-compose.sh up`でクリーンな状態で起動します。
  
  これで`http://$(docker-machine's ip)/hello`にアクセスします。
  <img width="344" alt="スクリーンショット 2016-04-20 0.34.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/34692/9ebe08f1-80fd-dc3a-d97a-dcc723769d38.png">

  きちんと表示されます。
  
  次に、`app/controller/hello_controller.rb`を編集してメッセージ(`world`の部分)を変更してみます。
  
  ```
  class HelloController < ApplicationController
    def hello
      @msg = "docker"
    end
  end
  ```
  
  これで`hello, world`から`hello, docker`になるはずです、がreloadしても`hello, world`のままです。
  
  で、いろいろ紆余曲折してたどりついたのが、
  
  - ファイルの変更を検知しているのは`app/config/environments/development.rb`の`file_watcher`の部分。
  - その設定ファイル内で`config.file_watcher = ActiveSupport::EventedFileUpdateChecker`によって検知している
  - ただ、その検知方法がファイルの変更イベントを受けて検知しているが`docker-machine`の共有ファイルに関してはその変更イベントが発生しない。
    （これは`docker-machine`の問題でなく、共有ファイルとしてホスト側をマウントする方式では仕様としてどうしようもないそうです。)
    
  ということで、なんとかファイルの変更をトリガーに変更を検知するのではなく、ポーリング形式でファイルの変更を監視する方法が必要なようでした。
  で、いろいろ調べてみると、昔はその方式だったようでその方式でファイルの変更を検知すると自動でリロードされました。。
  
  - app/config/environments/developments.rbで、既に定義されている`config.file_watcher`を下記のものに置き換えます。
  
  ```
  config.file_watcher = ActiveSupport::FileUpdateChecker  
  ```
  
  変更後、`docker/bin/run-docker-compose.sh down; docker/bin/run-docker-compose.sh up`でクリーンな状態で起動します。
  で、`app/controller/hello_controller.rb`を再度編集してみます。
  
  ```rb
  class HelloController < ApplicationController
    def hello
      @msg = "docker docker docker"
    end
  end
  ```
  
  でもやっぱり変更されません。
  
  で、いろいろ調べてみると、`docker-machine`で`virtual box`をドライバにして普通に作成すると、ホスト側と時刻が微妙にずれるとのこと。
  そのずれた時間のせいでホストの時刻の方が未来時間になっていると上で設定した`ActiveSupport::FileUpdateChecker`がリロードしない、という処理になっていました。
  
```rb:./bundle/ruby/2.3.0/gems/activesupport-5.0.0.beta3/lib/active_support/file_update_checker.rb
    # This method returns the maximum mtime of the files in +paths+, or +nil+
    # if the array is empty.
    #
    # Files with a mtime in the future are ignored. Such abnormal situation
    # can happen for example if the user changes the clock by hand. It is
    # healthy to consider this edge case because with mtimes in the future
    # reloading is not triggered.
    def max_mtime(paths)
      time_now = Time.now
      paths.map {|path| File.mtime(path)}.reject {|mtime| time_now < mtime}.max
    end
```
  
　　　ここですね。最大の更新時刻を取得するんですが、「ファイルの更新時間が未来になっているようなファイルは`abnormal`なので無視する」となっています。最初この処理を知らずに、「未来日付なんだから絶対更新されているとみなされるだろう」と思い込んでいたので相当ハマりました。やっぱりソースを読むのは大事ですね。

  今回僕が試した環境では6分ぐらいホストと`docker-machine`で時刻がずれていました。その場合`docker-machine`のドライバが`virtual box`の場合は`docker-machine ssh`で`docmer-machine`にログインした上で
  
  ```
  sudo VBoxControl guestproperty set "/VirtualBox/GuestAdd/VBoxService/--timesync-set-threshold" 5000
  ```
  
  で、`docker-machine`のベースになっている`virtual　box`の時刻とホストマシンの時刻のズレを抑える（ここでは5秒(=5000))ことで解決しました。
  
  ココにたどり着くまでだいぶ時間ロスしました。。
  
#### `web-console`のエラー問題

  これで行けるかもと思いましたが、いざ表示すると`web-console`のエラー。。。
  
  これは、`docker-machine`でアクセスしているホスト（mac)と実際にアプリがあるホスト(docker-machine)が違うせいでした。
  
  どこからでも`web-console`にアクセスできるようにして解決
  
  `app/config/environments/development.rb`

  ```
  config.web_console.whitelisted_ips = %w( 0.0.0.0/0 ::/0 )
  ```
       
#### まとめ

   以上の考慮をいれたものをhttps://github.com/pocari/rails5-docker-sample/tree/ver01-rails-on-docker-setupにあげました。