DockerでRailsの本番環境を作る

馬

アドベントカレンダーに申し込まなかったので若干寂しさを感じているましです。

ホストOSに直接インストールしているましの質問箱をサーバー移行させるためにDocker化しようと思ったんですが、本番環境(データの永続化やhttps化など)で動かすのに詰まった部分がかなりあったので書いてみました。

先に書いておきますが本番というのはRAILS_ENV=productionの意味です。個人開発レベルの話ですしセキュリティなどについては再考お願いします🙇‍♀️もっと良い方法があるなども是非教えてください!

コードは githubで公開しています。

https://github.com/masibw/rails-docker

docker-compose.yml

以下の環境を作ります。

  • https-portal(nginx+https自動化)
  • Rails6環境(Dockerfileから生成)
  • MySQL(公式のmysql5.7そのまま)

docker-composeは以下になります。{DOMAIN_NAME}は自分の所持しているドメインに置き換えてください。

DBの初期化&データ投入はうまくやってください。DBデータはvolumeで永続化しているため、最初の1度だけコンテナ内に入ってbundle exec rake db:create db:migrateでもいいですし、データを移行したければdumpファイルから投入しても良いです。

version: '3'
services:
  db:
    image: mysql:5.7
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      # MySQLの設定ファイル
      - ./build/db_prod/my.cnf:/etc/mysql/conf.d/my.cnf
      # DBのデータを永続化する
      - db-data:/var/lib/mysql
    ports:
      - 3306:3306
    # environmentはenv_fileの前に読み込まれるのでenv_fileで直接環境変数を設定する
    env_file:
      - .env.prod

  nginx:
    image: steveltn/https-portal:1.7.3
    restart: always
    links:
      - app:app
    ports:
      - "80:80"
      - "443:443"
    volumes:
      # Nginxの設定ファイルを上書きする
      - ./build/nginx/conf/{DOMAIN_NAME}}.ssl.conf.erb:/var/lib/nginx-conf/{DOMAIN_NAME}.conf.erb:ro
      # unix domain socket通信するのでRailsとディレクトリを共有する
      - tmp-data:/docker_rails/tmp
    environment:
      STAGE: production
      DOMAINS: '{DOMAIN_NAME}} => https://{DOMAIN_NAME}'

  app:
    build:
      context: ./
      dockerfile: ./build/rails/Dockerfile
    command: bundle exec pumactl start
    volumes:
      - .:/myapp
      # unix domain socket通信するのでNginxとディレクトリを共有する
      - tmp-data:/myapp/tmp
      # logを永続化
      - log-data:/myapp/log
    depends_on:
      - db
    env_file:
      - .env.prod

volumes:
  db-data:
  tmp-data:
  log-data:

Rails用のDockerfileはこちらです。

FROM node:14.15.3-alpine as node

# 依存関係のインストールを行うだけのもの
FROM ruby:2.6.0-alpine as builder
RUN apk --update --no-cache add --virtual build-dependencies \
 shadow sudo busybox-suid mariadb-connector-c-dev tzdata alpine-sdk

WORKDIR /rails

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/include/node /usr/local/include/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /opt/yarn-* /opt/yarn
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && \
    ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
    ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn

# 依存しているライブラリのインストール
ADD Gemfile Gemfile.lock ./
ENV BUNDLE_JOBS=4
RUN gem install bundler -v 2.1.4 \
  && bundle install --without development test

#yarn install
ADD package.json yarn.lock ./
RUN yarn install

# assets precompile
COPY Rakefile Rakefile
COPY app/javascript app/javascript
COPY app/assets app/assets
COPY bin bin
COPY config config
RUN RAILS_ENV=production bundle exec rails assets:precompile

RUN apk del build-dependencies

# 実際にRailsを動作させるもの
FROM ruby:2.6.0-alpine
ENV DOCKERIZE_VERSION v0.6.1

# パッケージ全体を軽量化して、railsが起動する最低限のものにする
RUN apk --update --no-cache -q add shadow sudo busybox-suid execline tzdata mariadb-connector-c-dev libstdc++ && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

RUN mkdir /myapp
WORKDIR /myapp

# gemやassets:precompileの終わったファイルはbuilderからコピーしてくる
COPY --from=builder /usr/local/bundle /usr/local/bundle

COPY --from=builder /rails/public/assets/ /myapp/public/assets/
COPY --from=builder /rails/public/packs/ /myapp/public/packs/

## Railsはディレクトリを自動生成してくれないのでunix domain socket通信に必要なファイルを作っておく
RUN mkdir /myapp/tmp
RUN mkdir /myapp/tmp/pids
RUN mkdir /myapp/tmp/sockets

マルチステージビルドを用いて軽量化を試みています。

マルチステージビルドとは

bundle installや yarn installなど依存関係をインストールする際に必要なものは完成形のDockerイメージに残さず結果だけを完成形にCOPYすることでイメージサイズが小さくなります。

今回は準備用のものにbuilderと言う名前をつけ、そこからコピーしてきています

# 依存関係のインストールを行うだけのもの
FROM ruby:2.6.0-alpine as builder
# gemやassets:precompileの終わったファイルはbuilderからコピーしてくる
COPY --from=builder /usr/local/bundle /usr/local/bundle

COPY --from=builder /rails/public/assets/ /myapp/public/assets/
COPY --from=builder /rails/public/packs/ /myapp/public/packs/

https-portal(nginx)のconfファイルはこちらです

upstream backend{
server unix:///docker_rails/tmp/sockets/puma.sock;
}


