【Docker】Rails6/MySQLのコンテナを作って開発環境を構築

## 背景

これまでローカル/Railsサーバーの開発環境で開発を進めていたが、本番環境(EC2)との差分により環境依存が発生。そこで、Dockerを用いてより本番環境に近い開発環境を構築することで環境依存に取られる時間を削減しようと考え実装しました。


### 開発環境

Ruby 2.7.2
Rails 6.1
MySQL 5.7
Docker for Mac
Nginx
Puma
Supervisor


## docker開発環境


```:ターミナル
$ mkdir docker/dev
```
まず、アプリのroot配下にdockerフォルダ、その配下にdevフォルダを作ります。

このdev配下にdocker関連のファイルを作成していきます。

```:ディレクトリ構造
root
├ docker
     └ dev
        ├ app
           ├ nginx
               ├ dev.ドメイン名.conf
               └ nginx.conf
           ├ supervisor
               └ app.conf
           └ Dockerfile

        ├ mysql
           ├ Dockerfile
           └ mysql.cnf

        └ docker-compose.yml

├ config ─ dev_puma.rb
└ .env
```
最終的には上記のようなディレクトリ構造になります。
(アプリ自体のディレクトリは省略)


## appコンテナ

#### Nginx
```conf:app/nginx/dev.ドメイン名.conf
upstream puma {
     server unix:/root/tmp/puma.sock;
}
server {
  listen 80;
  server_name dev.ドメイン名;
  access_log /var/log/nginx/dev.access.log;
  error_log  /var/log/nginx/dev.error.log;
  root /var/www/app/public;

location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Client-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 60;
        proxy_read_timeout    60;
        proxy_send_timeout    60;
        send_timeout          60;
        
        proxy_pass http://puma;
 }
  client_max_body_size 100m;
  error_page 404             /404.html;
  error_page 505 502 503 504 /500.html;
  try_files  $uri/index.html $uri @app;
  keepalive_timeout 5;
}
```
```conf:app/nginx/nginx.conf
user  root;
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
log_format main '[nginx]\t'
                  'time:$time_iso8601\t'
                  'server_addr:$server_addr\t'
                  'host:$remote_addr\t'
                  'method:$request_method\t'
                  'reqsize:$request_length\t'
                  'uri:$request_uri\t'
                  'query:$query_string\t'
                  'status:$status\t'
                  'size:$body_bytes_sent\t'
                  'referer:$http_referer\t'
                  'ua:$http_user_agent\t'
                  'forwardedfor:$http_x_forwarded_for\t'
                  'reqtime:$request_time\t'
                  'apptime:$upstream_response_time\t';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    tcp_nopush     on;
    keepalive_timeout  65;
    gzip  on;
	include /etc/nginx/conf.d/*.conf;
}
```

Nginxのファイルです。
内容については以下の記事をご覧ください。

