MacでRailsアプリを開発しているとなぜかセグメンテーション違反が起きまくって開発に支障が出てきたので、開発環境を全部Dockerに載せてしまおうと思ってここ2週間くらい取り組んでいます。
とりあえず、CircleCIのテスト以外はちゃんと動くようになったかな?と思えるところまできたので、まとめておきます。
ベストプラクティスに学ぶ
当初はDockerのページに載っているサンプルを見ながら自分で書いていこうとしていましたが、Macで動かすとI/Oがあまりにも遅くて使い物にならないレベルでした。twitterで愚痴っていたら、神速さんにアドバイスをもらいました。
Macでしたら、以下の記事が参考になるかと思います。https://t.co/Bn5nfefBqM
— 神速 (@sinsoku_listy) February 12, 2020
とりあえず、これを読み進めながら、自分の環境に合うように修正を進めていきました。
ファイル群を公開
まずは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でインストールするパッケージはとりあえずvimはRailsの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用の設定(うちでは現時点で未使用) |
また、ミドルウェア系を全てコンテナ化してあります
- PostgreSQL
- Elasticsearch
- Redis
- Memcached
- Minio
- MailHog
- Chrome
各環境ごとに、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
Chromeはselenium-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
- rspecでhoge_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を使ってそのままテストができないかを探っている最中です。進捗があったらまたブログ書きます。