patorashのブログ

方向性はまだない

Macで開発環境のDocker化に取り組んでいるので一旦まとめる

MacRailsアプリを開発しているとなぜかセグメンテーション違反が起きまくって開発に支障が出てきたので、開発環境を全部Dockerに載せてしまおうと思ってここ2週間くらい取り組んでいます。

とりあえず、CircleCIのテスト以外はちゃんと動くようになったかな?と思えるところまできたので、まとめておきます。

ベストプラクティスに学ぶ

当初はDockerのページに載っているサンプルを見ながら自分で書いていこうとしていましたが、Macで動かすとI/Oがあまりにも遅くて使い物にならないレベルでした。twitterで愚痴っていたら、神速さんにアドバイスをもらいました。

とりあえず、これを読み進めながら、自分の環境に合うように修正を進めていきました。

techracho.bpsinc.jp

ファイル群を公開

まずはDocker関連のファイルだけ。

Dockerfile

Dockerfileは参考にしたサイトとほぼ同じ構成です。プロジェクトに.dockerdev/というディレクトリを作成し、その下に置いています。 docker-compose.yml側に引数としてバージョン情報を持つことで、開発環境のバージョン管理をしやすくなっているDockerfileです。また、aptでインストールするパッケージについては、Aptfileという外部ファイルに記述するようにしてあります。 変更点は、ENTRYPOINTを定義しているところです。Postgresが完全に起動したらコマンドを実行するようにしています。

ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION

ARG PG_MAJOR
ARG NODE_MAJOR
ARG BUNDLER_VERSION
ARG YARN_VERSION

# ソースリストにPostgreSQLを追加
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -\
  && echo 'deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list

# ソースリストにNodeJSを追加
RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -

# ソースリストにYarnを追加
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -\
  && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list