参考：[Nginx設定ファイル](https://qiita.com/vit_udon_husqy/items/8b488fd552a3a7a1ac64#nginx%E8%A8%AD%E5%AE%9A%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB)

logについてはわかりやすいように「dev.」を頭につけたファイルにしています。
開発環境ではソケットファイルをroot配下に置いています。マウントすると権限等による不具合発生とMacはファイルシステムがLinuxと比較すると違うため、遅延を防ぐためです。

#### Supervisor

```conf:app/supervisor/app.conf
[supervisord]
nodaemon=true
[program:app]
command=bundle exec puma -C config/dev_puma.rb -e development
autorestart=true
stopsignal=TERM
user=root
directory=/var/www/app/
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stopsignal=TERM
user=root
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
Supervisorはプロセス管理、Daemon化、永続化を役割としています。
SupervisorはDockerでのプロセス管理する上でベストプラクティスとして[Docker社の公式](https://docs.docker.jp/engine/admin/using_supervisord.html)で紹介されています。今回はpumaやNginxの起動を担当。
したがって、コンテナ内でpumaやNginxを再起動したい場合重宝するので必須になるかと思います。

#### Dockerfile/appコンテナ

```Dockerfile:app/Dockerfile
FROM ruby:2.7.2
ENV APP_ROOT /var/www/app
WORKDIR $APP_ROOT

RUN mkdir -p /root/tmp

RUN curl https://deb.nodesource.com/setup_12.x | bash
RUN curl https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

RUN apt-get update -y && \
    apt-get upgrade -y && \
    apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    locales \
    vim \
    nginx \
    supervisor \
    nodejs \
    yarn \
    mariadb-client && \
    rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/*

# setting locales
RUN localedef -f UTF-8 -i en_US en_US.UTF-8

# Setup UTC+9
RUN cp -p /etc/localtime /etc/localtime.UTC \
    && cp -p /usr/share/zoneinfo/Japan /etc/localtime

RUN gem install bundler 

# yarn install Preparation
RUN npm install n -g
RUN n 12.18.4

## nginx
RUN groupadd nginx
RUN useradd -g nginx nginx
ADD nginx/nginx.conf /etc/nginx/nginx.conf
ADD nginx/dev.ドメイン名.conf /etc/nginx/conf.d/dev.ドメイン名.conf

## supervisor
RUN mkdir -p /var/log/supervisor
ADD supervisor/app.conf /etc/supervisor/conf.d/app.conf

EXPOSE 80
CMD ["/usr/bin/supervisord"]
```
|インストラクション|説明|
|---|---|
|FROM|ベースとなるイメージ。今回はruby:2.7.2とmysql:5.7|
|ENV|環境変数。 `ENV 変数名=値`で定義。今回は=を省略した書き方。|
|WORKDIR|この命令文以下の命令文はWORKDIRで指定したディレクトリで実行される。今回はENVで定義されたAPP_ROOT(=/var/www/app)|
|RUN|実行コマンド。`RUN <コマンド>`や`RUN <実行ファイル>`の形で書く。|
|ADD|ADDはファイルを追加する命令。`ADD <追加元> <追加先>`や`ADD ["<追加元>",... "<追加先>"]`という形で書く。|
|EXPOSE|コンテナ実行時にリッスンするポートやプロトコルをDockerに通知する命令。`EXPOSE 80/TCP`という形で記述。プロトコルは省略するとデフォルトでTCPになる。|
|CMD|コンテナの初期設定の命令。CMD命令はDockerfileの中で１つだけ指定できる。仮に複数書いた場合は最後のものが採用される。今回はsupervisordを指定することで、管理対象のファイル一緒に実行している。|

余談だが、Docker imageにはLayerという概念があり、Layerが増えるとimage(コンテナの容量)が大きくなり作業に影響を与えてしまう。Layerを作るコマンドはRUN, COPY, ADDなのでこれらを使う場合は命令文を`&&`で繋げたり、バックスラッシュで改行することでLayerの節約に繋がる。

参考：[Dockerfileリファレンス](https://docs.docker.jp/engine/reference/builder.html#env)

## MySQLコンテナ

```Dockerfile:mysql/Dockerfile
FROM mysql:5.7

RUN apt-get update && \
  apt-get install -y apt-utils \
  locales && \
  rm -rf /var/lib/apt/lists/* && \
  echo "ja_JP.UTF-8 UTF-8" > /etc/locale.gen && \
  locale-gen ja_JP.UTF-8

ENV LC_ALL ja_JP.UTF-8
ADD mysql.cnf /etc/mysql/mysql.cnf
```

```:mysql/mysql.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
[client]
default-character-set=utf8mb4
```
命令文は割愛するが、DockerfileについてはAPPコンテナと同じ要領で書く。


## docker-compose.yml

```yaml:docker/dev/docker-compose.yml
version: '2'
services:
  app:
    container_name: アプリ名-app
    build: ./app/
    image: アプリ名-app
    ports:
      - '80:80'
    volumes:
      - ~/git/github/アプリディレクトリ名:/var/www/app/
    tty: true
    depends_on:
      - db
  db:
    container_name: アプリ名-db
    build: ./mysql/
    image: アプリ名-db
    ports:
      - '3306:3306'
    environment:
      MYSQL_DATABASE: アプリ名_dev
      MYSQL_ROOT_PASSWORD: password
      TZ: "Asia/Tokyo"
    volumes:
      - ./mysql/db_data:/var/lib/mysql
```
|設定|説明|
|---|---|
|version|バージョンは2もしくは3を選択しましょう。詳しくは[公式ドキュメント](https://matsuand.github.io/docs.docker.jp.onthefly/compose/compose-file/compose-versioning/#version-3)を確認。|
|services|サービスの指定。ネストさせてサービス名を指定。今回はappとdb。|
|container_name|コンテナ名(何でも良いです。わかりやすいのにしましょう)今回はapp側は「アプリ名-app」、db側は「アプリ名-db」としました。|
|build|servicesをビルドしたりリビルドしたりする。|
|image|イメージ名|
|ports|ポートのマッピング設定。'ホスト側:コンテナ側'という形で指定し、コンテナ側のポートをホスト側のポートにマッピングする。|
|environment|環境変数|
|volumes|*1|
|tty|コンテナを起動し続ける|
|depends_on|サービス間の依存関係を設定。今回はサービスdbに依存させている。|

*1
>volumes - volume（データの永続化領域）の定義
volume とは、コンテナのライフサイクルが終了した後でもデータを保管しておけるデータ領域です。特徴は以下の通りです。
データの永続化を目的とした機能のため、コンテナが削除されても volume が明示的に破棄されない限り、volume 中のデータは保持される。
volume は、特定のコンテナ専用の volume だけでなく、複数のコンテナ間から参照できる volume も作成できる。
ホスト側のディレクトリを volume としてコンテナ内にマウントできる。
本機能はホストとコンテナ間でファイルを受け渡すときに利用できる。
引用：[さわって理解する Docker 入門](https://www.ogis-ri.co.jp/otc/hiroba/technical/docker/part4.html)

`volumes:
      - ./mysql/db_data:/var/lib/mysql`
こちらはホスト側./mysql/db_dataにデータを保存しています。起動した後にローカルのdocker/deb/mysql配下を見るとわかると思いますが、`docker-compose down`してもMySQLコンテナのデータは削除されません。なのでデータ全体を削除したい場合は上記のディレクトリを削除する必要があります。
ちなみに`gitignore`に上記のディレクトリも追加しないとgitで管理されてしまうので必ずgitignoreに記載してください。


## その他
#### Puma設定ファイル

```Ruby:config/dev_puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"

bind "unix:///root/tmp/puma.sock"

environment ENV.fetch("RAILS_ENV") { "development" }

pidfile ENV.fetch("PIDFILE") { "/root/tmp/server.pid" }

plugin :tmp_restart
```
ここは本番環境と形は同じです。各コードが何を実行しているのか、確認したい場合は以下の記事に記載していますのでご確認ください。
参考： [puma設定ファイル](https://qiita.com/vit_udon_husqy/items/8b488fd552a3a7a1ac64#puma%E8%A8%AD%E5%AE%9A%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB)

先程Nginx設定ファイルでも説明しましたが、開発環境では権限等による不具合の発生、Macのファイルシステムによる遅延の防止の為、ソケットファイルやpidファイルをroot配下に置いています。


#### 環境変数

```ENV:.env.example
GOOGLE_MAP_API_KEY=XXXXXXXXXXXXXXX
AMAZON_S3_ACCESS_KEY_ID=XXXXXXXXXXXXXXX
AMAZON_S3_SECRET_ACCESS_KEY=XXXXXXXXXXXXXXX
AMAZON_S3_BUCKET=XXXXXXXXXXXXXXX
DATABASE_HOST=db
DATABASE_NAME=アプリ名_dev
DATABASE_USER=root
DATABASE_PASSWORD=password
DATABASE_SOCKET=/tmp/mysql.sock
```
私のポートフォリオではGoogleMapsAPIやS3を使っていたため、環境変数に入れています。ご自身のポートフォリオで使っているAPIキーは入れておきましょう。

今回、使っているのがMySQLなので必要な情報を入れています。
HOST名をdbにしておりますが、ここには本来MySQLコンテナのIPアドレスが指定されます。dbとしているのは、docker-compose.ymlでサービス名をdbとしているため、名前解決されMySQLコンテナのIPアドレスを参照して実行できます。


## Docker起動

それでは、dockerを起動して開発環境を構築しましょう。

- git clone (もしまだやっていなかったら)

```
$ mkdir -p ~/git/github
$ cd ~/git/github
$ git clone リモートリポジトリ
```

- add localhost /etc/hosts

```
$ sudo vi /etc/hosts

以下に修正
127.0.0.1 dev.ドメイン名
```

- 環境変数(もしくは自分で環境変数作ってもOKです。)

```
$ cd ~/git/github/Tamari-Ba
$ cp .env.sample .env
```

- docker run

```
$ cd docker/dev
$ docker-compose up -d
```

- app deploy

```
$ docker exec -it appコンテナ名 bash

$ yarn install
$ bundle install
$ rails db:migrate
$ /usr/bin/supervisorctl restart app
```

- Access

`http://dev.ドメイン名/`にアクセスしてブラウザにアプリ画面が表示されていれば成功です！
あとは開発できるか一通り確認しておきましょう。


- DB login

ローカルと同じようにmysqlにログインすることも可能です。

```
docker exec -it appコンテナ名 bash
mysql -u root -h db -p
```

## 注意点
- `docker rm`や`docker rmi`でコンテナ/イメージを削除してもpidは残ります。なので、新たにコンテナを立ち上げたときにmigrate等を失敗すると思います。これは、pidファイルを削除するか、個人ポートフォリオレベルであれば一度レポジトリごと削除してしまえば解消可能です。


- 以下のエラー、もしどこを直しても解消できない場合は右上のdockerアイコンを確認して、起動しているか確認してください。私は全然気が付かず再起動することで解消しましたw

```
ERROR: for コンテナ名  UnixHTTPConnectionPool(host='localhost', port=None): Read timed out. (read timeout=60)

ERROR: for app  UnixHTTPConnectionPool(host='localhost', port=None): Read timed out. (read timeout=60)
ERROR: An HTTP request took too long to complete. Retry with --verbose to obtain debug information.
If you encounter this issue regularly because of slow network conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher value (current value: 60).
```

## まとめ

- Docker環境構築は本番環境とできるだけ環境を近づけることができ、「環境依存」を防げるメリットがある。
- 扱うにはLinux, MySQL, Nginx等のサーバー周りの知識が必要。
- Dockerの公式ドキュメントはわかりやすい




## 参考記事

- [Docker ドキュメント日本語化プロジェクト](https://docs.docker.jp/index.html)
- [さわって理解する Docker 入門](https://www.ogis-ri.co.jp/otc/hiroba/technical/docker/part4.html)
- [Docker視点で見るSupervisorの使い方](https://qiita.com/taka4sato/items/1f59371ead748d88635a)