DXを大幅に低下させるDocker for Macを捨ててMac最速のDocker環境を手に入れる

※DXはデジタルトランスフォーメーションではなくてDeveloper Experienceの方です

2020/05/26追記
https://qiita.com/nanasess/items/16ab9274c34bdc34e567 を使うことでVagrantを経由せずにDocker for Macの機能だけでMutagenを使うことができるようになりそうです。
パフォーマンスの測定はしていませんが、Docker for Macの設定のみでこの記事の手法と同等の速度が出るようになるかもしれません。

## 概要

Macでネイティブでの動作とほぼ同等の速度の安定したDocker環境を手に入れることができたので、その知見について公開します。
ものによりますが、最大10倍程度パフォーマンスの向上が見られました。
具体的な手法としてはVirtualBox + Dockerを用います。
設定は2ファイル50行弱のコードでほぼ完結する程度なんで導入も特に難しくないはずです。

## Docker for Macは遅い

MacでDockerを使って開発している方は体感していると思うのですが、Docker for Macの速度はネイティブで動かすのと比べて相当遅く、リソースも消費します。
自分はRails + React(webpack) というWebでは比較的一般的なスタックで開発しているのですが、Railsの初期読み込み・webpackの（差分）トランスパイル・yarn installなどが致命的に重く、LinuxでのDocker環境と比較してかなりDXが低下します。
特に大量のファイルの読み込みや、ファイル変更のリアルタイム検知が弱い印象が強いです。