# 依存関係をインストール
COPY .dockerdev/Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade &&\
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends\
    build-essential\
    postgresql-client-$PG_MAJOR\
    nodejs\
    yarn=$YARN_VERSION-1\
    $(cat /tmp/Aptfile | xargs) &&\
    apt-get clean &&\
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\
    truncate -s 0 /var/log/*log

# bundlerとPATHを設定
ENV LANG=C.UTF-8\
  GEM_HOME=/bundle\
  BUNDLE_JOBS=4\
  BUNDLE_RETRY=3
ENV BUNDLE_PATH $GEM_HOME
ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH\
  BUNDLE_BIN=$BUNDLE_PATH/bin
ENV PATH /app/bin:$BUNDLE_BIN:$PATH

# RubyGemsをアップグレードして必要なバージョンのbundlerをインストール
RUN gem update --system &&\
    gem install bundler:$BUNDLER_VERSION

# appコードを置くディレクトリを作成
RUN mkdir -p /app

WORKDIR /app
COPY .dockerdev/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh", "postgres"]

Aptfile

aptでインストールするパッケージはとりあえずvimRailsのCredentialsを変更するのに必須なので入れてます。また、私が担当しているプロジェクトでは画像を扱うのでimagemagickも入れています。

vim
imagemagick

entrypoint.sh

これがエントリーポイントのshell scriptです。今見てみると、別にDockerfile側で引数を渡さなくてもいいかなと思えました…。 やっていることは、pg_isreadyコマンドを使ってPostgreSQLが立ち上がるまで待ち続けて、立ち上がったらコマンドを実行しています。

#!/bin/bash
set -e

# Then exec the container's main process (what's set as CMD in the Dockerfile).
host="$1"
shift
cmd="$@"

until pg_isready -h "$host" -U "postgres"; do
  >&2 echo "Postgres is unavailable - sleeping"
  sleep 5
done

>&2 echo "Postgres is up -executing command"
exec $cmd

docker-compose.yml

とりあえず、現時点でのdocker-compose.ymlを公開します。 基本的には、参考サイトの方針を踏襲しています。backendを引き継いだコンテナが5つあります。

コンテナ名 説明
rails develoment環境でRailsが起動
resque development環境でバックグラウンドジョブを担当するresqueが起動
test test環境でRailsアプリのあるコンテナを起動
runner Railsアプリのある環境のシェルを起動
webpacker webpacker用の設定(うちでは現時点で未使用)

また、ミドルウェア系を全てコンテナ化してあります

各環境ごとに、depends_onブロックを使って依存しているコンテナを表現しています。

version: '3.3'

services:
  app: &app
    build:
      context: .
      dockerfile: ./.dockerdev/Dockerfile
      args:
        RUBY_VERSION: '2.6.3'
        PG_MAJOR: '11'
        NODE_MAJOR: '13'
        YARN_VERSION: '1.21.1'
        BUNDLER_VERSION: '1.17.3'
    image: app-dev:1.0.0
    tmpfs:
      - /tmp

  backend: &backend
    <<: *app
    stdin_open: true
    tty: true
    volumes:
      - .:/app:cached
      - rails_cache:/app/tmp/cache
      - bundle:/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
      - data:/app/data
      - coverage:/app/coverage
      - .dockerdev/.psqlrc:/root/.psqlrc:ro
    environment:
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"
      NODE_ENV: ${NODE_ENV:-development}
      RAILS_ENV: ${RAILS_ENV:-development}
      BOOTSNAP_CACHE_DIR: /bundle/bootsnap
      WEBPACKER_DEV_SERVER_HOST: webpacker
      HISTFILE: /app/log/.bash_history
      PSQL_HISTFILE: /app/log/.psql_history
      EDITOR: vi
      MALLOC_ARENA_MAX: 2
      WEB_CONCURRENCY: ${WEB_CONCURRENCY:-1}
    depends_on:
      - postgres
      - redis
      - elasticsearch
      - minio
      - memcached
      - mailhog

  runner:
    <<: *backend
    command: /bin/bash

  rails:
    <<: *backend
    command: ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
    ports:
      - '3000:3000'

  test:
    <<: *backend
    command: /bin/bash
    environment:
      TZ: "/usr/share/zoneinfo/Asia/Tokyo"
      NODE_ENV: ${NODE_ENV:-development}
      RACK_ENV: ${RACK_ENV:-test}
      RAILS_ENV: ${RAILS_ENV:-test}
      BOOTSNAP_CACHE_DIR: /bundle/bootsnap
      WEBPACKER_DEV_SERVER_HOST: webpacker
      HISTFILE: /app/log/.bash_history
      PSQL_HISTFILE: /app/log/.psql_history
      SELENIUM_DRIVER_URL: http://chrome:4444/wd/hub
    depends_on:
      - postgres
      - redis
      - elasticsearch
      - memcached
      - chrome

  resque:
    <<: *backend
    command: ["bundle", "exec", "rake", "environment", "resque:work"]
    environment:
      TERM_CHILD: 1
      QUEUE: "*"

  postgres:
    image: mdillon/postgis:11-alpine
    volumes:
      - .dockerdev/.psqlrc:/root/.psqlrc:ro
      - pg_data:/var/lib/postgresql/data
      - ./log:/root/log:cached
    environment:
      PSQL_HISTFILE: /root/log/.psql_history
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1"]
      interval: 5s

  elasticsearch:
    image: patorash/elasticsearch-kuromoji:5.6.14-alpine
    ports:
      - "9200:9200"
      - "9300:9300"
    volumes:
      - elasticsearch_data:/usr/share/elasticsearch/data
    environment:
      ES_JAVA_OPTS: "-Xms512m -Xmx512m"
    healthcheck:
      test: ["CMD", "curl --silent --fail localhost:9200/_cluster/health || exit 1"]
      interval: 60s
      timeout: 30s
      retries: 3

  redis:
    image: redis:3.2.12-alpine
    volumes:
      - .docker/redis/data:/data
    ports:
      - 6379
    healthcheck:
      test: redis-cli ping
      interval: 10s
      timeout: 3s
      retries: 30

  minio:
    image: minio/minio:RELEASE.2020-02-07T23-28-16Z
    ports:
      - "9000:9000"
    command: [server, /data]
    volumes:
      - .docker/minio/data:/data
    environment:
      MINIO_ACCESS_KEY: access_key
      MINIO_SECRET_KEY: access_secret
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  memcached:
    image: memcached:1.5.12-alpine
    ports:
      - "11211:11211"
    healthcheck:
      test: echo stats | nc 127.0.0.1 11211
      interval: 10s
      retries: 60

  mailhog:
    image: mailhog/mailhog:v1.0.0
    ports:
      - '8025:8025'
    environment:
      MH_STORAGE: maildir
      MH_MAILDIR_PATH: /tmp
    volumes:
      - mail_dir:/tmp

  chrome:
    image: selenium/standalone-chrome:latest
    ports:
      - '4444:4444'

  webpacker:
    <<: *app
    command: ./bin/webpack-dev-server
    ports:
      - '3035:3035'
    volumes:
      - .:/app:cached
      - bundle:/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
    environment:
      NODE_ENV: ${NODE_ENV:-development}
      RAILS_ENV: ${RAILS_ENV:-development}
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0

volumes:
  postgres:
  redis:
  bundle:
  node_modules:
  rails_cache:
  packs:
  data:
  coverage:
  pg_data:
  mail_dir:
  elasticsearch_data:
railsコンテナとtestコンテナを分けている理由

テストを実行する際に、railsコンテナで毎回、環境変数RAILS_ENVをtestにするのが面倒だったので、分けました。 また、railsコンテナを起動する時にはChromeコンテナは必要ないため除外するなど、開発時は軽くなるような工夫をしています。 Chromeコンテナはtestのときだけ起動するようにしています。

参考サイトからの学びポイント

参考サイトを読み進めながら、自分が気付いた高速化のポイントやイメージの縮小のためのTipsをまとめます。

Dockerのvolumesを使いこなす

docker-composeのvolumesってホストOSとコンテナでファイル共有をするためのものだと認識していたのですが、違いました。Dockerでvolumeを定義して、そのvolume上にデータを配置することで、MacとDocker間のI/Oの影響を受けずに済むようになりました。(docker-composeを雰囲気で使っていることがわかる…)

gemやnode_modulesなどライブラリ系のデータや、データベースをはじめとするミドルウェアのデータは全てvolumesに逃すことで、高速化できました。

ただし、volumesにデータを置くと、ホストOSからは直接ファイルを見られなくなるので、その点は注意が必要です。そのため、minio(AWS S3の代替)に関しては、ホストOSとファイル共有のままにしてあります。

.dockerignoreを使いこなす

RailsアプリケーションをDocker化する場合は、プロジェクト自体を含んだ状態でdocker buildすることになります。そのため、不要なファイルを置いていると、一緒にコンテナに保存されます。不要なデータがあると、その分buildする時間がかかるようになるので、.dockerignoreを定義して無視させるようにしましょう。これはHerokuでいう.slugignoreと同等のものになります。

.circleci
.docker
.idea
.git
.github
doc

.env*
.gitignore
.ruby-version
.slugignore
*.dump*

コンテナ間のネットワーク指定

ホストOSからミドルウェアのコンテナを参照していた頃と違って、Railsアプリのコンテナからミドルウェアのコンテナを参照する場合には、ネットワークの指定の方法が異なります。

ホストOSからの場合、基本的にhttp://localhost:9200のようにホスト名をlocalhostを指定すればよかったのですが、コンテナ間の場合、http://elasticsearch:9200のように変わります。 そのため、コード上からそれらを全て指定し直しました。

PostgreSQL

database.ymlを修正しました。

default: &default
  adapter: postgresql
  encode: unicode
  host: postgres # <= localhostから変更
  username: <%= ENV.fetch("DOCKER_POSTGRES_USER") { "postgres" } %>
  port: <%= ENV.fetch("DOCKER_POSTGRES_PORT") { 5432 } %>
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
 <<: *default
  database: app_development

test:
  <<: *default
  database: app_test<%= ENV['TEST_ENV_NUMBER'] %>
  min_messages: WARNING
Elasticsearch

config/initializers/elasticsearch_model.rbを修正しました。

Elasticsearch::Model.client = case
                                when Rails.env.development?
                                  Elasticsearch::Client.new(host: 'elasticsearch:9200/', log: true)
                                when Rails.env.test?
                                  Elasticsearch::Client.new(host: 'elasticsearch:9200/')
                                else
                                  raise 'ELASTICSEARCH_URL not found.' unless ENV['ELASTICSEARCH_URL']
                                  Elasticsearch::Client.new(host: ENV['ELASTICSEARCH_URL'])
                              end
Redis

Redisはresqueで使っています。 config/initializers/resque.rbを修正しました。

Resque.redis = if ENV['REDIS_URL']
                 Redis.new(url: ENV['REDIS_URL'])
               else
                 Redis.new(
                   host: 'redis', # <= 修正
                   port: ENV.fetch('REDIS_PORT', 6379),
                 )
               end
Memcached

Memcachedはgemのdalliを使っているので、その辺りを修正します。 config/environments/development.rb を修正しました。

# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
  config.action_controller.perform_caching = true
  config.action_controller.enable_fragment_cache_logging = true
  config.cache_store = :dalli_store, 'memcached:11211' # <= 修正
  config.public_file_server.headers = {
    'Cache-Control': "public, max-age=#{2.days.to_i}",
  }
else
  config.action_controller.perform_caching = false
  config.cache_store = :null_store
end
Minio

MinioはPaperclipで使っているので(未だにPaperclip…。Docker化が終わったらActiveStorageに移行予定)、Paperclipの指定を変更します。 config/environments/development.rb を修正しました。

config.paperclip_defaults = {
  storage: :s3,
  s3_protocol: :http,
  s3_host_name: 'localhost:9000', # <= ん?こいつは…?
  s3_credentials: {
    access_key_id: Rails.application.credentials.aws_access_key_id,
    secret_access_key: Rails.application.credentials.aws_secret_access_key,
  },
  s3_region: Rails.application.credentials.aws_region,
  bucket: Rails.application.credentials.s3_bucket_name,
  s3_options: {
    endpoint: 'http://minio:9000', # <= 変更
    force_path_style: true, # for aws-sdk (required for minio)
  },
  s3_permissions: :private,
  url: ':s3_path_url',
  path: ':class/:attachment/:id/:style/:filename',
}

とりあえず、s3_optionsのendpointを変えたらOKです。s3_host_nameは、publicな場合のみ有効になるっぽくて、期限付きURLを発行するとhttp://minio:9000になってしまいました。

Minioの場合は、ファイルのアップロードに関してはこれでいいのですが、ブラウザからファイルを参照しようとすると、http://minio:9000が見つからないということでエラーになってしまいます。解決策としては、今のところは、/etc/hostsを編集するしかなさそうです。

127.0.0.1 minio

これで、minio経由のファイルを参照できるようになりました。

MailHog

MailHogはメールサーバを代用してくれます。これまではgem letter_openerを使っていたのですが、Docker環境だとうまく機能しないという話を聞き、オススメされました。 確かにうまく機能しませんでした(メールがクリアできなかったり等)。 そのため、MailHogに切り替えました。 config/environments/development.rb を修正しました。

config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'mailhog',
  port: 1025,
}

あっさりと変更できてよかったです。

Chrome

Chromeselenium-chromeのDockerイメージを使うことにしました。 RubyのイメージにChromeを含めるのはイメージサイズも大きくなるし、ビルド時間も長くなるという点、そして、スタンドアロン版のDockerイメージがあるということだったので、 採用するようにしたのですが、これをCapybaraで指定するのにかなりハマッたので、メモを残しておきます。

spec/support/capybara.rbを編集しました。過去の設定も含まれているので、不要なものも多々あるかもしれません。

Capybara.register_driver :selenium do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.headless!
  options.add_argument '--disable-gpu'
  options.add_argument '--window-size=1680,1050'
  options.add_argument '--blink-settings=imagesEnabled=false'
  options.add_argument '--lang=ja'
  # options.add_argument '--no-sandbox'
  # options.add_argument '--no-zygote'
  driver = Capybara::Selenium::Driver.new(app,
                                          url: ENV.fetch('SELENIUM_DRIVER_URL'), # <= 追加
                                          browser: :remote, # <= :chromeから:remoteに変更
                                          options: options,
                                          desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
                                            login_prefs: { browser: 'ALL' },
                                            loggingPrefs: { browser: 'ALL' },
                                          ),
  )
  bridge = driver.browser.send(:bridge)

  path = "session/#{bridge.session_id}/chromium/send_command"

  bridge.http.call(:post, path, cmd: 'Page.setDownloadBehavior',
                                params: {
                                  behavior: 'allow',
                                  downloadPath: WaitForDownload::PATH,
                                })
  driver
end

Capybara.configure do |config|
  config.server_host = "test" # <= chromeから見た、テストを実行するイメージ名を指定
  config.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i
  config.app_host = "http://#{config.server_host}:#{config.server_port}" # <= Seleniumが接続するテストを実行するAppHostのURL

  config.javascript_driver = :selenium
  config.default_max_wait_time = ENV['CI'].present? ? 15 : 15
  config.ignore_hidden_elements = true
  config.server = :puma, { Silent: true }
end

肝は、Capybara::Selenium::Driver.newするところでの、urlの指定と、browser: :remoteにするところと、Capybara自体のconfigの設定で、Chrome側から見たtestコンテナへの接続設定です。config.app_hostが重要です。これがないと、Capybaraは真っ白なページを表示して、visitを実行しても何も起きませんでした。

docker-composeで実行するコマンドの紹介

では、このdocker-composeを使って開発する時のコマンドについて書いていきます。

docker-compose build rails
Dockerfileを元にRailsアプリを含むRubyのイメージを作成します。
docker-compose up rails
Railsと、依存するコンテナを全て起動します。通常の開発時はこれでOKです。実行すると、http://localhost:3000にアクセス可能になります。
docker-compose up resque
Resqueと、依存するコンテナを全て起動します。通常の開発時はこれでOKです。
docker-compose run --rm runner
依存するコンテナを起動した状態でRubyコンテナのshellを開きます。`--rm`オプションにより、コンテナは終了時に削除されます。 これを利用することで、railsを起動することなく、様々なことが可能です。例えば、bundle installを行なったり、yarn installを行なったり、rails consoleを開いたり等です。
docker-compose up -d test
testのコンテナをバックグラウンドで立ち上げます。Capybaraの関わるテストが動かないため、バックグラウンドで立ち上げておかなくてはなりません。
docker-compose exec test bundle exec rspec spec/models/hoge_spec.rb
rspechoge_spec.rbのテストを実行します。execではなく、runで実行すると、一時的にコンテナが立ち上がるため、名前解決がうまくいかないため、Capybaraの絡むテストが失敗します。
docker-compose down
コンテナを全て落とします

docker-composeの打ち疲れ対策

この体制で開発を始めると、docker-composeって何度も打つことになるので、エイリアスを設定しておくと楽です。

alias dcu="docker-compose up"
alias dcd="docker-compose down"
alias dcr="docker-compose run"
alias dce="docker-compose exec"
alias dcb="docker-compose build"
alias dcp="docker-compose ps"

今後について

ひとまず、開発環境としては速度的にもストレスのないスピードで開発できるようになりました!神速さんに感謝! でもまだCircleCIでテストが通ってないのでマージできず、本格運用には至っていません…。 今はCircleCI上でこのdocker-compose.ymlを使ってそのままテストができないかを探っている最中です。進捗があったらまたブログ書きます。