Ruby on Railsの環境構築をDocker上で"ゼロから"やってみた

# はじめに
この記事では、Ruby on Railsのアプリを開発する際に必要となるDockerfileやcompose.ymlといったファイルを**コピペではなくゼロから**作成した手順を紹介します。環境構築の際に必要な情報はググればたくさんヒットするのですが、なぜそのような書き方をするのかを理解しないままコピペして使うのは個人的に嫌だったため、本記事を執筆するに至りました。

使用するDockerのバージョンは次の通りです。
```sh
docker --version                                              
# Docker version 27.1.1, build 6312585
```

:::note
本記事を参考に環境構築を行う場合、最終的に必要な手順は最終節に書かれているので、お急ぎの方はそちらをご覧ください。
:::

# コンテナ上で試行錯誤してみる
ゼロからいきなりDockerfile、compose.ymlを書くのはさすがに難しいです。そこで、まず最初に、Railsアプリを立ち上げるまでに必要なプロセスをコンテナ上で確認します。

コンテナを立ち上げるためのコマンドは次の通りです。
```sh
docker run -it -p 3000:3000 ruby:3.3.4-slim-bookworm bash
```
ベースイメージのバージョンは本記事執筆時の最新安定版です。今回使用しているベースイメージ`ruby:3.3.4-slim-bookworm`のサイズは約210MBで、slimでない`ruby:3.3.4-bookworm`はサイズが約1GBになります。どちらを使ってもいいのですが、イメージサイズの小さい方がビルドにかかる時間を節約できるので、今回は`ruby:3.3.4-slim-bookworm`をベースイメージとしています。（ただし`ruby:3.3.4-bookworm`をベースイメージにするとこの後のプロセスのいくつかを省略することができます。）

コンテナを立ち上げた時点でrubyとBundlerを使用することができます。
```sh:bash
ruby --version
# ruby 3.3.4 (2024-07-09 revision be1089c8ec) [aarch64-linux]
bundle --version
# Bundler version 2.5.11
```

## `rails` gemをインストールする
まず最初にRailsアプリを作成するディレクトリ`app`を作ります。
```sh:bash
mkdir /app
cd /app
```
続いて`app`ディレクトリで`Gemfile`を作成します。
```sh:bash
bundle init
# Writing new Gemfile to /app/Gemfile
```
`Gemfile`を編集したいので以下の手順でvimをインストールします。
```sh:bash
apt-get update
apt-get install -y vim
```
vimで`Gemfile`の最終行のコメントアウトを解除します。
```ruby:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "rails"
```
この`Gemfile`をもとに`rails` gemをインストールします。
```sh:bash
bundle install
```
実はこのコマンドは失敗します。エラーメッセージの中に、
```
To see why this extension failed to compile, please check the mkmf.log which can be found here:

  /usr/local/bundle/extensions/aarch64-linux/3.3.0/bigdecimal-3.1.8/mkmf.log
```
という記述があるので、このログファイルを見てみます。
```sh:bash
cat /usr/local/bundle/extensions/aarch64-linux/3.3.0/bigdecimal-3.1.8/mkmf.log
# LD_LIBRARY_PATH=.:/usr/local/lib "gcc -o conftest -I/usr/local/include/ruby-3.3.0/aarch64-linux -I/usr/local/include/ruby-3.3.0/ruby/backward -I/usr/local/include/ruby-3.3.0 -I.    -O3 -fno-fast-math -ggdb3 -Wall -Wextra -Wdeprecated-declarations -Wdiv-by-zero -Wduplicated-cond -Wimplicit-function-declaration -Wimplicit-int -Wpointer-arith -Wwrite-strings -Wold-style-definition -Wimplicit-fallthrough=0 -Wmissing-noreturn -Wno-cast-function-type -Wno-constant-logical-operand -Wno-long-long -Wno-missing-field-initializers -Wno-overlength-strings -Wno-packed-bitfield-compat -Wno-parentheses-equality -Wno-self-assign -Wno-tautological-compare -Wno-unused-parameter -Wno-unused-value -Wsuggest-attribute=format -Wsuggest-attribute=noreturn -Wunused-variable -Wmisleading-indentation -Wundef  -fPIC conftest.c  -L. -L/usr/local/lib -Wl,-rpath,/usr/local/lib -L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed     -Wl,-rpath,/usr/local/lib -L/usr/local/lib -lruby  -lm -lpthread  -lc"
# checked program was:
# /* begin */
# 1: #include "ruby.h"
# 2:
# 3: int main(int argc, char **argv)
# 4: {
# 5:   return !!argv[argc];
# 6: }
# /* end */
#
```
どうやらgccコマンドを利用しようとしているようですが、そもそもslimイメージにはgccは入っていません。
```sh:bash
gcc --version
# bash: gcc: command not found
```
そこでgccをインストールします。
```sh:bash
apt-get install -y gcc
```
インストールが完了した後、途中で終わっていた`bundle install`を再度実行します。すると今度は別のエラーが発生します。表示されるエラーメッセージを見ると、
```
make failedNo such file or directory - make
```
の記述があることがわかります。そこでmakeをインストールします。
```sh:bash
apt-get install -y make
```
インストール完了後、再度`bundle install`を実行すると、今度は成功し、`Gemfile.lock`が生成されます。ちなみにインストールされたrailsのバージョンは本記事執筆時の最新版である7.2.0です。