server {
listen 443 ssl http2 default_server;
server_name <%= domain.name %>;
root /var/www/myapp/current/public;

ssl_certificate <%= domain.chained_cert_path %>;
ssl_certificate_key <%= domain.key_path %>;

ssl_protocols       TLSv1 TLSv1.1 TLSv1.2;
ssl_session_cache shared:SSL:50m;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA;
ssl_prefer_server_ciphers on;
ssl_session_timeout 10m;

ssl_dhparam <%= dhparam_path %>;

location /{
try_files $uri @app;
}

location @app{
proxy_pass http://backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

パスワードなどgit管理したくないものだったり環境変数を記載するenvファイルは.env.prodと言う名前で配置しています

MYSQL_ROOT_PASSWORD=pass

RAILS_ENV=production

https-portal

https://github.com/SteveLTN/https-portal

NginxにLet’s encryptを用いたSSL認証を加えたDockerイメージです。自動更新もしてくれる有能くんです。

リダイレクトは環境変数から設定できるのですがunix domain socketは設定できなさそうだったのでconfファイルを上書きしました。conf形式ではなくerbで変数を埋め込む必要があります。

普通のNginxのように/etc/nginx/conf.dへマウントやdocker cpする方法ではダメなので気をつけてください。/var/lib/nginx-conf/へ配置する必要があります。

  volumes:
      # Nginxの設定ファイルを上書きする
      - ./build/nginx/conf/{DOMAIN_NAME}.ssl.conf.erb:/var/lib/nginx-conf/{DOMAIN_NAME}.ssl.conf.erb:ro

httpリクエストをhttpsへリダイレクトするために以下のように記述します。{DOMAIN_NAME}は適宜所有しているドメインへ置き換えてください。

environment:
      STAGE: production
      DOMAINS: '{DOMAIN_NAME} => https://{DOMAIN_NAME}'

Rails(puma)

本番ではpumaで動作させ unix domain socketを用いる想定です。

config/database.ymlはこんな感じです。rootでアクセスしているので権限を狭めたユーザーを作成した方がセキュアです。database名は適切なものに変更してくださいね。

# MySQL. Versions 5.5.8 and up are supported.
#
# Install the MySQL driver
#   gem install mysql2
#
# Ensure the MySQL gem is defined in your Gemfile
#   gem 'mysql2'
#
# And be sure to use new-style password hashing:
#   https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html
#
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: <%= ENV.fetch("MYSQL_ROOT_PASSWORD")%>
  host: db

development:
  <<: *default
  database: rails_docker_development

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: rails_docker_test

# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
# ever seen by anyone, they now have access to your database.
#
# Instead, provide the password as a unix environment variable when you boot
# the app. Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full rundown on how to provide these environment variables in a
# production deployment.
#
# On Heroku and other platform providers, you may have a full connection URL
# available as an environment variable. For example:
#
#   DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase"
#
# You can use this database configuration with:
#
#   production:
#     url: <%= ENV['DATABASE_URL'] %>
#
production:
  <<: *default
  database: rails_docker_production
  username: root
  password: <%= ENV.fetch("MYSQL_ROOT_PASSWORD")%>

unix domain socketを使うためにpuma.rbの該当部分を以下のように書き換えます。

# port        ENV.fetch("PORT") { 3000 }
rails_env = ENV.fetch("RAILS_ENV") {"development"}
if rails_env == 'production'
  bind "unix:///myapp/tmp/sockets/puma.sock"
elsif rails_env =='development'
  # bind "unix:s"
end

あとは起動して該当のドメインへアクセスすればページが表示されるはずです。

docker-compose -f docker-compose.production.yml up -d --build

本番環境へ.env.prodファイルとmaster.keyを配置するのを忘れないでくださいね。

詰まったところ

色々詰まったところを書いていきます。

sockets または pids not found

unix domain socketを使う際に/tmp/socketsの中にsockファイルを作るようになっていますが、自動でディレクトリを作ってくれないのでDockerfileでディレクトリを作成するようにしました。もしかしたら良い方法があるかも…?

## Railsはディレクトリを自動生成してくれないのでunix domain socket通信に必要なファイルを作っておく
RUN mkdir /myapp/tmp
RUN mkdir /myapp/tmp/pids
RUN mkdir /myapp/tmp/sockets

Railsって定数がないよってエラー

uninitialized constant #<Class:#<Puma::DSL:0x0000563f72a05c28>>::Rails

こう言うのがでます。Railsコマンドが有効でもなぜか出るので以下のように迂回しました。

rails_env = ENV.fetch("RAILS_ENV") {"development"}
if rails_env == 'production'
 bind "unix:///myapp/tmp/sockets/puma.sock"
elsif rails_env =='development'
  # bind "unix:s"
end

https-portalをlocalで使いたい

localhostで動かしたいときは以下のようにしてブラウザでアクセスするだけで見れる。

   environment:
      STAGE: local
      DOMAINS: 'localhost => https://localhost'

envファイルの扱い

docker-composeではenvironmentはenv_fileよりも早く読み込まれるので以下のような使い方は出来ない。

DBPASS=password
environment:
 - MYSQL_ROOT_PASS: ${DBPASS}
env_file:
 - .env.prod

空文字が設定されちゃいます。

Mysqlのパスワードが更新されない

既にvolumeがマウントされて起動済みだと後からMYSQL_ROOT_PASSを変更してもパスワードは変更されません。

参考リンク

https://github.com/SteveLTN/https-portal

https://hub.docker.com/_/mysql

RailsのDockerイメージを一番小さくする方法