DockerでRails環境(Nginx+Unicorn+MySQL)を構築してFargateへデプロイするまで〜開発環境編〜 

# 概要
RailsアプリをECS Fargateにデプロイしようとしたところ、思っていた以上に苦労したので、自分のアウトプット＆備忘録を兼ねて執筆しました。ECSのイメージとして、「開発環境で作成したイメージをそのままデプロイすればOK☆＼(^o^)／」だったのですが、そう簡単にはいきませんでした。今回は開発環境編です。


# 環境
- M1mac
- Visual Studio Code
- Docker(20.10.17)


# 構成
今回、**Dockerfileを開発環境と本番環境で分けるか否か**でとても悩みました。環境毎で動きが異なる部分があるため、分けた方が正直楽でした。しかし同時に「環境差分も生まれてしまう」こと、「DRYの原則に乗っ取っていない」ことから、Dockerfileは環境統一を図っています。実務ではどうなのでしょうか。。
```
.(各Railsファイル)
├── config
|   └──　datebase.yml
|   └──　unicorn.rb
├── nginx
|    ├── Dockerfile
|    └── nginx.conf
├── docker-compose.dev.yml
├── （docker-compose.prd.yml）←次回作成
├── Dockerfile
├── entrypoint.sh
├── Gemfile
└── Gemfile.lock
```