なお今回gccとmakeをインストールしましたが、代わりに[build-essential](https://packages.debian.org/ja/bookworm/build-essential)というパッケージをインストールしても構いません。（そちらの方が一般的かと思います。）このパッケージにはgccやmakeを含む様々なライブラリが含まれています。

## アプリに必要なファイルを`rails new`で用意する
続いてRailsアプリを作成します。今回はDBにMySQLを使用します。（デフォルトではSQLiteです。）
```sh:bash
rails new . --database=mysql
```
するとGemfileを上書きするかの確認を求められるので上書きします。

実はこの時点でもエラーがでます。これは、`rails new`中に自動で実行される`git init`が失敗するからです。
```sh:bash
git --version
# bash: git: command not found
```
そこでgitを入れます。
```sh:bash
apt-get install -y git
```
git自体はrailsに必須のものではないですし、`rails new`の`--skip-git`オプションで`git init`をスキップできます。しかしこのオプションは`.gitignore`の自動生成もスキップしてしまいます。今回はこの`.gitignore`を使用したいので`--skip-git`オプションは用いないことにします。

gitを入れてもう一度`rails new`を実行すると、`bundle install`中にエラーが発生します。これはMySQLクライアントが入っていないためです。エラーメッセージに
```
mysql client is missing. You may need to 'sudo apt-get install libmariadb-dev', 'sudo apt-get install
libmysqlclient-dev' or 'sudo yum install mysql-devel', and try again.
```
とあるので、これを参考にしてMySQLクライアントをインストールします。まず今回はDebianを使用しているので`yum install mysql-devel`は使いません。またMariaDBとMySQLは微妙に違うので
```sh:bash
apt-get install -y libmysqlclient-dev
```
を選択します。しかしこのコマンドを実行すると
```
Package libmysqlclient-dev is not available, but is referred to by another package.
This may mean that the package is missing, has been obsoleted, or
is only available from another source
However the following packages replace it:
  libmariadb-dev-compat libmariadb-dev

E: Package 'libmysqlclient-dev' has no installation candidate
```
とエラーが表示され、MariaDBが提案されてしまいます。`apt-cache search`でパッケージを検索するとlibmysqlclient-devではなくdefault-libmysqlclient-devがヒットします。
```sh:bash
apt-cache search libmysqlclient-dev
# default-libmysqlclient-dev - MySQL database development files (metapackage)
```
このパッケージはMySQLクライアントライブラリの開発用ヘッダーファイルとライブラリを提供するようです。

https://packages.debian.org/bookworm/default-libmysqlclient-dev

ちなみにこのパッケージは[Railsガイド](https://railsguides.jp/development_dependencies_install.html#debian)にも載っています。このパッケージを次のコマンドでインストールします。
```sh:bash
apt-get install -y default-libmysqlclient-dev
```
インストール完了後`bundle install`を実行すると今度は成功します。

## DBサーバーに接続
次のコマンドでサーバーを立ち上げます。
```sh:bash
rails server -b 0.0.0.0
```
この`-b`オプションを使用しないとコンテナ外からアプリにアクセスできません。

https://qiita.com/Masato338/items/f162394fbc37fc490dfb

https://please-sleep.cou929.nu/rails-server-bind-localhost-any.html

http://0.0.0.0:3000 にアクセスするとエラーが表示されます。
```
ActiveRecord::ConnectionNotEstablished (Can't connect to local server through socket '/run/mysqld/mysqld.sock' (2)):
```
これはMySQLサーバーが起動していないためです。そこでMySQL用のコンテナを立ち上げます。先に現在railsの環境構築を行なっているコンテナを止めて`docker commit`します。
```sh
docker commit <rails-container-id> myapp:version1
```
以下のコマンドでMySQLサーバーを立ち上げます。
```sh
docker network create myapp
docker run -d \
  --network myapp --network-alias mysql \
  -e MYSQL_ROOT_PASSWORD=secret \
  mysql:9.0.1-oraclelinux9
```
次にrailsのコンテナを立ち上げます。
```sh
docker run -it -p 3000:3000 \
  --network myapp \
  -e MYSQL_PASSWORD=secret \
  myapp:version1 bash
```
続いてconfig/database.ymlを編集してDBに接続するための情報を追記します。
```sh:bash
cd app
vim config/database.yml
```
```diff_yaml:config/database.yml
# 略
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
- password:
+ password: secret
- host: <%= ENV.fetch("DB_HOST") { "localhost" } %>
+ host: mysql
# 略
```
最後に次のコマンドでDBを作成します。
```sh:bash
rails db:create
# Created database 'app_development'
# Created database 'app_test'
```
その後、
```sh:bash
rails server -b 0.0.0.0
```
でサーバーを起動し、http://0.0.0.0:3000 にアクセスすると、Railsの初期画面が出ます。

![rails_new_app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513731/828b9f1b-0302-9c41-49de-4cb0fd0ebce3.png)


## DBを操作できるようにする
次のコマンドでMySQLサーバーを立ち上げているコンテナに入ります。
```
docker exec -it <mysql-container-id> mysql -u root -p
```
このコンテナ上でDBの一覧を確認すると、先ほど`rails db:create`で作成したDBが表示されます。
```sql:mysql
SHOW DATABASES;
```
```
+--------------------+
| Database           |
+--------------------+
| app_development    |
| app_test           |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
6 rows in set (0.00 sec)
```
ただ通常Railsアプリを開発中にDBを操作するときは`rails db`コマンドを使用します。このコマンドは現時点では使用することができないので、追加で設定が必要です。

まず次のコマンドでRailsのコンテナに入ります。
```sh
docker exec -it -w /app <rails-container-id> bash
```
試しに`rails db`コマンドを叩くとエラーが表示されます。
```sh:bash
rails db
# Couldn't find database client: mysql, mysql5. Check your $PATH and try again.
```
MySQLクライアントが入っていないので、次のパッケージをインストールします。
```sh:bash
apt-get install -y default-mysql-client
```
インストール完了後、`rails db`を実行するとDBに接続することができます。
```sql:MySQL [app_development]
SHOW TABLES;
```
```
+---------------------------+
| Tables_in_app_development |
+---------------------------+
| ar_internal_metadata      |
| schema_migrations         |
+---------------------------+
2 rows in set (0.003 sec)
```
以上でRailsアプリをゼロから立ち上げるために必要なプロセスがわかりました。このプロセスをDockerfileまたはcompose.ymlに書き起こしていきます。

# Dockerfile、compose.ymlを書いてみる
まず最初にDockerfileを用意します。基本的にコンテナ上で行ったコマンドを書き起こせば大丈夫です。
```Dockerfile:Dockerfile
FROM ruby:3.3.4-slim-bookworm

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        gcc \
        make \
        git \
        default-libmysqlclient-dev \
        default-mysql-client && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY Gemfile Gemfile.lock /app/

RUN bundle install

EXPOSE 3000

CMD [ "bash" ]
```
`apt-get install`の`--no-install-recommends`オプションは、あるパッケージをインストールするときに推奨パッケージもインストールされてしまうのを防ぎます。

https://wiki.ubuntu.com/IntrepidReleaseNotes#Recommended_packages_installed_by_default

また`rm -rf /var/lib/apt/lists/*`はインストール時のキャッシュを削除します。これらの記述を追加することでイメージサイズの削減に繋がります。また`apt-get update`と`apt-get install`を一つのRUN句内に記述しているのはパッケージの最新バージョンが正しくインストールされるのを保証するためです。このあたりの話はDockerの公式ガイドに詳しい記述があります。

https://docs.docker.com/develop/develop-images/instructions/#apt-get

またGemfileは以下の内容で用意します。
```ruby:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "rails"
```
Gemfile.lockは空ファイルを用意します。

続いてcompose.ymlを用意します。ここにはdockerコマンドに渡したオプションを明記していくイメージです。
```yaml:compose.yml
x-db-config: &db-config
  MYSQL_ROOT_PASSWORD: secret

services:
  web:
    build: .
    command: rails server -b 0.0.0.0
    ports:
      - "3000:3000"
    volumes:
      - ./:/app
    environment:
      <<: *db-config
    depends_on:
      - db
  
  db:
    image: mysql:9.0.1-oraclelinux9
    volumes:
      - db-data:/var/lib/mysql
    environment:
      <<: *db-config

volumes:
  db-data:
```
ここでローカルのディレクトリとコンテナ上のディレクトリをバインドマウントしているので、コンテナを作成した後ローカルでファイルを編集すれば、その変更がコンテナにも反映されます。そのためDockerfileでvimをインストールする手順は省いています。

これらのファイル（Dockerfile、compose.yml、Gemfile、Gemfile.lock）を用意してコンテナを立ち上げます。まず次のコマンドでRailsアプリに必要なファイルを作成します。
```sh
docker compose run --rm web rails new . --database=mysql --skip-docker
```
`--skip-docker`オプションはDockerfileの上書きと.dockerignoreなどのファイルの生成をスキップします。
このコマンドを実行するとGemfileとGemfile.lockが上書きされるので、それらを元にイメージを作成します。
```sh
docker compose build
```
続いてconfig/database.ymlを編集し、DBに接続できるようにします。
```diff_yaml:config/database.yml
# 略
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
- password:
+ password: secret
- host: localhost
+ host: db
# 略
```
続いてDBを作成します。
```sh
docker compose run --rm web rails db:create
```
以上で完成です。次のコマンドを実行するとWebサーバーとDBサーバーが立ち上がります。
```sh
docker compose up
```
コンテナ上でコマンドを実行するときは、例えば次のように実行すればいけます。
```sh
docker compose exec web rails db
```

# おわりに
ここまで読んでくださりありがとうございました。何か不備があればコメントで教えてください。