こんにちは、メドピアCTO室 SREの侘美(たくみ)です。
普段はRails/Vue.js/terraform/Lambdaなどを書いています。
趣味は飼い猫と遊ぶことで、生傷が絶えません。
入社してから約半年間、Railsのプロジェクトで実装をしつつ、合間に開発環境の改善をいろいろとやってきました。けっこうな分量となったので、紹介したいと思います。
なお、本記事で扱う開発環境とは下記2つを指すこととします。
- ソースコードの修正/テストの実行/静的解析の実行環境
- サービスを起動し、ブラウザでデバッグする環境
特徴
主な改善対象である、「MedPeer」サービスの特徴をご紹介します。
- Ruby on Rails製
- 社内では最も巨大なRailsプロジェクト
- モデル数693
- 認証サービス、旧サービス(PHP製)と連携している
- 開発環境はDocker for Macを利用
- コンテナ数は旧システム、認証システムを入れて32個
- ソースコードはdocker-syncを使ってローカルとコンテナ内で同期
- RSpecやrubocopの実行もコンテナ内で実行
システム構成としては、ざっくり下図のようになります。
課題
私が入社した当時、MedPeerサービスの開発環境には下記のような課題がありました。
- 電池消費が激しい
- 起動に時間がかかる
- メモリ消費が激しい
- 起動に失敗するときがある
- docker-syncが不安定
- ファイルが同期されない
- CPUが高負荷になる
- 依存システムのコンテナが多い
- Linux対応していないため、一部テスト環境のメンテナンスが放置され気味
改善内容の紹介
それでは、改善内容を順に紹介していきます。
電池消費
開発環境を立ち上げていると電池をモリモリ消費する状態でした。
開発環境のコンテナ群を立ち上げっぱなしで、ミーティングに出ると、だいたい、2時間程度で電池が切れてしまうくらいでした。
docker stats
でCPU使用率が高いコンテナを絞り込み、top
コマンドでプロセスのCPU使用率を確認したところ、SpringのプロセスがCPUを常に利用していることを特定できました。
SpringはRails 4.1から標準で付属するようになったapplication loaderです。
常にバックグラウンドで実行しつづけることで、rails console
やRSpecの実行等、ロードを伴うコマンドの実行を高速にしてくれます。
しかし、MedPeerサービスではロード対象となるファイル数が多いためか、常時それなりのCPUを消費していました。
Springには、DISABLE_SPRING
という環境変数を指定することで、無効にする機能があります。
この環境変数を設定して一律で無効にすることもできるのですが、電池消費よりもスピードを優先するエンジニアも当然いますので、任意に設定できるように対応しました。
medpeer.jpの開発環境にはdirenvが導入されているので、これを利用し、各エンジニアのローカルで指定した環境変数に応じて、コンテナ内にDISABLE_SPRING
を引き渡すように設定しています。
起動が不安定対策
メモリ消費が多いこともあり、開発環境を落とすことが多いのですが、起動しようとすると、Railsの起動や依存コンテナの起動に失敗することが多々ありました。
原因は様々あったのですが、大半は下記のように起動順序によるものでした。
- gem、npmの依存ライブラリのインストール量で起動順序が変わっている
- DBやElasticsearch、fluentd等依存コンテナの起動より先にこれらに接続するコンテナが立ち上がり、エラーとなる
もちろん、docker-composeによるlinks
やdepends_on
を指定し、コンテナの起動順序は担保しているのですが、これらではコンテナ内のプロセスがreadyになったことまでは担保してくれません。
そこで、コンテナ内のプロセスが応答するまでwaitしてくれるufoscout/docker-compose-waitを利用することにしました。 似たツールはいくつもあるのですが、複数のコンテナ/ポートへの疎通チェックができる点で、docker-compose-waitを採用しました。
READMEに書いてあるように取得したスクリプトを /wait
にマウントし、環境変数と起動時のcommandを設定することで、指定したコンテナの指定したポートに疎通することをチェックしてから、任意のコマンドを実行することが可能です。
サンプルのdocker-compose.yml
は以下のようになります。
--- version: '3' services: web: image: ${ECR_NAME}/app:1.0 environment: # チェックする対象を環境変数に定義する WAIT_HOSTS: fluentd:24224,elasticsearch:9200 # /waitが終了したら、実行したいコマンドを実行する command: /bin/sh -lc "/wait && ./bin/setup" volumes: - ./bin/wait:/wait # その他ソースコードのマウント設定 fluentd: # fluentdコンテナの設定 elasticsearch: # elasticsearchコンテナの設定
この設定により、依存モジュールのインストール状況等で、各コンテナの起動スピードが多少変化しても、コンテナの起動順序を担保し、起動に失敗し辛い設定することができました。
脱docker-sync
docker-syncが不安定であることもエンジニア内で問題視されていました。
- 異常にCPUを消費するときがある(暴走状態!)
- ホスト-コンテナ間の同期が遅いときがあり、ソースコードの変更が反映されていないときがある(突然の死!)
(上記の問題はdocker-syncが利用しているunison起因であることまでは確認しています)
docker-syncはネイティブなDocker環境ではないMac等の環境において、ホスト-コンテナ間のファイルの参照を高速化されるために開発されたツールです。
そもそも、Docker for MacはVM上でLinuxを起動し、そのLinux上のDockerを利用する形なので、ホスト-コンテナ間のファイルの参照(同期)が遅いものとして有名です。
ということで、全員Linuxで開発すれば解決です!!
docker volumeのcacheオプションを利用することで、docker-syncをやめて安定/高速なソースコード同期を実現しようとチャレンジしてみました。
dockerでは、volumeマウントのdelegated
オプションを利用することで、ホスト-コンテナ間でのファイルの参照の遅延を許容し、高速な参照を実現することが可能です。
# before volumes: # sync-volumeへの./appのマウントはdocker-sync.ymlファイルで定義されている - sync-volume:/app # after volumes: - ./app:/app:delegated
結論から言うと、脱docker-syncはできませんでした。
開発環境を立ち上げ、複数のページの表示速度を計測した結果、いずれも約2.3倍程度表示スピードが劣化し、ページの表示に2~3秒かかるようになってしまいました。 この数値はしばしば見かける「docker-syncで約2倍程度高速になる」という記述とも合致しており、確からしい結果となりました。
ある程度巨大でファイル数のあるプロジェクトにおいて、Docker for Macで開発を行う場合、docker-syncが最も高速であるという知見を得ました。 一方で規模の小さいアプリケーションであれば、docker volumeのcacheオプションだけでも開発環境のページ描画はそこまで遅くならないので、安定性を重視してdocker-syncの利用は不要だと思います。
また、docker-syncを利用していないプロジェクトにdelegated
オプションを導入したところ、jest
の実行が数倍高速になるといった副次効果を得ることもできました。
依存モジュールをコンテナ内に閉じ込める
こちらは、docker-syncを導入していないプロジェクトに導入した設定です。
前述したように、Docker for Macのvolumeマウントはとても遅いので、ホスト側のファイルを参照する量が増えるほど、コンテナ内でのRailsの挙動は遅くなります。
上記の課題を解決するため、下記のような対策を採りました。
現状、vendor/bundle
配下のファイルがホスト側で必要になるシーンが特にないため、vendor/bundle
以下のファイルはコンテナ内のみに存在するように構成を変更します。
また、コンテナを破棄/再作成した際に、vendor/bundle
以下のファイルが削除されてしまうと、再度bundle install
するのに時間がかかってしまうため、docker volumeを使い、コンテナのライフサイクルとは別に永続化しています。
具体的なdocker-compose.yml
ファイルは下記のようになります。
# before services: web: volumes: - ./app:/app # after services: web: volumes: - ./app:/app - bundle:/app/vendor/bundle - node_modules:/app/node_modules volumes: bundle: {} node_modules: {}
図で示すと、下図のようになります。
この構成により、コンテナ内から参照するホスト側のファイル数を減らし、Railsの動作を高速化することができます。
Linux対応
MedPeerサービスの一部のテスト環境は、Ubuntu上に構築した開発環境で動作しています。
過去にdocker-sync導入後、Ubuntu上の環境は別ブランチで構築する構成となっていたため、メンテナンスされず放置される傾向となっていました。 この問題を解決するため、同じコードでdocker-syncを利用したMac OSでも、docker-syncを導入していないLinux OSでも開発環境が構築できるように修正しました。
具体的な方法としては、docker-composeのoverride機能を利用します。
Mac OS上では、docker-compose.yml
を読み込み、docker-sync start
とdocker-compose up
コマンドで環境を立ち上げます。
Linux OS上では、環境変数にCOMPOSE_FILE=docker-compose.yml:linux.yml
を設定し、docker-compose.yml
に加え、linux.yml
を読み込み、設定を一部上書きします。その設定でdocker-compose up
コマンドで環境を立ち上げます。
Linux OSの場合にdocker-compose.yml
を上書きするlinux.yml
には、docker-syncに関するvolumeの設定を上書きし、通常のdockerによるvolume mountの仕組みでソースコードをマウントするように設定します。
--- # docker-compose.yml version: '3' services: web: image: ruby:alpine volumes: - sync-volume:/app volumes: sync-volume: external: true
--- # linux.yml version: '3' services: web: volumes: - ./app:/app
さらに、Makefile
中でOSに応じてCOMPOSE_FILE
の設定を変更するように設定してあるため、同じmakeコマンドを実行することで、Mac OSでも、Linux OSでも環境が立ち上がるように構築されています。
認証機能の有無の切り替え
われわれのプロジェクトの中には既存の認証サービスと連携するものがいくつかあります。
開発環境でも本番環境と同等の認証の仕組みを動かそうとすると、認証に関係するコンテナだけで、13個ものコンテナを追加で起動する必要があります。
これでは明らかにローカルマシンのリソース消費が増えてしまうため、開発環境では認証機能をダミーに変更し、これら13個のコンテナを削除したい気持ちになります。 しかし、一方で認証機能の検証を行いたいシーンもあります。
そこで、新しいいくつかのプロジェクトでは開発環境で認証機能の有無を切り替えられるようにし、認証機能無しで開発環境を立ち上げた場合は、余計なコンテナが起動しないように設定しています。
こちらに関しても、docker-composeのoverride機能で実現しています。
認証機能を利用しない構成でdocker-compose.yml
ファイルを用意し、これに認証サービスを追加するためのauth.yml
ファイルを用意します。
これらのdocker-composeで利用するファイルは、USE_AUTH
環境変数をdirenvを利用して設定し、Makefile中でCOMPOSE_FILE
変数に設定することで制御しています。
また、Rails内部の実装でも認証をダミーの実装に切り替える必要があるため、USE_AUTH
環境変数をRailsのコンテナに渡し、実装が切り替わるようにしています。
認証機能を利用しない構成の場合は、ベースとなるdocker-compose.yml
のみで構築されます。
--- # docker-compose.yml version: '3' services: rails: # rails用コンテナの設定 mysql: # mysql用コンテナの設定
認証機能を利用する場合は、Makefileにて環境変数COMPOSE_FILE=docker-compose.yml:auth.yml
を設定することで、認証機能を追加します。
COMPOSE_FILE
に指定したことで、下記のauth.yml
でdocker-compose.yml
がoverrideされ、下の図のような構成でコンテナが立ち上がります。
--- # auth.yml version: '3' services: rails: environment: USE_AUTH: auth: # 認証サービスのコンテナの設定 # その他認証系のコンテナが合計13個
このような切り替え機構を導入することで、ほとんどの開発シーンでは認証機能を省略し、省リソースでの開発環境を実現することができました。
不要なコンテナの停止
「MedPeer」サービスの開発環境には、一部の機能の動作を検証すためのコンテナや、モバイル版含めてすべてのサービスが動作するように下記のコンテナも含まれています。
つまり、デフォルトはmaximumな構成となっています。
起動しているコンテナの中には、下記のようなものも含まれています。
- メールの内容を確認するためのmailcatcherコンテナ
- S3へのファイルアップロードを検証するためのminioコンテナ
- モバイル版等、一部の機能で利用するAPI用コンテナ
こういったコンテナはすべての開発者の環境で必要となるわけではないので、不要なコンテナを停止させることで、開発環境のリソース消費量を削減しておきたいです。
一から構築する場合は、何度か紹介したdocker-composeのoverrideを利用し、デフォルトをminimumな構成とし、特定の開発時に必要なコンテナは別ファイルで定義し、環境変数で切り替えるのが良いでしょう。
しかし、今回はすでにデフォルトがmaximumな構成となっているため、docker-composeのoverride機能を利用し、不要なコンテナは起動後即終了するように設定することで、コンテナの起動数を減らす方向としました。
こちらも最小構成で十分な開発者は、direnvを利用し環境変数にCOMPOSE_FILE
を設定することで、構成の切り替えをできるようにしています。
--- # docker-compose.yml version: '3' services: rails: # rails用コンテナ mailcatcher: # メールをwebUIで確認できるmailcatcherコンテナ minio: # AWS S3互換のminioコンテナ
--- # docker-compose.minimum.yml version: '3' services: mailcatcher: entrypoint: ['echo', 'Service disabled'] minio: entrypoint: ['echo', 'Service disabled']
個人ごとのカスタマイズを可能に
上記を応用することで、自分のみのコンテナを追加したり、逆に特定のコンテナを起動しないようにするといったカスタマイズも可能になっています。
docker-compose.custom.yml
のようなファイルを作成し、任意の設定を追加します。
また、.git/info/exclude
に設定しコミットから除外します。
環境変数のCOMPOSE_FILE
を設定し、作成したdocker-compose.custom.yml
を読み込むようにすることで切り替えを実現できます。
まとめ
今までの半年間で行ってきた開発環境の改善を振り返ってみました。
単純なものからちょっとテクニカルなものまで、いろいろやったなあ、という感想です。
今後も改善を続けてイケてるモダンな開発環境を実現していきたいと思います!
(☝︎ ՞ਊ ՞)☝︎是非読者になってください
メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!
■募集ポジションはこちら
https://medpeer.co.jp/recruit/entry/
■開発環境はこちら