MacでRailsアプリを開発しているとなぜかセグメンテーション違反が起きまくって開発に支障が出てきたので、開発環境を全部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でインストールするパッケージはとりあえずvimはRailsのCredentialsを変更するのに必須なので入れてます。また、私が担当しているプロジェクトでは画像を扱うのでimagemagickも入れています。
vim
imagemagick
entrypoint.sh
これがエントリーポイントのshell scriptです。今見てみると、別にDockerfile側で引数を渡さなくてもいいかなと思えました…。
やっていることは、pg_isready
コマンドを使ってPostgreSQLが立ち上がるまで待ち続けて、立ち上がったらコマンドを実行しています。
set -e
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
のように変わります。
そのため、コード上からそれらを全て指定し直しました。
database.ymlを修正しました。
default: &default
adapter: postgresql
encode: unicode
host: postgres
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はgemのdalliを使っているので、その辺りを修正します。
config/environments/development.rb を修正しました。
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,
},
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は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'
driver = Capybara::Selenium::Driver.new(app,
url: ENV.fetch('SELENIUM_DRIVER_URL'),
browser: :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"
config.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i
config.app_host = "http://#{config.server_host}:#{config.server_port}"
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を使ってそのままテストができないかを探っている最中です。進捗があったらまたブログ書きます。