また、Docker for Macは不安定な面もあり、（例えばgitのブランチ変更などで）大量のファイル変更が起こった場合にCPU使用率が100%に張り付いてしまい、再起動しないと戻ってこないなどの現象も起きることがあります。
[com.docker.hyperkit 100% cpu usage is back again · Issue #3499 · docker/for-mac](https://github.com/docker/for-mac/issues/3499)

この問題の大部分は調べた限りファイルシステムをマウントした際のオーバーヘッドに起因しており、[docker-sync](https://github.com/EugenMayer/docker-sync)などを使ってファイルシステムの扱い方を変えることである程度改善することが知られています。
しかし、docker-syncも不安定さが残るなど、懸念点があります。
参考: [hanhan's blog - Docker for Macのmount遅い問題まとめ](https://blog.hanhans.net/2017/05/23/docker-for-mac-slow/)

また、自分は設定の煩雑さから使ったことがないのですが、Docker for MacはNFS Volume sharingに対応しているので、それを使うことでもある程度改善されるのではないかと思います。

この記事ではパフォーマンス低下の最も大きな原因となっているファイルシステムについていくつか改善案を試し、その中で最も効果があったVirtualBox(Ubuntu)を使い、ファイルシステムのマウントを行わずに（VMのネイティブ？ファイルシステム上で）Dockerを使う、という手法について解説しようと思います。

## Docker for Macと今回構築する環境(VirtualBox + Docker)の違い

![docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32836/6fa856ea-1d9a-5e76-3f1e-0b30ca2e5c18.png)

基本的にはどちらもVM上でDockerが動作することになります。

### Docker for Mac

Docker for Macでは、インストール時にAlpine LinuxベースのHyperkit VMがインストールされ、起動時に `/var/run/docker.sock` が生成されます。
このVMはDocker for Mac上で自動的に管理され、Mac上で `docker`, `docker-compose` コマンドを実行する際に特に設定をしていない限り `docker.sock` を経由して透過的にVM内でDockerコマンドが実行されるようになっています。

### VirtualBox + Docker

一方、今回構築する環境ではVirtualBoxを使って実際にVMを管理することになります。
Vagrantを用いてVirtualBoxでUbuntu環境を構築し、その中でDockerを動作させます。
完全に独立した環境のため、ファイルシステムについては **VMに設定しているものがDocker上にマウントされ** 、Dockerコマンドについてはsshで接続した上でVM内から実行することになります。
VM内の `docker.sock` をMacにマウントすることでMac上から透過的にDockerコマンドを扱えるかもしれませんが、まだ検証していません。

ファイルシステムについてですが、Vagrantでファイルを同期する `sync_folder` 機能で指定できる同期タイプとして

- VirtualBox（オプション指定なし）
- NFS
- rsync
- SMB

があります。
しかし、今回はどれも使わずに（VM起動時に1度だけrsyncをする）、Vagrantから独立した手段でファイル同期を行います。

## 実際の速度差

Docker for Mac, VirtualBox + Docker, Macネイティブについて、計測が容易で差が大きかったものについて環境毎の実測値を記載します。
Railsへのアクセスについて、ネイティブよりVMの方が早い理由についてはよく分かっていません。

| Command                             | Docker for Mac | VirtualBox + Docker | Macネイティブ | 差          |
|-------------------------------------|----------------|---------------------|---------------|-------------|
| yarn install                        | 117.84s        | 16.86s              | 16.88s        | 6.8倍高速化 |
| Rails起動後の初回アクセス<br>(curl) | 22.43s         | 2.199s              | 3.32s         | 10倍高速化  |

## VMの設定や使い方

### VMの構築・設定

Vagrantを使ってVMを構築します。
実際に使っているVagrantfileの構成は以下です。
CPU・メモリ・ディスクサイズなどは適宜調整してください。

追加でプラグインとして

- vagrant-disksize
- vagrant-hostsupdater
- vagrant-mutagen

が必要になるので、 `$ vagrant plugin install vagrant-disksize vagrant-hostsupdater vagrant-mutagen` を実行してプラグインをインストールしておいてください。

```ruby
Vagrant.configure('2') do |config|
  config.vm.box = 'ubuntu/xenial64'

  config.vm.hostname = 'my-app'

  config.vm.network :private_network, ip: '192.168.50.10'

  config.vm.provider :virtualbox do |vb|
    vb.gui = false
    vb.cpus = 4
    vb.memory = 8192
    vb.customize ['modifyvm', :id, '--natdnsproxy1', 'off']
    vb.customize ['modifyvm', :id, '--natdnshostresolver1', 'off']
  end

  config.disksize.size = '30GB'
  config.mutagen.orchestrate = true

  config.vm.synced_folder './', '/home/vagrant/app', type: "rsync",
    rsync_auto: true,
    rsync__exclude: ['.git/', 'node_modules/', 'log/', 'tmp/']

  config.vm.provision 'shell', inline: <<-SHELL
    curl -fsSL https://get.docker.com -o get-docker.sh
    sh get-docker.sh
    usermod -aG docker vagrant

    curl -L "https://github.com/docker/compose/releases/download/1.25.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
    chmod +x /usr/local/bin/docker-compose
  SHELL
end
```

### VMの使い方

（実際のコマンドの実行はファイル同期の章に書いている内容を行った後にしてください。）
`$ vagrant up` を実行すると各設定が適用されたVMの作成・起動とrsyncの実行が行われ、 `./` が `/home/vagrant/app` に同期されます。rsyncの実行は起動時のみで自動的な実行は行われません。
初回はprovisionでDockerとDocker Composeのインストールが行われます。

その後、 `$ vagrant ssh` をしてVMにSSHでログインし、 `$ cd app && docker-compose up {rails}` のようなコマンドを実行することでDockerコンテナが立ち上がります。
イメージのビルドなどの処理は必要に応じて適宜行ってください。
立ち上がったアプリケーションにはVagrantfileで指定しているIPでアクセスできます。 （`http://192.168.50.10:{3000}` など）

## ファイル同期の手段

開発を行う際にはローカルとVM上でファイルを同期する必要がありますが、その際にVirtualBoxの標準の共有フォルダやNFSを使うと、VM内からDockerへマウントした際のパフォーマンスが結局大幅に低下します。
そのため、MacからVMへのファイルシステム自体のマウントは行わず、ファイル変更を検知して相互にファイルを転送するような同期手段が望ましいです。

Vagrantのrsyncでの同期がそれに近いのですが、その場合ホストからVMへ単方向の同期しか行われないという問題があります。
具体的にはVM内で `$ bundle install` や `$ yarn install` を実行した際に更新される `Gemfile.lock` や `yarn.lock` のようなファイルについてVMからMacへ同期が行われません。

VM内のファイル変更を検知してホストにrsyncを実行するようなコマンド実行も試したのですが、双方向にrsyncで適切な同期を行うというのは実現できませんでした。

### ファイル同期のソリューション 「[Mutagen](https://mutagen.io/)」

上記の問題についてですが、最終的に[Mutagen](https://mutagen.io/)を使うことで解決しました。
今回Vagrantを採用している理由の一つとして、Vagrant PluginでVMに対してMutagenの設定が簡単に行える、というものもあります。
VMの構築・設定の節で既に記載しているのですが、Mutagenをインストールし、 `vagrant-mutagen` プラグインを導入することで双方向のファイル同期が適切に行われます。
Mutagenのインストールですが、Macでは `$ brew install mutagen-io/mutagen/mutagen` を実行することで行う事ができます。

その後、Vagrantfileと同じ階層に以下の `mutagen.yml` を配置してください。

```yaml
sync:
  app:
    mode: "two-way-resolved"
    alpha: "./"
    beta: "{my-app (Vagrantfileで指定したホスト名)}:/home/vagrant/app"
    ignore:
      vcs: true
      paths:
        - "/node_modules"
        - "/log"
        - "/tmp"
```

その後 `$ vagrant up` でVMを起動すると、Mutagenによって双方向のファイル同期が行われます。
この同期は双方向にほぼリアルタイムで行われ、ファイルシステムのマウントではなくファイルの転送で実現されているため、最終的にDockerコンテナへマウントされた際のオーバーヘッドはほぼ発生しません。

## 結果

結果として現在業務で行っている開発にVagrantとMutagenを導入したことで、Macでも快適にDockerを使った開発が行えるようになりました。
Docker for Macでは速度・リソース・安定性のどの面でも苦労していたのですが、現在はある程度安定したDocker環境を構築できています。
sshしなければ使えないなど多少不便な点も残ってはいるのですが、それを上回るメリットが享受できていると感じています。

Docker for Macでの開発に消耗している方は数多くいる印象なので、もし機会があればこの構成を試してみていただけると幸いです。

## 備考・注意点

### 実行環境のスペック

MacBook Pro (16-inch, 2019)
CPU: 2.4GHz 8コア Intel Core i9
メモリ: 64GB 2667MHz DDR4

### Docker for MacへのMutagenの適用

MutagenはDocker for Macで扱うこともできるようです。
その際のパフォーマンスは計測できていないのですが、もしかしたらVagrantを経由した場合と同等の速度が出るかもしれません。

今回はMutagen導入時には既にVagrant + rsyncを使った場合の速度が非常に速いことが確認できていたので、設定がシンプルだったvagrant-mutagenを採用したのですが、Docker for Mac + Mutagenでの開発も調査する価値があるかと思います。

### マイクロサービス開発への対応

場合によっては複数のリポジトリに横断するマイクロサービス開発をDockerのnetwork機能を用いて行っている場合があるかと思います。
この構成ではリポジトリ毎に完全に独立したVMが作成されるという特性上、Docker networkを使った開発が難しくなってしまう可能性があります。

現在マイクロサービスでの開発は行っていないため確認できていないのですが、解決策として

- 上記Docker for MacへのMutagenの適用
- 必要なマイクロサービスをsubtree(submodule)として配置したリポジトリを作り、そのリポジトリ上でVMを構築する

というものが使えないかと考えています。
これについては今後マイクロサービス開発が必要になったタイミングで検証を進めていこうかと考えています。

### Mutagenの同期セッションについて

vagrant-mutagenでのファイル同期についてですが、 `$ vagrant halt` を行わずにVMが終了した場合にはセッションが削除されずに残ってしまうという問題が確認できています。
セッションが残っていた場合には次回のVM起動時にセッションが新たに作成され、多重にセッションが貼られたまま残り続けるという状態になってしまいます。
また、残っているセッションは `mutagen.yml` の設定を読み込まずに古い設定のまま動作し続けるためバグの原因となります（なりました）。

MacのクラッシュやVMの正常な終了が行えなかった際には `$ mutagen sync list` でセッションを確認し、不要なセッションについては `$ mutagen sync terminate {session_id}` で削除するようにしてください。

### docker-composeの設定について

`docker-compose.yml` に以下のようにgem用、npm用のvolumeを作成しているのですが、設定に不備があってDocker for Macでパフォーマンスが落ちているなどあればコメントをいただけると助かります。

```yaml
services:
  rails:
    volumes:
      - .:/usr/src/app:cached
      - bundle:/usr/local/bundle:cached
  frontend:
    volumes:
      - .:/usr/src/app:cached
      - yarn:/usr/local/yarn:cached

volumes:
  bundle:
  yarn:
```