こんな感じでRailsコンテナーを作ると良いのではないか？

### やってみたこと

RailsでWebサービスを行うプロジェクトに参画したがどんな風にプロダクション用のDocker化をするのが良いか勉強がてらやってみた。

### これを書いてる人のスペック

ここ10年ぐらいの RubyとRailsの文化を知らない。出始めにちょっと触ったことはあるが、すごく流行っていた時期にはほとんど触れる機会がなく、今更だけどRailsを勉強中。

- 実は[RailsによるアジャイルWebアプリケーション開発](https://www.amazon.co.jp/dp/4274066401)の初版本は読んでいた。
- プロダクションレベルでRailsアプリの開発運用経験なし。(ゼロ年代にPoCや簡易ツールの為には利用あり)
- [gem](https://rubygems.org/profiles/hkato)パッケージを一つだけ登録したことがあるがゼロ年代の話(対象Adobeのサービスは終了済み)。
- Railsで書かれているRedmineからGitLabの[プロジェクト移行ツール](https://github.com/redmine-gitlab-migrator/redmine-gitlab-migrator)へのコントリビュートはしたが、このツール自体はRubyでなくPythonで書かれている。
- 現在Railsで書かれたコードを読んでいるが、`webpacker`(使われなくなるらしいが)とか`sidekiq`とかその他便利な周辺ツールが盛り込まれていることを最近知った。
- AWSやAzureでPythonによるバックエンドやETL処理を書きTerraform/Serverless Frameworkでインフラを作る仕事がここ最近のメイン。CI/CDパイプラインやDockerfileなども書く。

### 環境構成

- 開発環境: M1 Mac
- Ruby 3.0 + Rails 6 + Node.js v16(LTS)
   - Ruby 3.1 は 3.2で廃止予定のメソッドをRails側が対応していないようで警告が出たり、標準ライブラリーのgem化があったり…。なので2.6->2.7->3.0->3.1で試したが、無難そうな3.0に落ち着いた
   - Rails 7 は webpackerの非標準化などの対応が面倒なので Rails 6 にした
   - Ruby 2.6, 2.7 + Rails 6 でも大体同じ感じで動くとおもう(最初は2.6,2.7で試していた)
   - Node.jsを入れてる通り、APIモードではなく、WebUI付きでWebPackしたいから

### Dockerfile例

#### 概要

- マルチステージビルドで構築する
- 余計なものはなるべく入れない
- Alpineは小さくなるがCライブラリーがmuslの件もあるので避けた(Rubyでの状況は知らないが他の言語で面倒なことがあったので)

#### ビルド

##### ビルド用イメージ準備

まずビルド用のイメージとして、nodeイメージからnode/yarnを持ってきて、rubyイメージのbuild-essential入りと合体させる。これで、gcc/g++などでnative extensionsをビルドでき、yarnでnpmパッケージを取得しトランスパイル等が可能になる。

```dockerfile
FROM node:16.16.0-slim as node
FROM ruby:3.0.4 as builder

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn-* /opt/yarn
RUN ln -fs /opt/yarn/bin/yarn /usr/local/bin/yarn
```

##### ビルド実行

- gemのインストール
    - `bundle install`にて`/usr/local/bundle/`へgemパッケージ群をインストール
    - C言語等で書かれたnative extentionsはビルドされる
- JavaScriptとCSSなどの作成
    - `webpacker:compile`と`assets:precompile`にて`public/packs`,`public/assets`へトランスパイルと最小化されたJavaScriptやCSSなどがインストールされる

```dockerfile
WORKDIR /src

# Copy source code
COPY . /src

# Ruby gem packages
RUN bundle install

# Node.js packages & webpack
RUN yarn install && \
    bin/rails webpacker:compile && \
    bin/rails assets:precompile
```
#### 実行に必要なものをまとめる 

Rubyコードと設定、上記でビルドされたJavaScript/CSSを一旦`/dist`にまとめる。builderイメージからそれぞれをコピーしても良いが、後述の実際のイメージにコピーする際にまとめておいた方が楽なのでこうした。

```dockerfile
# Create runtime distribution
RUN mkdir -p /dist && \
    cp -pr Gemfile Gemfile.lock Rakefile config.ru app bin config db lib public \
    /dist/
```

#### 実行イメージ

##### OS・パッケージ基本環境

ビルド時のイメージではgcc/g++などの開発ツール入りのイメージを利用したが、実行時にコンパイラーなどの開発ツールは不要だし、JavaScript/CSSも変換済みなのでnode/yarnも不要、`ruby:x.y.z-slim`のRubyだけの`slim`イメージを利用する。

また、SQLite3, MySQL, PostgreSQLが主に使われる3大データベースだと思うが、それぞれのgemにはネイティブのライブラリーが必要になる。また、mysqlやpsqlなどのCLIでSQLのアクセスを行う必要があるならmysql-client, postgresql-clientなを入れる必要がある。(APKやRPM系は調べてません)。まあ、ここはケチって`lib*-dev`でなく`*-client`を入れれば良いですかね？

|Database|gem|Library (apt)|SQL client (apt)|
|--------|---|-------------|----------------|
|SQLite3|sqlite3|libsqlite3-dev|sqlite3|
|MySQL|mysql2|default-libmysqlclient-dev|default-mysql-client|
|PostgreSQL|pg|libpq-dev|postgresql-client|

とりあえず単純なWeb+DBのプロジェクトで試したが、その他のネイティブライブラリーを参照するgemを使ったプロジェクトでは`libfoo-dev`などのインストールも必要。

```dockerfile
FROM ruby:3.0.4-slim

RUN apt-get update && apt-get install -y \
    libsqlite3-dev \
    default-libmysqlclient-dev \
    libpq-dev # sqlite3 postgresql-client default-mysql-client
```

##### ビルド済みのものをコピー

上に書いた通り、builderイメージの`/usr/local/bundle`にgem群、`/dist`にRubyコードや設定ファイル、変換済みJavaScript/CSSがあるので、こちらのイメージにコピーし、`tmp`や`log`などの空ディレクトリーを作成しておく。

```dockerfile
WORKDIR /app
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /dist /app
RUN mkdir log storage tmp
```

##### エントリーポイントとサーバー実行

ここは、Docker公式ドキュメントある通りにしてみる。(`tmp/pids/server.pid`を消す`entriypoint.sh`)

https://docs.docker.com/samples/rails/


```dockerfile
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["/app/bin/rails", "server", "-b", "0.0.0.0"]
```

#### Dockerfileまとめ

こんな感じか？ nodeとrubyのバージョンは適宜変更し、ライブラリーのパッケージも適宜追加変更。

```dockerfile
#
# Builder image
#   = Ruby + build-essential(gcc/g++) for native extensions
#   + Node.js for webpack
#
FROM node:16.16.0-slim as node
FROM ruby:3.0.4 as builder

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn-* /opt/yarn
RUN ln -fs /opt/yarn/bin/yarn /usr/local/bin/yarn

WORKDIR /src

# Copy source code
COPY . /src

# Ruby gem packages
RUN bundle install

# Node.js packages & webpack
RUN yarn install && \
    bin/rails webpacker:compile && \
    bin/rails assets:precompile

# Create runtime distribution
RUN mkdir -p /dist && \
    cp -pr Gemfile Gemfile.lock Rakefile config.ru app bin config db lib public \
    /dist/

#
# Runtime image
#
FROM ruby:3.0.4-slim

RUN apt-get update && apt-get install -y \
    libsqlite3-dev \
    default-libmysqlclient-dev \
    libpq-dev # sqlite3 postgresql-client default-mysql-client

WORKDIR /app
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /dist /app
RUN mkdir log storage tmp

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["/app/bin/rails", "server", "-b", "0.0.0.0"]
```

### その他

#### 運用向け環境変数

- `RAILS_ENV`: development, productionなどの環境を渡す。
- `RAILS_SERVE_STATIC_FILES`: 開発環境ではnode/yarnでJavaScript/CSSをその場でビルドする様だが、上記の様にproduction用実行環境には入れないと思うので、この値を`true`にすることにより、事前生成済みのファイルを配信する。
- `RAILS_LOG_TO_STDOUT`: productionだとデフォルトでログを吐かないし、コンテナ内の`log`に吐かれても困るので標準出力に出したい。この値を`true`にする。
- `DATABASE_URL`: コンテナ内の`config/database.yml`に`username` `password`などを直接クレデンシャル情報を記述するのも良くないのでこちらで渡す(`database.yml`よりも環境変数が優先)
  - MySQL: `mysql2://username:password@mysql:3306/database?encoding=utf8mb4` (`mysql`でなくgemの`mysql2`であることに注意)
  - PostgreSQL: `postgres://username:password@postgres:5432/database?encoding=unicode`

#### bundle -> Gemfile.lock

M1/M2 Macなどarm64アーキテクチャで開発するが、実行環境はx86_64/amd64ということが多いと思う。すると次のようなlockファイルになってしまうので、

```ruby
...
    nokogiri (1.13.8-arm64-darwin)
...
PLATFORMS
  arm64-darwin-21
```
```sh
$ bundle config set force_ruby_platform true
```
を実行し(`~/.bundle/config`に反映)。プラットフォームをrubyに強制するとCPUアーキテクチャの情報が入らないのでDocker環境には良いと思われる。

```ruby
...
    nokogiri (1.13.8)
...
PLATFORMS
  ruby
```

### その他

あと、なんかヒントになることあったら教えてください。