# 設定手順
[1. Rails用Dockerfile&docker-compose.dev.ymlの作成](#1-rails用dockerfiledocker-composedevymlの作成)
[2. Railsのセットアップ](#2-railsのセットアップ)
[3. 各設定ファイルの編集](#3-各設定ファイルの編集)
[4. Rails用Dockerfile&docker-compose.dev.ymlの編集](#4-rails用dockerfiledocker-composedevymlの編集)
[5. RailsのWebアプリ作成](#5-railsのwebアプリ作成)


## 1. Rails用Dockerfile&docker-compose.dev.ymlの作成
まずは、アプリのための新しいフォルダと必要なファイルを作成していきます。
ここではデスクトップ配下にフォルダを作成していきます。
```terminal:terminal
cd ~/Desktop
mkdir myapp-miguchi && cd myapp-miguchi
touch Dockerfile docker-compose.dev.yml Gemfile Gemfile.lock
```
それぞれのファイルの作成が完了したら、順番にファイルの中身を記述していきます。


### Dockerfile
```dockerfile:Dockerfile
FROM --platform=linux/x86_64 ruby:3.1

#環境変数
ENV APP="/myapp-miguchi"  \
    CONTAINER_ROOT="./" 

RUN apt-get update && apt-get install -y \
        nodejs \
        mariadb-client \
        build-essential \
        wget \
        yarn

WORKDIR $APP
COPY Gemfile Gemfile.lock $CONTAINER_ROOT
RUN bundle install
```

`FROM`でベースとなるイメージを指定するのですが、ここで`--platform=linux/x86_64`を記述し、CPUアーキテクチャを指定しています。M1Macではrubyのイメージをインストールする際、`--platform=linux/arm64`で作成されます。ローカルでコンテナを動かす際は問題ないのですが、デプロイ先のECSでは対応していないため、ここで指定しています。詳しくは以下の記事をご参照ください。

参考記事：
[M1 Mac の基礎知識](https://zenn.dev/suzuki_hoge/books/2021-12-m1-docker-5ac3fe0b1c05de/viewer/2-arm)

1番下と下から2番目の記述では、Gemfileが新しくなった時だけ、bundle installが実行されるようになっています。

### Gemfile
```ruby:Gemfile
source "https://rubygems.org"
gem "rails", "~> 7.0.3"
```

### docker-compose.dev.yml
```yaml:docker-compose.dev.yml
version: '3'

services:
  web:
    build: .
    tty: true
    stdin_open: true
    ports:
      - '3000:3000'
    volumes:
      - .:/myapp-miguchi #任意のアプリ名
```
開発環境ではローカルにあるファイルをコンテナにマウントして使用したかったので、volumesでマウントさせています。コンテナをマウントさせる利点としては、「コマンドを実行時のみファイルを使用するため、コンテナが大きくならない」こと、「コンテナ内での変更をそのままローカルファイルに反映させることができる」ことだと理解しています。

以上、3つのファイルの編集が完了したら、以下のコマンドでコンテナを立ち上げます。

```terminal:terminal
docker-compose -f docker-compose.dev.yml up -d
```


## 2. Railsのセットアップ
先ほどdocker-composeで構築したwebコンテナの中に入っていきます。
```terminal:terminal
docker-compose -f docker-compose.dev.yml exec web bash
```
次に、このWebコンテナに新規のRailsプロジェクトを生成していきます。
```terminal:terminal（コンテナ内）
rails new . --force --database=mysql --skip-bundle
```
データベースにはMySQLを使用するため、`--database`で指定しています。
bundle installはコンテナ起動時に行ってもらうため、ここではスキップしています。

各Railsアプリのファイルが追加され、Gemfileも更新されます。
Gemfileが更新された後、後ほど設定するunicornのgemを追加しておきます。
```diff:Gemfile
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby "3.1.2"
+ gem "unicorn"

~以下省略~
```
完了したら、一度コンテナから出ます。
```terminal:terminal（コンテナ内）
exit
```


## 3. 各設定ファイルの編集
ここでは3つの種類のファイルを編集していきます。
[database.ymlの編集](#databaseymlの編集)
[Nginx用Dockerfile&設定ファイルの作成](#nginx用dockerfile設定ファイルの作成)
[Unicorn用設定ファイルの作成](#unicorn用設定ファイルの作成)

### database.ymlの編集
database.ymlは先ほどのrails newでconfigディレクトリ配下に生成されるため、
そちらのファイルを編集していきます。
```yaml:config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: <%= ENV.fetch("DB_USERNAME", "root") %>
  password: <%= ENV.fetch("DB_PASSWORD", "password") %>
  host: <%= ENV.fetch("DB_HOST", "db") %>

development:
  <<: *default
  database: myapp_miguchi_development

test:
  <<: *default
  database: myapp_miguchi_test

production:
  <<: *default
  database: <%= ENV["DB_DATABASE"] %>
  username: <%= ENV["DB_USERNAME"] %>
  password: <%= ENV["DB_PASSWORD"] %>
  host: <%= ENV['DB_HOST'] %>
```
defaultの値には`ENV.fetch`を使用しています。環境変数に値が入っていない場合は、第2引数の値(`username`の場合は`root`)が返されるようになっています。今回は後ほどdocker-composeを再編集する際に、環境変数を記載します。
productionの環境変数は本番環境であるAWSで設定します。（本番環境編）

参考記事：
[ENV[]とENV.fetch()の違い【Rails/Ruby】](https://qiita.com/keisukee/items/576f109d57218c8397d5)



### Nginx用Dockerfile&設定ファイルの作成
Nginxの設定では、Nginx用のDockerfileとnginx.confを作成していきます。
```terminal:terminal
cd ~/Desktop/myapp-miguchi
mkdir nginx && cd nginx
touch Dockerfile nginx.conf
```
```Dockerfile:nginx/Dockerfile
FROM --platform=linux/x86_64 nginx:stable
#デフォルトのnginxファイルを削除して作成したものコンテナ内にコピー
RUN rm -f /etc/nginx/conf.d/*
#自分のapp名.confを記述
COPY nginx.conf /etc/nginx/conf.d/myapp-miguchi.conf
#-c以降の設定ファイルを指定して起動 daemon offでフォアグラウンドで起動
CMD /usr/sbin/nginx -g 'daemon off;' -c /etc/nginx/nginx.conf
```
Nginx用DockerfileもRails用同様にCPUアーキテクチャを指定しています。

```conf:nginx/nginx.conf
upstream unicorn {
  #ユニコーンソケットの設定
  server unix:/myapp-miguchi/tmp/sockets/.unicorn.sock fail_timeout=0;
}

server {
  #IPとポートの指定
  listen 80 default;
  #サーバーネームの指定
  server_name localhost;
　　　　#アクセスログとエラーログの出力先
  access_log /var/log/nginx/access.log;
  error_log  /var/log/nginx/error.log;
  #ドキュメントルートの指定
  root /myapp-miguchi/public;

  client_max_body_size 100m;
  error_page 404             /404.html;
  error_page 505 502 503 504 /500.html;
  try_files  $uri/index.html $uri @unicorn;
  keepalive_timeout 5;

  location @unicorn {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_pass http://unicorn;
  }
}
```
NginxとUnicornの接続方法はソケット通信を採用しています。そのため最初の`upstream unicorn`において、Unicornと接続するファイルの場所を指定しています。

参考記事：
[UnicornとNginxの接続方法は、UNIXドメインソケットとリバースプロキシの2つの方法がある](https://blog.kasei-san.com/entry/2020/09/02/175828)

### Unicorn用設定ファイルの作成
Unicornの設定では、config配下に設定ファイルを作成していきます。
```terminal:terminal
cd ~/Desktop/myapp-miguchi/config
touch unicorn.rb
```
```ruby:unicorn.rb
# frozen_string_literal: true

#ワーカーの数。後述
$worker = 2
#何秒経過すればワーカーを削除するのかを決める
$timeout = 30
#自分のアプリケーション名。
$app_dir = '/myapp-miguchi'
#リクエストを受け取るポート番号を指定。後述
$listen = File.expand_path 'tmp/sockets/.unicorn.sock', $app_dir
#PIDの管理ファイルディレクトリ
$pid = File.expand_path 'tmp/pids/unicorn.pid', $app_dir
#エラーログを吐き出すファイルのディレクトリ
#$std_log = File.expand_path 'log/unicorn.log', $app_dir

#上記で設定したものが適応されるよう定義
worker_processes  $worker
working_directory $app_dir
stderr_path $std_log
stdout_path $std_log
timeout $timeout
listen  $listen
pid $pid

#ホットデプロイをするかしないかを設定
preload_app true

#fork前に行うことを定義。後述
before_fork do |server, _worker|
  defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.disconnect!
  old_pid = "#{server.config[:pid]}.oldbin"
  if old_pid != server.pid
    begin
      Process.kill 'QUIT', File.read(old_pid).to_i
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end
end

after_fork do |server, worker|
  defined?(ActiveRecord::Base) and ActiveRecord::Base.establish_connection
end
```
Unicornの設定ファイルにおいては、アプリをデプロイしていく中でlogの出力先に悩みました。上記ファイルでは`エラーログを吐き出すファイルのディレクトリ`の設定をコメントアウトしています。最初ログの出力先を指定していたのですが、本番環境においてログを見ることができませんでした。どうやら標準出力させる必要があるようで、コメントアウトすることでログを出力させることができたため、今はこの設定に落ち着いています。。

参考記事：
[DOCKERでUNICORNを動かすのにログを標準出力する](https://wiki.kazuma.tokyo/docker%E3%81%A7unicorn%E3%82%92%E5%8B%95%E3%81%8B%E3%81%99%E3%81%AE%E3%81%AB%E3%83%AD%E3%82%B0%E3%82%92%E6%A8%99%E6%BA%96%E5%87%BA%E5%8A%9B%E3%81%99%E3%82%8B/)
[ECS で Amazon CloudWatch Logs にログ出力が出来るようになったのでチュートリアル](https://iret.media/20770)


## 4. Rails用Dockerfile&docker-compose.dev.ymlの編集
ここからは、Railsアプリをlocalhostで表示、本番環境へデプロイしていくために、Dockerfileとdocker-composeに記述を加えていきます。
```Dockerfile:Dockerfile
FROM --platform=linux/x86_64 ruby:3.1

#環境変数
ENV APP="/myapp-miguchi"  \
        CONTAINER_ROOT="./" 

#ライブラリのインストール
RUN apt-get update && apt-get install -y \
        nodejs \
        mariadb-client \
        build-essential \
        wget \
        yarn 

#実行するディレクトリの指定
WORKDIR $APP
COPY Gemfile Gemfile.lock $CONTAINER_ROOT
RUN bundle install
#↓懸念点（開発環境ではCOPYをしたくないが、本番環境でする必要がある）
COPY . .

#DB関連の実行
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

#nginxコンテナからrailsコンテナの以下のファイルをマウントすることでソケット通信を可能にする
VOLUME ["/myapp-miguchi/public"]
VOLUME ["/myapp-miguchi/tmp"]

#railsアプリ起動コマンド
CMD ["unicorn", "-p", "3000", "-c", "/myapp-miguchi/config/unicorn.rb", "-E", "$RAILS_ENV"]
```
環境毎で動きが異なる、`COPY`の部分。開発環境ではローカルからファイルをマウントできるため、この記述は必要ありません。しかし、本番環境ではローカルからファイルをマウントすることができないため、コンテナ内へとファイルをコピーする必要があります。[docker-compose.dev.yml](#docker-composedevyml)で言っていたvolumeの利点を活かせていませんが、苦肉の策です。。

railsアプリ起動コマンドでは、環境を指定する部分を`$RAILS_ENV`と環境変数を置くことで、開発環境は`devlopment`、本番環境は`production`で起動させられるようにしています。



```yaml:docker-compose.dev.yml
version: '3'

services:
  web:
    build:
      context: .
    tty: true
    stdin_open: true
    ports:
      - '3000:3000'
    volumes:
      - .:/myapp-miguchi #任意のアプリ名
    depends_on:
      - db
    links:
      - db
    environment:
      RAILS_ENV: development
      DB_USER: root
      DB_PASSWORD: root
      DB_HOST: db

  db:
    platform: linux/x86_64 #M1チップ対応のため追記
    restart: always
    image: mysql:8
    ports:
      - 3306:3306
    volumes:
      - mysql-data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      TZ: Asia/Tokyo

  nginx:
    build:
      context: ./nginx
    ports:
      - 80:80
    restart: always #明示的にstopさせるまでリスタートする。（失敗するたび遅延あり）
    depends_on:
      - web 

volumes:
  mysql-data:
```
docker-composeは、主にDB、Nginxコンテナに関する記載と、環境変数`environment`などを追記しています。

最後に、pid削除とDB作成を実行する、entrypoint.shを作成していきます。
```terminal:terminal
cd ~/Desktop/myapp-miguchi
touch entrypoint.sh
```

```sh:entrypoint.sh
#!/bin/bash
set -e

#pidの削除＆ディレクトリの作成
rm -f tmp/pids/server.pid
mkdir -p tmp/sockets
mkdir -p tmp/pids

#DBコンテナが起動するまで待機する処理
until mysqladmin ping -h $DB_HOST -P 3306 -u root --silent; do
  echo "waiting for mysql..."
  sleep 3s
done
echo "success to connect mysql"

#DB作成コマンド
bundle exec rails db:create
bundle exec rails db:migrate
bundle exec rails db:migrate:status
bundle exec rails db:seed
#本番環境のみ実行したいが、現状開発環境でも実行されてしまう。
bundle exec rails assets:precompile RAILS_ENV=production

#Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```
ここで1番行いたかったことは、`DBコンテナが起動するまで待機する処理`です。アプリ作成当初、RailsとMySQLのコンテナを立ち上げ、`db:create`を行う際、MySQLに接続できないという事象が発生しました。その事象の発生原因は「DBコンテナは立ち上がっているが、起動に時間がかかる」ことでした。MySQLが起動中で接続できないのに、Railsコンテナ側が`db:create`しに行こうとしていたんですね。そのため、「MySQLに接続できることが確認できたら、`db:create`を行う」処理をしています。
`assets:precompile`は本番環境のみ必要なのですが、現状開発環境でも実行されてしまいます。特に影響はありませんが、ここも要改善です。

参考記事：
[DockerCompose上のRailsでMySQLの起動を待ってdb:migrateする](https://qiita.com/k_tada/items/66c872104beabcfb4340)
[【Rails】本番環境におけるアセットプリコンパイルの設定](https://qiita.com/aiandrox/items/408724ab8a4482fb5873)

編集が完了したら、localhostでRailsのトップ画面を表示させてみます。

```terminal:terminal
docker-compose -f docker-compose.dev.yml up --build -d
```
コンテナの構築状況やログはDockerデスクトップで確認できます。
![DockerDesktop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2131579/07228491-0c21-b46b-e2e2-8f45e1b40d98.png)
コンテナ構築後、localhostで表示されれば、成功です！
http://localhost:3000/
![rails-top.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2131579/f50070c5-a64e-a3fb-f256-d0202790c477.png)


## 5. RailsのWebアプリ作成
ここからは、簡単なサンプルアプリを作成し、localhostで確認してみます。
先ほどdocker-composeで再構築した、webコンテナの中に入ります。
```terminal:terminal
docker-compose -f docker-compose.dev.yml exec web bash
```
アプリ生成コマンドを実行します。
```terminal:terminal（コンテナ内）
rails g scaffold product name:string price:integer vender:string
```
生成が完了したら、コンテナから出ます。
```terminal:terminal（コンテナ内）
exit
```
docker-composeでコンテナを立て直します。
```terminal:terminal
docker-compose -f docker-compose.dev.yml up --build -d
```
コンテナの立て直しが完了したら、localhostにアクセスします。
以下の画面が表示されれば、成功です！
http://localhost:3000/products
![products.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2131579/0f9f8008-e4b7-e830-69d3-24113234bee9.png)
CRUD処理も正常に行われています。
![products for fix.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2131579/74545368-d340-962e-34da-a2815d977686.png)
今回は以上で終了です。
次回はこのアプリをECSにデプロイし、IPアドレスで表示させます。（記事作成中）


# 参考記事等
Dockerの学習：
非常に分かりやすく、オススメのDocker講座です。

https://www.udemy.com/course/aidocker/

参考記事：
[M1 Mac の基礎知識](https://zenn.dev/suzuki_hoge/books/2021-12-m1-docker-5ac3fe0b1c05de/viewer/2-arm)
[ENV[]とENV.fetch()の違い【Rails/Ruby】](https://qiita.com/keisukee/items/576f109d57218c8397d5)
[UnicornとNginxの接続方法は、UNIXドメインソケットとリバースプロキシの2つの方法がある](https://blog.kasei-san.com/entry/2020/09/02/175828)
[DOCKERでUNICORNを動かすのにログを標準出力する](https://wiki.kazuma.tokyo/docker%E3%81%A7unicorn%E3%82%92%E5%8B%95%E3%81%8B%E3%81%99%E3%81%AE%E3%81%AB%E3%83%AD%E3%82%B0%E3%82%92%E6%A8%99%E6%BA%96%E5%87%BA%E5%8A%9B%E3%81%99%E3%82%8B/)
[ECS で Amazon CloudWatch Logs にログ出力が出来るようになったのでチュートリアル](https://iret.media/20770)
[DockerCompose上のRailsでMySQLの起動を待ってdb:migrateする](https://qiita.com/k_tada/items/66c872104beabcfb4340)
[【Rails】本番環境におけるアセットプリコンパイルの設定](https://qiita.com/aiandrox/items/408724ab8a4482fb5873)
[DockerCompose上のRailsでMySQLの起動を待ってdb:migrateする](https://qiita.com/k_tada/items/66c872104beabcfb4340)
[【Rails】本番環境におけるアセットプリコンパイルの設定](https://qiita.com/aiandrox/items/408724ab8a4482fb5873)