こんにちは、Sally社 CTO の@aitaro です。 マーダーミステリーアプリ「ウズ」とマダミス制作ツール「ウズスタジオ」、マダミス情報サイト「マダミス.jp」を開発しています。
https://uzu-app.com
この記事ではウズの開発当初から利用していた Docker Compose をやめることにした背景についてご紹介します。 Docker Compose は各マシンの開発環境での差異を吸収するというメリットがあり、多くの開発現場で導入されていますが、Docker Composeの抱えているデメリットを勘案して、最終的に一部を残して辞める決断をしました。
Docker Composeは、複数のコンテナを定義し、管理するためのツールです。ウズの開発環境では、バックエンド、フロントエンド、データベースなどをそれぞれコンテナ化して、Composeで一括管理していました。これによって Docker さえ入れたら、開発者全員で同じ環境で動かすことが可能になります。また、ミドルウェア等含めて、Dockerfile で記述することによって、開発者がバラバラにローカルインストールしなくても、コマンドひとつで環境をセットアップできます。
開発組織としてDocker Composeに対して以下の様々な課題を抱えていました。特に今回移行の決め手になったのは、「複雑なサービス間依存関係が記述できない」と言う問題でした。
Docker は仮想化レイヤーを挟むため、構造上 File の IO が遅くなります。特に Docker for MacOS は 開発体験に影響が出るほど遅くなります。docker/for-mac#1592 やdocker/roadmap#7 で言及されているようにさまざまな対策がなされてきましたが、長年解決されませんでした。(v4.27 で mutagen が Docker Desktop に統合されたとのことなので、現在は比較的改善されているかもしれません。)
Hot Reload に毎回余分に時間がかかると、開発体験が格段に下がります。ウズではエンジニアがパフォーマンスを発揮できるように、開発体験改善は優先して取り組んでおり、DockerのファイルIOが遅い問題はかなりネックとなっていました。
近代的な IDE は LSP が搭載されていて、快適な開発体験が提供されています。しかし、この恩恵を受けるためには、ローカル環境で Golang や Node.js のインストールなど、LSPが実行されるための環境を用意しなければなりません。
元々、Docker Compose で一括で環境が立ち上がるのが利点だったのに、結局ローカルでも環境整備が必要となると、Docker Compose のメリットは半減します。
さらに二重で環境整備を行うことは厄介な問題を引き起こします。たとえば、ローカルの Node.js と Docker で利用している Node.js のバージョンが変わることが容易に発生し、VSCode 上の静的解析ではエラーにならないが、Dockerでいざ実行するとエラーになる、みたいな問題を引き起こします。このような問題は発見が困難であり、エンジニアの時間を浪費させます。
これを解決するために、VSCode には Dev Containers という機能があります。VSCode を Container 上で動かして、 VSCode の LSP の実行等は Container で実行するというものです。
実は弊社でも何回か Dev Containers での開発環境構築をトライしたのですが、デバッグが辛すぎる、オーバヘッドが無視できずかなりのマシンスペックが要求される、開発環境がVSCodeに固定化されるといった辛みがあり断念しました。
Docker で build しているとビルドキャッシュが増えていきます。 docker builder prune を定期的に実行する必要があります。また node_modules や Go のパッケージ等もどんどん溜まっていきます。こちらも docker volume pruneを定期的に実行する必要があります。特に弊社ではモノレポでプロダクト数も多いので、Dockerに数十GBとられることもあります。ストレージを圧迫するとそれだけで面倒ですし、MacOS ではメモリのスワップもなされなくなるので、パフォーマンスが低下します。
さらに、node_modules や Go のパッケージ は 結局 VSCode のLintを動かすために必要になるので、ローカルでもダウンロードすることになり二重で存在し、これも圧迫する原因になります。
docker-compose.yamlでサービス間依存関係を書こうとしたら、depends_on を使います。これはこのサービスが実行するときはこっちも実行しとくというものですが、複雑な依存関係がかけません。
例として、web frontend と backend を同時に開発したいこともあれば、backendは共通の開発サーバーに接続して開発したいこともあるとします。仮に frontend の depends_on に backend を設定してしまったら常にbackend が立ち上がるので frontend のみで開発するのができなくなります。
巨大なモノレポで運用しているとき、全部のサービスを起動するわけにいかず、ここのサービス間依存関係をきめ細かく制御したいというニーズがあります。これが docker-compose.yaml で実現できないのが、ネックでした。
こちらのQiita記事でも紹介されているように、開発環境と本番環境でDockerfileを共通化することで、環境差異を無くして、バグ発生確率を下げると言うメリットがよく docker化のメリットとして挙げられます。しかし、弊社の環境では、開発環境では開発体験を向上させるため、HotReloadの仕組みを入れたり、本番環境ではマルチステージビルドで軽量な docker image を利用していたり、なんなら本番環境ではVercelにデプロイするのでDockerを利用してなかったりするので、結局 Dockerfileを共通化はできないと判断しました。
弊社ではアプリをFlutterで開発しています。FlutterをDockerで動かそうという試みはあるものの、iOSシミュレータを動かしたりする必要があるので、仮想化層が含まれているとだいぶ厳しいです。Flutter for Web はポートを指定したらギリいける気がしますが、モバイルアプリはFlutterをホストマシンで動かす必要があります。
Container 技術はそれほど簡単なものではありません。Dockerfile のメンテも大変ですし、NetworkやVolume Mount についても意識する必要があります。もちろん今風の技術であるDocker Composeを巧みに使いこなしながらローカル開発環境を実現しているのは格好いいですが、我々ソフトウェアエンジニアの仕事は、Docker Composeを巧みに使いこなすことではなく、機能実装を通じてユーザーに価値をデリバリーすることです。それを実現するためのツールはシンプルであればあるほど良いのです。
Taskfileは、Go言語で書かれたタスクランナーで、シンプルでありながら強力な機能を持っています。Makefileのような感覚で使用でき、複雑な設定なしでタスクの自動化が可能です。
以下のような特徴を持っています。
Taskfile と似たような機能を提供するものとして Makefile が存在します。
Makefile は歴史のあるツールで、主にCやC++のビルドに使われますが、それに以外にも自由にスクリプトを記述できるので、汎用的に利用可能です。 Makefile の特徴は、依存関係管理が強力で、複雑なビルドプロセスを効率的に管理できる点です。また、Linux や MacOS では追加のインストールなしで利用可能な点もメリットになります。しかし、書き方に癖があり、シンタックスが難解な点がデメリットとして挙げられます。今回の目的は開発環境の依存関係を宣言的にシンプルに記述すると言うものがあるので、yamlを用いてシンプルな構文で記述可能な Taskfile を採用しました。
ローカル環境で開発するので、Docker Composeの課題のうち「ファイルの IO が遅い問題」「VSCode と Dev Containers の辛み」「Docker のキャッシュ、ボリュームにストレージが圧迫される」「Flutter は管理できない」「学習コストがあり、シンプルに一段階レイヤーが挟まる分面倒」は解決します。Taskfile も学習コストはありますが、実態は単なるスクリプトの集合なので、Docker Compose に比べてそのコストは100分の1以下です。
また、Taskfile での依存関係の表現力は、Docker Compose に比べて柔軟であり、「複雑なサービス間依存関係が記述できない」についても解決されました。
開発環境が整備できているかをチェックするためのスクリプトを整備します。 Taskfile でtask doctor というコマンドを用意し、各開発者はそれを実行して開発に必要なコマンド群が用意されていることを確認します。
たとえば弊社のtask doctorの出力は以下のようになっています。
❯ task doctor[📝][📝] VSCode の環境を確認します。[📝][📝] $ code --version[📝] 1.90.0[📝] 89de5a8d4d6205e5b11647eb6a74844ca23d2573[📝] arm64[📝] dangmai.workspace-default-settings[📝] streetsidesoftware.code-spell-checker[📝][📝] [✅] VSCode の開発環境の確認に成功しました。[🐳] docker の環境を確認します。[🐳][🐳] $ docker --version[🐳] Docker version 25.0.5, build 5dc9bcc[🐳][🐳] [✅] docker の開発環境の確認に成功しました。[🐹][🐹] go の環境を確認します。[🐹][🐹] $ go version[🐹] go version go1.22.1 darwin/arm64[🐹][🐹] [✅] go の開発環境の確認に成功しました。[🐹][🐹] $ air -v[🐹][🐹] __ _ ___[🐹] / /\ | | | |_)[🐹] /_/--\ |_| |_| \_ v1.51.0, built with Go go1.22.1[🐹][🐹][🐹] [✅] air の開発環境の確認に成功しました。[🌐][🌐] node の環境を確認します。[🌐][🌐] $ node --version[🌐] v20.13.1[🌐][🌐] [✅] node の開発環境の確認に成功しました。[🦋][🦋] Flutter の環境を確認します。[🦋][🦋] $ flutter --version[🦋] Flutter 3.22.1 • channel stable • https://github.com/flutter/flutter.git[🦋] Framework • revision a14f74ff3a (3 weeks ago) • 2024-05-22 11:08:21 -0500[🦋] Engine • revision 55eae6864b[🦋] Tools • Dart 3.4.1 • DevTools 2.34.3[🦋][🦋] $ flutter doctor[🦋] Doctor summary (to see all details, run flutter doctor -v):[🦋] [✓] Flutter (Channel stable, 3.22.1, on macOS 14.5 23F79 darwin-arm64, locale ja-JP)[🦋] [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)[🦋] [✓] Xcode - develop for iOS and macOS (Xcode 15.4)[🦋] [✓] Chrome - develop for the web[🦋] [✓] Android Studio (version 2023.1)[🦋] [✓] VS Code (version 1.90.0)[🦋] [✓] Connected device (4 available)[🦋] [✓] Network resources[🦋][🦋] • No issues found![🦋][🦋] [✅] flutter の開発環境の確認に成功しました。[🦋][🦋] $ melos --version[🦋] 6.0.0[😶🌫️][😶🌫️] gcloud の環境を確認します。[😶🌫️][😶🌫️] $ gcloud --version[😶🌫️] Google Cloud SDK 469.0.0[😶🌫️] alpha 2024.03.15[😶🌫️] bq 2.1.1[😶🌫️] core 2024.03.15[😶🌫️] gcloud-crc32c 1.0.0[😶🌫️] gke-gcloud-auth-plugin 0.5.8[😶🌫️] gsutil 5.27[😶🌫️][😶🌫️] [✅] gcloud の開発環境の確認に成功しました。[🐘][🐘] psql の環境を確認します。[🐘][🐘] $ psql --version[🐘] psql (PostgreSQL) 16.2[🐘][🐘] [✅] psql の開発環境の確認に成功しました。失敗した場合は、解決方法が書かれたドキュメントのリンクに飛べるので、そちらを確認して開発者は自力で環境を構築できるようになります。
ここ数年で開発で必要な各種ツールチェインの発展は目覚ましいです。Windows では WSL がサポートされたことにより、それを用いると、Linux, MacOS との環境差異はだいぶ減りますし、Ruby や Python と比べて、 最近の言語である Go や Dart は依存関係の管理はかなり厳密に行われており、プログラムを実行するのに暗示的な依存関係を求める、たとえばローカルでこのバイナリもインストール必要になるといったことは少ないです。
クラウド開発環境を用いない限り、結局ローカルマシンで開発する以上、ローカルの環境差異は完全に0にはできず、その前提に立つと Docker Compose で環境差異を隠蔽しようとするよりかは、task doctor で環境差異をコントロールする方が理にかなっていると考えています。
以下のようなメリットが生まれました。
仮想化レイヤーの削減による開発環境の軽量化
Taskfileを利用することで、Docker Composeに依存しない開発環境を構築できました。これにより、仮想化レイヤーが削減され、開発環境が軽量になりました。特にファイルIOが改善されたことで、ホットリロードの速度が向上し、開発者の生産性が大幅に向上しました。
複雑なサービス間依存関係の柔軟な管理
Taskfileで複雑なサービス間依存関係をシンプルに管理できました。その結果環境構築のドキュメントの量が減り、新しい開発者が直感的に環境を整えることができるようになりました。例えば、フロントエンドとバックエンドの同時開発と、共通の開発サーバーへの接続の切り替えが容易になりました。他にも開発環境をセットアップするためのシードデータ投入用のスクリプトについて、今までは環境構築のドキュメントに記載されていたのを、taskfile で記載してコマンドひとつで用意できるようになりました。
Docker Compose の学習コスト不要で開発を始められる。
Docker Composeの学習コストはネックになっており、特に普段Dockerを利用しないモバイルアプリエンジニアにとって、Docker Compose 開発環境で開発を行うのはハードルになっていました。弊社では Flutter 開発メンバーがバックエンド開発を始めやすいと言うメリットがありました。
環境差異の管理が容易
Taskfileのtask doctorコマンドを利用することで、開発環境の整備状況を簡単に確認できるようになりました。これにより、VSCodeの動くローカル環境でも各開発者の環境差異を最小限に抑え、統一された開発環境を維持することが可能になりました。
Taskfileを導入にすることによって、ウズの開発環境で抱えていたDocker Composeの課題を解決しました。結果、開発効率の向上をもたらし、開発者が本来の業務に集中できる環境が整いました。
Docker Composeに疲弊している方々にとって、Taskfileは開発環境の効率化を実現する有力な選択肢となるでしょう。ぜひ、Taskfileを導入して、快適な開発環境を実現してください。
株式会社 Sally では Flutter・React・Golang エンジニアの採用活動を積極的に行っています。一緒にマーダーミステリーのプロダクトを作りたい方、ご興味があれば以下ご確認ください!
https://uzu-app.notion.site/jp-253f3185e7c5421486a27f3b3e72f5b5?pvs=74