開発を加速させるためにDocker設定を見直した話をする

[パーソンリンクアドベントカレンダー](https://qiita.com/advent-calendar/2019/person-link) 3日目の投稿です。

# はじめに

今担当しているプロジェクトでは、フルDockerの開発環境でRailsとVue.jsを使ったWebサービスを開発しています。

昔は個人個人で環境作ったりしていたこともあり、細かなライブラリのバージョン違いで起きるトラブルとか、そういった手順をまとめたりするためのコストとか、いろいろと大変なことも多かったりしました。それが今やDockerさえ入っていればコマンド一発で誰が動かしても同じ環境が作れる。それこそProductionでも大きな変更を入れることなく使えてしまう。大変便利になったものですね！

本日は、そんな便利なDocker開発開発で遭遇したつらみや、どうやって改善したかについて語って行きたいと思います(｀･ω･´)


# どういう課題があったか

自分が今のプロジェクトにJoinした時、すでにDockerを使った開発が実施されていました。
docker-compose.ymlに構成を書いて、それを使って開発が行われていました。構成的にはこんな感じですね。

![docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32363/d96a8f95-713b-0a27-b444-64868ecd1145.png)



Nginxでリクエストを受けて、Rails側にproxyするやつです。あ、図には書き忘れたんですが、RailsはUnicornを使ってリクエストを受け付けるようにしています。まあよく見る構成だと思います。このプロジェクトっではAWSを使ってリリースする予定だったので開発時点からそれに合わせています。


さて、当初自分がJoinしたときは以下の用な問題がありまして、めちゃくちゃ辛いと言うものではないけれど地味に面倒な現象に見舞われてました。

- コンテナに直接入ってmigration, パッケージアップデート
- docker buildすると都度Gemのフルインストールしてしまう
- .envと重複しているenvironment
- 個人ごとにパスを書き直さないといけないdocker-sync
- まれにdbに接続できず落ちるappコンテナ

1つ1つは大したことないのですが、これが積み重なっていくととってもイライラします。

やってられるかー( ﾟдﾟ)
という気持ちから、早めにこの問題を片付けることを誓ったのです。

# どう考えて実施したのか

本来、dockerの特性として「その時のサーバーの状態が固定されており、素早く同じ構成を量産できる」ことがあります。
軽量で高速に起動・停止できることがDockerの一番の強みだと考えていて、特に本番環境で同じ構成を一瞬で増やして早期にスケールできることを狙っているのだと考えています。完成した状態でイメージを作りあげるために柔軟性を捨てています。

一方、開発環境は柔軟でなければいけません。追加され続けるmigration、都度増えていくライブラリ等。Dockerは状態を固定化するものなので、色々と工夫する必要が出てきます。

今回はBtoBで且つ計画メンテが前提のプロジェクトで、Dockerや、Kubernatesを採用する意味が薄かったこともあり、開発環境だけでDockerを使うことだけを考えられるのでシンプルでした。が、ProductionにDockerなりを採用しているところでは、Production用と開発用で異なるDockerfile, docker-composeをそれぞれ用意することになるかと思います。もし参考にするのであればそういう運用をオススメします。

# どう解決していったのか

## コンテナに直接入ってmigration, パッケージアップデート

都度コンテナの中に入って作業していました。
`docker ps`でコンテナID探して、`docker exec`で入る。そしてコマンド実行。

...都度これをやるのは面倒なので、こんなMakefileを作成した上で、一つ構成を変更しました。

``` Makefile
rails/bundle:
	docker-compose run --rm app bundle install
 
rails/migrate:
	docker-compose run --rm app bundle exec rails db:create db:migrate

yarn/install:
	docker-compose run --rm app yarn install
```

`docker run --rm`で実行しています。`docker exec`を使う方法も考えたんですが、この場合docker-composeをup済みにしていないと実行できません。なのでいつでも気軽に叩けるように`docker run --rm`し、余計なコンテナを活かし続けないように、処理が終わったらすぐ落とすようにしています。

## docker buildすると都度Gemのフルインストールしてしまう

さて、`docker run --rm`なのでそのまま実行すると処理が終わった後はコンテナが消えてしまいます。そうすれば当然bundle installしたGemも居なくなってしまいます。

これを防ぐため、docker-composeで以下の設定を追加しています。

```yml
volumes:
  bundle-gems:
    external: true

app:
  environment:
    BUNDLE_PATH: /app/vendor/bundle
  volumes:
    - bundle-gems:/app/vendor/bundle
```

bundle installで保存する先をボリュームコンテナに移してこれをマウントします。インストールしたGemはコンテナを消しても削除されず、ボリュームコンテナに残りっぱなしになります。Gemの入れ直しがないため、なにかの拍子で環境がおかしくなっても気軽にbuildを叩くことができます。

BUNDLE_PATHを指定しているのは、bundle install時に明示的にパスを指定しなくてもこの設定に合わせて配置してくれるからです。
[Bundler: The best way to manage a Ruby application's gems](https://bundler.io/v0.9/bundle_install.html)

Productionで動かす場合は、Dockerfile側にBUNDLE_PATHを書くべきですが、今回は2箇所で管理したくなかったのでdocker-composeで書いてます。
こうして、docker buildが走っても都度フルインストールせずに済むようになりました。

唯一例外があるとすればベースImageを変更した時くらいでしょうか。
このときはGemをビルドし直さないといけないのですが、ほぼ切り替えることはないので、手動でやることにしています。

## .envと重複しているenvironment

担当しているプロジェクトでは昔ながらの環境変数の管理をしており、\.envが現役です。
もちろんバージョン管理はされていないファイルで管理していたのですが、ふとdocker-composeを見ると.envと同じ設定が存在していました。

```yml
environment:
  DATABASE_USERNAME: 'xxxx'
  DATABASE_PASSWORD: 'xxxx'
  DATABASE_HOST: 'xxxx'
  RAILS_ENV: 'developemt'
  ...
```

これがバージョン管理されている。ローカルの開発環境なので別に漏れても大した問題にはならないのですが、

- 本来隠すべき情報が乗ってしまっている
- 2箇所で同じ設定値が定義されている
  
が気になり、手をいれました。

docker-composeには、`env_file`というまさにこの\.envを読み込むための設定があります。これを使うと、

```yml
env_file: 
  - .env
environment:
  - '.envで定義していないやつ'
```

と書け、.envで設定しているものは書かなくて良くなります。なお、\.envでの設定値は、`environment`で上書きすることができるので、共通の\.envを読みつつ、あるコンテナだけは設定を切り替えることができるようになります。


実は、docker-compose.ymlと同じ階層の\.envを読み取ってくれる機能があるため、今回のケースであれば設定不要です。が、明示的に読み込んでいることを示すためにあえて書くようにしています。
[Declare default environment variables in file \| Docker Documentation](https://docs.docker.com/compose/env-file/)


これで重複している設定がなくなってスッキリし、秘匿情報も書かなくて良くなりました。

## 個人ごとにパスを書き直さないといけないdocker-sync

弊社では好きなPCをある程度選ぶことができるのですが、Mac率が高い会社です。
そのため、Dockerを使うときはdocker-syncを併用するようにしています。事情が色々あってコンテナ中のデータやnode_moduleをホスト側と同期する必要があり大量のファイルがやり取りされるので、ホストとコンテナのボリューム同期だと転送追いつかないのが理由です。

さて、このdocker-syncですが、こう設定されているの見たことありませんか？

```yml
version: '2'
options:
  max_attempt: 200
syncs:
  app-sync-volume:
    src: '~/workspace/app/' # <- これ
    sync_excludes: ['.git']
```
sync対象のパスを直接書いているケース。これ結構イライラものでした。syncするディレクトリは当然個人個人でバラバラです。特に自分は[ghq](https://github.com/motemen/ghq)を使ってgitリポジトリをcloneしているので独特のパスになっています。いちいち書き換えるのがめんどう(´・ω・`)

しかもこのファイルはgit管理されているので、個人でパスを変更しているといつまで経ってもdiffに残り続ける。実際間違ってコミットされて、コンフリクトが発生するという事態もおきていました。

こんなふうに設定を変更することで、個人毎のディレクトリ指定が不要になります。

```yml
version: '2'
options:
  max_attempt: 200
  project_root: 'config_path' # <- 追加
syncs:
  app-sync-volume:
    src: '.'                  # <- 変更
    sync_excludes: ['.git']　　
```

`project_root: 'config_path'`は、docker-sync.ymlのパスと同じものが入ります。docker-sync.ymlをリポジトリのルートディレクトリに配置しているので、`src`にcurrent pathを設定するだけで済みます。

[Configuration — docker-sync 0.5.11 documentation](https://docker-sync.readthedocs.io/en/latest/getting-started/configuration.html#docker-sync-yml)


これでまた一つ面倒なことが減りました。

## まれにdbに接続できず落ちるappコンテナ

問題が発生する時のdocker-compose.ymlはこんな設定をしていました。

```yml
  db:
    ports:
      - '3306:3306'
    volumes:
      - ./containers_data/mysql:/var/lib/mysql
  app:
    command: bundle exec rm -f ./tmp/pids/unicorn.pid && bundle exec unicorn_rails -p 3000 -c config/unicorn.rb
    depends_on:
      - db
    ports:
      - "3000:3000"
    tty: true
    stdin_open: true
```

app側にdbへの`depends_on`を設定してし、dbコンテナが立ち上がった後にappを立ち上げる設定になっています。まあよくあるやり方だと思います。実際、これはこのままでもうまく動きました。ちゃんとMySQLが立ち上がってからRailsが上がる。問題ない。

ところが、まれにRailsとMySQLとの疎通が失敗して、コンテナが立ち上がらない現象が発生します。そのたびに一度止めて`docker-compose up`みたいな運用をしており、発生するたびにそういう手作業をやる必要に迫られました。

`depends_on`では、対象のコンテナが起動できたか、しか見てくれません。そのため、何らかの要因でdbコンテナ中のMySQLの起動が遅延すると、Railsが動き出す時にDBへの疎通が取れずunicorn_railsで落ちる現象が発生してしまうのです。

それを回避するため、appコンテナ中でdbコンテナのMySQLへの疎通をチェックできるようにしました。

```yml
  db:
    ports:
      - '13306:3306'
    volumes:
      - ./containers_data/mysql:/var/lib/mysql
  app:
    command: sh ./containers/app/entrypoint.sh # <- 変更
    depends_on:
      - db
    ports:
      - "3000:3000"
    tty: true
    stdin_open: true
```

`command`にentrypoint.shを指定して、この中で疎通チェックをしています。

```sh
#!/bin/bash
set -e

until mysqladmin ping -h db --silent; do
  >&2 echo "mysql wake up......"
  sleep 3
done

>&2 echo "=== mysql < Hi, got up now. ===="

bundle exec rm -f ./tmp/pids/unicorn.pid && bundle exec unicorn_rails -p 3000 -D -c config/unicorn.rb
echo "=== start unicorn. pids: `cat ./tmp/pids/unicorn.pid` ===="
bash
```

3秒間隔でMySQLが起動しているかをチェックし、起動していたらunicornを起動する。
Railsが起動しているときは、かならずMySQLが起動済みなので、unicorn_railsで落ちる現象は発生しなくなりました。

これでまた一つ心に平穏が訪れます。

# どうなったのか？

- コンテナに直接入ってmigration, パッケージアップデート
- docker buildすると都度Gemのフルインストールしてしまう
- .envと重複しているenvironment
- 個人ごとにパスを書き直さないといけないdocker-sync
- まれにdbに接続できず落ちるappコンテナ

以上遭遇していた、ちょっと面倒なやつが消え去ったおかげで、開発のスピードを落とす要因がいなくなり、これまでよりストレスフリーで開発ができるようになりました(｀･ω･´)


# おわりに

普段開発に追われていると、ちょっとした面倒は放置されがちです。
優先しなければ行けないものがあると、こういった面倒は後回しにしてしまいます。
しかし、それが積もり重なっていくと、いつの間にか無視できない労力がかかる問題になってしまいます。

面倒だなと思った時。それは開発時のちょっとした困りごとを倒す、またとない機会になるのだと思います。


---

明日の[パーソンリンクアドベントカレンダー](https://qiita.com/advent-calendar/2019/person-link)は、[mgmgOmO](https://qiita.com/mgmgOmO)さんのLookerのお話です。BIプラットフォーム構築している人の希望になりそうなLooker。その概要を説明してくれるとのことで期待です。